Phase 7: Agent Selector — per-agent soul.md + ChromaDB memory filtering
- Agent dropdown UI (chip button + hidden select) in composer header - Session.agent field persists agent selection across refresh - soul.md loaded per-agent via ephemeral_system_prompt injection - ChromaDB memory filtered by agent topic (lotus/, sunflower/, etc.) - Fixed streaming.py: agent→_ai_agent variable shadowing (lines 1161, 1163) - New API endpoints: /api/agents/topology, /api/agents/memory/search - Agent metadata registry with emoji, name, description per Tier-2 agent
This commit is contained in:
@@ -1067,3 +1067,40 @@ def _search_all_agents_memory(query: str, limit: int = 20) -> list:
|
|||||||
return matches
|
return matches
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Topology Graph ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_topology() -> dict:
|
||||||
|
"""
|
||||||
|
Build a network graph of all agents and their connections.
|
||||||
|
Returns {nodes: [...], edges: [...]} for D3.js visualization.
|
||||||
|
"""
|
||||||
|
# Nodes: Rose + all Tier-2 agents
|
||||||
|
nodes = [
|
||||||
|
{"id": "rose", "name": "Rose 🌹", "type": "orchestrator",
|
||||||
|
"color": "#f44336", "domain": "Orchestrator"},
|
||||||
|
]
|
||||||
|
for agent_id, meta in TIER2_AGENTS.items():
|
||||||
|
nodes.append({
|
||||||
|
"id": agent_id,
|
||||||
|
"name": f"{meta['emoji']} {meta['name']}",
|
||||||
|
"type": "tier2",
|
||||||
|
"color": meta["color"],
|
||||||
|
"domain": meta["domain"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Edges: Rose connects to all Tier-2 agents
|
||||||
|
edges = []
|
||||||
|
for agent_id in TIER2_AGENTS:
|
||||||
|
edges.append({
|
||||||
|
"source": "rose", "target": agent_id,
|
||||||
|
"type": "orchestrates", "strength": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"nodes": nodes, "edges": edges}
|
||||||
|
|
||||||
|
|
||||||
|
def get_topology() -> dict:
|
||||||
|
"""API: GET /api/agents/topology — return agent network graph."""
|
||||||
|
return _get_topology()
|
||||||
|
|||||||
@@ -3,6 +3,55 @@ Phase 5 — Memory Search (ChromaDB)
|
|||||||
Appended to agents.py functions for Memory Search.
|
Appended to agents.py functions for Memory Search.
|
||||||
"""
|
"""
|
||||||
import chromadb
|
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():
|
def _get_chroma_client():
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class Session:
|
|||||||
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
input_tokens: int=0, output_tokens: int=0, estimated_cost=None,
|
||||||
personality=None,
|
personality=None,
|
||||||
active_stream_id: str=None,
|
active_stream_id: str=None,
|
||||||
|
agent: str=None,
|
||||||
pending_user_message: str=None,
|
pending_user_message: str=None,
|
||||||
pending_attachments=None,
|
pending_attachments=None,
|
||||||
pending_started_at=None,
|
pending_started_at=None,
|
||||||
@@ -68,6 +69,7 @@ class Session:
|
|||||||
self.estimated_cost = estimated_cost
|
self.estimated_cost = estimated_cost
|
||||||
self.personality = personality
|
self.personality = personality
|
||||||
self.active_stream_id = active_stream_id
|
self.active_stream_id = active_stream_id
|
||||||
|
self.agent = agent
|
||||||
self.pending_user_message = pending_user_message
|
self.pending_user_message = pending_user_message
|
||||||
self.pending_attachments = pending_attachments or []
|
self.pending_attachments = pending_attachments or []
|
||||||
self.pending_started_at = pending_started_at
|
self.pending_started_at = pending_started_at
|
||||||
@@ -103,6 +105,7 @@ class Session:
|
|||||||
'title': self.title,
|
'title': self.title,
|
||||||
'workspace': self.workspace,
|
'workspace': self.workspace,
|
||||||
'model': self.model,
|
'model': self.model,
|
||||||
|
'agent': getattr(self, 'agent', None),
|
||||||
'message_count': len(self.messages),
|
'message_count': len(self.messages),
|
||||||
'created_at': self.created_at,
|
'created_at': self.created_at,
|
||||||
'updated_at': self.updated_at,
|
'updated_at': self.updated_at,
|
||||||
|
|||||||
275
api/routes.py
275
api/routes.py
@@ -55,6 +55,7 @@ from api.helpers import (
|
|||||||
)
|
)
|
||||||
from api import mc as _mc
|
from api import mc as _mc
|
||||||
from api import agents as _agents
|
from api import agents as _agents
|
||||||
|
from api import heartbeats as _heartbeats
|
||||||
|
|
||||||
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
|
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
|
||||||
import re as _re
|
import re as _re
|
||||||
@@ -570,6 +571,23 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
if parsed.path == "/api/projects":
|
if parsed.path == "/api/projects":
|
||||||
return j(handler, {"projects": load_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":
|
if parsed.path == "/api/session/export":
|
||||||
return _handle_session_export(handler, parsed)
|
return _handle_session_export(handler, parsed)
|
||||||
|
|
||||||
@@ -786,6 +804,13 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
agent_id = parsed.path.split("/")[-1]
|
agent_id = parsed.path.split("/")[-1]
|
||||||
return j(handler, _agents.get_agent_inbox(agent_id))
|
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/"):
|
if parsed.path.startswith("/api/agents/config/"):
|
||||||
agent_id = parsed.path.split("/")[-1]
|
agent_id = parsed.path.split("/")[-1]
|
||||||
return j(handler, _agents.get_agent_config(agent_id))
|
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]
|
agent_id = parsed.path.split("/")[-2]
|
||||||
return j(handler, _agents.get_agent_status(agent_id))
|
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
|
# GET /api/agents/{id}/tasks
|
||||||
if parsed.path.startswith("/api/agents/") and "/tasks" in parsed.path:
|
if parsed.path.startswith("/api/agents/") and "/tasks" in parsed.path:
|
||||||
parts = parsed.path.split("/")
|
parts = parsed.path.split("/")
|
||||||
@@ -892,21 +839,6 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
if parsed.path == "/api/agents/message-bus":
|
if parsed.path == "/api/agents/message-bus":
|
||||||
return j(handler, _agents.get_message_bus_status())
|
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
|
# GET /api/agents/memory/search?q= — search all agents
|
||||||
if parsed.path == "/api/agents/memory/search":
|
if parsed.path == "/api/agents/memory/search":
|
||||||
return _handle_memory_search(handler, parsed, agent_id=None)
|
return _handle_memory_search(handler, parsed, agent_id=None)
|
||||||
@@ -920,6 +852,51 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
if _mem_match:
|
if _mem_match:
|
||||||
return _handle_memory_search(handler, parsed, agent_id=_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) ──
|
# ── Profile API (GET) ──
|
||||||
if parsed.path == "/api/profiles":
|
if parsed.path == "/api/profiles":
|
||||||
from api.profiles import list_profiles_api, get_active_profile_name
|
from api.profiles import list_profiles_api, get_active_profile_name
|
||||||
@@ -983,6 +960,12 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return j(handler, {"error": str(e)}, status=500)
|
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
|
return False # 404
|
||||||
|
|
||||||
|
|
||||||
@@ -1003,6 +986,45 @@ def handle_post(handler, parsed) -> bool:
|
|||||||
|
|
||||||
body = read_body(handler)
|
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":
|
if parsed.path == "/api/session/new":
|
||||||
try:
|
try:
|
||||||
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
|
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())
|
handler.wfile.write(json.dumps({"ok": True}).encode())
|
||||||
return True
|
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
|
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 ─────────────────────────────────────────────────────────
|
# ── GET route helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# MIME types for static file serving. Hoisted to module scope to avoid
|
# MIME types for static file serving. Hoisted to module scope to avoid
|
||||||
@@ -1854,8 +1937,9 @@ def _handle_list_dir(handler, parsed):
|
|||||||
return j(
|
return j(
|
||||||
handler,
|
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],
|
"path": qs.get("path", ["."])[0],
|
||||||
|
"search": qs.get("search", [""])[0],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, ValueError) as e:
|
except (FileNotFoundError, ValueError) as e:
|
||||||
@@ -2403,6 +2487,7 @@ def _handle_chat_start(handler, body):
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return bad(handler, str(e))
|
return bad(handler, str(e))
|
||||||
model = body.get("model") or s.model
|
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.
|
# Prevent duplicate runs in the same session while a stream is still active.
|
||||||
# This commonly happens after page refresh/reconnect races and can produce
|
# This commonly happens after page refresh/reconnect races and can produce
|
||||||
# duplicated clarify cards for what appears to be a single user request.
|
# 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
|
stream_id = uuid.uuid4().hex
|
||||||
s.workspace = workspace
|
s.workspace = workspace
|
||||||
s.model = model
|
s.model = model
|
||||||
|
s.agent = agent
|
||||||
s.active_stream_id = stream_id
|
s.active_stream_id = stream_id
|
||||||
s.pending_user_message = msg
|
s.pending_user_message = msg
|
||||||
s.pending_attachments = attachments
|
s.pending_attachments = attachments
|
||||||
@@ -2436,6 +2522,7 @@ def _handle_chat_start(handler, body):
|
|||||||
thr = threading.Thread(
|
thr = threading.Thread(
|
||||||
target=_run_agent_streaming,
|
target=_run_agent_streaming,
|
||||||
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
||||||
|
kwargs={"agent": agent},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
thr.start()
|
thr.start()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from api.config import (
|
|||||||
resolve_model_provider,
|
resolve_model_provider,
|
||||||
)
|
)
|
||||||
from api.helpers import redact_session_data
|
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
|
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
|
||||||
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
|
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
|
||||||
@@ -774,7 +775,7 @@ def _sse(handler, event, data):
|
|||||||
handler.wfile.flush()
|
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]."""
|
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
|
||||||
q = STREAMS.get(stream_id)
|
q = STREAMS.get(stream_id)
|
||||||
if q is None:
|
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 = get_session(session_id)
|
||||||
s.workspace = str(Path(workspace).expanduser().resolve())
|
s.workspace = str(Path(workspace).expanduser().resolve())
|
||||||
s.model = model
|
s.model = model
|
||||||
|
if agent:
|
||||||
|
s.agent = agent
|
||||||
|
|
||||||
_agent_lock = _get_session_agent_lock(session_id)
|
_agent_lock = _get_session_agent_lock(session_id)
|
||||||
# TD1: set thread-local env context so concurrent sessions don't clobber globals
|
# 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:
|
else:
|
||||||
_fallback_resolved = None
|
_fallback_resolved = None
|
||||||
|
|
||||||
agent = _AIAgent(
|
_ai_agent = _AIAgent(
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
provider=resolved_provider,
|
provider=resolved_provider,
|
||||||
base_url=resolved_base_url,
|
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
|
# Store agent instance for cancel/interrupt propagation
|
||||||
with STREAMS_LOCK:
|
with STREAMS_LOCK:
|
||||||
AGENT_INSTANCES[stream_id] = agent
|
AGENT_INSTANCES[stream_id] = _ai_agent
|
||||||
# Check if cancel was requested during agent initialization
|
# Check if cancel was requested during agent initialization
|
||||||
if stream_id in CANCEL_FLAGS and CANCEL_FLAGS[stream_id].is_set():
|
if stream_id in CANCEL_FLAGS and CANCEL_FLAGS[stream_id].is_set():
|
||||||
# Cancel arrived during agent creation - interrupt immediately
|
# Cancel arrived during agent creation - interrupt immediately
|
||||||
try:
|
try:
|
||||||
agent.interrupt("Cancelled before start")
|
_ai_agent.interrupt("Cancelled before start")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to interrupt agent before start")
|
logger.debug("Failed to interrupt agent before start")
|
||||||
put('cancel', {'message': 'Cancelled by user'})
|
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)
|
_personality_prompt = str(_pval)
|
||||||
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
|
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
|
||||||
if _personality_prompt:
|
if _personality_prompt:
|
||||||
agent.ephemeral_system_prompt = _personality_prompt
|
_ai_agent.ephemeral_system_prompt = _personality_prompt
|
||||||
_previous_messages = list(s.messages or [])
|
_previous_messages = list(s.messages or [])
|
||||||
result = agent.run_conversation(
|
result = _ai_agent.run_conversation(
|
||||||
user_message=workspace_ctx + msg_text,
|
user_message=workspace_ctx + msg_text,
|
||||||
system_message=workspace_system_msg,
|
system_message=workspace_system_msg,
|
||||||
conversation_history=_sanitize_messages_for_api(s.messages),
|
conversation_history=_sanitize_messages_for_api(s.messages),
|
||||||
|
|||||||
@@ -309,20 +309,39 @@ def safe_resolve_ws(root: Path, requested: str) -> Path:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def list_dir(workspace: Path, rel: str='.'):
|
def list_dir(workspace: Path, rel: str='.', search: str=''):
|
||||||
target = safe_resolve_ws(workspace, rel)
|
target = safe_resolve_ws(workspace, rel)
|
||||||
if not target.is_dir():
|
if not target.is_dir():
|
||||||
raise FileNotFoundError(f"Not a directory: {rel}")
|
raise FileNotFoundError(f"Not a directory: {rel}")
|
||||||
|
query = search.lower().strip()
|
||||||
entries = []
|
entries = []
|
||||||
for item in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
if query:
|
||||||
entries.append({
|
# Recursive search
|
||||||
'name': item.name,
|
try:
|
||||||
'path': str(item.relative_to(workspace)),
|
for item in target.rglob('*'):
|
||||||
'type': 'dir' if item.is_dir() else 'file',
|
if item.is_file():
|
||||||
'size': item.stat().st_size if item.is_file() else None,
|
if query in item.name.lower():
|
||||||
})
|
entries.append({
|
||||||
if len(entries) >= 200:
|
'name': item.name,
|
||||||
break
|
'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
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
server.py
26
server.py
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
from api.auth import check_auth
|
from api.auth import check_auth
|
||||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||||
from api.helpers import j
|
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
|
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)
|
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
|
||||||
return j(self, {'error': 'Internal server error'}, status=500)
|
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:
|
def main() -> None:
|
||||||
# Load ~/.hermes/.env into os.environ so API keys are available
|
# Load ~/.hermes/.env into os.environ so API keys are available
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ $('modelSelect').onchange=async()=>{
|
|||||||
await api('/api/session/update',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,workspace:S.session.workspace,model:selectedModel})});
|
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;
|
S.session.model=selectedModel;
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||||
syncTopbar();
|
syncTopbar();
|
||||||
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
// Warn if selected model belongs to a different provider than what Hermes is configured for
|
||||||
if(typeof _checkProviderMismatch==='function'){
|
if(typeof _checkProviderMismatch==='function'){
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ const LOCALES = {
|
|||||||
tab_memory: 'Memory',
|
tab_memory: 'Memory',
|
||||||
tab_workspaces: 'Spaces',
|
tab_workspaces: 'Spaces',
|
||||||
tab_profiles: 'Profiles',
|
tab_profiles: 'Profiles',
|
||||||
tab_todos: 'Todos',
|
|
||||||
new_conversation: 'New conversation',
|
new_conversation: 'New conversation',
|
||||||
filter_conversations: 'Filter conversations...',
|
filter_conversations: 'Filter conversations...',
|
||||||
session_time_unknown: 'Unknown',
|
session_time_unknown: 'Unknown',
|
||||||
@@ -249,7 +249,7 @@ const LOCALES = {
|
|||||||
search_skills: 'Search skills...',
|
search_skills: 'Search skills...',
|
||||||
new_skill: 'New skill',
|
new_skill: 'New skill',
|
||||||
personal_memory: 'Personal memory',
|
personal_memory: 'Personal memory',
|
||||||
current_task_list: 'Current task list',
|
|
||||||
workspace_desc: 'Add and switch workspaces for your sessions.',
|
workspace_desc: 'Add and switch workspaces for your sessions.',
|
||||||
new_profile: 'New profile',
|
new_profile: 'New profile',
|
||||||
transcript: 'Transcript',
|
transcript: 'Transcript',
|
||||||
@@ -403,7 +403,7 @@ const LOCALES = {
|
|||||||
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
|
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
|
||||||
status_failed: 'failed',
|
status_failed: 'failed',
|
||||||
status_completed: 'completed',
|
status_completed: 'completed',
|
||||||
todos_no_active: 'No active task list in this session.',
|
|
||||||
clear_conversation_title: 'Clear conversation',
|
clear_conversation_title: 'Clear conversation',
|
||||||
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
||||||
clear_failed: 'Clear failed: ',
|
clear_failed: 'Clear failed: ',
|
||||||
@@ -667,7 +667,7 @@ const LOCALES = {
|
|||||||
tab_memory: 'Память',
|
tab_memory: 'Память',
|
||||||
tab_workspaces: 'Рабочие пространства',
|
tab_workspaces: 'Рабочие пространства',
|
||||||
tab_profiles: 'Профили',
|
tab_profiles: 'Профили',
|
||||||
tab_todos: 'Список дел',
|
|
||||||
new_conversation: 'Новая беседа',
|
new_conversation: 'Новая беседа',
|
||||||
filter_conversations: 'Фильтр бесед...',
|
filter_conversations: 'Фильтр бесед...',
|
||||||
session_time_unknown: 'Неизвестно',
|
session_time_unknown: 'Неизвестно',
|
||||||
@@ -714,7 +714,7 @@ const LOCALES = {
|
|||||||
search_skills: 'Поиск навыков...',
|
search_skills: 'Поиск навыков...',
|
||||||
new_skill: 'Новый навык',
|
new_skill: 'Новый навык',
|
||||||
personal_memory: 'Личная память',
|
personal_memory: 'Личная память',
|
||||||
current_task_list: 'Текущий список задач',
|
|
||||||
workspace_desc: 'Добавляйте рабочие пространства и переключайтесь между ними в своих сеансах.',
|
workspace_desc: 'Добавляйте рабочие пространства и переключайтесь между ними в своих сеансах.',
|
||||||
new_profile: 'Новый профиль',
|
new_profile: 'Новый профиль',
|
||||||
transcript: 'Транскрипт',
|
transcript: 'Транскрипт',
|
||||||
@@ -864,7 +864,7 @@ const LOCALES = {
|
|||||||
cron_completion_status: (name, status) => `Cron-задание «${name}» — ${status}`,
|
cron_completion_status: (name, status) => `Cron-задание «${name}» — ${status}`,
|
||||||
status_failed: 'неудачно',
|
status_failed: 'неудачно',
|
||||||
status_completed: 'завершено',
|
status_completed: 'завершено',
|
||||||
todos_no_active: 'В этой сессии нет активного списка задач.',
|
|
||||||
clear_conversation_title: 'Очистить беседу',
|
clear_conversation_title: 'Очистить беседу',
|
||||||
clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.',
|
clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.',
|
||||||
clear_failed: 'Не удалось очистить: ',
|
clear_failed: 'Не удалось очистить: ',
|
||||||
@@ -1133,7 +1133,7 @@ const LOCALES = {
|
|||||||
tab_memory: 'Memoria',
|
tab_memory: 'Memoria',
|
||||||
tab_workspaces: 'Espacios',
|
tab_workspaces: 'Espacios',
|
||||||
tab_profiles: 'Perfiles',
|
tab_profiles: 'Perfiles',
|
||||||
tab_todos: 'Todos',
|
|
||||||
new_conversation: 'Nueva conversación',
|
new_conversation: 'Nueva conversación',
|
||||||
filter_conversations: 'Filtrar conversaciones...',
|
filter_conversations: 'Filtrar conversaciones...',
|
||||||
session_time_unknown: 'Desconocido',
|
session_time_unknown: 'Desconocido',
|
||||||
@@ -1153,7 +1153,7 @@ const LOCALES = {
|
|||||||
search_skills: 'Buscar skills...',
|
search_skills: 'Buscar skills...',
|
||||||
new_skill: 'Nueva skill',
|
new_skill: 'Nueva skill',
|
||||||
personal_memory: 'Memoria personal',
|
personal_memory: 'Memoria personal',
|
||||||
current_task_list: 'Lista de tareas actual',
|
|
||||||
workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.',
|
workspace_desc: 'Añade y cambia espacios de trabajo para tus sesiones.',
|
||||||
new_profile: 'Nuevo perfil',
|
new_profile: 'Nuevo perfil',
|
||||||
transcript: 'Transcripción',
|
transcript: 'Transcripción',
|
||||||
@@ -1307,7 +1307,7 @@ const LOCALES = {
|
|||||||
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
|
cron_completion_status: (name, status) => `Cron "${name}" ${status}`,
|
||||||
status_failed: 'failed',
|
status_failed: 'failed',
|
||||||
status_completed: 'completed',
|
status_completed: 'completed',
|
||||||
todos_no_active: 'No active task list in this session.',
|
|
||||||
clear_conversation_title: 'Clear conversation',
|
clear_conversation_title: 'Clear conversation',
|
||||||
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
clear_conversation_message: 'Clear all messages? This cannot be undone.',
|
||||||
clear_failed: 'Clear failed: ',
|
clear_failed: 'Clear failed: ',
|
||||||
@@ -1580,7 +1580,7 @@ const LOCALES = {
|
|||||||
tab_memory: 'Gedächtnis',
|
tab_memory: 'Gedächtnis',
|
||||||
tab_workspaces: 'Spaces',
|
tab_workspaces: 'Spaces',
|
||||||
tab_profiles: 'Profile',
|
tab_profiles: 'Profile',
|
||||||
tab_todos: 'Todos',
|
|
||||||
new_conversation: 'Neuer Chat',
|
new_conversation: 'Neuer Chat',
|
||||||
filter_conversations: 'Chats filtern...',
|
filter_conversations: 'Chats filtern...',
|
||||||
scheduled_jobs: 'Geplante Aufgaben',
|
scheduled_jobs: 'Geplante Aufgaben',
|
||||||
@@ -1589,7 +1589,7 @@ const LOCALES = {
|
|||||||
search_skills: 'Skills suchen...',
|
search_skills: 'Skills suchen...',
|
||||||
new_skill: 'Neuer Skill',
|
new_skill: 'Neuer Skill',
|
||||||
personal_memory: 'Persönliches Gedächtnis',
|
personal_memory: 'Persönliches Gedächtnis',
|
||||||
current_task_list: 'Aktuelle Aufgabenliste',
|
|
||||||
workspace_desc: 'Workspaces hinzufügen und wechseln.',
|
workspace_desc: 'Workspaces hinzufügen und wechseln.',
|
||||||
new_profile: 'Neues Profil',
|
new_profile: 'Neues Profil',
|
||||||
transcript: 'Protokoll',
|
transcript: 'Protokoll',
|
||||||
@@ -1797,7 +1797,7 @@ const LOCALES = {
|
|||||||
tab_memory: '记忆',
|
tab_memory: '记忆',
|
||||||
tab_skills: '技能',
|
tab_skills: '技能',
|
||||||
tab_tasks: '任务',
|
tab_tasks: '任务',
|
||||||
tab_todos: '待办',
|
|
||||||
tab_workspaces: '工作区',
|
tab_workspaces: '工作区',
|
||||||
tab_profiles: '配置',
|
tab_profiles: '配置',
|
||||||
new_conversation: '新建对话',
|
new_conversation: '新建对话',
|
||||||
@@ -1819,7 +1819,7 @@ const LOCALES = {
|
|||||||
new_skill: '新技能',
|
new_skill: '新技能',
|
||||||
save_skill: '保存技能',
|
save_skill: '保存技能',
|
||||||
personal_memory: '个人记忆',
|
personal_memory: '个人记忆',
|
||||||
current_task_list: '当前任务列表',
|
|
||||||
workspace_desc: '为你的会话添加并切换工作区。',
|
workspace_desc: '为你的会话添加并切换工作区。',
|
||||||
new_profile: '新配置',
|
new_profile: '新配置',
|
||||||
transcript: '记录',
|
transcript: '记录',
|
||||||
@@ -1971,7 +1971,7 @@ const LOCALES = {
|
|||||||
cron_completion_status: (name, status) => `定时任务“${name}”${status}`,
|
cron_completion_status: (name, status) => `定时任务“${name}”${status}`,
|
||||||
status_failed: '失败',
|
status_failed: '失败',
|
||||||
status_completed: '完成',
|
status_completed: '完成',
|
||||||
todos_no_active: '此会话暂无活动任务列表。',
|
|
||||||
clear_conversation_title: '清空对话',
|
clear_conversation_title: '清空对话',
|
||||||
clear_conversation_message: '要清空所有消息吗?此操作无法撤销。',
|
clear_conversation_message: '要清空所有消息吗?此操作无法撤销。',
|
||||||
clear_failed: '清空失败:',
|
clear_failed: '清空失败:',
|
||||||
@@ -2246,7 +2246,6 @@ const LOCALES = {
|
|||||||
new_skill: '\u65b0\u6280\u80fd',
|
new_skill: '\u65b0\u6280\u80fd',
|
||||||
save_skill: '\u5132\u5b58\u6280\u80fd',
|
save_skill: '\u5132\u5b58\u6280\u80fd',
|
||||||
personal_memory: '\u500b\u4eba\u8a18\u61b6',
|
personal_memory: '\u500b\u4eba\u8a18\u61b6',
|
||||||
current_task_list: '\u76ee\u524d\u4efb\u52d9\u6e05\u55ae',
|
|
||||||
new_profile: '\u65b0\u914d\u7f6e\u6a94',
|
new_profile: '\u65b0\u914d\u7f6e\u6a94',
|
||||||
transcript: '\u8a18\u9304',
|
transcript: '\u8a18\u9304',
|
||||||
download_transcript: '\u4e0b\u8f09\u8a18\u9304',
|
download_transcript: '\u4e0b\u8f09\u8a18\u9304',
|
||||||
|
|||||||
@@ -22,10 +22,12 @@
|
|||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks" data-i18n-title="tab_tasks"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
<button class="nav-tab" data-panel="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="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="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="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>
|
</div>
|
||||||
<!-- Chat panel -->
|
<!-- Chat panel -->
|
||||||
<div class="panel-view active" id="panelChat">
|
<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 id="memEditError" style="font-size:11px;color:var(--accent);margin-top:6px;display:none"></div>
|
||||||
</div>
|
</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) -->
|
<!-- Agents panel (Rose + Tier-2) -->
|
||||||
<div class="panel-view" id="panelAgents">
|
<div class="panel-view" id="panelAgents">
|
||||||
<div style="padding:14px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--border)">
|
<div style="padding:14px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--border)">
|
||||||
@@ -194,17 +120,103 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">Rose + 7 Tier-2 Domain Agents</div>
|
<div style="font-size:10px;color:var(--muted);margin-top:4px">Rose + 7 Tier-2 Domain Agents</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Agents list -->
|
<!-- agents list -->
|
||||||
<div style="flex:1;overflow-y:auto;padding:10px 16px" id="agentsList">
|
<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 style="color:var(--muted);font-size:12px;text-align:center;padding:20px">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Agent inbox slide-in panel -->
|
<!-- 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 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>
|
</div>
|
||||||
<!-- Workspaces panel -->
|
|
||||||
<div class="panel-view" id="panelWorkspaces">
|
<!-- Projects panel -->
|
||||||
<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 class="panel-view" id="panelProjects">
|
||||||
<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>
|
<!-- 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>
|
||||||
|
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
@@ -242,6 +254,14 @@
|
|||||||
</button>
|
</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 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="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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,21 +381,6 @@
|
|||||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<div class="composer-model-wrap">
|
||||||
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
|
<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>
|
<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>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<div class="composer-right">
|
<div class="composer-right">
|
||||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
||||||
@@ -432,6 +454,7 @@
|
|||||||
<div class="profile-dropdown" id="profileDropdown"></div>
|
<div class="profile-dropdown" id="profileDropdown"></div>
|
||||||
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown"></div>
|
||||||
<div class="model-dropdown" id="composerModelDropdown"></div>
|
<div class="model-dropdown" id="composerModelDropdown"></div>
|
||||||
|
<div class="agent-dropdown" id="composerAgentDropdown"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
<div class="upload-bar-wrap" id="uploadBarWrap"><div class="upload-bar" id="uploadBar"></div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,6 +496,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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-overlay" id="onboardingOverlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="onboardingTitle">
|
||||||
<div class="onboarding-card">
|
<div class="onboarding-card">
|
||||||
<div class="onboarding-shell">
|
<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>
|
<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>
|
<span class="settings-tab-title">Logs</span>
|
||||||
</button>
|
</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>
|
||||||
<div class="settings-main">
|
<div class="settings-main">
|
||||||
<div class="settings-pane active" id="settingsPaneConversation" role="tabpanel" aria-labelledby="settingsTabConversation">
|
<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)">
|
<input type="checkbox" id="logsAutoRefresh" onchange="toggleLogAutoRefresh()" style="width:13px;height:13px;accent-color:var(--accent)">
|
||||||
Live
|
Live
|
||||||
</label>
|
</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>
|
||||||
</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-body" id="logsBody">
|
||||||
<div class="logs-footer" id="logsFooter" style="display:none;font-size:10px;color:var(--muted);padding:4px 12px;border-top:1px solid var(--border)">
|
<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>
|
||||||
<span id="logsMatchCount"></span>
|
<div id="logsContent" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,5 +837,93 @@
|
|||||||
<script src="/static/panels.js"></script>
|
<script src="/static/panels.js"></script>
|
||||||
<script src="/static/onboarding.js"></script>
|
<script src="/static/onboarding.js"></script>
|
||||||
<script src="/static/boot.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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ async function send(){
|
|||||||
try{
|
try{
|
||||||
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
|
const startData=await api('/api/chat/start',{method:'POST',body:JSON.stringify({
|
||||||
session_id:activeSid,message:msgText,
|
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
|
attachments:uploaded.length?uploaded:undefined
|
||||||
})});
|
})});
|
||||||
streamId=startData.stream_id;
|
streamId=startData.stream_id;
|
||||||
|
|||||||
1030
static/panels.js
1030
static/panels.js
File diff suppressed because it is too large
Load Diff
865
static/style.css
865
static/style.css
@@ -347,25 +347,41 @@
|
|||||||
.cron-list{flex:1;overflow-y:auto;padding:8px;}
|
.cron-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{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-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-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.active{background:rgba(34,197,94,.15);color:#4ade80;}
|
||||||
.cron-status.paused{background:rgba(201,168,76,.15);color:var(--gold);}
|
.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.disabled{background:rgba(255,255,255,.07);color:var(--muted);}
|
||||||
.cron-status.error{background:rgba(233,69,96,.15);color:var(--accent);}
|
.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{display:none;padding:0 12px 10px;border-top:1px solid var(--border);overflow:hidden;}
|
||||||
.cron-body.open{display:block;}
|
.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-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-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{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: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{border-color:rgba(124,185,255,.4);color:var(--blue);}
|
||||||
.cron-btn.run:hover{background:rgba(124,185,255,.12);}
|
.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.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{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-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;}
|
.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 */
|
/* 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-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-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-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;}
|
.composer-status{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px;}
|
||||||
/* Context usage indicator */
|
/* Context usage indicator */
|
||||||
.ctx-indicator-wrap{position:relative;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;}
|
.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;
|
flex-shrink: 0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Heartbeats Panel ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#heartbeatsPanelContent {
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heartbeats-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-stat b { color: var(--text); }
|
||||||
|
|
||||||
|
.hb-manager-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-dot-ok { background: #22c55e; }
|
||||||
|
.hb-dot-dead { background: #ef4444; }
|
||||||
|
|
||||||
|
.hb-stat-warn b { color: #f59e0b; }
|
||||||
|
|
||||||
|
.hb-quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hb-action {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hb-action:hover { background: var(--surface-3); }
|
||||||
|
|
||||||
|
.hb-create-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-create-section h4,
|
||||||
|
.hb-list-section h4 {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-input {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-input:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.hb-select {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-mini { width: 70px !important; }
|
||||||
|
.hb-agent-select { flex: 1; min-width: 160px; }
|
||||||
|
.hb-checkbox { font-size: 11px; color: var(--muted); cursor: pointer; }
|
||||||
|
|
||||||
|
.hb-list-section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-item-pending { border-left: 3px solid #f59e0b; }
|
||||||
|
.hb-item-fired { border-left: 3px solid var(--accent); opacity: 0.6; }
|
||||||
|
.hb-item-cancelled { border-left: 3px solid #6b7280; opacity: 0.5; }
|
||||||
|
|
||||||
|
.hb-item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-action-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-source {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-priority {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-due {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-iter {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-item-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 3px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-cancel-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-cancel-btn:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Projects Panel ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#panelProjects {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-quick-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Projects panel completely unless it is the active panel */
|
||||||
|
.panel-view#panelProjects:not(.active) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure only the active panel is visible in sidebar */
|
||||||
|
.sidebar .panel-view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sidebar .panel-view.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-quick-add input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-quick-add input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-quick-add select {
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-sidebar {
|
||||||
|
width: 240px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-header {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item:hover {
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-task-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-check {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-check:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title.done {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-prio {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-tag {
|
||||||
|
font-size: 9px;
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-kanban {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(255,255,255,.03);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col-header {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 10px;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .15s, box-shadow .15s;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-header {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-project {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-due {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-tag {
|
||||||
|
font-size: 9px;
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded Mode - reusable for all panels */
|
||||||
|
.panel-view.panel-expanded {
|
||||||
|
display: flex !important;
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
background: var(--bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task Detail Modal ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input,
|
||||||
|
.modal-select {
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input:focus,
|
||||||
|
.modal-select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-select option {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--bg);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-primary:hover { opacity: .9; }
|
||||||
|
|
||||||
|
.modal-btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-secondary:hover {
|
||||||
|
border-color: var(--muted);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #ef4444;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn-danger:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task Owner Badge ───────────────────────────────────────────────────────── */
|
||||||
|
.task-owner {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Projects Sidebar Section Headers ─────────────────────────────────────── */
|
||||||
|
.sidebar-section-header {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task Item: clicking opens modal ───────────────────────────────────────── */
|
||||||
|
.task-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Due Date Colors ───────────────────────────────────────────────────────── */
|
||||||
|
.task-due {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-due.overdue {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Quick Add subtle styling ───────────────────────────────────────────────── */
|
||||||
|
.projects-quick-add input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats Grid ────────────────────────────────────────────────────────────── */
|
||||||
|
.stats-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
background: rgba(255,255,255,.04);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.stat-overdue {
|
||||||
|
border-color: #ef444450;
|
||||||
|
background: rgba(239,68,68,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Due Date in Kanban ───────────────────────────────────────────────────── */
|
||||||
|
.kanban-due {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-due.overdue {
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Owner Badge ──────────────────────────────────────────────────────────── */
|
||||||
|
.owner-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-badge.rose { background: rgba(251,113,133,.15); color: #fb7185; }
|
||||||
|
.owner-badge.agent { background: rgba(132,204,22,.15); color: #a3e635; }
|
||||||
|
.owner-badge.sabo, .owner-badge.user { background: rgba(96,165,250,.15); color: #60a5fa; }
|
||||||
|
|||||||
111
static/ui.js
111
static/ui.js
@@ -91,6 +91,7 @@ async function populateModelDropdown(){
|
|||||||
_applyModelToDropdown(data.default_model, sel);
|
_applyModelToDropdown(data.default_model, sel);
|
||||||
}
|
}
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||||
// Kick off a background live-model fetch for the active provider.
|
// Kick off a background live-model fetch for the active provider.
|
||||||
// This runs after the static list is already shown (no blocking flicker).
|
// This runs after the static list is already shown (no blocking flicker).
|
||||||
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
|
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
|
// API unavailable -- keep the hardcoded HTML options as fallback
|
||||||
console.warn('Failed to load models from server:',e.message);
|
console.warn('Failed to load models from server:',e.message);
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +150,7 @@ async function _fetchLiveModels(provider, sel){
|
|||||||
// Restore selection
|
// Restore selection
|
||||||
if(currentVal) _applyModelToDropdown(currentVal, sel);
|
if(currentVal) _applyModelToDropdown(currentVal, sel);
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||||
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
|
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
|
||||||
}
|
}
|
||||||
}catch(e){
|
}catch(e){
|
||||||
@@ -315,6 +318,108 @@ window.addEventListener('resize',()=>{
|
|||||||
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
|
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 ──────────────────────────────────────────────────────────
|
// ── Scroll pinning ──────────────────────────────────────────────────────────
|
||||||
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
|
// 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.
|
// Once the user scrolls back to within 80px of the bottom, re-pin.
|
||||||
@@ -1033,6 +1138,7 @@ function syncTopbar(){
|
|||||||
document.title=window._botName||'Hermes';
|
document.title=window._botName||'Hermes';
|
||||||
if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
|
if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays();
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||||
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
|
if(typeof _syncHermesPanelSessionActions==='function') _syncHermesPanelSessionActions();
|
||||||
else {
|
else {
|
||||||
const sidebarName=$('sidebarWsName');
|
const sidebarName=$('sidebarWsName');
|
||||||
@@ -1072,6 +1178,7 @@ function syncTopbar(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(typeof syncModelChip==='function') syncModelChip();
|
if(typeof syncModelChip==='function') syncModelChip();
|
||||||
|
if(typeof syncAgentChip==='function') syncAgentChip();
|
||||||
// Show Clear button only when session has messages
|
// Show Clear button only when session has messages
|
||||||
const clearBtn=$('btnClearConv');
|
const clearBtn=$('btnClearConv');
|
||||||
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
|
||||||
@@ -1283,10 +1390,6 @@ function renderMessages(){
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
// Apply syntax highlighting after DOM is built
|
// Apply syntax highlighting after DOM is built
|
||||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();renderKatexBlocks();});
|
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){
|
function toolIcon(name){
|
||||||
|
|||||||
Reference in New Issue
Block a user