Phase 7: Agent Selector — per-agent soul.md + ChromaDB memory filtering
- Agent dropdown UI (chip button + hidden select) in composer header - Session.agent field persists agent selection across refresh - soul.md loaded per-agent via ephemeral_system_prompt injection - ChromaDB memory filtered by agent topic (lotus/, sunflower/, etc.) - Fixed streaming.py: agent→_ai_agent variable shadowing (lines 1161, 1163) - New API endpoints: /api/agents/topology, /api/agents/memory/search - Agent metadata registry with emoji, name, description per Tier-2 agent
This commit is contained in:
@@ -1067,3 +1067,40 @@ def _search_all_agents_memory(query: str, limit: int = 20) -> list:
|
||||
return matches
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── Topology Graph ───────────────────────────────────────────────────────────
|
||||
|
||||
def _get_topology() -> dict:
|
||||
"""
|
||||
Build a network graph of all agents and their connections.
|
||||
Returns {nodes: [...], edges: [...]} for D3.js visualization.
|
||||
"""
|
||||
# Nodes: Rose + all Tier-2 agents
|
||||
nodes = [
|
||||
{"id": "rose", "name": "Rose 🌹", "type": "orchestrator",
|
||||
"color": "#f44336", "domain": "Orchestrator"},
|
||||
]
|
||||
for agent_id, meta in TIER2_AGENTS.items():
|
||||
nodes.append({
|
||||
"id": agent_id,
|
||||
"name": f"{meta['emoji']} {meta['name']}",
|
||||
"type": "tier2",
|
||||
"color": meta["color"],
|
||||
"domain": meta["domain"],
|
||||
})
|
||||
|
||||
# Edges: Rose connects to all Tier-2 agents
|
||||
edges = []
|
||||
for agent_id in TIER2_AGENTS:
|
||||
edges.append({
|
||||
"source": "rose", "target": agent_id,
|
||||
"type": "orchestrates", "strength": 1,
|
||||
})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def get_topology() -> dict:
|
||||
"""API: GET /api/agents/topology — return agent network graph."""
|
||||
return _get_topology()
|
||||
|
||||
@@ -3,6 +3,55 @@ Phase 5 — Memory Search (ChromaDB)
|
||||
Appended to agents.py functions for Memory Search.
|
||||
"""
|
||||
import chromadb
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
|
||||
|
||||
def _get_agent_soul(agent_id: str) -> str | None:
|
||||
"""
|
||||
Load soul.md for a specific agent.
|
||||
|
||||
Searches in this order:
|
||||
1. ~/.hermes/agents/{agent_id}/soul.md
|
||||
2. ~/.hermes/agents/{agent_id}/SOUL.md
|
||||
|
||||
Returns None if not found.
|
||||
"""
|
||||
if not agent_id or agent_id == "rose":
|
||||
return None # Rose uses the global HERMES_HOME/SOUL.md
|
||||
|
||||
for fname in ("soul.md", "SOUL.md"):
|
||||
path = HERMES_HOME / "agents" / agent_id / fname
|
||||
if path.exists():
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
return content
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_agent_memory_context(agent_id: str, query: str, limit: int = 5) -> str | None:
|
||||
"""
|
||||
Build a memory context string by searching ChromaDB for the agent's memories.
|
||||
|
||||
Searches rose_memory collection filtered by topic matching "{agent_id}/".
|
||||
Returns formatted text block or None if nothing found.
|
||||
"""
|
||||
if not agent_id or agent_id == "rose":
|
||||
return None
|
||||
|
||||
matches = _search_agent_memory(agent_id, query, limit=limit)
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
blocks = []
|
||||
for m in matches:
|
||||
blocks.append(f"## {m['topic']}\n{m['content'][:300]}")
|
||||
return "\n\n".join(blocks) if blocks else None
|
||||
|
||||
|
||||
def _get_chroma_client():
|
||||
|
||||
@@ -45,6 +45,7 @@ class Session:
|
||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||
personality=None,
|
||||
active_stream_id: str=None,
|
||||
agent: str=None,
|
||||
pending_user_message: str=None,
|
||||
pending_attachments=None,
|
||||
pending_started_at=None,
|
||||
@@ -68,6 +69,7 @@ class Session:
|
||||
self.estimated_cost = estimated_cost
|
||||
self.personality = personality
|
||||
self.active_stream_id = active_stream_id
|
||||
self.agent = agent
|
||||
self.pending_user_message = pending_user_message
|
||||
self.pending_attachments = pending_attachments or []
|
||||
self.pending_started_at = pending_started_at
|
||||
@@ -103,6 +105,7 @@ class Session:
|
||||
'title': self.title,
|
||||
'workspace': self.workspace,
|
||||
'model': self.model,
|
||||
'agent': getattr(self, 'agent', None),
|
||||
'message_count': len(self.messages),
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
|
||||
275
api/routes.py
275
api/routes.py
@@ -55,6 +55,7 @@ from api.helpers import (
|
||||
)
|
||||
from api import mc as _mc
|
||||
from api import agents as _agents
|
||||
from api import heartbeats as _heartbeats
|
||||
|
||||
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
|
||||
import re as _re
|
||||
@@ -570,6 +571,23 @@ def handle_get(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/projects":
|
||||
return j(handler, {"projects": load_projects()})
|
||||
|
||||
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
|
||||
from api import projects as _projects
|
||||
|
||||
if parsed.path == "/api/projects/tasks":
|
||||
return j(handler, {"tasks": _projects.get_all_tasks()})
|
||||
|
||||
if parsed.path == "/api/projects/stats":
|
||||
return j(handler, _projects.get_stats())
|
||||
|
||||
if parsed.path.startswith("/api/projects/") and parsed.path.endswith("/tasks"):
|
||||
# GET /api/projects/{id}/tasks
|
||||
project_id = parsed.path.split("/")[3]
|
||||
proj = _projects.get_project(project_id)
|
||||
if not proj:
|
||||
return j(handler, {"error": "Project not found"}, status=404)
|
||||
return j(handler, {"tasks": proj.get("tasks", [])})
|
||||
|
||||
if parsed.path == "/api/session/export":
|
||||
return _handle_session_export(handler, parsed)
|
||||
|
||||
@@ -786,6 +804,13 @@ def handle_get(handler, parsed) -> bool:
|
||||
agent_id = parsed.path.split("/")[-1]
|
||||
return j(handler, _agents.get_agent_inbox(agent_id))
|
||||
|
||||
# GET /api/agents/{id}/inbox — alternative inbox route
|
||||
if parsed.path.startswith("/api/agents/") and "/inbox" in parsed.path and parsed.path.count("/") == 4:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "inbox":
|
||||
agent_id = parts[3]
|
||||
return j(handler, _agents.get_agent_inbox(agent_id))
|
||||
|
||||
if parsed.path.startswith("/api/agents/config/"):
|
||||
agent_id = parsed.path.split("/")[-1]
|
||||
return j(handler, _agents.get_agent_config(agent_id))
|
||||
@@ -803,84 +828,6 @@ def handle_get(handler, parsed) -> bool:
|
||||
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))
|
||||
|
||||
# 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
|
||||
if parsed.path.startswith("/api/agents/") and "/chat-history" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "chat-history":
|
||||
agent_id = parts[3]
|
||||
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
|
||||
return j(handler, _agents.get_agent_chat_history(agent_id, limit=limit))
|
||||
|
||||
# GET /api/agents/{id}/health
|
||||
if parsed.path.startswith("/api/agents/") and "/health" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "health":
|
||||
agent_id = parts[3]
|
||||
return j(handler, _agents.get_agent_health(agent_id))
|
||||
|
||||
# GET /api/agents/{id}/tasks
|
||||
if parsed.path.startswith("/api/agents/") and "/tasks" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
@@ -892,21 +839,6 @@ def handle_get(handler, parsed) -> bool:
|
||||
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)
|
||||
@@ -920,6 +852,51 @@ def handle_get(handler, parsed) -> bool:
|
||||
if _mem_match:
|
||||
return _handle_memory_search(handler, parsed, agent_id=_mem_match)
|
||||
|
||||
# GET /api/agents/{id}/activity — agent activity events
|
||||
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]
|
||||
qs = parse_qs(parsed.query)
|
||||
limit = int(qs.get("limit", ["50"])[0])
|
||||
return j(handler, _agents.get_agent_activity(agent_id, limit))
|
||||
|
||||
# GET /api/agents/{id}/errors — agent error log
|
||||
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]
|
||||
qs = parse_qs(parsed.query)
|
||||
limit = int(qs.get("limit", ["20"])[0])
|
||||
return j(handler, _agents.get_agent_errors(agent_id, limit))
|
||||
|
||||
# GET /api/agents/{id}/health — agent health status
|
||||
if parsed.path.startswith("/api/agents/") and "/health" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "health":
|
||||
agent_id = parts[3]
|
||||
return j(handler, _agents.get_agent_health(agent_id))
|
||||
|
||||
# GET /api/agents/{id}/chat-history — agent chat sessions
|
||||
if parsed.path.startswith("/api/agents/") and "/chat-history" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) == 5 and parts[4] == "chat-history":
|
||||
agent_id = parts[3]
|
||||
qs = parse_qs(parsed.query)
|
||||
limit = int(qs.get("limit", ["20"])[0])
|
||||
return j(handler, _agents.get_agent_chat_history(agent_id, limit))
|
||||
|
||||
# GET /api/agents/{id}/usage — agent token 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/topology — agent network graph
|
||||
if parsed.path == "/api/agents/topology":
|
||||
return j(handler, _agents.get_topology())
|
||||
|
||||
# ── Profile API (GET) ──
|
||||
if parsed.path == "/api/profiles":
|
||||
from api.profiles import list_profiles_api, get_active_profile_name
|
||||
@@ -983,6 +960,12 @@ def handle_get(handler, parsed) -> bool:
|
||||
except Exception as e:
|
||||
return j(handler, {"error": str(e)}, status=500)
|
||||
|
||||
# GET /api/heartbeats — list all + status
|
||||
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
|
||||
result = _heartbeats.handle_get(parsed.path)
|
||||
if result is not None:
|
||||
return j(handler, result)
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
@@ -1003,6 +986,45 @@ def handle_post(handler, parsed) -> bool:
|
||||
|
||||
body = read_body(handler)
|
||||
|
||||
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
|
||||
from api import projects as _projects
|
||||
|
||||
if parsed.path == "/api/projects/tasks":
|
||||
task = _projects.create_task(body.get("project_id"), body)
|
||||
return j(handler, task)
|
||||
|
||||
# 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]
|
||||
result = _agents.send_bus_message(
|
||||
to_agent=agent_id,
|
||||
from_agent=body.get("from_agent", "rose"),
|
||||
subject=body.get("subject", ""),
|
||||
content=body.get("content", ""),
|
||||
msg_type=body.get("type", "request"),
|
||||
)
|
||||
return j(handler, result)
|
||||
|
||||
# POST /api/agents/{id}/message
|
||||
if parsed.path.endswith("/message"):
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
return j(handler, _agents.send_agent_message(agent_id, body))
|
||||
|
||||
# POST /api/agents/{id}/ack/{msg_id}
|
||||
if "/ack/" in parsed.path:
|
||||
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"):
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
action = parsed.path.split("/")[-1]
|
||||
return j(handler, _agents.set_agent_enabled(agent_id, action == "enable"))
|
||||
|
||||
if parsed.path == "/api/session/new":
|
||||
try:
|
||||
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
|
||||
@@ -1714,8 +1736,69 @@ def handle_post(handler, parsed) -> bool:
|
||||
handler.wfile.write(json.dumps({"ok": True}).encode())
|
||||
return True
|
||||
|
||||
# POST /api/heartbeats — create heartbeat
|
||||
if parsed.path == "/api/heartbeats" or parsed.path.startswith("/api/heartbeats/"):
|
||||
result = _heartbeats.handle_post(parsed.path, body)
|
||||
if result is not None:
|
||||
status = 200
|
||||
if isinstance(result, tuple):
|
||||
result, status = result
|
||||
return j(handler, result, status=status)
|
||||
|
||||
# DELETE /api/heartbeats/{id} — cancel heartbeat
|
||||
if parsed.path.startswith("/api/heartbeats/"):
|
||||
result = _heartbeats.handle_delete(parsed.path)
|
||||
if result is not None:
|
||||
return j(handler, result)
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
# ── PUT routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def handle_put(handler, parsed) -> bool:
|
||||
"""Handle all PUT routes. Returns True if handled, False for 404."""
|
||||
body = read_body(handler)
|
||||
|
||||
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
|
||||
if parsed.path.startswith("/api/projects/tasks/"):
|
||||
task_id = parsed.path.split("/")[-1]
|
||||
from api import projects as _projects
|
||||
result = _projects.update_task(task_id, body)
|
||||
if result is None:
|
||||
return j(handler, {"error": "Task not found"}, status=404)
|
||||
return j(handler, result)
|
||||
|
||||
# PUT /api/agents/{id}/soul
|
||||
if parsed.path.endswith("/soul"):
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
return j(handler, _agents.update_agent_soul(agent_id, body.get("content", "")))
|
||||
|
||||
# PUT /api/agents/{id}/memory
|
||||
if parsed.path.endswith("/memory"):
|
||||
agent_id = parsed.path.split("/")[-2]
|
||||
return j(handler, _agents.update_agent_memory(agent_id, body.get("content", "")))
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
# ── DELETE routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def handle_delete(handler, parsed) -> bool:
|
||||
"""Handle all DELETE routes. Returns True if handled, False for 404."""
|
||||
|
||||
# ── Projects Tab Tasks (NEW) ──────────────────────────────────────────────
|
||||
if parsed.path.startswith("/api/projects/tasks/"):
|
||||
task_id = parsed.path.split("/")[-1]
|
||||
from api import projects as _projects
|
||||
_projects.delete_task(task_id)
|
||||
return j(handler, {"ok": True})
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
# ── GET route helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
# MIME types for static file serving. Hoisted to module scope to avoid
|
||||
@@ -1854,8 +1937,9 @@ def _handle_list_dir(handler, parsed):
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"entries": list_dir(Path(workspace), qs.get("path", ["."])[0]),
|
||||
"entries": list_dir(Path(workspace), qs.get("path", ["."])[0], qs.get("search", [""])[0]),
|
||||
"path": qs.get("path", ["."])[0],
|
||||
"search": qs.get("search", [""])[0],
|
||||
},
|
||||
)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
@@ -2403,6 +2487,7 @@ def _handle_chat_start(handler, body):
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
model = body.get("model") or s.model
|
||||
agent = body.get("agent") or s.agent
|
||||
# Prevent duplicate runs in the same session while a stream is still active.
|
||||
# This commonly happens after page refresh/reconnect races and can produce
|
||||
# duplicated clarify cards for what appears to be a single user request.
|
||||
@@ -2424,6 +2509,7 @@ def _handle_chat_start(handler, body):
|
||||
stream_id = uuid.uuid4().hex
|
||||
s.workspace = workspace
|
||||
s.model = model
|
||||
s.agent = agent
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = msg
|
||||
s.pending_attachments = attachments
|
||||
@@ -2436,6 +2522,7 @@ def _handle_chat_start(handler, body):
|
||||
thr = threading.Thread(
|
||||
target=_run_agent_streaming,
|
||||
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
||||
kwargs={"agent": agent},
|
||||
daemon=True,
|
||||
)
|
||||
thr.start()
|
||||
|
||||
@@ -23,6 +23,7 @@ from api.config import (
|
||||
resolve_model_provider,
|
||||
)
|
||||
from api.helpers import redact_session_data
|
||||
from api.agents_memory import _get_agent_soul, _get_agent_memory_context
|
||||
|
||||
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
|
||||
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
|
||||
@@ -774,7 +775,7 @@ def _sse(handler, event, data):
|
||||
handler.wfile.flush()
|
||||
|
||||
|
||||
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None):
|
||||
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None, agent=None):
|
||||
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
|
||||
q = STREAMS.get(stream_id)
|
||||
if q is None:
|
||||
@@ -814,6 +815,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
s = get_session(session_id)
|
||||
s.workspace = str(Path(workspace).expanduser().resolve())
|
||||
s.model = model
|
||||
if agent:
|
||||
s.agent = agent
|
||||
|
||||
_agent_lock = _get_session_agent_lock(session_id)
|
||||
# TD1: set thread-local env context so concurrent sessions don't clobber globals
|
||||
@@ -1071,7 +1074,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
else:
|
||||
_fallback_resolved = None
|
||||
|
||||
agent = _AIAgent(
|
||||
_ai_agent = _AIAgent(
|
||||
model=resolved_model,
|
||||
provider=resolved_provider,
|
||||
base_url=resolved_base_url,
|
||||
@@ -1096,14 +1099,26 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
),
|
||||
)
|
||||
|
||||
# ── Per-agent identity: load soul.md + memory context ──────────────────
|
||||
if _ai_agent:
|
||||
_soul = _get_agent_soul(agent) # agent = selected agent_id string
|
||||
_mem_ctx = _get_agent_memory_context(agent, msg_text, limit=5)
|
||||
if _soul or _mem_ctx:
|
||||
_parts = []
|
||||
if _soul:
|
||||
_parts.append(f"=== AGENT IDENTITY: {agent.upper()} ===\n{_soul}")
|
||||
if _mem_ctx:
|
||||
_parts.append(f"=== PERTINENT MEMORY ===\n{_mem_ctx}")
|
||||
_ai_agent.ephemeral_system_prompt = "\n\n".join(_parts)
|
||||
|
||||
# Store agent instance for cancel/interrupt propagation
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES[stream_id] = agent
|
||||
AGENT_INSTANCES[stream_id] = _ai_agent
|
||||
# Check if cancel was requested during agent initialization
|
||||
if stream_id in CANCEL_FLAGS and CANCEL_FLAGS[stream_id].is_set():
|
||||
# Cancel arrived during agent creation - interrupt immediately
|
||||
try:
|
||||
agent.interrupt("Cancelled before start")
|
||||
_ai_agent.interrupt("Cancelled before start")
|
||||
except Exception:
|
||||
logger.debug("Failed to interrupt agent before start")
|
||||
put('cancel', {'message': 'Cancelled by user'})
|
||||
@@ -1143,9 +1158,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
_personality_prompt = str(_pval)
|
||||
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
|
||||
if _personality_prompt:
|
||||
agent.ephemeral_system_prompt = _personality_prompt
|
||||
_ai_agent.ephemeral_system_prompt = _personality_prompt
|
||||
_previous_messages = list(s.messages or [])
|
||||
result = agent.run_conversation(
|
||||
result = _ai_agent.run_conversation(
|
||||
user_message=workspace_ctx + msg_text,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(s.messages),
|
||||
|
||||
@@ -309,20 +309,39 @@ def safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def list_dir(workspace: Path, rel: str='.'):
|
||||
def list_dir(workspace: Path, rel: str='.', search: str=''):
|
||||
target = safe_resolve_ws(workspace, rel)
|
||||
if not target.is_dir():
|
||||
raise FileNotFoundError(f"Not a directory: {rel}")
|
||||
query = search.lower().strip()
|
||||
entries = []
|
||||
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': str(item.relative_to(workspace)),
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
})
|
||||
if len(entries) >= 200:
|
||||
break
|
||||
if query:
|
||||
# Recursive search
|
||||
try:
|
||||
for item in target.rglob('*'):
|
||||
if item.is_file():
|
||||
if query in item.name.lower():
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': str(item.relative_to(workspace)),
|
||||
'type': 'file',
|
||||
'size': item.stat().st_size,
|
||||
})
|
||||
if len(entries) >= 200:
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
entries.sort(key=lambda x: x['name'].lower())
|
||||
else:
|
||||
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'path': str(item.relative_to(workspace)),
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
})
|
||||
if len(entries) >= 200:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
|
||||
26
server.py
26
server.py
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j
|
||||
from api.routes import handle_get, handle_post
|
||||
from api.routes import handle_get, handle_post, handle_put, handle_delete
|
||||
from api.startup import auto_install_agent_deps, fix_credential_permissions
|
||||
|
||||
|
||||
@@ -84,6 +84,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
result = handle_put(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self._req_t0 = time.time()
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
result = handle_delete(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||
return j(self, {'error': 'Internal server error'}, status=500)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Load ~/.hermes/.env into os.environ so API keys are available
|
||||
|
||||
@@ -439,6 +439,7 @@ $('modelSelect').onchange=async()=>{
|
||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
||||
S.session.model=selectedModel;
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||
syncTopbar();
|
||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||
if(typeof _checkProviderMismatch==='function'){
|
||||
|
||||
@@ -229,7 +229,7 @@ const LOCALES = {
|
||||
tab_memory: 'Memory',
|
||||
tab_workspaces: 'Spaces',
|
||||
tab_profiles: 'Profiles',
|
||||
tab_todos: 'Todos',
|
||||
|
||||
new_conversation: 'New conversation',
|
||||
filter_conversations: 'Filter conversations...',
|
||||
session_time_unknown: 'Unknown',
|
||||
@@ -249,7 +249,7 @@ const LOCALES = {
|
||||
search_skills: 'Search skills...',
|
||||
new_skill: 'New skill',
|
||||
personal_memory: 'Personal memory',
|
||||
current_task_list: 'Current task list',
|
||||
|
||||
workspace_desc: 'Add and switch workspaces for your sessions.',
|
||||
new_profile: 'New profile',
|
||||
transcript: 'Transcript',
|
||||
@@ -403,7 +403,7 @@ const LOCALES = {
|
||||
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
|
||||
status_failed: 'failed',
|
||||
status_completed: 'completed',
|
||||
todos_no_active: 'No active task list in this session.',
|
||||
|
||||
clear_conversation_title: 'Clear conversation',
|
||||
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
||||
clear_failed: 'Clear failed: ',
|
||||
@@ -667,7 +667,7 @@ const LOCALES = {
|
||||
tab_memory: 'Память',
|
||||
tab_workspaces: 'Рабочие пространства',
|
||||
tab_profiles: 'Профили',
|
||||
tab_todos: 'Список дел',
|
||||
|
||||
new_conversation: 'Новая беседа',
|
||||
filter_conversations: 'Фильтр бесед...',
|
||||
session_time_unknown: 'Неизвестно',
|
||||
@@ -714,7 +714,7 @@ const LOCALES = {
|
||||
search_skills: 'Поиск навыков...',
|
||||
new_skill: 'Новый навык',
|
||||
personal_memory: 'Личная память',
|
||||
current_task_list: 'Текущий список задач',
|
||||
|
||||
workspace_desc: 'Добавляйте рабочие пространства и переключайтесь между ними в своих сеансах.',
|
||||
new_profile: 'Новый профиль',
|
||||
transcript: 'Транскрипт',
|
||||
@@ -864,7 +864,7 @@ const LOCALES = {
|
||||
cron_completion_status: (name, status) => `Cron-задание «${name}» — ${status}`,
|
||||
status_failed: 'неудачно',
|
||||
status_completed: 'завершено',
|
||||
todos_no_active: 'В этой сессии нет активного списка задач.',
|
||||
|
||||
clear_conversation_title: 'Очистить беседу',
|
||||
clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.',
|
||||
clear_failed: 'Не удалось очистить: ',
|
||||
@@ -1133,7 +1133,7 @@ const LOCALES = {
|
||||
tab_memory: 'Memoria',
|
||||
tab_workspaces: 'Espacios',
|
||||
tab_profiles: 'Perfiles',
|
||||
tab_todos: 'Todos',
|
||||
|
||||
new_conversation: 'Nueva conversación',
|
||||
filter_conversations: 'Filtrar conversaciones...',
|
||||
session_time_unknown: 'Desconocido',
|
||||
@@ -1153,7 +1153,7 @@ const LOCALES = {
|
||||
search_skills: 'Buscar skills...',
|
||||
new_skill: 'Nueva skill',
|
||||
personal_memory: 'Memoria personal',
|
||||
current_task_list: 'Lista de tareas actual',
|
||||
|
||||
workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.',
|
||||
new_profile: 'Nuevo perfil',
|
||||
transcript: 'Transcripción',
|
||||
@@ -1307,7 +1307,7 @@ const LOCALES = {
|
||||
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
|
||||
status_failed: 'failed',
|
||||
status_completed: 'completed',
|
||||
todos_no_active: 'No active task list in this session.',
|
||||
|
||||
clear_conversation_title: 'Clear conversation',
|
||||
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
||||
clear_failed: 'Clear failed: ',
|
||||
@@ -1580,7 +1580,7 @@ const LOCALES = {
|
||||
tab_memory: 'Gedächtnis',
|
||||
tab_workspaces: 'Spaces',
|
||||
tab_profiles: 'Profile',
|
||||
tab_todos: 'Todos',
|
||||
|
||||
new_conversation: 'Neuer Chat',
|
||||
filter_conversations: 'Chats filtern...',
|
||||
scheduled_jobs: 'Geplante Aufgaben',
|
||||
@@ -1589,7 +1589,7 @@ const LOCALES = {
|
||||
search_skills: 'Skills suchen...',
|
||||
new_skill: 'Neuer Skill',
|
||||
personal_memory: 'Persönliches Gedächtnis',
|
||||
current_task_list: 'Aktuelle Aufgabenliste',
|
||||
|
||||
workspace_desc: 'Workspaces hinzufügen und wechseln.',
|
||||
new_profile: 'Neues Profil',
|
||||
transcript: 'Protokoll',
|
||||
@@ -1797,7 +1797,7 @@ const LOCALES = {
|
||||
tab_memory: '记忆',
|
||||
tab_skills: '技能',
|
||||
tab_tasks: '任务',
|
||||
tab_todos: '待办',
|
||||
|
||||
tab_workspaces: '工作区',
|
||||
tab_profiles: '配置',
|
||||
new_conversation: '新建对话',
|
||||
@@ -1819,7 +1819,7 @@ const LOCALES = {
|
||||
new_skill: '新技能',
|
||||
save_skill: '保存技能',
|
||||
personal_memory: '个人记忆',
|
||||
current_task_list: '当前任务列表',
|
||||
|
||||
workspace_desc: '为你的会话添加并切换工作区。',
|
||||
new_profile: '新配置',
|
||||
transcript: '记录',
|
||||
@@ -1971,7 +1971,7 @@ const LOCALES = {
|
||||
cron_completion_status: (name, status) => `定时任务“${name}”${status}`,
|
||||
status_failed: '失败',
|
||||
status_completed: '完成',
|
||||
todos_no_active: '此会话暂无活动任务列表。',
|
||||
|
||||
clear_conversation_title: '清空对话',
|
||||
clear_conversation_message: '要清空所有消息吗?此操作无法撤销。',
|
||||
clear_failed: '清空失败:',
|
||||
@@ -2246,7 +2246,6 @@ const LOCALES = {
|
||||
new_skill: '\u65b0\u6280\u80fd',
|
||||
save_skill: '\u5132\u5b58\u6280\u80fd',
|
||||
personal_memory: '\u500b\u4eba\u8a18\u61b6',
|
||||
current_task_list: '\u76ee\u524d\u4efb\u52d9\u6e05\u55ae',
|
||||
new_profile: '\u65b0\u914d\u7f6e\u6a94',
|
||||
transcript: '\u8a18\u9304',
|
||||
download_transcript: '\u4e0b\u8f09\u8a18\u9304',
|
||||
|
||||
@@ -22,10 +22,12 @@
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
||||
<button class="nav-tab" data-panel="skills" data-label="Skills" onclick="switchPanel('skills')" title="Skills" data-i18n-title="tab_skills"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></button>
|
||||
<button class="nav-tab" data-panel="memory" data-label="Memory" onclick="switchPanel('memory')" title="Memory" data-i18n-title="tab_memory"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a7 7 0 0 1 7 7c0 2.5-1.3 4.7-3.2 6H8.2C6.3 13.7 5 11.5 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="17" x2="15" y2="17"/><line x1="10" y1="20" x2="14" y2="20"/></svg></button>
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<button class="nav-tab" data-panel="missioncontrol" data-label="MC" onclick="switchPanel('missioncontrol')" title="Mission Control" data-i18n-title="tab_mc"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></button>
|
||||
|
||||
|
||||
|
||||
<button class="nav-tab" data-panel="agents" data-label="Agents" onclick="switchPanel('agents')" title="Rose + Tier-2 Agents" data-i18n-title="tab_agents"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></button>
|
||||
|
||||
<button class="nav-tab" data-panel="projects" data-label="Projects" onclick="switchPanel('projects')" title="Projects & Tasks" data-i18n-title="tab_projects"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
</div>
|
||||
<!-- Chat panel -->
|
||||
<div class="panel-view active" id="panelChat">
|
||||
@@ -104,84 +106,8 @@
|
||||
<div id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Todo panel -->
|
||||
<div class="panel-view" id="panelTodos">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted);flex-shrink:0" data-i18n="current_task_list">Current task list</div>
|
||||
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
|
||||
</div>
|
||||
<!-- Mission Control panel -->
|
||||
<div class="panel-view" id="panelMissioncontrol">
|
||||
<!-- Header -->
|
||||
<div style="padding:14px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--border)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:16px">🎯</span>
|
||||
<span style="font-size:14px;font-weight:700;color:var(--text)">Mission Control</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span id="mcHealthBadge" style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:12px;background:rgba(0,0,0,.06)"></span>
|
||||
<button onclick="refreshMC()" title="Refresh" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:12px;padding:2px">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px 16px;flex-shrink:0">
|
||||
<div style="background:var(--surface);border-radius:10px;padding:10px 12px;border:1px solid var(--border)">
|
||||
<div style="font-size:10px;color:var(--muted);margin-bottom:2px">Tasks</div>
|
||||
<div style="font-size:18px;font-weight:700;color:var(--blue)" id="mcTasksCount">–</div>
|
||||
<div style="font-size:9px;color:var(--muted)" id="mcTasksLabel">loading...</div>
|
||||
</div>
|
||||
<div style="background:var(--surface);border-radius:10px;padding:10px 12px;border:1px solid var(--border)">
|
||||
<div style="font-size:10px;color:var(--muted);margin-bottom:2px">Priorities</div>
|
||||
<div style="font-size:18px;font-weight:700;color:var(--gold)" id="mcPrioritiesCount">–</div>
|
||||
<div style="font-size:9px;color:var(--muted)" id="mcPrioritiesLabel">loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div style="padding:0 16px 10px;flex-shrink:0">
|
||||
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--muted);margin-bottom:4px">
|
||||
<span>Progress</span><span id="mcProgressPct">0%</span>
|
||||
</div>
|
||||
<div style="background:var(--border);border-radius:6px;height:8px;overflow:hidden">
|
||||
<div id="mcProgressBar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--blue),var(--accent));border-radius:6px;transition:width .4s ease"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Task -->
|
||||
<div style="padding:0 16px 10px;flex-shrink:0">
|
||||
<div style="font-size:10px;font-weight:600;color:var(--muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em">New Task</div>
|
||||
<input id="mcNewTaskTitle" placeholder="What needs to be done?" style="width:100%;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:12px;color:var(--text);outline:none;margin-bottom:6px" onkeydown="if(event.key==='Enter')createMCTask()">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 80px;gap:6px">
|
||||
<select id="mcNewTaskPriority" style="background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:11px;color:var(--text);outline:none">
|
||||
<option value="1">🔴 Critical</option>
|
||||
<option value="2" selected>🟠 High</option>
|
||||
<option value="3">🟡 Medium</option>
|
||||
<option value="4">🟢 Low</option>
|
||||
</select>
|
||||
<select id="mcNewTaskStatus" style="background:var(--input-bg);border:1px solid var(--border);border-radius:8px;padding:6px 8px;font-size:11px;color:var(--text);outline:none">
|
||||
<option value="backlog" selected>○ Backlog</option>
|
||||
<option value="progress">◐ In Progress</option>
|
||||
</select>
|
||||
<button onclick="createMCTask()" style="background:var(--accent);border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:600;color:#fff;cursor:pointer">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Filter -->
|
||||
<div style="padding:0 16px 6px;flex-shrink:0">
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap" id="mcPriorityFilters"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks List -->
|
||||
<div style="flex:1;overflow-y:auto;padding:0 16px" id="mcTasksList"></div>
|
||||
|
||||
<!-- Feed -->
|
||||
<div style="padding:8px 16px 4px;border-top:1px solid var(--border);flex-shrink:0">
|
||||
<div style="font-size:10px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">Recent Activity</div>
|
||||
</div>
|
||||
<div id="mcFeed" style="flex-shrink:0;max-height:100px;overflow-y:auto;padding:0 16px 12px;font-size:10px;color:var(--muted)"></div>
|
||||
</div>
|
||||
<!-- Agents panel (Rose + Tier-2) -->
|
||||
<div class="panel-view" id="panelAgents">
|
||||
<div style="padding:14px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--border)">
|
||||
@@ -194,17 +120,103 @@
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">Rose + 7 Tier-2 Domain Agents</div>
|
||||
</div>
|
||||
<!-- Agents list -->
|
||||
<!-- agents list -->
|
||||
<div style="flex:1;overflow-y:auto;padding:10px 16px" id="agentsList">
|
||||
<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px">Loading...</div>
|
||||
</div>
|
||||
<!-- Agent inbox slide-in panel -->
|
||||
<div id="agentInbox" style="display:none;position:absolute;top:0;right:0;bottom:0;width:320px;background:var(--surface);border-left:1px solid var(--border);z-index:100;overflow-y:auto;padding:16px;box-shadow:-4px 0 20px rgba(0,0,0,.3)"></div>
|
||||
</div>
|
||||
<!-- Workspaces panel -->
|
||||
<div class="panel-view" id="panelWorkspaces">
|
||||
<div style="padding:10px 12px 4px;font-size:11px;color:var(--muted)" data-i18n="workspace_desc">Add and switch workspaces for your sessions.</div>
|
||||
<div style="flex:1;overflow-y:auto;padding:0 12px 12px" id="workspacesPanel"><div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
|
||||
<!-- Projects panel -->
|
||||
<div class="panel-view" id="panelProjects">
|
||||
<!-- Header: Title + Expand Button -->
|
||||
<div class="projects-header">
|
||||
<div class="projects-title">📋 Projects</div>
|
||||
<div class="projects-header-stats" id="projectsHeaderStats"></div>
|
||||
<button class="panel-icon-btn" id="btnExpandProjects" title="Expand" onclick="expandPanel('projects')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Add Bar -->
|
||||
<div class="projects-quick-add">
|
||||
<span class="quick-add-icon">+</span>
|
||||
<input id="quickAddInput" placeholder="Add a task..." onkeydown="if(event.key==='Enter')quickAddTask()">
|
||||
<input id="quickAddDue" type="date" title="Due date" id="quickAddDue">
|
||||
<select id="quickAddType">
|
||||
<option value="project">📁 Project</option>
|
||||
<option value="daily">📅 Daily</option>
|
||||
<option value="recurring">🔄 Recurring</option>
|
||||
</select>
|
||||
<button class="quick-add-btn" onclick="quickAddTask()">Add</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="projects-filter-bar" id="projectsFilterBar">
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn active" data-filter="all" onclick="filterTasks('all')">All</button>
|
||||
<button class="filter-btn" data-filter="project" onclick="filterTasks('project')">📁 Projects</button>
|
||||
<button class="filter-btn" data-filter="daily" onclick="filterTasks('daily')">📅 Daily</button>
|
||||
<button class="filter-btn" data-filter="recurring" onclick="filterTasks('recurring')">🔄 Recurring</button>
|
||||
</div>
|
||||
<div class="filter-sep"></div>
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn p1" onclick="filterTasks('p1')">🔴 P1</button>
|
||||
<button class="filter-btn p2" onclick="filterTasks('p2')">🟡 P2</button>
|
||||
<button class="filter-btn p3" onclick="filterTasks('p3')">🟢 P3</button>
|
||||
</div>
|
||||
<div class="filter-sep"></div>
|
||||
<div class="filter-group filter-group-right">
|
||||
<span class="filter-streak" id="filterStreak"></span>
|
||||
<span class="filter-overdue" id="filterOverdue"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content: Split View -->
|
||||
<div class="projects-main" id="projectsMain">
|
||||
<!-- Linke Spalte: Projektliste -->
|
||||
<div class="projects-sidebar" id="projectsSidebar">
|
||||
<div class="sidebar-section-header">📁 Projects</div>
|
||||
<div id="projectsList"></div>
|
||||
<div class="sidebar-section-header" style="margin-top:16px">📅 Daily Tasks</div>
|
||||
<div id="dailyTasksList"></div>
|
||||
<div class="sidebar-section-header" style="margin-top:16px">🔄 Recurring</div>
|
||||
<div id="recurringTasksList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Globales Kanban -->
|
||||
<div class="projects-kanban" id="projectsKanban">
|
||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'todo')">
|
||||
<div class="kanban-col-header">
|
||||
<span class="kanban-col-title">📋 TODO</span>
|
||||
<span class="kanban-col-count" id="kanbanTodoCount"></span>
|
||||
</div>
|
||||
<div class="kanban-col-content" id="kanbanTodoContent"></div>
|
||||
</div>
|
||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'in_progress')">
|
||||
<div class="kanban-col-header">
|
||||
<span class="kanban-col-title">⚡ IN PROGRESS</span>
|
||||
<span class="kanban-col-count" id="kanbanInProgressCount"></span>
|
||||
</div>
|
||||
<div class="kanban-col-content" id="kanbanInProgressContent"></div>
|
||||
</div>
|
||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'review')">
|
||||
<div class="kanban-col-header">
|
||||
<span class="kanban-col-title">👀 REVIEW</span>
|
||||
<span class="kanban-col-count" id="kanbanReviewCount"></span>
|
||||
</div>
|
||||
<div class="kanban-col-content" id="kanbanReviewContent"></div>
|
||||
</div>
|
||||
<div class="kanban-col" ondragover="onKanbanDragOver(event)" ondrop="onKanbanDrop(event, 'done')">
|
||||
<div class="kanban-col-header">
|
||||
<span class="kanban-col-title">✅ DONE</span>
|
||||
<span class="kanban-col-count" id="kanbanDoneCount"></span>
|
||||
</div>
|
||||
<div class="kanban-col-content" id="kanbanDoneContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
@@ -242,6 +254,14 @@
|
||||
</button>
|
||||
<div style="flex:1;min-width:0;overflow:hidden"><div class="topbar-title" id="topbarTitle">Hermes</div><div class="topbar-meta" id="topbarMeta" data-i18n="new_conversation">Start a new conversation</div></div>
|
||||
<div class="topbar-chips">
|
||||
<div class="ws-selector-wrap" id="wsSelectorWrap" style="position:relative">
|
||||
<button class="chip ws-selector-chip" id="wsSelectorChip" type="button" onclick="toggleWsSelectorDropdown()" title="Switch workspace">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span id="wsSelectorLabel">Workspace</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="ws-selector-dropdown" id="wsSelectorDropdown" style="display:none;position:absolute;top:100%;left:0;margin-top:4px;background:var(--surface);border:1px solid var(--border);border-radius:10px;min-width:260px;max-height:320px;overflow-y:auto;z-index:200;box-shadow:0 8px 24px rgba(0,0,0,.3)"></div>
|
||||
</div>
|
||||
<button class="chip workspace-toggle-btn" id="btnWorkspacePanelToggle" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span class="workspace-toggle-label">Files</span></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,21 +381,6 @@
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="composer-divider" aria-hidden="true"></div>
|
||||
<div id="profileChipWrap" class="composer-profile-wrap">
|
||||
<button class="composer-profile-chip profile-chip" id="profileChip" type="button" onclick="toggleProfileDropdown()" title="Switch profile">
|
||||
<span class="composer-profile-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
|
||||
<span class="composer-profile-label" id="profileChipLabel">default</span>
|
||||
<span class="composer-profile-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-ws-wrap">
|
||||
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
|
||||
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="composer-workspace-label" id="composerWorkspaceLabel">Workspace</span>
|
||||
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-model-wrap">
|
||||
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
|
||||
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
|
||||
@@ -401,6 +406,23 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="composer-agent-wrap">
|
||||
<button class="composer-agent-chip" id="composerAgentChip" type="button" onclick="toggleAgentDropdown()" title="Chat with agent">
|
||||
<span class="composer-agent-icon" id="composerAgentIcon" aria-hidden="true">🌹</span>
|
||||
<span class="composer-agent-label" id="composerAgentLabel">Rose</span>
|
||||
<span class="composer-agent-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
<select id="agentSelect" class="composer-agent-select" title="Chat with agent" aria-hidden="true" tabindex="-1">
|
||||
<option value="rose">🌹 Rose</option>
|
||||
<option value="lotus">🪷 Lotus</option>
|
||||
<option value="forget-me-not">🌼 Forget-me-not</option>
|
||||
<option value="sunflower">🌻 Sunflower</option>
|
||||
<option value="iris">⚜️ Iris</option>
|
||||
<option value="ivy">🌿 Ivy</option>
|
||||
<option value="dandelion">🛡️ Dandelion</option>
|
||||
<option value="root">🌳 Root</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer-right">
|
||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
||||
@@ -432,6 +454,7 @@
|
||||
<div class="profile-dropdown" id="profileDropdown"></div>
|
||||
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
||||
<div class="model-dropdown" id="composerModelDropdown"></div>
|
||||
<div class="agent-dropdown" id="composerAgentDropdown"></div>
|
||||
</div>
|
||||
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
||||
</div>
|
||||
@@ -473,6 +496,72 @@
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Task Detail Modal -->
|
||||
<div id="taskDetailModal" class="modal-overlay" style="display:none" onclick="if(event.target===this)closeTaskModal()">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title" id="taskModalTitle"></div>
|
||||
<button class="modal-close" onclick="closeTaskModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label>Title</label>
|
||||
<input id="taskModalInputTitle" class="modal-input" type="text" placeholder="Task title...">
|
||||
</div>
|
||||
<div class="modal-field-row">
|
||||
<div class="modal-field">
|
||||
<label>Status</label>
|
||||
<select id="taskModalSelectStatus" class="modal-select">
|
||||
<option value="todo">Todo</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Priority</label>
|
||||
<select id="taskModalSelectPrio" class="modal-select">
|
||||
<option value="p1">🔴 P1 — Critical</option>
|
||||
<option value="p2">🟡 P2 — Normal</option>
|
||||
<option value="p3">🟢 P3 — Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field-row">
|
||||
<div class="modal-field">
|
||||
<label>Due Date</label>
|
||||
<input id="taskModalInputDue" class="modal-input" type="date">
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Owner</label>
|
||||
<select id="taskModalSelectOwner" class="modal-select">
|
||||
<option value="user">👤 Sabo</option>
|
||||
<option value="rose">🌹 Rose</option>
|
||||
<option value="agent:lotus">🪷 Lotus</option>
|
||||
<option value="agent:sunflower">🌻 Sunflower</option>
|
||||
<option value="agent:iris">⚜️ Iris</option>
|
||||
<option value="agent:ivy">🌿 Ivy</option>
|
||||
<option value="agent:dandelion">🛡️ Dandelion</option>
|
||||
<option value="agent:root">🌳 Root</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input id="taskModalInputTags" class="modal-input" type="text" placeholder="webui, api, bug...">
|
||||
</div>
|
||||
<div class="modal-meta" id="taskModalMeta"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-btn-danger" id="taskModalDeleteBtn" onclick="deleteTaskFromModal()">Delete</button>
|
||||
<div style="flex:1"></div>
|
||||
<button class="modal-btn-secondary" onclick="closeTaskModal()">Cancel</button>
|
||||
<button class="modal-btn-primary" onclick="saveTaskFromModal()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="onboarding-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
||||
<div class="onboarding-card">
|
||||
<div class="onboarding-shell">
|
||||
@@ -527,6 +616,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="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>
|
||||
<button class="settings-tab" id="settingsTabHeartbeats" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneHeartbeats" onclick="switchSettingsSection('heartbeats')">
|
||||
<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="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||
<span class="settings-tab-title">Heartbeats</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="settings-main">
|
||||
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
|
||||
@@ -689,19 +782,30 @@
|
||||
<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>
|
||||
<button class="icon-btn" id="logsRefreshBtn" style="display:none" onclick="loadLogsPanel()" title="Refresh logs">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
</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 class="logs-body" id="logsBody">
|
||||
<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center" id="logsEmptyState">Select a log file from the sidebar to view its contents.</div>
|
||||
<div id="logsContent" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-pane" id="settingsPaneHeartbeats" role="tabpanel" aria-labelledby="settingsTabHeartbeats">
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title">Heartbeats</div>
|
||||
<div class="settings-section-meta">Proaktive zeitbasierte Callbacks für Rose.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="heartbeatsPanelContent" style="padding:16px">
|
||||
<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px">Lädt...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -733,5 +837,93 @@
|
||||
<script src="/static/panels.js"></script>
|
||||
<script src="/static/onboarding.js"></script>
|
||||
<script src="/static/boot.js"></script>
|
||||
<!-- Task Edit Modal -->
|
||||
<div id="taskEditModal" class="modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:2000;justify-content:center;align-items:center">
|
||||
<div style="background:var(--surface);border:1px solid var(--border2);border-radius:12px;padding:24px;width:420px;max-width:90vw">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<span style="font-weight:600;font-size:15px;color:var(--text)">Edit Task</span>
|
||||
<button onclick="closeTaskEditModal()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;padding:4px">✕</button>
|
||||
</div>
|
||||
<input id="editTaskTitle" style="width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-size:14px;box-sizing:border-box;margin-bottom:12px;outline:none">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">TYPE</label>
|
||||
<select id="editTaskType" onchange="onEditTypeChange()" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px;outline:none">
|
||||
<option value="project">Project</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="recurring">Recurring</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">PRIORITY</label>
|
||||
<select id="editTaskPriority" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px;outline:none">
|
||||
<option value="p1">🔴 P1 — Critical</option>
|
||||
<option value="p2">🟡 P2 — High</option>
|
||||
<option value="p3">🟢 P3 — Normal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">PROJECT</label>
|
||||
<select id="editTaskProject" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px;outline:none">
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">DUE DATE</label>
|
||||
<input id="editTaskDue" type="date" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px;outline:none;box-sizing:border-box;color-scheme:dark">
|
||||
</div>
|
||||
</div>
|
||||
<div id="editTaskRecurringOpts" style="display:none;margin-bottom:12px">
|
||||
<label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">RECURRING — EVERY</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input id="editRecInterval" type="number" min="1" value="1" style="width:60px;padding:8px;background:var(--bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px;text-align:center;outline:none">
|
||||
<select id="editRecUnit" style="padding:8px;background:var(--bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);font-size:13px;outline:none">
|
||||
<option value="days">day(s)</option>
|
||||
<option value="weeks">week(s)</option>
|
||||
<option value="months">month(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:space-between;margin-top:16px">
|
||||
<button onclick="deleteTask(editingTaskId)" style="padding:8px 16px;background:rgba(239,68,68,.15);border:1px solid rgba(239,68,68,.3);border-radius:8px;color:#ef4444;cursor:pointer;font-size:13px">🗑 Delete</button>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button onclick="closeTaskEditModal()" style="padding:8px 16px;background:var(--bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);cursor:pointer;font-size:13px">Cancel</button>
|
||||
<button onclick="saveTaskEdit()" style="padding:8px 16px;background:var(--accent);border:none;border-radius:8px;color:#fff;cursor:pointer;font-size:13px;font-weight:500">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Edit Modal -->
|
||||
<div id="projectEditModal" class="modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:2000;justify-content:center;align-items:center">
|
||||
<div style="background:var(--surface);border:1px solid var(--border2);border-radius:12px;padding:24px;width:400px;max-width:90vw">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<span style="font-weight:600;font-size:15px;color:var(--text)">Edit Project</span>
|
||||
<button onclick="closeProjectEditModal()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;padding:4px">✕</button>
|
||||
</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">NAME</label>
|
||||
<input id="editProjectName" style="width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);font-size:14px;box-sizing:border-box;outline:none">
|
||||
</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">COLOR</label>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="color-dot selected" data-color="#6366f1" onclick="selectProjectColor(this)" style="width:24px;height:24px;border-radius:50%;background:#6366f1;border:2px solid transparent;cursor:pointer"></button>
|
||||
<button class="color-dot" data-color="#f59e0b" onclick="selectProjectColor(this)" style="width:24px;height:24px;border-radius:50%;background:#f59e0b;border:2px solid transparent;cursor:pointer"></button>
|
||||
<button class="color-dot" data-color="#10b981" onclick="selectProjectColor(this)" style="width:24px;height:24px;border-radius:50%;background:#10b981;border:2px solid transparent;cursor:pointer"></button>
|
||||
<button class="color-dot" data-color="#ef4444" onclick="selectProjectColor(this)" style="width:24px;height:24px;border-radius:50%;background:#ef4444;border:2px solid transparent;cursor:pointer"></button>
|
||||
<button class="color-dot" data-color="#8b5cf6" onclick="selectProjectColor(this)" style="width:24px;height:24px;border-radius:50%;background:#8b5cf6;border:2px solid transparent;cursor:pointer"></button>
|
||||
<button class="color-dot" data-color="#ec4899" onclick="selectProjectColor(this)" style="width:24px;height:24px;border-radius:50%;background:#ec4899;border:2px solid transparent;cursor:pointer"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
|
||||
<button onclick="closeProjectEditModal()" style="padding:8px 16px;background:var(--bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);cursor:pointer;font-size:13px">Cancel</button>
|
||||
<button onclick="saveProjectEdit()" style="padding:8px 16px;background:var(--accent);border:none;border-radius:8px;color:#fff;cursor:pointer;font-size:13px;font-weight:500">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -71,7 +71,9 @@ async function send(){
|
||||
try{
|
||||
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
|
||||
session_id:activeSid,message:msgText,
|
||||
model:S.session.model||$('modelSelect').value,workspace:S.session.workspace,
|
||||
model:S.session.model||$('modelSelect').value,
|
||||
agent:S.session.agent||$('agentSelect').value,
|
||||
workspace:S.session.workspace,
|
||||
attachments:uploaded.length?uploaded:undefined
|
||||
})});
|
||||
streamId=startData.stream_id;
|
||||
|
||||
1030
static/panels.js
1030
static/panels.js
File diff suppressed because it is too large
Load Diff
865
static/style.css
865
static/style.css
@@ -347,25 +347,41 @@
|
||||
.cron-list{flex:1;overflow-y:auto;padding:8px;}
|
||||
.cron-item{border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);}
|
||||
.cron-item:hover{border-color:var(--border2);}
|
||||
.cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;}
|
||||
.cron-header{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;}
|
||||
.cron-left{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px;}
|
||||
.cron-name{flex:1;font-size:13px;color:var(--text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.cron-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;flex-shrink:0;}
|
||||
.cron-meta{display:flex;gap:6px;align-items:center;}
|
||||
.cron-badge-sched{font-size:10px;font-weight:600;background:rgba(124,185,255,.12);color:var(--blue);padding:1px 7px;border-radius:99px;border:1px solid rgba(124,185,255,.25);white-space:nowrap;}
|
||||
.cron-right{display:flex;align-items:center;gap:10px;flex-shrink:0;}
|
||||
.cron-next-time{font-size:12px;font-weight:700;color:var(--text);white-space:nowrap;}
|
||||
.cron-last-time{font-size:11px;color:var(--muted);white-space:nowrap;}
|
||||
.cron-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;flex-shrink:0;letter-spacing:.04em;}
|
||||
.cron-status.active{background:rgba(34,197,94,.15);color:#4ade80;}
|
||||
.cron-status.paused{background:rgba(201,168,76,.15);color:var(--gold);}
|
||||
.cron-status.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
|
||||
.cron-status.error{background:rgba(233,69,96,.15);color:var(--accent);}
|
||||
.cron-body{display:none;padding:0 12px 10px;border-top:1px solid var(--border);overflow:hidden;}
|
||||
.cron-body.open{display:block;}
|
||||
.cron-detail-grid{display:flex;flex-direction:column;gap:4px;margin:8px 0 6px;padding:8px;background:rgba(255,255,255,.03);border-radius:8px;border:1px solid var(--border);}
|
||||
.cron-detail-row{display:flex;justify-content:space-between;align-items:center;font-size:11px;}
|
||||
.cron-detail-label{color:var(--muted);font-weight:500;}
|
||||
.cron-detail-value{color:var(--text);font-weight:600;}
|
||||
.cron-next-val{color:var(--blue);}
|
||||
.cron-prompt-preview{display:flex;align-items:baseline;gap:8px;margin-bottom:8px;font-size:11px;}
|
||||
.cron-prompt-label{color:var(--muted);font-weight:500;flex-shrink:0;}
|
||||
.cron-prompt-text{color:var(--muted);line-height:1.4;overflow:hidden;text-overflow:ellipsis;}
|
||||
.cron-schedule{font-size:11px;color:var(--muted);margin:8px 0 6px;}
|
||||
.cron-prompt{font-size:11px;color:var(--muted);line-height:1.55;max-height:80px;overflow-y:auto;background:rgba(0,0,0,.2);padding:6px 8px;border-radius:6px;white-space:pre-wrap;margin-bottom:8px;box-sizing:border-box;}
|
||||
.cron-actions{display:flex;gap:6px;margin-bottom:8px;}
|
||||
.cron-actions{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap;}
|
||||
.cron-btn{padding:4px 10px;border-radius:6px;font-size:11px;font-weight:600;border:1px solid var(--border2);background:rgba(255,255,255,.05);color:var(--muted);cursor:pointer;transition:all .15s;}
|
||||
.cron-btn:hover{background:rgba(255,255,255,.1);color:var(--text);}
|
||||
.cron-btn.run{border-color:rgba(124,185,255,.4);color:var(--blue);}
|
||||
.cron-btn.run:hover{background:rgba(124,185,255,.12);}
|
||||
.cron-btn.pause{border-color:rgba(201,168,76,.4);color:var(--gold);}
|
||||
.cron-btn.danger{border-color:rgba(233,69,96,.3);color:var(--accent);}
|
||||
.cron-btn.danger:hover{background:rgba(233,69,96,.12);}
|
||||
.cron-last{font-size:11px;color:var(--muted);border-top:1px solid var(--border);padding-top:8px;max-height:220px;overflow-y:auto;white-space:pre-wrap;line-height:1.5;word-break:break-word;}
|
||||
.cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;}
|
||||
.cron-last-header{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;}
|
||||
.cron-schedule-inline{font-size:11px;color:var(--text);opacity:.6;flex-shrink:0;white-space:nowrap;margin-left:auto;}
|
||||
.cron-last-inline{font-size:10px;color:var(--muted);opacity:.7;flex-shrink:0;white-space:nowrap;margin-right:4px;}
|
||||
/* YAML / code syntax highlighting classes */
|
||||
@@ -559,7 +575,28 @@
|
||||
.composer-model-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.composer-model-icon,.composer-model-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
|
||||
.composer-model-select{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;pointer-events:none!important;}
|
||||
.composer-right{display:flex;gap:8px;align-items:center;flex-shrink:0;}
|
||||
/* Agent selector chip */
|
||||
.composer-agent-wrap{position:relative;flex:0 1 auto;min-width:0;}
|
||||
.composer-agent-chip{display:inline-flex;align-items:center;gap:6px;max-width:160px;padding:8px 10px 8px 10px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
|
||||
.composer-agent-chip:hover{color:var(--text);background-color:var(--hover-bg);}
|
||||
.composer-agent-chip.active{color:var(--text);background:rgba(124,185,255,.08);border-color:rgba(124,185,255,.22);}
|
||||
.composer-agent-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.composer-agent-icon,.composer-agent-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
|
||||
.composer-agent-select{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;pointer-events:none!important;}
|
||||
.agent-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:220px;max-width:min(380px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.agent-dropdown.open{display:block;}
|
||||
.agent-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;}
|
||||
.agent-opt:hover{background:rgba(255,255,255,.07);}
|
||||
.agent-opt.active{background:rgba(124,185,255,.1);}
|
||||
.agent-opt-name{display:block;font-size:13px;color:var(--text);font-weight:500;line-height:1.25;}
|
||||
.agent-opt-domain{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;}
|
||||
/* Typing indicator */
|
||||
.typing-indicator{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,.06);font-size:11px;color:var(--muted);}
|
||||
.typing-dots{display:flex;gap:3px;}
|
||||
.typing-dot{width:5px;height:5px;border-radius:50%;background:var(--muted);animation:typingPulse .9s ease-in-out infinite;}
|
||||
.typing-dot:nth-child(2){animation-delay:.15s;}
|
||||
.typing-dot:nth-child(3){animation-delay:.3s;}
|
||||
@keyframes typingPulse{0%,60%,100%{opacity:.3;transform:scale(.8);}30%{opacity:1;transform:scale(1);}}
|
||||
.composer-status{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px;}
|
||||
/* Context usage indicator */
|
||||
.ctx-indicator-wrap{position:relative;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;}
|
||||
@@ -1714,3 +1751,821 @@ mark.log-highlight{background:rgba(255,220,50,.35);color:inherit;border-radius:2
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Heartbeats Panel ─────────────────────────────────────────────────────── */
|
||||
|
||||
#heartbeatsPanelContent {
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.heartbeats-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hb-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hb-stat b { color: var(--text); }
|
||||
|
||||
.hb-manager-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hb-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hb-dot-ok { background: #22c55e; }
|
||||
.hb-dot-dead { background: #ef4444; }
|
||||
|
||||
.hb-stat-warn b { color: #f59e0b; }
|
||||
|
||||
.hb-quick-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-hb-action {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-hb-action:hover { background: var(--surface-3); }
|
||||
|
||||
.hb-create-section {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hb-create-section h4,
|
||||
.hb-list-section h4 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.hb-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hb-input {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hb-input:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
|
||||
|
||||
.hb-select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hb-form-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hb-mini { width: 70px !important; }
|
||||
.hb-agent-select { flex: 1; min-width: 160px; }
|
||||
.hb-checkbox { font-size: 11px; color: var(--muted); cursor: pointer; }
|
||||
|
||||
.hb-list-section {
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hb-empty {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hb-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hb-item-pending { border-left: 3px solid #f59e0b; }
|
||||
.hb-item-fired { border-left: 3px solid var(--accent); opacity: 0.6; }
|
||||
.hb-item-cancelled { border-left: 3px solid #6b7280; opacity: 0.5; }
|
||||
|
||||
.hb-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hb-action-badge {
|
||||
font-size: 10px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hb-source {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hb-priority {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-3);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hb-due {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hb-iter {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hb-item-sub {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hb-cancel-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hb-cancel-btn:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Projects Panel ────────────────────────────────────────────────────────── */
|
||||
|
||||
#panelProjects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projects-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.projects-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.projects-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.projects-quick-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide Projects panel completely unless it is the active panel */
|
||||
.panel-view#panelProjects:not(.active) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure only the active panel is visible in sidebar */
|
||||
.sidebar .panel-view {
|
||||
display: none;
|
||||
}
|
||||
.sidebar .panel-view.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.projects-quick-add input {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,.05);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.projects-quick-add input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.projects-quick-add select {
|
||||
background: rgba(255,255,255,.05);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.projects-filter-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.projects-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projects-sidebar {
|
||||
width: 240px;
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 4px 4px;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: rgba(255,255,255,.05);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-task-count {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: rgba(255,255,255,.05);
|
||||
}
|
||||
|
||||
.task-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.task-check:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.task-title {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.task-title.done {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.task-prio {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.task-tag {
|
||||
font-size: 9px;
|
||||
background: rgba(255,255,255,.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.projects-kanban {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.kanban-col {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(255,255,255,.03);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kanban-col-header {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 8px 10px;
|
||||
letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kanban-col-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
.kanban-card-header {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.kanban-project {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kanban-card-footer {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.kanban-due {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kanban-tag {
|
||||
font-size: 9px;
|
||||
background: rgba(255,255,255,.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Expanded Mode - reusable for all panels */
|
||||
.panel-view.panel-expanded {
|
||||
display: flex !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
z-index: 1000 !important;
|
||||
background: var(--bg) !important;
|
||||
}
|
||||
|
||||
/* ── Task Detail Modal ─────────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
.modal-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-field label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.modal-field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-input,
|
||||
.modal-select {
|
||||
background: rgba(255,255,255,.05);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-input:focus,
|
||||
.modal-select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-select option {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-btn-primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--bg);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-btn-primary:hover { opacity: .9; }
|
||||
|
||||
.modal-btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-btn-secondary:hover {
|
||||
border-color: var(--muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 6px;
|
||||
color: #ef4444;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-btn-danger:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Task Owner Badge ───────────────────────────────────────────────────────── */
|
||||
.task-owner {
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Projects Sidebar Section Headers ─────────────────────────────────────── */
|
||||
.sidebar-section-header {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: 4px 4px;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
/* ── Task Item: clicking opens modal ───────────────────────────────────────── */
|
||||
.task-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Due Date Colors ───────────────────────────────────────────────────────── */
|
||||
.task-due {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.task-due.overdue {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Quick Add subtle styling ───────────────────────────────────────────────── */
|
||||
.projects-quick-add input::placeholder {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Stats Grid ────────────────────────────────────────────────────────────── */
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid var(--border2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.stat-overdue {
|
||||
border-color: #ef444450;
|
||||
background: rgba(239,68,68,.08);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
|
||||
/* ── Due Date in Kanban ───────────────────────────────────────────────────── */
|
||||
.kanban-due {
|
||||
font-size: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kanban-due.overdue {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Owner Badge ──────────────────────────────────────────────────────────── */
|
||||
.owner-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,.1);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.owner-badge.rose { background: rgba(251,113,133,.15); color: #fb7185; }
|
||||
.owner-badge.agent { background: rgba(132,204,22,.15); color: #a3e635; }
|
||||
.owner-badge.sabo, .owner-badge.user { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||
|
||||
111
static/ui.js
111
static/ui.js
@@ -91,6 +91,7 @@ async function populateModelDropdown(){
|
||||
_applyModelToDropdown(data.default_model, sel);
|
||||
}
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||
// Kick off a background live-model fetch for the active provider.
|
||||
// This runs after the static list is already shown (no blocking flicker).
|
||||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
|
||||
@@ -98,6 +99,7 @@ async function populateModelDropdown(){
|
||||
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||
console.warn('Failed to load models from server:',e.message);
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +150,7 @@ async function _fetchLiveModels(provider, sel){
|
||||
// Restore selection
|
||||
if(currentVal) _applyModelToDropdown(currentVal, sel);
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
|
||||
}
|
||||
}catch(e){
|
||||
@@ -315,6 +318,108 @@ window.addEventListener('resize',()=>{
|
||||
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
|
||||
});
|
||||
|
||||
// ── Agent selector dropdown ─────────────────────────────────────────────────
|
||||
|
||||
const AGENT_META = {
|
||||
rose: {emoji:'🌹', name:'Rose', domain:'Orchestrator & Main Interface'},
|
||||
lotus: {emoji:'🪷', name:'Lotus', domain:'Health, Fitness & Recovery'},
|
||||
'forget-me-not':{emoji:'🌼', name:'Forget-me-not', domain:'Calendar, Time & Social'},
|
||||
sunflower: {emoji:'🌻', name:'Sunflower', domain:'Finance, Wealth & Subscriptions'},
|
||||
iris: {emoji:'⚜️', name:'Iris', domain:'Career, Learning & Focus'},
|
||||
ivy: {emoji:'🌿', name:'Ivy', domain:'Smart Home & Environment'},
|
||||
dandelion: {emoji:'🛡️', name:'Dandelion', domain:'Communication Triage'},
|
||||
root: {emoji:'🌳', name:'Root', domain:'DevOps, Logs & System Health'},
|
||||
};
|
||||
|
||||
function renderAgentDropdown(){
|
||||
const dd=$('composerAgentDropdown');
|
||||
const sel=$('agentSelect');
|
||||
if(!dd||!sel) return;
|
||||
const current=sel.value;
|
||||
const groups={'Tier-1':['rose'],'Tier-2':['lotus','forget-me-not','sunflower','iris','ivy','dandelion','root']};
|
||||
let html='';
|
||||
for(const [grp,ids] of Object.entries(groups)){
|
||||
html+=`<div class="model-group">${grp}</div>`;
|
||||
for(const id of ids){
|
||||
const m=AGENT_META[id];
|
||||
const active=id===current?' active':'';
|
||||
html+=`<div class="agent-opt${active}" onclick="selectAgentFromDropdown('${id}')">
|
||||
<span class="agent-opt-name">${m.emoji} ${m.name}</span>
|
||||
<span class="agent-opt-domain">${m.domain}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
dd.innerHTML=html;
|
||||
}
|
||||
|
||||
function toggleAgentDropdown(){
|
||||
const dd=$('composerAgentDropdown');
|
||||
const chip=$('composerAgentChip');
|
||||
if(!dd||!chip) return;
|
||||
if(dd.classList.contains('open')){
|
||||
dd.classList.remove('open');
|
||||
chip.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
closeModelDropdown();
|
||||
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
|
||||
if(typeof closeWsDropdown==='function') closeWsDropdown();
|
||||
renderAgentDropdown();
|
||||
dd.classList.add('open');
|
||||
chip.classList.add('active');
|
||||
// position below chip
|
||||
const chipRect=chip.getBoundingClientRect();
|
||||
const wrap=chip.closest('.composer-agent-wrap');
|
||||
const wrapRect=wrap.getBoundingClientRect();
|
||||
dd.style.left=(chipRect.left-wrapRect.left)+'px';
|
||||
}
|
||||
|
||||
function closeAgentDropdown(){
|
||||
const dd=$('composerAgentDropdown');
|
||||
const chip=$('composerAgentChip');
|
||||
if(dd) dd.classList.remove('open');
|
||||
if(chip) chip.classList.remove('active');
|
||||
}
|
||||
|
||||
function selectAgentFromDropdown(value){
|
||||
const sel=$('agentSelect');
|
||||
if(!sel) return;
|
||||
sel.value=value;
|
||||
syncAgentChip();
|
||||
closeAgentDropdown();
|
||||
// Save to session / localStorage
|
||||
if(typeof S!=='undefined'&&S.session) S.session.agent=value;
|
||||
try{localStorage.setItem('hermes-webui-agent',value);}catch(e){}
|
||||
}
|
||||
|
||||
function syncAgentChip(){
|
||||
const sel=$('agentSelect');
|
||||
const icon=$('composerAgentIcon');
|
||||
const label=$('composerAgentLabel');
|
||||
if(!sel||!icon||!label) return;
|
||||
const m=AGENT_META[sel.value]||AGENT_META.rose;
|
||||
icon.textContent=m.emoji;
|
||||
label.textContent=m.name;
|
||||
}
|
||||
|
||||
// Init agent chip from localStorage on load
|
||||
window.addEventListener('DOMContentLoaded',()=>{
|
||||
try{
|
||||
const saved=localStorage.getItem('hermes-webui-agent');
|
||||
if(saved){
|
||||
const sel=$('agentSelect');
|
||||
if(sel&&Array.from(sel.options).some(o=>o.value===saved)){
|
||||
sel.value=saved;
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
syncAgentChip();
|
||||
});
|
||||
|
||||
document.addEventListener('click',e=>{
|
||||
if(!e.target.closest('#composerAgentChip') && !e.target.closest('#composerAgentDropdown')) closeAgentDropdown();
|
||||
});
|
||||
|
||||
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
||||
// Once the user scrolls back to within 80px of the bottom, re-pin.
|
||||
@@ -1033,6 +1138,7 @@ function syncTopbar(){
|
||||
document.title=window._botName||'Hermes';
|
||||
if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
|
||||
else {
|
||||
const sidebarName=$('sidebarWsName');
|
||||
@@ -1072,6 +1178,7 @@ function syncTopbar(){
|
||||
}
|
||||
}
|
||||
if(typeof syncModelChip==='function') syncModelChip();
|
||||
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||
// Show Clear button only when session has messages
|
||||
const clearBtn=$('btnClearConv');
|
||||
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
||||
@@ -1283,10 +1390,6 @@ function renderMessages(){
|
||||
scrollToBottom();
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();renderKatexBlocks();});
|
||||
// Refresh todo panel if it's currently open
|
||||
if(typeof loadTodos==='function' && document.getElementById('panelTodos') && document.getElementById('panelTodos').classList.contains('active')){
|
||||
loadTodos();
|
||||
}
|
||||
}
|
||||
|
||||
function toolIcon(name){
|
||||
|
||||
Reference in New Issue
Block a user