From c705fad6265760e7a6b33f3002cae49877c8e946 Mon Sep 17 00:00:00 2001 From: Rose Date: Mon, 20 Apr 2026 17:34:58 +0200 Subject: [PATCH] =?UTF-8?q?Phase=207:=20Agent=20Selector=20=E2=80=94=20per?= =?UTF-8?q?-agent=20soul.md=20+=20ChromaDB=20memory=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/agents.py | 37 ++ api/agents_memory.py | 49 ++ api/models.py | 3 + api/routes.py | 275 +++++++---- api/streaming.py | 27 +- api/workspace.py | 39 +- server.py | 26 +- static/boot.js | 1 + static/i18n.js | 29 +- static/index.html | 402 ++++++++++++----- static/messages.js | 4 +- static/panels.js | 1030 ++++++++++++++++++++++++++++++++++++++---- static/style.css | 865 ++++++++++++++++++++++++++++++++++- static/ui.js | 111 ++++- 14 files changed, 2578 insertions(+), 320 deletions(-) diff --git a/api/agents.py b/api/agents.py index 2e58c3a..df5edcf 100644 --- a/api/agents.py +++ b/api/agents.py @@ -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() diff --git a/api/agents_memory.py b/api/agents_memory.py index fb60ba0..9030f42 100644 --- a/api/agents_memory.py +++ b/api/agents_memory.py @@ -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(): diff --git a/api/models.py b/api/models.py index 22144d6..ba71954 100644 --- a/api/models.py +++ b/api/models.py @@ -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, diff --git a/api/routes.py b/api/routes.py index 2826743..e99ab68 100644 --- a/api/routes.py +++ b/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() diff --git a/api/streaming.py b/api/streaming.py index 6bd7201..da1df2c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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), diff --git a/api/workspace.py b/api/workspace.py index dddae29..140055f 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -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 diff --git a/server.py b/server.py index 301af2e..4466307 100644 --- a/server.py +++ b/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 diff --git a/static/boot.js b/static/boot.js index e869311..b7a0d98 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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'){ diff --git a/static/i18n.js b/static/i18n.js index 253c986..23110dd 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/index.html b/static/index.html index 0358ee0..f7d54a7 100644 --- a/static/index.html +++ b/static/index.html @@ -22,10 +22,12 @@ - - - + + + + +
@@ -104,84 +106,8 @@
- -
-
Current task list
-
-
- -
- -
-
-
- 🎯 - Mission Control -
-
- - -
-
-
- -
-
-
Tasks
-
-
loading...
-
-
-
Priorities
-
-
loading...
-
-
- -
-
- Progress0% -
-
-
-
-
- - -
-
New Task
- -
- - - -
-
- - -
-
-
- - -
- - -
-
Recent Activity
-
-
-
@@ -194,17 +120,103 @@
Rose + 7 Tier-2 Domain Agents
- +
Loading...
- -
-
Add and switch workspaces for your sessions.
-
Loading...
+ + +
+ +
+
📋 Projects
+
+ +
+ + +
+ + + + + + +
+ + +
+
+ + + + +
+
+
+ + + +
+
+
+ + +
+
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+
+
+ 📋 TODO + +
+
+
+
+
+ ⚡ IN PROGRESS + +
+
+
+
+
+ 👀 REVIEW + +
+
+
+
+
+ ✅ DONE + +
+
+
+
+
@@ -361,21 +381,6 @@ - -
- -
-
- -
+
+ + +
@@ -432,6 +454,7 @@
+
@@ -473,6 +496,72 @@ + + + + +
+
+
+
Heartbeats
+ +
+
+
+
Lädt...
+
+
- - @@ -733,5 +837,93 @@ + + + + + + diff --git a/static/messages.js b/static/messages.js index e584837..2380f15 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; diff --git a/static/panels.js b/static/panels.js index ce9b239..ebe01c2 100644 --- a/static/panels.js +++ b/static/panels.js @@ -13,14 +13,49 @@ async function switchPanel(name) { if (name === 'tasks') await loadCrons(); if (name === 'skills') await loadSkills(); if (name === 'memory') await loadMemory(); - if (name === 'workspaces') await loadWorkspacesPanel(); if (name === 'profiles') await loadProfilesPanel(); - if (name === 'todos') loadTodos(); - if (name === 'missioncontrol') await loadMissionControl(); if (name === 'agents') await loadAgentsPanel(); + if (name === 'heartbeats') await loadHeartbeatsPanel(); + if (name === 'projects') await loadProjectsPanel(); } // ── Cron panel ── +// ── Relative time helpers ────────────────────────────────────────────────── +function _relTime(dateStr) { + if (!dateStr) return null; + const diff = Date.now() - new Date(dateStr).getTime(); + const abs = Math.abs(diff); + const mins = Math.floor(abs / 60000); + const hours = Math.floor(abs / 3600000); + const days = Math.floor(abs / 86400000); + const future = diff < 0; + if (mins < 1) return future ? 'in few sec' : 'just now'; + if (mins < 60) return future ? `in ${mins}m` : `${mins}m ago`; + if (hours < 24) return future ? `in ${hours}h` : `${hours}h ago`; + if (days === 1) return future ? 'tomorrow' : 'yesterday'; + return future ? `in ${days}d` : `${days}d ago`; +} + +function _nextIn(dateStr) { + if (!dateStr) return '—'; + const diff = new Date(dateStr).getTime() - Date.now(); + if (diff <= 0) return 'now'; + const mins = Math.floor(diff / 60000); + if (mins < 60) return `in ${mins} min`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `in ${hours}h ${mins % 60}m`; + return `in ${Math.floor(hours / 24)}d`; +} + +function _friendlySchedule(expr) { + if (!expr) return ''; + // humanize common patterns + if (/^\d+\s+h$/.test(expr)) return `every ${expr.replace('h', ' hour')}s`; + if (/^\d+\s+m$/.test(expr)) return `every ${expr.replace('m', ' min')}s`; + if (/\bat\b|\b\d{1,2}:\d{2}/.test(expr)) return expr; + return expr; +} + async function loadCrons() { const box = $('cronList'); try { @@ -35,41 +70,54 @@ async function loadCrons() { item.className = 'cron-item'; item.id = 'cron-' + job.id; const statusClass = job.enabled === false ? 'disabled' : job.state === 'paused' ? 'paused' : job.last_status === 'error' ? 'error' : 'active'; - const statusLabel = job.enabled === false ? t('cron_status_off') : job.state === 'paused' ? t('cron_status_paused') : job.last_status === 'error' ? t('cron_status_error') : t('cron_status_active'); - const nextRun = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : t('not_available'); - const lastRun = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : t('never'); + const statusLabel = job.enabled === false ? 'OFF' : job.state === 'paused' ? 'PAUSED' : job.last_status === 'error' ? 'ERROR' : 'ACTIVE'; + const next = job.next_run_at ? _nextIn(job.next_run_at) : '—'; + const last = job.last_run_at ? _relTime(job.last_run_at) : 'never'; + const schedule = job.schedule_display || job.schedule?.expression || ''; + const scheduleFriendly = _friendlySchedule(schedule); + const prompt = job.prompt || ''; + const promptPreview = prompt.length > 120 ? prompt.slice(0, 120) + '…' : prompt; item.innerHTML = `
- ${esc(job.name)} - ${esc(job.schedule_display || job.schedule?.expression || '')} - ${job.last_run_at ? esc(new Date(job.last_run_at).toLocaleString()) : ''} - ${statusLabel} -
-
-
${li('clock',12)} ${esc(job.schedule_display || job.schedule?.expression || '')}  |  ${esc(t('cron_next'))}: ${esc(nextRun)}  |  ${esc(t('cron_last'))}: ${esc(lastRun)}
-
${esc((job.prompt||'').slice(0,300))}${(job.prompt||'').length>300?'…':''}
-
- - ${job.state==='paused' - ? `` - : ``} - - -
- -