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:
Rose
2026-04-20 17:34:58 +02:00
parent 00045314f8
commit c705fad626
14 changed files with 2578 additions and 320 deletions

View File

@@ -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()

View File

@@ -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():

View File

@@ -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,

View File

@@ -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()

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -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'){

View File

@@ -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',

View File

@@ -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>

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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; }

View File

@@ -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){