Ship War Room UX, CEO inbox, mobile chat, and Agents NAS workspace.

Make A2A togglable, keep reply context while reading, route agent data to //10.4.7.11/share/Agents, and polish themes plus export-intel sync cancel.
This commit is contained in:
Aissa
2026-08-16 22:40:32 +00:00
parent 910ba465d7
commit c5ea8cbb63
26 changed files with 2891 additions and 407 deletions
+2 -2
View File
@@ -21,9 +21,9 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
active_tab = request.query_params.get("tab", "warroom")
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
active_tab = "souls"
active_tab = "warroom"
events: list = []
try:
events = fetch_all(
+47 -1
View File
@@ -551,6 +551,7 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
class WarroomMessageBody(BaseModel):
content: str = Field("", max_length=8000)
attachments: list[dict] = Field(default_factory=list)
reply_to_id: Optional[int] = None
@router.get("/warroom/rooms")
@@ -575,9 +576,12 @@ async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int |
from app.services import agent_chat
agent_chat.ensure_warroom()
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id)
deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else []
return {"ok": True, "items": items, "deleted_ids": deleted_ids}
return {"ok": True, "room": room, "items": items, "deleted_ids": deleted_ids}
@router.post("/warroom/rooms/{room_id}/messages")
@@ -592,6 +596,7 @@ async def warroom_post_message(request: Request, room_id: int, body: WarroomMess
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@@ -615,6 +620,7 @@ async def warroom_post_message_stream(request: Request, room_id: int, body: Warr
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
@@ -715,6 +721,46 @@ async def warroom_continue(request: Request, room_id: int, turns: int = 2):
@router.get("/warroom/ceo-inbox")
async def warroom_ceo_inbox(unread: bool = False, limit: int = 50) -> dict[str, Any]:
"""Direct messages from agents to CEO (@aissa)."""
from app.services import agent_chat
items = agent_chat.list_ceo_inbox(unread_only=unread, limit=min(limit, 100))
return {
"ok": True,
"items": items,
"unread": agent_chat.ceo_inbox_unread_count(),
}
class CeoInboxReadBody(BaseModel):
ids: Optional[list[int]] = None
all: bool = False
@router.post("/warroom/ceo-inbox/read")
async def warroom_ceo_inbox_read(body: CeoInboxReadBody) -> dict[str, Any]:
from app.services import agent_chat
n = agent_chat.mark_ceo_inbox_read(body.ids, all_unread=bool(body.all))
return {"ok": True, "marked": n, "unread": agent_chat.ceo_inbox_unread_count()}
@router.delete("/warroom/rooms/{room_id}/messages")
async def warroom_clear_messages(room_id: int) -> dict[str, Any]:
"""Soft-delete all messages in a War Room / DM."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
n = agent_chat.clear_messages(room_id)
return {"ok": True, "cleared": n}
@router.delete("/warroom/rooms/{room_id}/messages/{message_id}")
async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]:
"""Soft-delete a War Room message."""
+5
View File
@@ -305,6 +305,11 @@ async def api_sync_places(request: Request):
return r.json()
@router.post("/api/export-intel/sync/cancel")
async def api_sync_cancel():
return await _proxy("POST", "/sync/cancel")
@router.get("/api/export-intel/sync/status")
async def api_sync_status():
return await _proxy("GET", "/sync/status")
+353 -53
View File
@@ -9,7 +9,7 @@ from typing import Any
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import agent_souls, hermes_memory, llm_router
from app.services import agent_nas, agent_souls, hermes_memory, llm_router
from app.services.agent_names import normalize_agent_key
log = logging.getLogger("cockpit.agent_chat")
@@ -20,7 +20,7 @@ MAX_REPLIES_PER_ROUND = 3
HISTORY_LIMIT = 16
WARROOM_MAX_TOKENS = 512
WARROOM_TIMEOUT = 55.0
A2A_CONTINUE_MAX = 3
A2A_CONTINUE_MAX = 1
COCKPIT_SNAPSHOT_TTL = 60.0
@@ -132,6 +132,14 @@ def _iso(row: dict[str, Any] | None) -> dict[str, Any] | None:
return out
def _bootstrap_agents_nas() -> None:
try:
keys = [str(s.get("agent_key")) for s in agent_souls.list_souls()]
agent_nas.bootstrap_all(keys)
except Exception:
log.exception("agents NAS bootstrap failed")
def ensure_warroom() -> dict[str, Any]:
row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'warroom' LIMIT 1")
if row:
@@ -173,6 +181,18 @@ def list_rooms() -> list[dict[str, Any]]:
return [_iso(r) for r in rows] # type: ignore
def get_message(message_id: int, room_id: int | None = None) -> dict[str, Any] | None:
if room_id is not None:
return _iso(fetch_one(
"SELECT * FROM warroom_messages WHERE id = %s AND room_id = %s AND deleted_at IS NULL",
(message_id, room_id),
))
return _iso(fetch_one(
"SELECT * FROM warroom_messages WHERE id = %s AND deleted_at IS NULL",
(message_id,),
))
def get_room(room_id: int) -> dict[str, Any] | None:
return _iso(fetch_one("SELECT * FROM warroom_rooms WHERE id = %s", (room_id,)))
@@ -213,6 +233,21 @@ def delete_message(room_id: int, message_id: int) -> dict[str, Any] | None:
return _iso(row) if row else None # type: ignore
def clear_messages(room_id: int) -> int:
"""Soft-delete all messages in a room. Returns count cleared."""
rows = fetch_all(
"""UPDATE warroom_messages
SET deleted_at = NOW(),
content = '',
metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true, "cleared": true}'::jsonb
WHERE room_id = %s AND deleted_at IS NULL
RETURNING id""",
(room_id,),
)
execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,))
return len(rows or [])
def list_deleted_ids(room_id: int) -> list[int]:
"""Recently soft-deleted message ids (for live UI sync across clients)."""
rows = fetch_all(
@@ -260,6 +295,121 @@ def _active_agent_keys() -> set[str]:
return {str(s.get("agent_key")) for s in agent_souls.list_souls() if s.get("is_active", True)}
CEO_ALIASES = {"aissa", "ceo", "eigenaar", "ervoorz"} # ervoorz = legacy alias
CEO_MENTION_RE = re.compile(r"@([A-Za-z0-9_\-]{2,40})", re.I)
def parse_ceo_mentions(text: str) -> bool:
"""True if message is directed at CEO via @aissa (legacy: @ervoorz / @ceo)."""
for m in CEO_MENTION_RE.findall(text or ""):
if m.lower() in CEO_ALIASES:
return True
return False
def _ceo_excerpt(text: str, limit: int = 280) -> str:
t = (text or "").strip()
if len(t) <= limit:
return t
return t[: limit - 1] + "…"
def record_ceo_direct(message_row: dict[str, Any] | None) -> dict[str, Any] | None:
"""If an agent message @mentions CEO, store in ceo inbox."""
if not message_row or message_row.get("sender_type") != "agent":
return None
content = message_row.get("content") or ""
if not parse_ceo_mentions(content):
return None
ensure_warroom()
# ensure table exists (idempotent)
try:
execute(
"""CREATE TABLE IF NOT EXISTS warroom_ceo_inbox (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT,
room_id BIGINT,
agent_key VARCHAR(64),
content TEXT NOT NULL,
excerpt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
read_at TIMESTAMPTZ,
recipient TEXT NOT NULL DEFAULT 'aissa'
)"""
)
except Exception:
pass
row = fetch_one(
"""INSERT INTO warroom_ceo_inbox (message_id, room_id, agent_key, content, excerpt, recipient)
VALUES (%s, %s, %s, %s, %s, 'aissa')
RETURNING *""",
(
message_row.get("id"),
message_row.get("room_id"),
message_row.get("agent_key"),
content,
_ceo_excerpt(content),
),
)
return _iso(row) if row else None
def list_ceo_inbox(*, unread_only: bool = False, limit: int = 50) -> list[dict[str, Any]]:
ensure_warroom()
try:
if unread_only:
rows = fetch_all(
"""SELECT i.*, s.display_name AS agent_display_name
FROM warroom_ceo_inbox i
LEFT JOIN agent_souls s ON s.agent_key = i.agent_key
WHERE i.recipient = 'aissa' AND i.read_at IS NULL
ORDER BY i.created_at DESC LIMIT %s""",
(limit,),
)
else:
rows = fetch_all(
"""SELECT i.*, s.display_name AS agent_display_name
FROM warroom_ceo_inbox i
LEFT JOIN agent_souls s ON s.agent_key = i.agent_key
WHERE i.recipient = 'aissa'
ORDER BY i.created_at DESC LIMIT %s""",
(limit,),
)
except Exception:
return []
return [_iso(r) for r in (rows or [])] # type: ignore
def ceo_inbox_unread_count() -> int:
try:
row = fetch_one(
"""SELECT COUNT(*) AS n FROM warroom_ceo_inbox
WHERE recipient = 'aissa' AND read_at IS NULL"""
)
return int((row or {}).get("n") or 0)
except Exception:
return 0
def mark_ceo_inbox_read(ids: list[int] | None = None, *, all_unread: bool = False) -> int:
if all_unread:
rows = fetch_all(
"""UPDATE warroom_ceo_inbox SET read_at = NOW()
WHERE recipient = 'aissa' AND read_at IS NULL
RETURNING id"""
)
return len(rows or [])
if not ids:
return 0
rows = fetch_all(
"""UPDATE warroom_ceo_inbox SET read_at = NOW()
WHERE recipient = 'aissa' AND id = ANY(%s) AND read_at IS NULL
RETURNING id""",
(ids,),
)
return len(rows or [])
def parse_mentions(text: str) -> list[str]:
keys = _active_agent_keys()
found: list[str] = []
@@ -327,7 +477,7 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str:
name = soul.get("display_name") or soul.get("agent_key")
key = soul.get("agent_key")
role = soul.get("role_title") or "agent"
soul_md = (soul.get("soul_md") or "")[:700]
soul_md = (soul.get("soul_md") or "")[:1600]
resp = (soul.get("responsibilities") or "")[:350]
roster = _live_roster_line()
hard = (
@@ -353,22 +503,35 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str:
"- Jij bent orchestrator: bij feitelijke retail/CRM-vragen eerst @retail of @bizdev laten antwoorden, "
"of alleen samenvatten wat zij/data al zeiden. Liever geen antwoord dan een verkeerd antwoord.\n"
)
extra = (
accuracy
+ "War Room-regels:\n"
"- Max 4 korte zinnen, Nederlands.\n"
"- Geen @zelf-prefix / geen herhalingen.\n"
"- Peer @mentions oké; zeg nooit dat agents uit staan.\n"
f"{snap}\n"
if room_kind == "warroom"
else accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten.\n"
)
if room_kind == "warroom":
extra = (
accuracy
+ "War Room-regels:\n"
+ "- Max 4 korte zinnen, Nederlands.\n"
+ "- Geen @zelf-prefix / geen herhalingen.\n"
+ "- Peer @mentions verplicht: eindig met @iemand + gerichte vraag of reactie.\n"
+ "- Zeg nooit dat agents uit staan.\n"
+ "- Menselijk & echt: reageer concreet op de vorige spreker (feit, mop of vraag).\n"
+ "- Lichte food-moppen / Foodlinkk-humor mag (respectvol, geen geloof/politiek/grof).\n"
+ "- Mix: meestal inhoud, af en toe 1 mop — daarna weer professioneel.\n"
+ "- Na een mop van een collega: lach kort terug of bouw erop voort, en @mention door.\n"
+ "- CEO heet Aissa. Spreek hem normaal aan met 'Aissa' (of je/jij).\n"
+ "- Volg Aissa's laatste input als prioriteit. Bouw daarop voort, niet ernaast.\n"
+ "- Escalatie naar CEO-inbox: gebruik @aissa + concrete vraag/actie (alleen als echt nodig).\n"
+ "- Goed: 'Aissa, hier is de status…' of '@aissa we hebben jouw go nodig voor X'.\n"
+ "- Gebruik NOOIT @ervoorz (verouderd/verwarrend).\n"
+ "- Nodig collega's uit: @mention wie iets kan toevoegen (finance/bizdev/retail/…).\n"
+ f"{snap}\n"
)
else:
extra = accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten.\n"
return (
f"{hard}\n"
f"Je bent {name} (@{key}), {role} bij Foodlinkk.\n"
f"Spreek ALLEEN als {name} — niet als een andere agent.\n"
f"Focus: {resp or '—'}\n"
f"{soul_md}\n\n{extra}"
f"{soul_md}\n\n{extra}\n\n{agent_nas.memory_rule_text()}"f"- Jouw NAS-werkmap: {agent_nas.agent_paths(str(key))['smb']}\n"
)
@@ -426,6 +589,53 @@ _ALIAS_MAP = {
}
def _topic_contributors(
text: str,
*,
exclude: set[str] | None = None,
limit: int = 2,
) -> list[str]:
"""Pick agents who can meaningfully add to the topic (beyond Herman)."""
exclude = set(exclude or set())
active = _active_agent_keys()
low = (text or "").lower()
rules: list[tuple[tuple[str, ...], str]] = [
(("omzet", "marge", "kosten", "factuur", "cash", "btw", "budget", "winst", "prijs"), "finance"),
(("klant", "deal", "pipeline", "lead", "offerte", "sales", "partner", "b2b"), "bizdev"),
(("retail", "supermarkt", "schap", "ah ", "jumbo", "plus ", "lidl", "listing"), "retail"),
(("campagne", "merk", "content", "social", "branding", "advert"), "marketing"),
(("sku", "assortiment", "recept", "product", "formul"), "product"),
(("inkoop", "leverancier", "sourcing", "moq", "supplier"), "sourcing"),
(("halal", "certific", "kosher"), "halal"),
(("verpakking", "label", "packaging", "design"), "packaging"),
(("onderzoek", "marktanalyse", "research", "concurrent"), "research"),
(("hr", "personeel", "werving", "team"), "hr"),
(("sysops", "server", "deploy", "infra", "bug"), "sysops"),
(("web", "site", "landing", "seo"), "webbuilder"),
(("kennis", "document", "wiki"), "knowledge"),
(("email", "mail", "newsletter"), "email"),
(("browser", "scrape", "website check"), "browser"),
]
picked: list[str] = []
for words, key in rules:
if key in exclude or key not in active or key in picked:
continue
if any(w in low for w in words):
picked.append(key)
if len(picked) >= limit:
return picked
# fallback: rotate through core team not yet speaking
core = ["bizdev", "finance", "retail", "marketing", "product", "sourcing", "research"]
for key in core:
if key in exclude or key not in active or key in picked:
continue
picked.append(key)
if len(picked) >= limit:
break
return picked
def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]:
"""@mentions + soft name matches (bizdev/finance zonder @)."""
active = _active_agent_keys()
@@ -459,6 +669,11 @@ def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]:
# Alleen zonder @mentions: "meeting met jou en bizdev" → Herman erbij
if re.search(r"\b(jou|jullie|samen|meeting|overleg|bellen|call|afspraak)\b", low):
found.insert(0, "herman")
# Zonder expliciete @mentions: laat 1–2 relevante agents meedoen naast Herman
if found and not explicit:
for extra in _topic_contributors(text, exclude=set(found), limit=2):
if extra not in found:
found.append(extra)
return found[:MAX_REPLIES_PER_ROUND]
@@ -720,6 +935,7 @@ async def post_user_message(
role: str | None = None,
sender_avatar_url: str | None = None,
attachments: list[dict[str, Any]] | None = None,
reply_to_id: int | None = None,
) -> dict[str, Any]:
text = (content or "").strip()
files = _normalize_attachments(attachments)
@@ -733,6 +949,19 @@ async def post_user_message(
meta: dict[str, Any] = {"hermes_user": hermes_user}
if files:
meta["attachments"] = files
if reply_to_id:
parent = get_message(int(reply_to_id), room_id)
if parent:
excerpt = (parent.get("content") or "").strip()
if len(excerpt) > 220:
excerpt = excerpt[:219] + "…"
meta["reply_to"] = {
"id": parent.get("id"),
"agent_key": parent.get("agent_key"),
"username": parent.get("username"),
"sender_type": parent.get("sender_type"),
"excerpt": excerpt,
}
user_msg = _insert_message(
room_id,
@@ -841,17 +1070,28 @@ async def _agent_speak_stream(
chunks.append(delta)
yield ("token", {"agent_key": agent_key, "delta": delta})
reply = _sanitize_warroom_reply(agent_key, "".join(chunks))
meta = {
"llm": {"provider_type": "hermes", "hermes_user": agent_user, "streamed": True},
"in_reply_to": in_reply_to,
"model": "hermes-agent/mimo",
}
if parse_ceo_mentions(reply):
meta["ceo_direct"] = True
meta["ceo_alias"] = "aissa"
msg = _insert_message(
int(room["id"]),
sender_type="agent",
agent_key=agent_key,
content=reply,
metadata={
"llm": {"provider_type": "hermes", "hermes_user": agent_user, "streamed": True},
"in_reply_to": in_reply_to,
"model": "hermes-agent/mimo",
},
metadata=meta,
)
try:
inbox = record_ceo_direct(msg)
if inbox:
msg = dict(msg)
msg["ceo_inbox_id"] = inbox.get("id")
except Exception:
log.exception("ceo inbox record failed")
yield ("done", msg)
except Exception as exc:
log.exception("agent stream failed %s", agent_key)
@@ -872,6 +1112,7 @@ async def post_user_message_stream(
role: str | None = None,
sender_avatar_url: str | None = None,
attachments: list[dict[str, Any]] | None = None,
reply_to_id: int | None = None,
):
"""Async generator of SSE-ready event dicts for live typing."""
text = (content or "").strip()
@@ -886,6 +1127,22 @@ async def post_user_message_stream(
meta: dict[str, Any] = {"hermes_user": hermes_user}
if files:
meta["attachments"] = files
parent = None
if reply_to_id:
parent = get_message(int(reply_to_id), room_id)
if parent:
excerpt = (parent.get("content") or "").strip()
if len(excerpt) > 220:
excerpt = excerpt[:219] + "…"
meta["reply_to"] = {
"id": parent.get("id"),
"agent_key": parent.get("agent_key"),
"username": parent.get("username"),
"sender_type": parent.get("sender_type"),
"excerpt": excerpt,
}
user_msg = _insert_message(
room_id,
sender_type="user",
@@ -897,14 +1154,31 @@ async def post_user_message_stream(
yield {"event": "user", "message": user_msg}
trigger = _trigger_text(text, files)
if parent:
who = parent.get("agent_key") or parent.get("username") or "bericht"
excerpt = (meta.get("reply_to") or {}).get("excerpt") or ""
trigger = (
f"CEO Aissa REPLY op bericht van @{who}:\n"
f'\"{excerpt}\"\n\n'
f"Antwoord van Aissa: {trigger}\n\n"
f"BELANGRIJK: reageer DIRECT op dit reply van Aissa. Geen losse A2A-chat."
)
kind = room.get("kind")
speakers: list[str] = []
if kind == "dm":
key = room.get("agent_key")
if key:
speakers = [str(key)]
elif parent and parent.get("sender_type") == "agent" and parent.get("agent_key"):
# reply to agent → die agent eerst, max 1 extra contributor
primary = str(parent.get("agent_key"))
speakers = [primary]
for extra in _topic_contributors(text, exclude={primary}, limit=1):
speakers.append(extra)
else:
speakers = resolve_speakers(trigger, default_herman=True)
# op user-bericht: max 2 speakers (Herman + 1 relevant) — geen storm
speakers = resolve_speakers(trigger, default_herman=True)[:2]
produced: list[dict[str, Any]] = []
@@ -923,35 +1197,25 @@ async def post_user_message_stream(
produced.append(payload)
yield {"event": "system", "message": payload}
user_focus = (
f"Prioriteit: beantwoord EERST het bericht van {username or 'Aissa'}. "
f"Spreek hem aan als Aissa. "
f"Voor escalatie naar CEO-inbox: @aissa + concrete vraag. Daarna optioneel 1 @mention naar een collega."
)
for key in speakers:
async for ev in _emit_speak(key, trig=trigger):
async for ev in _emit_speak(key, trig=f"{trigger}\n\n{user_focus}"):
yield ev
# A2A rounds: agents @mention each other and answer with cockpit data
for _round in range(MAX_ROUNDS - 1):
nxt = _next_a2a_speakers(produced)
if not nxt:
# Force a light peer handoff so the room stays collaborative
last_agents = [m.get("agent_key") for m in produced if m.get("sender_type") == "agent"]
last = str(last_agents[-1]) if last_agents else (speakers[0] if speakers else "herman")
peers = [k for k in ("bizdev", "finance", "retail", "marketing", "herman") if k != last and k in _active_agent_keys()]
if not peers:
break
target = peers[_round % len(peers)]
nudge = (
f"War Room topic van {username or 'user'}: {trigger}\n"
f"@{last} heeft gesproken. Jij bent @{target}. Stel 1 korte vraag aan een peer OF geef 1 cockpit-feit + @mention wie moet antwoorden."
)
async for ev in _emit_speak(target, trig=nudge, reply_to=last):
yield ev
continue
for target, src in nxt:
follow = (
f"War Room topic van {username or 'user'}: {trigger}\n"
f"@{src} noemde je. Antwoord met cockpit-data en @mention zo nodig een andere agent."
)
async for ev in _emit_speak(target, trig=follow, reply_to=src):
yield ev
# Max 1 A2A follow-up — alleen bij expliciete @mention (geen geforceerde storm)
nxt = _next_a2a_speakers(produced)
for target, src in nxt[:1]:
follow = (
f"Bericht van {username or 'Aissa'}: {text}\n"
f"@{src} noemde je. Reageer kort op @{src} EN blijf relevant voor Aissa's bericht. "
f"Geen ellenlange ketting."
)
async for ev in _emit_speak(target, trig=follow, reply_to=src):
yield ev
yield {"event": "done", "ok": True, "a2a": True}
@@ -973,7 +1237,26 @@ async def continue_warroom_stream(
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom")
recent = list_messages(room_id, limit=16)
if not recent:
raise ValueError("Geen gesprek om te vervolgen")
# stil kanaal: toch A2A kickstarten via Herman
topic = "Open A2A check-in: korte status + lichte food-mop, daarna @mention een collega."
produced = []
yield {"event": "continue_start", "topic": topic}
async for kind_ev, payload in _agent_speak_stream(
room, "herman", trigger=(
"Het is stil in de War Room. Start A2A: 1 korte professionele check-in "
"of milde food-mop. Spreek de CEO aan als Aissa; escalatie via @aissa. @mention @bizdev of @retail."
), hermes_user=hermes_user, in_reply_to=None
):
if kind_ev == "start":
yield {"event": "typing_start", **payload}
elif kind_ev == "token":
yield {"event": "typing", **payload}
elif kind_ev == "done":
yield {"event": "agent", "message": payload}
elif kind_ev == "error":
yield {"event": "system", "message": payload}
yield {"event": "done", "ok": True, "continued": True}
return
# Find last user topic
topic = ""
@@ -982,7 +1265,7 @@ async def continue_warroom_stream(
topic = (m.get("content") or "").strip()
break
if not topic:
topic = "Blijf het Foodlinkk cockpit-dossier bespreken (pipeline, deals, retail, finance)."
topic = "Volg Aissa's input als leidraad; spreek hem aan als Aissa; wissel cockpit-dossier met lichte humor; escaleer alleen met @aissa als echt nodig."
produced = [m for m in recent if m.get("sender_type") == "agent"][-6:]
yield {"event": "continue_start", "topic": topic[:200]}
@@ -991,20 +1274,37 @@ async def continue_warroom_stream(
nxt = _next_a2a_speakers(produced)
if not nxt:
last = str((produced[-1] or {}).get("agent_key") or "herman") if produced else "herman"
peers = [k for k in ("herman", "bizdev", "finance", "retail", "marketing", "product") if k != last and k in _active_agent_keys()]
spoken = {str(m.get("agent_key") or "") for m in produced}
spoken.add(last)
contrib = _topic_contributors(topic, exclude=spoken, limit=2)
peers = contrib or [
k for k in (
"herman", "bizdev", "finance", "retail", "marketing",
"product", "sourcing", "research", "halal", "packaging",
)
if k not in spoken and k in _active_agent_keys()
]
if not peers:
peers = [
k for k in ("herman", "bizdev", "finance", "retail", "marketing", "product")
if k != last and k in _active_agent_keys()
]
if not peers:
break
target = peers[turn % len(peers)]
src = last
trig = (
f"Autonoom War Room-overleg (user kan meelezen). Topic: {topic}\n"
f"Jij bent @{target}. Reageer op @{src} met 1 cockpit-feit en @mention 1 collega met een gerichte vraag."
f"Autonoom War Room A2A (user kan meelezen). Topic: {topic}\n"
f"Jij bent @{target}. Reageer ECHT op @{src}.\n"
f"Kies passend: (a) 1 cockpit-feit + gerichte vraag, OF (b) korte respectvolle food-mop + @mention terug naar de inhoud.\n"
f"Breng JOUW expertise in (niet herhalen wat @{src} zei). Spreek de CEO aan als Aissa. Altijd @mention 1 collega; escalatie naar CEO-inbox via @aissa. Max 4 zinnen. Natuurlijk NL."
)
else:
target, src = nxt[0]
trig = (
f"Autonoom War Room-overleg. Topic: {topic}\n"
f"@{src} vroeg jouw input. Antwoord met cockpit-data en @mention desnoods een andere agent."
f"Autonoom War Room A2A. Topic: {topic}\n"
f"@{src} sprak je aan. Reageer op die inhoud (of mop) alsof je in een echt teamoverleg zit.\n"
f"Geef waarde vanuit JOUW rol (feit/actie/vraag). Trek er 1 collega bij die iets kan toevoegen via @mention — niet alleen Herman. Korte food-humor mag."
)
async for kind_ev, payload in _agent_speak_stream(
+139
View File
@@ -0,0 +1,139 @@
"""Agents NAS workspace — alle agent-data op //10.4.7.11/share/Agents."""
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
AGENTS_NAS_ROOT = Path(os.getenv("NAS_AGENTS_ROOT", "/data/nas-share/Agents"))
AGENTS_NAS_UNC = os.getenv("NAS_AGENTS_UNC", "//10.4.7.11/share/Agents")
AGENTS_NAS_SMB = os.getenv("NAS_AGENTS_SMB", "//10.4.7.11/share/Agents")
AGENT_SUBDIRS = ("notes", "exports", "memory", "inbox", "outbox")
def slugify(name: str, max_len: int = 40) -> str:
s = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "file").strip()).strip("-._")
return (s[:max_len] or "file")
def root() -> Path:
return AGENTS_NAS_ROOT
def unc() -> str:
return AGENTS_NAS_UNC
def smb() -> str:
return AGENTS_NAS_SMB
def ensure_root() -> Path:
AGENTS_NAS_ROOT.mkdir(parents=True, exist_ok=True)
sys_dir = AGENTS_NAS_ROOT / "_system"
sys_dir.mkdir(parents=True, exist_ok=True)
readme = sys_dir / "README.txt"
if not readme.exists():
readme.write_text(
"Foodlinkk Agents NAS workspace\n"
f"Pad: {AGENTS_NAS_SMB}\n"
"Elke agent schrijft ALLEEN in Agents/<agent_key>/...\n"
"Submappen: notes, exports, memory, inbox, outbox\n",
encoding="utf-8",
)
meta = {
"unc": AGENTS_NAS_UNC,
"smb": AGENTS_NAS_SMB,
"container_path": str(AGENTS_NAS_ROOT),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
(sys_dir / "workspace.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8"
)
return AGENTS_NAS_ROOT
def ensure_agent_folder(agent_key: str) -> Path:
ensure_root()
key = slugify(agent_key or "unknown", 32).lower()
base = AGENTS_NAS_ROOT / key
for sub in AGENT_SUBDIRS:
(base / sub).mkdir(parents=True, exist_ok=True)
meta = {
"agent_key": key,
"path": str(base),
"smb": f"{AGENTS_NAS_SMB}/{key}",
"subdirs": list(AGENT_SUBDIRS),
}
(base / "agent.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
return base
def bootstrap_all(agent_keys: list[str] | None = None) -> dict[str, Any]:
ensure_root()
keys = agent_keys or []
created = []
for k in keys:
p = ensure_agent_folder(str(k))
created.append(str(p))
return {
"ok": True,
"root": str(AGENTS_NAS_ROOT),
"smb": AGENTS_NAS_SMB,
"agents": created,
"count": len(created),
}
def memory_rule_text() -> str:
return (
"NAS DATA (HARDCODED — VERPLICHT):\n"
"- Alle bestanden/data die jij creëert schrijf je naar de Agents-map op de FL-nas.\n"
f"- Pad: {AGENTS_NAS_SMB}/<jouw_agent_key>/\n"
f"- Server/container pad: {AGENTS_NAS_ROOT}/<jouw_agent_key>/\n"
"- Submappen: notes/ (notities), exports/ (rapporten), memory/ (lange-termijn), "
"inbox/ (inkomend), outbox/ (uitgaand).\n"
"- NOOIT alleen in /tmp laten staan — altijd op deze NAS-map opslaan.\n"
"- Noem in je antwoord het NAS-pad als je iets hebt opgeslagen.\n"
)
def write_text(agent_key: str, relative_path: str, content: str, *, subdir: str = "notes") -> dict[str, Any]:
base = ensure_agent_folder(agent_key)
sub = subdir if subdir in AGENT_SUBDIRS else "notes"
rel = relative_path.lstrip("/\\").replace("..", "")
if not rel:
rel = f"note-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.txt"
target = (base / sub / rel).resolve()
if not str(target).startswith(str(base.resolve())):
raise ValueError("Pad buiten agent-map niet toegestaan")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content if content.endswith("\n") else content + "\n", encoding="utf-8")
key = slugify(agent_key).lower()
return {
"ok": True,
"path": str(target),
"smb": f"{AGENTS_NAS_SMB}/{key}/{sub}/{rel}",
}
def write_json(agent_key: str, relative_path: str, data: Any, *, subdir: str = "exports") -> dict[str, Any]:
name = relative_path if relative_path.endswith(".json") else relative_path + ".json"
return write_text(
agent_key,
name,
json.dumps(data, indent=2, ensure_ascii=False, default=str),
subdir=subdir,
)
def agent_paths(agent_key: str) -> dict[str, str]:
key = slugify(agent_key or "unknown", 32).lower()
return {
"root": str(AGENTS_NAS_ROOT / key),
"smb": f"{AGENTS_NAS_SMB}/{key}",
}
+26 -1
View File
@@ -8,7 +8,7 @@ from typing import Any, Optional
import httpx
from app.db import fetch_all, fetch_one
from app.services import agent_integration, agent_souls, herman, webbuilder_agent
from app.services import agent_integration, agent_nas, agent_souls, herman, webbuilder_agent
from app.services.agent_events_log import log_agent_event
from app.services.agent_names import normalize_agent_key
@@ -24,6 +24,8 @@ GLOBAL_HELP = [
"handoff <agent> <type> [notitie]",
"say <bericht> — log een notitie",
"ask <vraag> — AI-antwoord in rol van deze agent",
"nas — toon Agents NAS-pad",
"save <bestand> <tekst> — schrijf naar NAS Agents/<agent>/notes/",
]
AGENT_HELP: dict[str, list[str]] = {
@@ -430,6 +432,29 @@ async def execute_terminal_command(
else:
note = " ".join(args[2:]) if len(args) > 2 else ""
lines = _handoff(key, args[0], args[1], note)
elif cmd == "nas":
try:
agent_nas.ensure_agent_folder(key)
paths = agent_nas.agent_paths(key)
lines = [
_line("Agents NAS: " + str(agent_nas.smb())),
_line("Jouw map: " + str(paths.get("smb") or "")),
_line("Container: " + str(paths.get("root") or "")),
_line("Sub: notes/ exports/ memory/ inbox/ outbox/"),
]
except Exception as exc:
lines = [_line("NAS fout: " + str(exc), line_type="error")]
elif cmd == "save":
if len(args) < 2:
lines = [_line("Gebruik: save <bestandsnaam> <tekst>", line_type="error")]
else:
fname = args[0]
body = " ".join(args[1:])
try:
result = agent_nas.write_text(key, fname, body, subdir="notes")
lines = [_line("Opgeslagen op NAS: " + str(result.get("smb") or result.get("path")))]
except Exception as exc:
lines = [_line("Save mislukt: " + str(exc), line_type="error")]
elif cmd == "say":
if not rest:
lines = [_line("Gebruik: say <bericht>", line_type="error")]
+21
View File
@@ -138,6 +138,12 @@
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 1200;
display: none;
pointer-events: none;
}
.agent-events-backdrop.is-open {
display: block;
pointer-events: auto;
}
.agent-events-drawer {
position: fixed;
@@ -301,3 +307,18 @@
0%, 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
50% { box-shadow: 0 0 12px rgba(251,191,36,0.25); }
}
/* Brighter, readable agent avatars on dark UI */
.agent-char-wrap {
background: linear-gradient(145deg, #1e4a6e, #163a58) !important;
border: 1px solid rgba(56, 189, 248, 0.35) !important;
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.12), 0 6px 16px rgba(0,0,0,0.25);
overflow: hidden;
}
.agent-char-wrap .agent-char-svg {
filter: brightness(1.18) contrast(1.08) saturate(1.15);
}
.agent-soul-card .agent-char-wrap {
width: 72px;
height: 72px;
}
+285 -8
View File
@@ -279,12 +279,23 @@
background: rgba(255,255,255,0.06);
}
/* Call overlay must escape wr-main overflow clipping */
.wr-main { position: relative; }
.wr-main { position: relative; z-index: 1; }
.wr-composer {
position: relative;
z-index: 6;
pointer-events: auto !important;
}
.wr-composer textarea,
.wr-composer .form-input {
pointer-events: auto !important;
position: relative;
z-index: 7;
}
.wr-log { position: relative; z-index: 1; }
.wr-call-overlay {
position: fixed !important;
inset: 0 !important;
z-index: 11000 !important;
display: none !important;
pointer-events: none !important;
visibility: hidden !important;
}
.btn.is-recording, button.is-recording {
@@ -300,14 +311,16 @@
.wr-voice-overlay {
position: fixed !important; inset: 0 !important; z-index: 11000 !important;
background: rgba(3, 8, 18, 0.88);
display: none; /* CRITICAL: never cover chat when closed */
display: none !important;
align-items: center; justify-content: center;
padding: 1rem;
pointer-events: none;
pointer-events: none !important;
visibility: hidden !important;
}
.wr-voice-overlay.is-open {
display: flex !important;
pointer-events: auto;
pointer-events: auto !important;
visibility: visible !important;
}
.wr-voice-panel {
width: min(920px, 96vw);
@@ -385,3 +398,267 @@
border-top: 1px solid rgba(255,255,255,0.08);
font-size: 0.78rem;
}
/* War Room roster / message avatars — brighter */
.wr-roster .agent-char-wrap,
.wr-avatar .agent-char-wrap {
background: linear-gradient(145deg, #245a82, #1a4568) !important;
border: 1px solid rgba(56, 189, 248, 0.4) !important;
border-radius: 10px;
overflow: hidden;
}
.wr-roster .agent-char-wrap .agent-char-svg,
.wr-avatar .agent-char-wrap .agent-char-svg {
filter: brightness(1.22) contrast(1.1) saturate(1.2);
}
.wr-chan-icon {
display: inline-flex;
width: 16px;
height: 16px;
color: #38bdf8;
margin-right: 0.35rem;
vertical-align: -2px;
}
.wr-chan-icon svg { width: 16px; height: 16px; display: block; }
/* Agents vertical tabs — SVG icons */
.vtabs-nav .vtab-icon-svg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
color: #7dd3fc;
background: color-mix(in srgb, #22d3ee 14%, rgba(15,23,42,.6));
border: 1px solid color-mix(in srgb, #22d3ee 35%, transparent);
font-size: 0;
}
.vtabs-nav .vtab-icon-svg svg { width: 15px; height: 15px; display: block; }
.vtabs-nav .vtab-btn.active .vtab-icon-svg {
color: #fff;
background: color-mix(in srgb, #22d3ee 45%, #0b1220);
border-color: #22d3ee;
box-shadow: 0 0 12px rgba(34, 211, 238, 0.35);
}
/* War Room richer toolbar / search / dates */
.wr-head-title { min-width: 0; }
.wr-msg-count { display: block; margin-top: 0.15rem; font-size: 0.7rem; opacity: 0.65; }
.wr-tool-btn.active {
border-color: rgba(34, 211, 238, 0.55) !important;
background: rgba(34, 211, 238, 0.15) !important;
}
.wr-tools-wrap { position: relative; }
.wr-tools-menu {
position: absolute; right: 0; top: calc(100% + 4px); z-index: 40;
min-width: 200px;
background: #0f2438;
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 10px;
padding: 0.35rem;
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
display: flex; flex-direction: column; gap: 0.15rem;
}
.wr-tools-menu button {
text-align: left; background: transparent; border: 0; color: #e2e8f0;
padding: 0.45rem 0.55rem; border-radius: 7px; cursor: pointer; font-size: 0.8rem;
}
.wr-tools-menu button:hover { background: rgba(56, 189, 248, 0.12); }
.wr-tools-menu button.wr-danger { color: #fda4af; }
.wr-tools-menu button.wr-danger:hover { background: rgba(244, 63, 94, 0.15); }
.wr-tools-menu hr {
border: 0; border-top: 1px solid rgba(148,163,184,0.2); margin: 0.25rem 0;
}
.wr-searchbar {
display: flex; gap: 0.4rem; align-items: center;
margin-bottom: 0.4rem; flex: 0 0 auto;
}
.wr-searchbar .form-input { flex: 1; min-width: 0; font-size: 0.85rem; padding: 0.4rem 0.65rem; }
.wr-day-sep {
display: flex; align-items: center; justify-content: center;
margin: 0.75rem 0 0.55rem;
}
.wr-day-sep span {
font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em;
text-transform: uppercase;
color: #7dd3fc;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
padding: 0.2rem 0.7rem;
border-radius: 999px;
}
.wr-bubble-meta {
display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap;
}
.wr-who { font-weight: 600; color: #e2e8f0; opacity: 1; }
.wr-handle { opacity: 0.55; font-size: 0.65rem; }
.wr-time {
margin-left: auto;
font-variant-numeric: tabular-nums;
opacity: 0.55;
font-size: 0.65rem;
}
.wr-bubble.is-user .wr-time { margin-left: 0; margin-right: auto; order: -1; }
.wr-a2a-btn {
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
}
.wr-a2a-btn.is-on {
background: rgba(34, 197, 94, 0.18) !important;
border: 1px solid rgba(34, 197, 94, 0.45) !important;
color: #86efac !important;
}
.wr-a2a-btn.is-off {
background: rgba(148, 163, 184, 0.12) !important;
border: 1px solid rgba(148, 163, 184, 0.35) !important;
color: #94a3b8 !important;
}
.wr-mobile-bar { display: none; }
/* CEO inbox */
.wr-ceo-btn { position: relative; }
.wr-ceo-badge {
display: inline-grid; place-items: center;
min-width: 1.1rem; height: 1.1rem; padding: 0 0.25rem;
margin-left: 0.25rem; border-radius: 999px;
background: #f43f5e; color: #fff; font-size: 0.65rem; font-weight: 700;
}
.wr-ceo-overlay {
position: absolute; inset: 0; z-index: 80;
background: rgba(2, 8, 20, 0.72);
display: flex; align-items: stretch; justify-content: flex-end;
border-radius: 10px;
}
.wr-main { position: relative; }
.wr-ceo-panel {
width: min(420px, 100%);
background: linear-gradient(180deg, #0f2744, #0a1c33);
border-left: 1px solid rgba(56, 189, 248, 0.35);
box-shadow: -12px 0 40px rgba(0,0,0,0.45);
display: flex; flex-direction: column;
padding: 0.75rem;
}
.wr-ceo-head { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.65rem; }
.wr-ceo-head h3 { margin: 0; font-size: 0.95rem; }
.wr-ceo-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: flex-start; }
.wr-ceo-list { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 0.55rem; }
.wr-ceo-item {
border: 1px solid rgba(148,163,184,0.18);
background: rgba(15, 23, 42, 0.55);
border-radius: 10px;
padding: 0.55rem 0.65rem;
}
.wr-ceo-item.unread {
border-color: rgba(251, 191, 36, 0.55);
box-shadow: inset 3px 0 0 #fbbf24;
background: rgba(251, 191, 36, 0.08);
}
.wr-ceo-item-meta {
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
font-size: 0.72rem; opacity: 0.85; margin-bottom: 0.35rem;
}
.wr-ceo-unread-dot {
background: #fbbf24; color: #111; font-weight: 700;
padding: 0.05rem 0.4rem; border-radius: 999px; font-size: 0.65rem;
}
.wr-ceo-item-body {
white-space: pre-wrap; word-break: break-word;
font-size: 0.86rem; line-height: 1.35;
}
.wr-ceo-item-actions { margin-top: 0.4rem; }
.wr-bubble.is-ceo-direct .wr-bubble-body {
border-color: rgba(251, 191, 36, 0.55) !important;
box-shadow: inset 3px 0 0 #fbbf24;
background: rgba(251, 191, 36, 0.08) !important;
}
/* Reply */
.wr-msg-reply {
margin-left: auto;
border: 0;
background: transparent;
color: inherit;
opacity: 0;
cursor: pointer;
font-size: 0.68rem;
padding: 0 0.2rem;
}
.wr-bubble:hover .wr-msg-reply { opacity: 0.7; }
.wr-msg-reply:hover { opacity: 1 !important; color: var(--accent, #3b82f6); }
.wr-reply-quote {
border-left: 3px solid color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent);
padding: 0.2rem 0.45rem;
margin: 0.15rem 0 0.35rem;
font-size: 0.72rem;
opacity: 0.85;
background: color-mix(in srgb, var(--surface-2, #111) 60%, transparent);
border-radius: 0 6px 6px 0;
}
.wr-reply-quote-who { display: block; font-weight: 600; margin-bottom: 0.1rem; }
.wr-reply-quote-text { display: block; opacity: 0.85; white-space: pre-wrap; }
.wr-reply-bar {
grid-column: 1 / -1;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
margin-bottom: 0.25rem;
border-radius: 8px;
border-left: 3px solid var(--accent, #3b82f6);
background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent);
font-size: 0.78rem;
}
.wr-reply-bar-text { flex: 1; min-width: 0; }
.wr-reply-bar-text strong { display: block; margin-bottom: 0.1rem; }
.wr-reply-bar-text span {
display: block;
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wr-bubble.is-reply-target .wr-bubble-body {
outline: 2px solid color-mix(in srgb, var(--accent, #38bdf8) 70%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, #38bdf8 18%, transparent);
}
.wr-msg-wrap { scroll-margin: 4rem; }
.wr-log-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.wr-log-wrap .wr-log {
flex: 1 1 auto;
min-height: 0;
}
.wr-jump-latest {
position: absolute;
right: 0.75rem;
bottom: 0.75rem;
z-index: 25;
border: 1px solid rgba(56, 189, 248, 0.45);
background: rgba(8, 20, 36, 0.94);
color: #e0f2fe;
border-radius: 999px;
padding: 0.45rem 0.85rem;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
}
.wr-jump-latest:hover {
background: rgba(14, 40, 64, 0.98);
border-color: rgba(56, 189, 248, 0.7);
}
+25 -10
View File
@@ -143,24 +143,39 @@
animation: none !important;
}
/* SVG line icons in sidebar */
.sidebar-nav-icon {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 0.95rem;
background: rgba(8, 12, 18, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
font-size: 0;
color: var(--nav-accent, #7dd3fc);
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 14%, rgba(255,255,255,.06));
border: 1px solid color-mix(in srgb, var(--nav-accent, #22d3ee) 35%, transparent);
box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
}
.sidebar-nav-icon svg {
width: 16px;
height: 16px;
display: block;
}
.app-sidebar a.nav-pill:hover .sidebar-nav-icon {
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 24%, rgba(255,255,255,.08));
border-color: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, transparent);
}
.app-sidebar a.nav-pill.active .sidebar-nav-icon {
background: rgba(15, 23, 42, 0.95);
border-color: var(--nav-accent);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
color: #fff;
background: color-mix(in srgb, var(--nav-accent, #22d3ee) 55%, #0b1220);
border-color: var(--nav-accent, #22d3ee);
box-shadow: 0 0 12px color-mix(in srgb, var(--nav-accent, #22d3ee) 45%, transparent);
}
.sidebar-nav-label {
flex: 1;
min-width: 0;
+8
View File
@@ -1108,3 +1108,11 @@ body.ei-profile-open { overflow: hidden; }
.ei-google-hint { font-size: 0.7rem; color: #64748b; margin: 0 0 0.45rem; }
.ei-google-key-row { display: flex; gap: 0.4rem; margin-bottom: 0.45rem; }
.ei-google-key-row .form-input { flex: 1; min-width: 0; font-size: 0.75rem; padding: 0.35rem 0.5rem; }
.ei-progress-actions { display:flex; gap:0.5rem; justify-content:center; margin-top:0.85rem; flex-wrap:wrap; }
.ei-btn-cancel {
background: rgba(239,68,68,.18) !important;
border: 1px solid rgba(239,68,68,.55) !important;
color: #fecaca !important;
}
.ei-btn-cancel:hover { background: rgba(239,68,68,.32) !important; }
+546 -135
View File
@@ -1,164 +1,318 @@
/* Foodlinkk UI themes — colorful cyan/blue family, mid-dark, vivid accents */
/* Applied via <html data-theme="..."> (localStorage key: flk-theme) */
/* Foodlinkk UI + Sidebar themes
* UI: <html data-theme="..."> localStorage: flk-theme
* Sidebar:<html data-sidebar="..."> localStorage: flk-sidebar-theme
*/
/* ========== UI THEMES (1–5 existing) ========== */
html[data-theme="ocean"] {
--bg-root: #07131f;
--bg-surface: #0c1c2e;
--bg-elevated: #123049;
--bg-hover: #183a58;
--border-subtle: #1f4b6e;
--border-strong: #2d6a96;
--text-primary: #eef9ff;
--text-secondary: #b6e3f7;
--text-muted: #7eb6d4;
--text-body: #d5effc;
--accent-cyan: #22d3ee;
--accent-cyan-dim: rgba(34, 211, 238, 0.22);
--accent-purple: #67e8f9;
--accent-purple-dim: rgba(103, 232, 249, 0.18);
--accent-amber: #38bdf8;
--accent-amber-dim: rgba(56, 189, 248, 0.2);
--accent-green: #2dd4bf;
--accent-green-dim: rgba(45, 212, 191, 0.2);
--accent-red: #fb7185;
--accent-red-dim: rgba(251, 113, 133, 0.18);
--shadow-panel: 0 8px 32px rgba(8, 47, 73, 0.55);
--flk-theme-glow: rgba(34, 211, 238, 0.35);
--bg-root: #07131f; --bg-surface: #0c1c2e; --bg-elevated: #123049; --bg-hover: #183a58;
--border-subtle: #1f4b6e; --border-strong: #2d6a96;
--text-primary: #eef9ff; --text-secondary: #b6e3f7; --text-muted: #7eb6d4; --text-body: #d5effc;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #67e8f9; --accent-purple-dim: rgba(103,232,249,.18);
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 8px 32px rgba(8,47,73,.55); --flk-theme-glow: rgba(34,211,238,.35);
}
html[data-theme="aurora"] {
--bg-root: #0a1020;
--bg-surface: #121a32;
--bg-elevated: #1a2550;
--bg-hover: #243266;
--border-subtle: #3347a0;
--border-strong: #4f6dff;
--text-primary: #f0f4ff;
--text-secondary: #c7d2fe;
--text-muted: #9db0f5;
--text-body: #dde4ff;
--accent-cyan: #22d3ee;
--accent-cyan-dim: rgba(34, 211, 238, 0.2);
--accent-purple: #a78bfa;
--accent-purple-dim: rgba(167, 139, 250, 0.22);
--accent-amber: #818cf8;
--accent-amber-dim: rgba(129, 140, 248, 0.22);
--accent-green: #34d399;
--accent-green-dim: rgba(52, 211, 153, 0.2);
--accent-red: #f472b6;
--accent-red-dim: rgba(244, 114, 182, 0.2);
--shadow-panel: 0 8px 32px rgba(49, 46, 129, 0.5);
--flk-theme-glow: rgba(167, 139, 250, 0.4);
--bg-root: #0a1020; --bg-surface: #121a32; --bg-elevated: #1a2550; --bg-hover: #243266;
--border-subtle: #3347a0; --border-strong: #4f6dff;
--text-primary: #f0f4ff; --text-secondary: #c7d2fe; --text-muted: #9db0f5; --text-body: #dde4ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #a78bfa; --accent-purple-dim: rgba(167,139,250,.22);
--accent-amber: #818cf8; --accent-amber-dim: rgba(129,140,248,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
--shadow-panel: 0 8px 32px rgba(49,46,129,.5); --flk-theme-glow: rgba(167,139,250,.4);
}
html[data-theme="neon"] {
--bg-root: #050b14;
--bg-surface: #0a1628;
--bg-elevated: #10243f;
--bg-hover: #163255;
--border-subtle: #1a4d6e;
--border-strong: #06b6d4;
--text-primary: #ecfeff;
--text-secondary: #a5f3fc;
--text-muted: #67e8f9;
--text-body: #cffafe;
--accent-cyan: #06b6d4;
--accent-cyan-dim: rgba(6, 182, 212, 0.28);
--accent-purple: #e879f9;
--accent-purple-dim: rgba(232, 121, 249, 0.2);
--accent-amber: #22d3ee;
--accent-amber-dim: rgba(34, 211, 238, 0.25);
--accent-green: #4ade80;
--accent-green-dim: rgba(74, 222, 128, 0.22);
--accent-red: #ff6bcb;
--accent-red-dim: rgba(255, 107, 203, 0.22);
--shadow-panel: 0 0 40px rgba(6, 182, 212, 0.18);
--flk-theme-glow: rgba(6, 182, 212, 0.45);
--bg-root: #050b14; --bg-surface: #0a1628; --bg-elevated: #10243f; --bg-hover: #163255;
--border-subtle: #1a4d6e; --border-strong: #06b6d4;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #06b6d4; --accent-cyan-dim: rgba(6,182,212,.28);
--accent-purple: #e879f9; --accent-purple-dim: rgba(232,121,249,.2);
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.25);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #ff6bcb; --accent-red-dim: rgba(255,107,203,.22);
--shadow-panel: 0 0 40px rgba(6,182,212,.18); --flk-theme-glow: rgba(6,182,212,.45);
}
html[data-theme="glacier"] {
--bg-root: #0b1522;
--bg-surface: #122033;
--bg-elevated: #1a2f4a;
--bg-hover: #223b5a;
--border-subtle: #2a5078;
--border-strong: #3b82f6;
--text-primary: #f0f9ff;
--text-secondary: #bae6fd;
--text-muted: #7dd3fc;
--text-body: #e0f2fe;
--accent-cyan: #38bdf8;
--accent-cyan-dim: rgba(56, 189, 248, 0.22);
--accent-purple: #60a5fa;
--accent-purple-dim: rgba(96, 165, 250, 0.2);
--accent-amber: #7dd3fc;
--accent-amber-dim: rgba(125, 211, 252, 0.2);
--accent-green: #2dd4bf;
--accent-green-dim: rgba(45, 212, 191, 0.2);
--accent-red: #fb7185;
--accent-red-dim: rgba(251, 113, 133, 0.18);
--shadow-panel: 0 8px 28px rgba(30, 64, 175, 0.35);
--flk-theme-glow: rgba(56, 189, 248, 0.35);
--bg-root: #0b1522; --bg-surface: #122033; --bg-elevated: #1a2f4a; --bg-hover: #223b5a;
--border-subtle: #2a5078; --border-strong: #3b82f6;
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.22);
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.2);
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 8px 28px rgba(30,64,175,.35); --flk-theme-glow: rgba(56,189,248,.35);
}
html[data-theme="cobalt"] {
--bg-root: #08101c;
--bg-surface: #0f1a2e;
--bg-elevated: #172642;
--bg-hover: #1f3256;
--border-subtle: #274878;
--border-strong: #3b82f6;
--text-primary: #eff6ff;
--text-secondary: #bfdbfe;
--text-muted: #93c5fd;
--text-body: #dbeafe;
--accent-cyan: #22d3ee;
--accent-cyan-dim: rgba(34, 211, 238, 0.2);
--accent-purple: #818cf8;
--accent-purple-dim: rgba(129, 140, 248, 0.22);
--accent-amber: #fbbf24;
--accent-amber-dim: rgba(251, 191, 36, 0.18);
--accent-green: #34d399;
--accent-green-dim: rgba(52, 211, 153, 0.2);
--accent-red: #f87171;
--accent-red-dim: rgba(248, 113, 113, 0.18);
--shadow-panel: 0 10px 36px rgba(37, 99, 235, 0.28);
--flk-theme-glow: rgba(59, 130, 246, 0.4);
--bg-root: #08101c; --bg-surface: #0f1a2e; --bg-elevated: #172642; --bg-hover: #1f3256;
--border-subtle: #274878; --border-strong: #3b82f6;
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.22);
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.18);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
--shadow-panel: 0 10px 36px rgba(37,99,235,.28); --flk-theme-glow: rgba(59,130,246,.4);
}
/* ========== UI THEMES (6–15 new) ========== */
html[data-theme="reef"] {
--bg-root: #04161a; --bg-surface: #0a242b; --bg-elevated: #11353f; --bg-hover: #174650;
--border-subtle: #1f6b78; --border-strong: #2dd4bf;
--text-primary: #ecfeff; --text-secondary: #99f6e4; --text-muted: #5eead4; --text-body: #ccfbf1;
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
--accent-amber: #fbbf24; --accent-amber-dim: rgba(251,191,36,.2);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.2);
--shadow-panel: 0 10px 36px rgba(13,148,136,.35); --flk-theme-glow: rgba(45,212,191,.45);
}
html[data-theme="plasma"] {
--bg-root: #0c0618; --bg-surface: #180b2e; --bg-elevated: #26114a; --bg-hover: #321860;
--border-subtle: #6d28d9; --border-strong: #a78bfa;
--text-primary: #faf5ff; --text-secondary: #e9d5ff; --text-muted: #c4b5fd; --text-body: #f3e8ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.25);
--accent-amber: #f0abfc; --accent-amber-dim: rgba(240,171,252,.2);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
--shadow-panel: 0 10px 40px rgba(109,40,217,.4); --flk-theme-glow: rgba(192,132,252,.5);
}
html[data-theme="lagoon"] {
--bg-root: #031a1f; --bg-surface: #072830; --bg-elevated: #0c3a45; --bg-hover: #114c59;
--border-subtle: #0e7490; --border-strong: #22d3ee;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.28);
--accent-purple: #2dd4bf; --accent-purple-dim: rgba(45,212,191,.22);
--accent-amber: #38bdf8; --accent-amber-dim: rgba(56,189,248,.22);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(8,145,178,.35); --flk-theme-glow: rgba(34,211,238,.45);
}
html[data-theme="indigo"] {
--bg-root: #07091a; --bg-surface: #0f1430; --bg-elevated: #1a2250; --bg-hover: #242d68;
--border-subtle: #3730a3; --border-strong: #6366f1;
--text-primary: #eef2ff; --text-secondary: #c7d2fe; --text-muted: #a5b4fc; --text-body: #e0e7ff;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.2);
--accent-purple: #818cf8; --accent-purple-dim: rgba(129,140,248,.25);
--accent-amber: #60a5fa; --accent-amber-dim: rgba(96,165,250,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.2);
--shadow-panel: 0 10px 36px rgba(67,56,202,.4); --flk-theme-glow: rgba(99,102,241,.45);
}
html[data-theme="skyline"] {
--bg-root: #06101c; --bg-surface: #0c1c33; --bg-elevated: #142d4d; --bg-hover: #1b3c64;
--border-subtle: #0284c7; --border-strong: #38bdf8;
--text-primary: #f0f9ff; --text-secondary: #bae6fd; --text-muted: #7dd3fc; --text-body: #e0f2fe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.28);
--accent-purple: #0ea5e9; --accent-purple-dim: rgba(14,165,233,.22);
--accent-amber: #7dd3fc; --accent-amber-dim: rgba(125,211,252,.22);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(2,132,199,.35); --flk-theme-glow: rgba(56,189,248,.5);
}
html[data-theme="mintvolt"] {
--bg-root: #04140f; --bg-surface: #0a221a; --bg-elevated: #10352a; --bg-hover: #164738;
--border-subtle: #059669; --border-strong: #34d399;
--text-primary: #ecfdf5; --text-secondary: #a7f3d0; --text-muted: #6ee7b7; --text-body: #d1fae5;
--accent-cyan: #2dd4bf; --accent-cyan-dim: rgba(45,212,191,.25);
--accent-purple: #22d3ee; --accent-purple-dim: rgba(34,211,238,.2);
--accent-amber: #a3e635; --accent-amber-dim: rgba(163,230,53,.22);
--accent-green: #4ade80; --accent-green-dim: rgba(74,222,128,.25);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 10px 36px rgba(5,150,105,.35); --flk-theme-glow: rgba(52,211,153,.45);
}
html[data-theme="sapphire"] {
--bg-root: #050d1f; --bg-surface: #0b1835; --bg-elevated: #122650; --bg-hover: #1a3470;
--border-subtle: #1d4ed8; --border-strong: #3b82f6;
--text-primary: #eff6ff; --text-secondary: #bfdbfe; --text-muted: #93c5fd; --text-body: #dbeafe;
--accent-cyan: #38bdf8; --accent-cyan-dim: rgba(56,189,248,.25);
--accent-purple: #60a5fa; --accent-purple-dim: rgba(96,165,250,.22);
--accent-amber: #22d3ee; --accent-amber-dim: rgba(34,211,238,.2);
--accent-green: #2dd4bf; --accent-green-dim: rgba(45,212,191,.2);
--accent-red: #f87171; --accent-red-dim: rgba(248,113,113,.18);
--shadow-panel: 0 10px 40px rgba(29,78,216,.4); --flk-theme-glow: rgba(59,130,246,.5);
}
html[data-theme="arctic"] {
--bg-root: #0a121c; --bg-surface: #121c2a; --bg-elevated: #1c2a3d; --bg-hover: #26384f;
--border-subtle: #64748b; --border-strong: #94a3b8;
--text-primary: #f8fafc; --text-secondary: #e2e8f0; --text-muted: #94a3b8; --text-body: #f1f5f9;
--accent-cyan: #67e8f9; --accent-cyan-dim: rgba(103,232,249,.25);
--accent-purple: #7dd3fc; --accent-purple-dim: rgba(125,211,252,.22);
--accent-amber: #bae6fd; --accent-amber-dim: rgba(186,230,253,.2);
--accent-green: #5eead4; --accent-green-dim: rgba(94,234,212,.2);
--accent-red: #fda4af; --accent-red-dim: rgba(253,164,175,.2);
--shadow-panel: 0 10px 32px rgba(100,116,139,.3); --flk-theme-glow: rgba(125,211,252,.4);
}
html[data-theme="hyperion"] {
--bg-root: #041018; --bg-surface: #081c2a; --bg-elevated: #0e2d42; --bg-hover: #143d58;
--border-subtle: #0891b2; --border-strong: #22d3ee;
--text-primary: #ecfeff; --text-secondary: #a5f3fc; --text-muted: #67e8f9; --text-body: #cffafe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.3);
--accent-purple: #a3e635; --accent-purple-dim: rgba(163,230,53,.22);
--accent-amber: #4ade80; --accent-amber-dim: rgba(74,222,128,.22);
--accent-green: #84cc16; --accent-green-dim: rgba(132,204,22,.22);
--accent-red: #fb7185; --accent-red-dim: rgba(251,113,133,.18);
--shadow-panel: 0 0 42px rgba(8,145,178,.3); --flk-theme-glow: rgba(34,211,238,.55);
}
html[data-theme="meridian"] {
--bg-root: #0a0716; --bg-surface: #140d28; --bg-elevated: #221645; --bg-hover: #2e1e5c;
--border-subtle: #7c3aed; --border-strong: #a78bfa;
--text-primary: #f5f3ff; --text-secondary: #ddd6fe; --text-muted: #c4b5fd; --text-body: #ede9fe;
--accent-cyan: #22d3ee; --accent-cyan-dim: rgba(34,211,238,.22);
--accent-purple: #c084fc; --accent-purple-dim: rgba(192,132,252,.28);
--accent-amber: #e879f9; --accent-amber-dim: rgba(232,121,249,.22);
--accent-green: #34d399; --accent-green-dim: rgba(52,211,153,.2);
--accent-red: #f472b6; --accent-red-dim: rgba(244,114,182,.22);
--shadow-panel: 0 10px 40px rgba(124,58,237,.4); --flk-theme-glow: rgba(167,139,250,.5);
}
/* Soft global accents when a theme is active */
html[data-theme] body {
background:
radial-gradient(1200px 600px at 10% -10%, color-mix(in srgb, var(--accent-cyan) 18%, transparent), transparent 60%),
radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--accent-purple) 14%, transparent), transparent 55%),
var(--bg-root);
}
html[data-theme] .sidebar,
html[data-theme] .app-sidebar {
border-right-color: color-mix(in srgb, var(--accent-cyan) 35%, var(--border-subtle));
box-shadow: inset -1px 0 0 color-mix(in srgb, var(--accent-cyan) 15%, transparent);
html[data-theme] .panel,
html[data-theme] .hm-neo {
border-color: color-mix(in srgb, var(--accent-cyan) 22%, var(--border-subtle));
}
html[data-theme] .btn-pulse,
html[data-theme] .btn-primary {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan) 40%, transparent),
0 8px 24px color-mix(in srgb, var(--accent-cyan) 22%, transparent);
}
html[data-theme] .panel,
html[data-theme] .hm-neo {
border-color: color-mix(in srgb, var(--accent-cyan) 22%, var(--border-subtle));
/* ========== SIDEBAR THEMES (independent, bright) ========== */
html[data-sidebar] .app-sidebar {
border-right: 1px solid color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 45%, transparent) !important;
box-shadow: inset -1px 0 0 color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 20%, transparent),
4px 0 28px color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 12%, transparent);
}
html[data-sidebar] .app-sidebar a.nav-pill:hover {
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 16%, transparent) !important;
border-color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 35%, transparent);
}
html[data-sidebar] .app-sidebar a.nav-pill.active {
background: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 22%, rgba(8,12,18,.9)) !important;
box-shadow: inset 3px 0 0 var(--flk-sidebar-accent, #22d3ee);
}
html[data-sidebar] .app-sidebar-label {
color: color-mix(in srgb, var(--flk-sidebar-accent, #22d3ee) 85%, #fff) !important;
}
/* Settings theme cards */
html[data-sidebar="aqua"] .app-sidebar {
--flk-sidebar-accent: #5eead4;
--flk-sidebar-solid: #0a4f5c;
--flk-sidebar-grad: linear-gradient(180deg, #062a32 0%, #0a4f5c 55%, #0d6b7a 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="azure"] .app-sidebar {
--flk-sidebar-accent: #60a5fa;
--flk-sidebar-solid: #163a7a;
--flk-sidebar-grad: linear-gradient(180deg, #0a1f45 0%, #163a7a 55%, #1e4f9e 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="turquoise"] .app-sidebar {
--flk-sidebar-accent: #2dd4bf;
--flk-sidebar-solid: #0a453f;
--flk-sidebar-grad: linear-gradient(180deg, #052925 0%, #0a453f 55%, #0f5f56 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="cerulean"] .app-sidebar {
--flk-sidebar-accent: #38bdf8;
--flk-sidebar-solid: #0c4a6e;
--flk-sidebar-grad: linear-gradient(180deg, #06263a 0%, #0c4a6e 55%, #126087 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="electric"] .app-sidebar {
--flk-sidebar-accent: #22d3ee;
--flk-sidebar-solid: #0a3d4a;
--flk-sidebar-grad: linear-gradient(180deg, #041820 0%, #0a3d4a 50%, #0e5a6b 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="violet"] .app-sidebar {
--flk-sidebar-accent: #818cf8;
--flk-sidebar-solid: #2a1f6b;
--flk-sidebar-grad: linear-gradient(180deg, #150e3a 0%, #2a1f6b 55%, #3b2f8a 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="emerald"] .app-sidebar {
--flk-sidebar-accent: #34d399;
--flk-sidebar-solid: #0a4a38;
--flk-sidebar-grad: linear-gradient(180deg, #05291e 0%, #0a4a38 55%, #0f6349 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="midnight"] .app-sidebar {
--flk-sidebar-accent: #7dd3fc;
--flk-sidebar-solid: #0d1f3c;
--flk-sidebar-grad: linear-gradient(180deg, #060e1c 0%, #0d1f3c 55%, #153056 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="steel"] .app-sidebar {
--flk-sidebar-accent: #94a3b8;
--flk-sidebar-solid: #1a2d42;
--flk-sidebar-grad: linear-gradient(180deg, #0d1622 0%, #1a2d42 55%, #243b55 100%);
background: var(--flk-sidebar-grad) !important;
}
html[data-sidebar="nordic"] .app-sidebar {
--flk-sidebar-accent: #67e8f9;
--flk-sidebar-solid: #0e3a4f;
--flk-sidebar-grad: linear-gradient(180deg, #071f2c 0%, #0e3a4f 55%, #15506a 100%);
background: var(--flk-sidebar-grad) !important;
}
/* Sidebar fill mode: gradient (default) vs plain solid */
html[data-sidebar][data-sidebar-style="plain"] .app-sidebar {
background: var(--flk-sidebar-solid, #0a3d4a) !important;
}
html[data-sidebar]:not([data-sidebar-style="plain"]) .app-sidebar {
background: var(--flk-sidebar-grad) !important;
}
.flk-style-toggle {
display: inline-flex;
gap: 0.35rem;
margin: 0.65rem 0 0.85rem;
padding: 0.25rem;
border-radius: 10px;
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent);
}
.flk-style-toggle button {
border: 0;
background: transparent;
color: var(--text-secondary, #b6e3f7);
padding: 0.4rem 0.85rem;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.flk-style-toggle button.is-active {
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent);
color: #fff;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-cyan, #22d3ee) 45%, transparent);
}
/* Settings cards */
.flk-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.85rem;
margin-top: 1rem;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
margin-top: 0.85rem;
}
.flk-theme-card {
border: 1px solid var(--border-subtle);
border-radius: 14px;
padding: 0.85rem;
padding: 0.75rem;
background: var(--bg-elevated);
cursor: pointer;
text-align: left;
@@ -171,10 +325,267 @@ html[data-theme] .hm-neo {
box-shadow: 0 0 0 2px var(--accent-cyan-dim), 0 12px 28px rgba(0,0,0,.35);
}
.flk-theme-swatch {
height: 52px;
border-radius: 10px;
margin-bottom: 0.65rem;
border: 1px solid rgba(255,255,255,0.12);
height: 52px; border-radius: 10px; margin-bottom: 0.55rem;
border: 1px solid rgba(255,255,255,.18);
background-color: #1e293b;
background-size: cover !important;
background-repeat: no-repeat !important;
}
.flk-theme-card strong { display: block; margin-bottom: 0.12rem; font-size: 0.9rem; }
.flk-theme-card small { opacity: 0.75; line-height: 1.3; display: block; font-size: 0.75rem; }
.flk-theme-section { margin-top: 1.75rem; }
.flk-theme-section h3 { margin: 0 0 0.25rem; font-size: 1.05rem; }
/* ========== PAGE BACKGROUNDS (independent of UI theme) ==========
* <html data-bg="..."> localStorage: flk-page-bg
* Applies to body + app-main + common subpage shells
*/
html[data-bg="navy"] { --flk-page-bg: #0d2b5c; --flk-page-bg2: #134078; --bg-root: #0d2b5c; }
html[data-bg="royal"] { --flk-page-bg: #1a3a8f; --flk-page-bg2: #274bb8; --bg-root: #1a3a8f; }
html[data-bg="azure"] { --flk-page-bg: #0a4f8a; --flk-page-bg2: #0d6cb8; --bg-root: #0a4f8a; }
html[data-bg="sky"] { --flk-page-bg: #0e5f9e; --flk-page-bg2: #1880c9; --bg-root: #0e5f9e; }
html[data-bg="cerulean"] { --flk-page-bg: #0c6a9e; --flk-page-bg2: #1490c8; --bg-root: #0c6a9e; }
html[data-bg="teal"] { --flk-page-bg: #0c5c68; --flk-page-bg2: #128090; --bg-root: #0c5c68; }
html[data-bg="lagoon"] { --flk-page-bg: #0a6b7a; --flk-page-bg2: #0f8fa3; --bg-root: #0a6b7a; }
html[data-bg="cyan"] { --flk-page-bg: #0a5568; --flk-page-bg2: #0e7a94; --bg-root: #0a5568; }
html[data-bg="indigo"] { --flk-page-bg: #2f2a7a; --flk-page-bg2: #4338ca; --bg-root: #2f2a7a; }
html[data-bg="violet"] { --flk-page-bg: #4a1d7a; --flk-page-bg2: #6d28d9; --bg-root: #4a1d7a; }
html[data-bg="sapphire"] { --flk-page-bg: #123a8c; --flk-page-bg2: #1d4ed8; --bg-root: #123a8c; }
html[data-bg="midnight"] { --flk-page-bg: #1a2f55; --flk-page-bg2: #274878; --bg-root: #1a2f55; }
html[data-bg="emerald"] { --flk-page-bg: #0d5c45; --flk-page-bg2: #059669; --bg-root: #0d5c45; }
html[data-bg="slate"] { --flk-page-bg: #243447; --flk-page-bg2: #334155; --bg-root: #243447; }
html[data-bg="graphite"] { --flk-page-bg: #1e2a3a; --flk-page-bg2: #2c3b52; --bg-root: #1e2a3a; }
html[data-bg] body {
background:
radial-gradient(ellipse 90% 55% at 12% -8%, color-mix(in srgb, var(--accent-cyan, #22d3ee) 18%, transparent), transparent 55%),
radial-gradient(ellipse 70% 45% at 92% 8%, color-mix(in srgb, var(--accent-purple, #818cf8) 14%, transparent), transparent 50%),
linear-gradient(165deg, var(--flk-page-bg) 0%, var(--flk-page-bg2, var(--flk-page-bg)) 100%) !important;
background-attachment: fixed !important;
}
html[data-bg] .app-main,
html[data-bg] .app-shell,
html[data-bg] main,
html[data-bg] .page,
html[data-bg] .page-wrap,
html[data-bg] .content-area,
html[data-bg] .settings-layout,
html[data-bg] .hm-page,
html[data-bg] .warroom-root,
html[data-bg] .agents-page {
background: transparent !important;
}
html[data-bg] .panel,
html[data-bg] .hm-neo,
html[data-bg] .card,
html[data-bg] .settings-panel {
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
backdrop-filter: blur(8px);
}
/* Kill leftover near-black shells when page bg is set */
html[data-bg] .app-main > *,
html[data-bg] .dashboard-root,
html[data-bg] .retail-page,
html[data-bg] .ops-page,
html[data-bg] .documents-page,
html[data-bg] .analytics-wrap,
html[data-bg] .export-intel,
html[data-bg] .clients-page,
html[data-bg] .wr-shell,
html[data-bg] .voice-page,
html[data-bg] .studio-page,
html[data-bg] .hermes-page,
html[data-bg] .projects-hub,
html[data-bg] .packaging-page,
html[data-bg] .beurs-page,
html[data-bg] .marketing-page,
html[data-bg] .revenue-cockpit {
background-color: transparent !important;
}
html[data-bg] {
background: var(--flk-page-bg) !important;
}
/* ========== SURFACE OVERRIDE (kill leftover blacks on subpages) ========== */
html[data-theme],
html[data-bg] {
--flk-surface: color-mix(in srgb, var(--bg-elevated, #123049) 88%, #fff 0%);
--flk-surface-2: color-mix(in srgb, var(--bg-surface, #0c1c2e) 92%, var(--accent-cyan, #22d3ee) 8%);
--flk-input: color-mix(in srgb, var(--bg-root, #07131f) 70%, var(--bg-elevated, #123049) 30%);
--flk-panel: color-mix(in srgb, var(--bg-elevated, #123049) 75%, transparent);
}
/* When a page background is chosen, lift surface tokens off near-black */
html[data-bg="navy"] { --bg-surface: #134078; --bg-elevated: #1a4f96; --bg-hover: #2460b0; }
html[data-bg="royal"] { --bg-surface: #274bb8; --bg-elevated: #3359d4; --bg-hover: #4169e8; }
html[data-bg="azure"] { --bg-surface: #0d6cb8; --bg-elevated: #1580d4; --bg-hover: #1e92ea; }
html[data-bg="sky"] { --bg-surface: #1880c9; --bg-elevated: #2493de; --bg-hover: #36a4ec; }
html[data-bg="cerulean"] { --bg-surface: #1490c8; --bg-elevated: #20a3dc; --bg-hover: #32b4ea; }
html[data-bg="teal"] { --bg-surface: #128090; --bg-elevated: #1898a8; --bg-hover: #22aebb; }
html[data-bg="lagoon"] { --bg-surface: #0f8fa3; --bg-elevated: #18a4ba; --bg-hover: #24b8ce; }
html[data-bg="cyan"] { --bg-surface: #0e7a94; --bg-elevated: #1490ae; --bg-hover: #1ea4c4; }
html[data-bg="indigo"] { --bg-surface: #4338ca; --bg-elevated: #5450e0; --bg-hover: #6666f0; }
html[data-bg="violet"] { --bg-surface: #6d28d9; --bg-elevated: #7e3aec; --bg-hover: #8f4cf8; }
html[data-bg="sapphire"] { --bg-surface: #1d4ed8; --bg-elevated: #2b5fec; --bg-hover: #3b70f8; }
html[data-bg="midnight"] { --bg-surface: #274878; --bg-elevated: #335c96; --bg-hover: #4070b0; }
html[data-bg="emerald"] { --bg-surface: #059669; --bg-elevated: #0aad78; --bg-hover: #14c08a; }
html[data-bg="slate"] { --bg-surface: #334155; --bg-elevated: #3f5068; --bg-hover: #4d6078; }
html[data-bg="graphite"] { --bg-surface: #2c3b52; --bg-elevated: #384a66; --bg-hover: #465a78; }
/* App chrome */
html[data-theme] .app-topbar,
html[data-bg] .app-topbar,
html[data-theme] .app-main,
html[data-bg] .app-main {
background: transparent !important;
}
html[data-theme] .app-topbar,
html[data-bg] .app-topbar {
background: color-mix(in srgb, var(--bg-elevated, #123049) 55%, transparent) !important;
border-bottom-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
backdrop-filter: blur(10px);
}
/* Generic panels / cards */
html[data-theme] .panel,
html[data-bg] .panel,
html[data-theme] .card,
html[data-bg] .card,
html[data-theme] .hm-neo,
html[data-bg] .hm-neo,
html[data-theme] .settings-panel,
html[data-bg] .settings-panel {
background: color-mix(in srgb, var(--bg-elevated, #123049) 78%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--border-subtle, #1f4b6e)) !important;
}
/* ========== Export Intel surfaces ========== */
html[data-theme] .ei-page,
html[data-bg] .ei-page {
background: transparent !important;
}
html[data-theme] .ei-kpi,
html[data-bg] .ei-kpi,
html[data-theme] .ei-neo-kpi,
html[data-bg] .ei-neo-kpi {
background: color-mix(in srgb, var(--bg-elevated, #123049) 82%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
}
html[data-theme] .ei-neo-ring-inner,
html[data-bg] .ei-neo-ring-inner {
background: var(--bg-surface, #0c1c2e) !important;
}
html[data-theme] .ei-filter-bar,
html[data-bg] .ei-filter-bar {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--accent-cyan, #22d3ee) 16%, var(--bg-elevated, #123049)),
color-mix(in srgb, var(--bg-surface, #0c1c2e) 90%, transparent)
) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-filters select,
html[data-theme] .ei-filters input,
html[data-theme] .ei-filter-label select,
html[data-theme] .ei-filter-label input,
html[data-bg] .ei-filters select,
html[data-bg] .ei-filters input,
html[data-bg] .ei-filter-label select,
html[data-bg] .ei-filter-label input {
background: color-mix(in srgb, var(--bg-root, #07131f) 55%, var(--bg-elevated, #123049) 45%) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
color: var(--text-primary, #eef9ff) !important;
}
html[data-theme] .ei-tab-panel,
html[data-bg] .ei-tab-panel,
html[data-theme] .ei-tab-panel:has(.ei-workspace-body),
html[data-bg] .ei-tab-panel:has(.ei-workspace-body) {
background: color-mix(in srgb, var(--bg-elevated, #123049) 72%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent) !important;
}
html[data-theme] .ei-side-panel,
html[data-bg] .ei-side-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-elevated, #123049) 92%, #fff 0%),
color-mix(in srgb, var(--bg-surface, #0c1c2e) 95%, transparent)
) !important;
border-left-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 25%, transparent) !important;
}
html[data-theme] .ei-side-panel-head,
html[data-bg] .ei-side-panel-head {
background: color-mix(in srgb, var(--bg-hover, #183a58) 55%, transparent) !important;
}
html[data-theme] .ei-map-wrap,
html[data-bg] .ei-map-wrap {
background: color-mix(in srgb, var(--bg-root, #07131f) 80%, var(--bg-elevated, #123049) 20%) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 28%, transparent) !important;
}
html[data-theme] .ei-map-loading,
html[data-bg] .ei-map-loading {
background: color-mix(in srgb, var(--bg-root, #07131f) 70%, transparent) !important;
}
html[data-theme] .ei-map-wrap .leaflet-popup-content-wrapper,
html[data-theme] .ei-map-wrap .leaflet-popup-tip,
html[data-bg] .ei-map-wrap .leaflet-popup-content-wrapper,
html[data-bg] .ei-map-wrap .leaflet-popup-tip {
background: var(--bg-elevated, #123049) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 40%, transparent) !important;
}
html[data-theme] .ei-table th,
html[data-bg] .ei-table th {
background: var(--bg-elevated, #123049) !important;
}
html[data-theme] .ei-table tbody tr,
html[data-bg] .ei-table tbody tr {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent);
}
html[data-theme] .ei-table tbody tr:nth-child(even),
html[data-bg] .ei-table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--bg-elevated, #123049) 45%, transparent);
}
html[data-theme] .ei-drawer,
html[data-bg] .ei-drawer,
html[data-theme] .ei-modal-card,
html[data-bg] .ei-modal-card,
html[data-theme] .ei-progress-card,
html[data-bg] .ei-progress-card {
background: var(--bg-elevated, #123049) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-sync-monitor,
html[data-bg] .ei-sync-monitor {
background: color-mix(in srgb, var(--bg-elevated, #123049) 88%, transparent) !important;
border-color: color-mix(in srgb, var(--accent-cyan, #22d3ee) 30%, transparent) !important;
}
html[data-theme] .ei-sync-job,
html[data-bg] .ei-sync-job,
html[data-theme] .ei-sync-coverage div,
html[data-bg] .ei-sync-coverage div,
html[data-theme] .ei-google-places,
html[data-bg] .ei-google-places {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 70%, transparent) !important;
}
html[data-theme] .wereldexport-workspace > .vtabs-nav,
html[data-bg] .wereldexport-workspace > .vtabs-nav {
background: color-mix(in srgb, var(--bg-elevated, #123049) 70%, transparent) !important;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, transparent);
}
html[data-theme] .vtab-btn,
html[data-bg] .vtab-btn {
background: color-mix(in srgb, var(--bg-surface, #0c1c2e) 55%, transparent) !important;
}
html[data-theme] .vtab-btn.active,
html[data-bg] .vtab-btn.active {
background: color-mix(in srgb, var(--accent-cyan, #22d3ee) 22%, var(--bg-elevated, #123049)) !important;
}
/* Persona / brand switch in topbar */
html[data-theme] .persona-btn,
html[data-bg] .persona-btn,
html[data-theme] .brand-switch .persona-btn,
html[data-bg] .brand-switch .persona-btn {
background: color-mix(in srgb, var(--bg-elevated, #123049) 60%, transparent) !important;
}
.flk-theme-card strong { display: block; margin-bottom: 0.15rem; }
.flk-theme-card small { opacity: 0.75; line-height: 1.35; display: block; }
+253
View File
@@ -191,3 +191,256 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
@media (display-mode: standalone), (display-mode: fullscreen) {
.pwa-install-bar { display: none !important; }
}
/* ===== War Room FULL CHAT on phone (Android + iOS) ===== */
@media (max-width: 900px) {
/* Tabs: compact chip row */
.vtabs-layout {
display: flex !important;
flex-direction: column !important;
gap: 0.3rem !important;
min-height: 0 !important;
}
.vtabs-nav {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
width: 100% !important;
max-width: 100% !important;
position: sticky !important;
top: 0 !important;
z-index: 50 !important;
padding: 0.3rem !important;
margin: 0 !important;
}
.vtabs-nav .vtab-btn {
flex: 0 0 auto !important;
width: auto !important;
white-space: nowrap !important;
min-height: 40px !important;
padding: 0.35rem 0.6rem !important;
}
.vtabs-nav-header { display: none !important; }
.vtabs-content { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; min-height: 0 !important; }
/* Hide bulky page chrome in War Room */
.hm-neo.tab-warroom-active > .wr-page-header,
.hm-neo.tab-warroom-active > .page-header {
display: none !important;
}
.hm-neo.tab-warroom-active {
display: flex !important;
flex-direction: column !important;
height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
max-height: calc(100dvh - 3.25rem - env(safe-area-inset-top, 0px)) !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.hm-neo.tab-warroom-active .vtabs-layout {
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.hm-neo.tab-warroom-active .vtabs-content {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
/* Let Alpine x-show hide inactive tabs; only stretch the visible warroom section */
.hm-neo.tab-warroom-active .vtabs-content > section:not([style*="display: none"]) {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.wr-hub-root {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
.wr-hub-root > .panel {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
max-height: none !important;
padding: 0.35rem !important;
margin: 0 !important;
border-radius: 10px !important;
}
.wr-layout {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
height: 100% !important;
max-height: none !important;
min-height: 0 !important;
gap: 0.3rem !important;
}
.wr-mobile-bar {
display: flex !important;
flex: 0 0 auto;
gap: 0.3rem;
align-items: center;
justify-content: space-between;
}
.wr-layout.roster-collapsed .wr-roster { display: none !important; }
.wr-roster {
flex: 0 0 auto !important;
max-height: 18vh !important;
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
border: 0 !important;
border-bottom: 1px solid rgba(148,163,184,0.15) !important;
padding: 0 0 0.25rem !important;
}
.wr-roster h3 { display: none !important; }
.wr-room-btn, .wr-agent-btn {
flex: 0 0 auto !important;
width: auto !important;
min-width: 6rem;
}
.wr-main {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: auto !important;
max-height: none !important;
}
/* Compact toolbar */
.wr-head {
flex: 0 0 auto !important;
margin: 0 !important;
gap: 0.25rem !important;
}
.wr-head-title h2 { font-size: 0.9rem !important; }
.wr-head-title .muted,
.wr-head-title .wr-msg-count { display: none !important; }
.wr-head-actions {
width: 100%;
gap: 0.25rem !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
.wr-head-actions .btn {
flex: 0 0 auto;
min-height: 36px !important;
padding: 0.3rem 0.45rem !important;
font-size: 0.75rem !important;
}
.wr-head-actions .wr-a2a-btn,
.wr-head-actions button[title="Open op tweede scherm"],
.wr-head-actions button[title="Spraakmeeting met agents"],
.wr-head-actions button[title="Snel praten"] {
display: none !important;
}
/* THE chat window — take all remaining space */
.wr-log {
flex: 1 1 auto !important;
min-height: 50dvh !important;
height: auto !important;
max-height: none !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important;
overscroll-behavior: contain;
padding: 0.5rem !important;
}
/* Readable bubbles */
.wr-bubble { gap: 0.4rem !important; margin-bottom: 0.55rem !important; }
.wr-avatar { width: 34px !important; height: 34px !important; flex-basis: 34px !important; }
.wr-avatar img, .wr-avatar .agent-char-wrap,
.wr-avatar .agent-char-wrap svg,
.wr-avatar-fallback { width: 34px !important; height: 34px !important; }
.wr-bubble-body {
max-width: calc(100% - 42px) !important;
width: auto !important;
padding: 0.45rem 0.55rem !important;
}
.wr-bubble-text {
font-size: 1rem !important;
line-height: 1.45 !important;
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.wr-bubble-meta { font-size: 0.72rem !important; }
.wr-msg-reply, .wr-msg-del { opacity: 0.8 !important; }
.wr-composer {
flex: 0 0 auto !important;
position: relative !important;
bottom: auto !important;
z-index: 20;
margin-top: 0.25rem !important;
padding-bottom: max(0.35rem, env(safe-area-inset-bottom, 0px)) !important;
background: rgba(8, 12, 18, 0.98);
}
.wr-composer textarea,
.wr-composer .form-input {
font-size: 16px !important;
min-height: 48px !important;
line-height: 1.35 !important;
}
.wr-composer .btn-pulse-green {
min-height: 48px !important;
min-width: 4.5rem !important;
}
.wr-ceo-panel {
width: 100% !important;
max-height: 85dvh !important;
}
}
/* Less top padding around agents content on phone */
@media (max-width: 900px) {
.app-main .main-topnav {
padding-top: 0.45rem !important;
padding-bottom: 0.45rem !important;
}
body:has(.tab-warroom-active) .app-main .main-topnav .page-tip { display: none !important; }
}
@supports not (height: 100dvh) {
@media (max-width: 900px) {
.hm-neo.tab-warroom-active {
height: calc(100vh - 3.25rem) !important;
max-height: calc(100vh - 3.25rem) !important;
}
.wr-log { min-height: 50vh !important; }
}
}
/* reply-target mobile */
@media (max-width: 900px) {
.wr-bubble.is-reply-target .wr-bubble-body {
outline: 2px solid #38bdf8;
}
.wr-msg-del { opacity: 0.35 !important; }
.wr-msg-reply { opacity: 1 !important; font-weight: 600; }
}
+52
View File
@@ -276,3 +276,55 @@
.vtab-btn.vtab-btn-rich .ei-vtab-text,
.vtab-btn.vtab-btn-rich .ei-vtab-key { display: none; }
}
/* MOBILE TABS FIX — horizontal chip bar (Android + iOS) */
@media (max-width: 900px) {
.vtabs-layout {
flex-direction: column !important;
align-items: stretch !important;
gap: 0.5rem !important;
min-height: 0 !important;
}
.vtabs-layout > .vtabs-nav {
flex: 0 0 auto !important;
width: 100% !important;
max-width: none !important;
}
.vtabs-layout > .vtabs-content {
flex: 1 1 auto !important;
min-width: 0 !important;
width: 100% !important;
}
.vtabs-nav {
position: sticky !important;
top: 0 !important;
z-index: 40 !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
gap: 0.3rem !important;
padding: 0.4rem !important;
overflow-x: auto !important;
overflow-y: hidden !important;
-webkit-overflow-scrolling: touch !important;
scrollbar-width: none;
}
.vtabs-nav::-webkit-scrollbar { display: none; }
.vtabs-nav::before { display: none !important; }
.vtabs-nav-header { display: none !important; }
.vtab-btn {
flex: 0 0 auto !important;
width: auto !important;
max-width: none !important;
min-width: auto !important;
white-space: nowrap !important;
padding: 0.45rem 0.7rem !important;
font-size: 0.82rem !important;
}
.vtab-btn .vtab-icon,
.vtab-btn .vtab-icon-svg {
width: 26px !important;
height: 26px !important;
}
}
+60 -49
View File
@@ -1,38 +1,48 @@
/** 3D-style agent poppetjes met headset — consistent team look */
/** Agent avatars — bright cool cyan/blue team look (readable on dark UI) */
window.AgentAvatars = (function () {
var CFG = {
herman: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'exec', accent: '#ffd700' },
marketing: { hoodie: '#9d174d', hair: '#1f2937', skin: '#f5c9a8', style: 'bob', accent: '#ff6b9d' },
bizdev: { hoodie: '#0e4d6e', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#00e5ff' },
finance: { hoodie: '#14532d', hair: '#3d2314', skin: '#e8b896', style: 'glasses', accent: '#22c55e' },
sourcing: { hoodie: '#9a3412', hair: '#1a1a1a', skin: '#c9a07a', style: 'short', accent: '#f97316' },
product: { hoodie: '#581c87', hair: '#4a3728', skin: '#f5c9a8', style: 'curl', accent: '#a855f7' },
halal: { hoodie: '#065f46', hair: '#1f2937', skin: '#d4a574', style: 'short', accent: '#34d399' },
design: { hoodie: '#831843', hair: '#111827', skin: '#f5c9a8', style: 'long', accent: '#ec4899' },
knowledge: { hoodie: '#312e81', hair: '#374151', skin: '#e8b896', style: 'glasses', accent: '#6366f1' },
retail: { hoodie: '#365314', hair: '#2d1810', skin: '#ddb896', style: 'short', accent: '#b8ff3c' },
browser: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#c9a07a', style: 'short', accent: '#38bdf8' },
hr: { hoodie: '#6b21a8', hair: '#2d1810', skin: '#ddb896', style: 'bob', accent: '#e879f9' },
email: { hoodie: '#334155', hair: '#4a3728', skin: '#ddb896', style: 'short', accent: '#94a3b8' },
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' },
herman: { hoodie: '#1d4ed8', hair: '#5b3a29', skin: '#f0c9a8', style: 'exec', accent: '#fbbf24' },
marketing: { hoodie: '#0891b2', hair: '#1f2937', skin: '#f5d0b0', style: 'bob', accent: '#22d3ee' },
bizdev: { hoodie: '#0284c7', hair: '#3d2314', skin: '#e8c4a0', style: 'short', accent: '#38bdf8' },
finance: { hoodie: '#0d9488', hair: '#3d2314', skin: '#f0c9a8', style: 'glasses', accent: '#2dd4bf' },
sourcing: { hoodie: '#0369a1', hair: '#1a1a1a', skin: '#e0b890', style: 'short', accent: '#7dd3fc' },
product: { hoodie: '#4f46e5', hair: '#5b3a29', skin: '#f5d0b0', style: 'curl', accent: '#818cf8' },
halal: { hoodie: '#059669', hair: '#1f2937', skin: '#e8c4a0', style: 'short', accent: '#34d399' },
design: { hoodie: '#0e7490', hair: '#111827', skin: '#f5d0b0', style: 'long', accent: '#67e8f9' },
knowledge: { hoodie: '#4338ca', hair: '#374151', skin: '#f0c9a8', style: 'glasses', accent: '#a5b4fc' },
retail: { hoodie: '#0f766e', hair: '#3d2314', skin: '#e8c4a0', style: 'short', accent: '#5eead4' },
browser: { hoodie: '#2563eb', hair: '#1f2937', skin: '#e0b890', style: 'short', accent: '#60a5fa' },
hr: { hoodie: '#6366f1', hair: '#3d2314', skin: '#e8c4a0', style: 'bob', accent: '#c7d2fe' },
email: { hoodie: '#475569', hair: '#5b3a29', skin: '#e8c4a0', style: 'short', accent: '#94a3b8' },
research: { hoodie: '#0f766e', hair: '#1a1a1a', skin: '#f5d0b0', style: 'glasses', accent: '#2dd4bf' },
sysops: { hoodie: '#1e40af', hair: '#5b3a29', skin: '#f0c9a8', style: 'short', accent: '#38bdf8', hat: 'cap' },
packaging: { hoodie: '#1d4ed8', hair: '#3d2314', skin: '#e0b890', style: 'short', accent: '#93c5fd' },
webbuilder: { hoodie: '#0284c7', hair: '#1f2937', skin: '#e8c4a0', style: 'glasses', accent: '#38bdf8', hat: 'cap' },
};
function cfg(key, fallbackColor) {
var k = (key || 'agent').toLowerCase();
var c = CFG[k] || { hoodie: fallbackColor || '#1e3a5f', hair: '#3d2314', skin: '#e8b896', style: 'short', accent: '#38bdf8' };
var c = CFG[k] || { hoodie: fallbackColor || '#0284c7', hair: '#3d2314', skin: '#f0c9a8', style: 'short', accent: '#38bdf8' };
return c;
}
function lighten(hex, amt) {
hex = (hex || '#0284c7').replace('#', '');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
var n = parseInt(hex, 16);
var r = Math.min(255, ((n >> 16) & 255) + amt);
var g = Math.min(255, ((n >> 8) & 255) + amt);
var b = Math.min(255, (n & 255) + amt);
return '#' + (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
function capSvg(hid, c) {
return (
'<path d="M22,44 Q50,30 78,44 L82,48 Q50,54 18,48Z" fill="#0f172a" opacity="0.35"/>' +
'<ellipse cx="50" cy="36" rx="26" ry="12" fill="' + c.hoodie + '"/>' +
'<path d="M24,40 Q50,26 76,40 L88,46 Q50,52 14,46Z" fill="#1e40af"/>' +
'<path d="M26,38 Q50,28 74,38" fill="none" stroke="#38bdf8" stroke-width="1.2" opacity="0.5"/>' +
'<rect x="46" y="28" width="8" height="5" rx="1" fill="#38bdf8" opacity="0.85"/>'
'<path d="M22,44 Q50,30 78,44 L82,48 Q50,54 18,48Z" fill="#0f172a" opacity="0.2"/>' +
'<ellipse cx="50" cy="36" rx="26" ry="12" fill="' + lighten(c.hoodie, 40) + '"/>' +
'<path d="M24,40 Q50,26 76,40 L88,46 Q50,52 14,46Z" fill="' + c.hoodie + '"/>' +
'<path d="M26,38 Q50,28 74,38" fill="none" stroke="' + c.accent + '" stroke-width="1.4" opacity="0.7"/>' +
'<rect x="46" y="28" width="8" height="5" rx="1" fill="' + c.accent + '"/>'
);
}
@@ -45,50 +55,51 @@ window.AgentAvatars = (function () {
function svgAvatar(key, color) {
var c = cfg(key, color);
var hid = 'av-' + key;
var hid = 'av-' + key + '-' + Math.random().toString(36).slice(2, 7);
var hoodLite = lighten(c.hoodie, 55);
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 120" class="agent-char-svg" aria-hidden="true">' +
'<defs>' +
'<linearGradient id="' + hid + '-bg" x1="0%" y1="0%" x2="100%" y2="100%">' +
'<stop offset="0%" stop-color="#0c1929"/><stop offset="50%" stop-color="#152238"/><stop offset="100%" stop-color="#0a1020"/>' +
'<stop offset="0%" stop-color="#1a3a5c"/><stop offset="55%" stop-color="#245a82"/><stop offset="100%" stop-color="#1e4a6e"/>' +
'</linearGradient>' +
'<linearGradient id="' + hid + '-hood" x1="30%" y1="0%" x2="70%" y2="100%">' +
'<stop offset="0%" stop-color="' + c.hoodie + '"/><stop offset="100%" stop-color="#0a0f18"/>' +
'<stop offset="0%" stop-color="' + hoodLite + '"/><stop offset="100%" stop-color="' + c.hoodie + '"/>' +
'</linearGradient>' +
'<linearGradient id="' + hid + '-skin" x1="40%" y1="0%" x2="60%" y2="100%">' +
'<stop offset="0%" stop-color="' + c.skin + '"/><stop offset="100%" stop-color="#c9956e"/>' +
'<stop offset="0%" stop-color="' + c.skin + '"/><stop offset="100%" stop-color="#d4a07a"/>' +
'</linearGradient>' +
'<radialGradient id="' + hid + '-glow" cx="75%" cy="25%" r="45%">' +
'<stop offset="0%" stop-color="' + c.accent + '" stop-opacity="0.35"/><stop offset="100%" stop-opacity="0"/>' +
'<radialGradient id="' + hid + '-glow" cx="70%" cy="20%" r="50%">' +
'<stop offset="0%" stop-color="' + c.accent + '" stop-opacity="0.55"/><stop offset="100%" stop-opacity="0"/>' +
'</radialGradient>' +
'<filter id="' + hid + '-sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.45"/></filter>' +
'<filter id="' + hid + '-sh"><feDropShadow dx="0" dy="2" stdDeviation="1.5" flood-opacity="0.3"/></filter>' +
'</defs>' +
'<rect width="100" height="120" fill="url(#' + hid + '-bg)"/>' +
'<ellipse cx="78" cy="28" rx="35" ry="30" fill="url(#' + hid + '-glow)"/>' +
'<ellipse cx="50" cy="100" rx="30" ry="14" fill="rgba(0,0,0,0.4)"/>' +
'<rect width="100" height="120" rx="14" fill="url(#' + hid + '-bg)"/>' +
'<ellipse cx="78" cy="26" rx="38" ry="32" fill="url(#' + hid + '-glow)"/>' +
'<ellipse cx="50" cy="100" rx="28" ry="12" fill="rgba(0,0,0,0.22)"/>' +
'<path d="M18,118 Q18,74 50,68 Q82,74 82,118Z" fill="url(#' + hid + '-hood)" filter="url(#' + hid + '-sh)"/>' +
'<path d="M20,70 Q50,58 80,70 L76,82 Q50,76 24,82Z" fill="' + c.hoodie + '" opacity="0.9"/>' +
'<path d="M20,70 Q50,58 80,70 L76,82 Q50,76 24,82Z" fill="' + hoodLite + '" opacity="0.95"/>' +
'<path d="M28,68 Q50,48 72,68 L68,78 Q50,72 32,78Z" fill="' + c.hoodie + '"/>' +
'<ellipse cx="50" cy="54" rx="23" ry="25" fill="url(#' + hid + '-skin)" filter="url(#' + hid + '-sh)"/>' +
'<ellipse cx="56" cy="56" rx="10" ry="14" fill="rgba(0,0,0,0.08)"/>' +
'<ellipse cx="56" cy="56" rx="10" ry="14" fill="rgba(255,255,255,0.12)"/>' +
(c.hat === 'cap' ? '' : '<path d="' + hairPath(c.style) + '" fill="' + c.hair + '"/>') +
(c.style === 'glasses'
? '<ellipse cx="42" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
'<ellipse cx="58" cy="52" rx="8" ry="6" fill="none" stroke="#334155" stroke-width="1.5"/>' +
'<line x1="50" y1="52" x2="50" y2="52" stroke="#334155" stroke-width="1.5"/>'
? '<ellipse cx="42" cy="52" rx="8" ry="6" fill="none" stroke="#e2e8f0" stroke-width="1.6"/>' +
'<ellipse cx="58" cy="52" rx="8" ry="6" fill="none" stroke="#e2e8f0" stroke-width="1.6"/>' +
'<line x1="50" y1="52" x2="50" y2="52" stroke="#e2e8f0" stroke-width="1.5"/>'
: '') +
'<ellipse cx="42" cy="52" rx="3" ry="3.5" fill="#1e293b"/><ellipse cx="58" cy="52" rx="3" ry="3.5" fill="#1e293b"/>' +
'<ellipse cx="43" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/><ellipse cx="59" cy="51" rx="1" ry="1.2" fill="#fff" opacity="0.7"/>' +
'<ellipse cx="42" cy="52" rx="3.2" ry="3.6" fill="#0f172a"/><ellipse cx="58" cy="52" rx="3.2" ry="3.6" fill="#0f172a"/>' +
'<ellipse cx="43.2" cy="50.8" rx="1.1" ry="1.3" fill="#fff" opacity="0.85"/><ellipse cx="59.2" cy="50.8" rx="1.1" ry="1.3" fill="#fff" opacity="0.85"/>' +
'<path d="M44,62 Q50,66 56,62" fill="none" stroke="#b45309" stroke-width="1.5" stroke-linecap="round"/>' +
'<path d="M16,50 Q50,26 84,50" fill="none" stroke="#cbd5e1" stroke-width="4" stroke-linecap="round"/>' +
'<rect x="10" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
'<path d="M16,50 Q50,26 84,50" fill="none" stroke="#e2e8f0" stroke-width="3.5" stroke-linecap="round"/>' +
'<rect x="10" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="' + c.accent + '" stroke-width="1.8"/>' +
'<rect x="12" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
'<rect x="76" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="#94a3b8" stroke-width="1.8"/>' +
'<rect x="76" y="44" width="14" height="18" rx="5" fill="#1e293b" stroke="' + c.accent + '" stroke-width="1.8"/>' +
'<rect x="78" y="47" width="10" height="12" rx="3" fill="#334155"/>' +
'<path d="M78,60 Q86,72 82,82" fill="none" stroke="#94a3b8" stroke-width="2.8" stroke-linecap="round"/>' +
'<circle cx="82" cy="84" r="3.5" fill="#64748b" stroke="#94a3b8" stroke-width="1"/>' +
'<circle cx="88" cy="32" r="2.5" fill="' + c.accent + '" opacity="0.9"/>' +
'<circle cx="12" cy="40" r="1.5" fill="' + c.accent + '" opacity="0.5"/>' +
'<path d="M78,60 Q86,72 82,82" fill="none" stroke="#cbd5e1" stroke-width="2.6" stroke-linecap="round"/>' +
'<circle cx="82" cy="84" r="3.5" fill="' + c.accent + '" stroke="#e2e8f0" stroke-width="1"/>' +
'<circle cx="88" cy="32" r="3" fill="' + c.accent + '"/>' +
'<circle cx="14" cy="38" r="2" fill="' + c.accent + '" opacity="0.7"/>' +
(c.hat === 'cap' ? capSvg(hid, c) : '') +
'</svg>'
);
@@ -98,7 +109,7 @@ window.AgentAvatars = (function () {
if (!el) return;
var k = (key || '').toLowerCase();
var c = color || '';
var sig = k + '|' + c;
var sig = k + '|v2|' + c;
if (!force && el.getAttribute('data-avatar-painted') === sig && el.querySelector('svg')) return;
el.innerHTML = svgAvatar(k, color);
el.setAttribute('data-avatar-painted', sig);
+518 -58
View File
@@ -17,6 +17,21 @@ window.WarRoomHub = (function () {
rooms: [],
room: null,
messages: [],
chatSearch: '',
searchOpen: false,
toolsOpen: false,
screenCleared: false,
autoDebate: true,
ceoInboxOpen: false,
ceoInbox: [],
ceoUnread: 0,
replyTo: null,
replyHighlightId: null,
stickToBottom: true,
showJumpLatest: false,
_typingScrollAt: 0,
rosterCollapsed: true,
a2aPausedUntil: 0,
draft: '',
busy: false,
me: { username: '', avatar_url: '', role: '' },
@@ -37,9 +52,9 @@ window.WarRoomHub = (function () {
callSlug: '',
voiceRecording: false,
voiceBusy: false,
speakReplies: true,
speakReplies: false,
voiceRoomOpen: false,
voiceRoomAgents: ['herman'],
voiceRoomAgents: ['herman', 'bizdev', 'finance', 'retail', 'marketing'],
voiceSpeakingKey: '',
voiceLastHeard: '',
_voiceRec: null,
@@ -49,6 +64,7 @@ window.WarRoomHub = (function () {
async init() {
this.voiceRoomOpen = false;
this.busy = false;
this._sendLock = false;
await Promise.all([this.loadMe(), this.loadSouls(), this.loadRooms()]);
var params = new URLSearchParams(window.location.search || '');
var want = params.get('room');
@@ -58,7 +74,13 @@ window.WarRoomHub = (function () {
if (wr) await this.openRoom(wr.id);
else if (this.rooms.length) await this.openRoom(this.rooms[0].id);
}
try {
var saved = localStorage.getItem('flk-warroom-a2a');
this.autoDebate = (saved === null || saved === '1' || saved === 'true');
} catch (e) { this.autoDebate = true; }
this.startPoll();
this.loadCeoInbox();
if (this.autoDebate && this.room && this.room.kind === 'warroom') this.scheduleContinue(60000);
},
destroy() {
@@ -129,26 +151,35 @@ window.WarRoomHub = (function () {
async openRoom(id) {
if (!id) return;
id = parseInt(id, 10);
this.busy = true;
this.status = '';
this.typing = null;
try {
if (!(this.rooms || []).length) await this.loadRooms();
var meta = (this.rooms || []).find(function (r) { return Number(r.id) === id; }) || null;
var r = await fetch('/api/agents/warroom/rooms/' + id + '/messages?limit=120').then(function (x) {
return x.json();
});
this.room = r.room;
// messages API returns {items}, NOT {room} — keep room from rooms list
this.room = meta || r.room || { id: id, kind: 'warroom', title: 'War Room' };
this.messages = this.normalizeList(r.items || []);
this.lastId = this.maxId(this.messages);
this.stickToBottom = true;
this.showJumpLatest = false;
this.$nextTick(function () {
this.paintAvatars();
this.scrollLog();
this.scrollLog(true);
this.focusComposer();
}.bind(this));
var last = (this.messages || [])[(this.messages || []).length - 1];
if (last && last.sender_type === 'agent' && this.room && this.room.kind === 'warroom') {
this.scheduleContinue(4000);
if (this.autoDebate) this.scheduleContinue(60000);
}
} catch (e) {
this.status = e.message || 'Laden mislukt';
// Still keep a usable room id so send works
if (!this.room) this.room = { id: id, kind: 'warroom', title: 'War Room' };
}
this.busy = false;
},
@@ -203,12 +234,44 @@ window.WarRoomHub = (function () {
return changed;
},
scrollLog() {
isNearBottom(el, px) {
if (!el) return true;
var gap = el.scrollHeight - el.scrollTop - el.clientHeight;
return gap <= (px == null ? 80 : px);
},
onLogScroll() {
var el = this.$refs && this.$refs.wrLog;
if (el) el.scrollTop = el.scrollHeight;
if (!el) return;
var near = this.isNearBottom(el, 100);
this.stickToBottom = near;
this.showJumpLatest = !near;
},
scrollLog(force) {
var el = this.$refs && this.$refs.wrLog;
if (!el) return;
if (!force && !this.stickToBottom) {
this.showJumpLatest = true;
return;
}
el.scrollTop = el.scrollHeight;
this.stickToBottom = true;
this.showJumpLatest = false;
},
jumpToLatest() {
this.stickToBottom = true;
this.showJumpLatest = false;
this.scrollLog(true);
},
startPoll() {
var selfCeo = this;
if (!this._ceoPoll) {
this._ceoPoll = setInterval(function () { selfCeo.loadCeoInbox(); }, 20000);
}
var self = this;
if (this._poll) clearInterval(this._poll);
this._poll = setInterval(function () { self.pollNew(); }, 4000);
@@ -283,6 +346,7 @@ window.WarRoomHub = (function () {
},
onDraftInput() {
if ((this.draft || '').trim()) this.pauseA2A(90000);
var d = this.draft || '';
var m = /(^|\s)@([a-zA-Z0-9_-]*)$/.exec(d);
if (m) {
@@ -344,51 +408,153 @@ window.WarRoomHub = (function () {
this.pendingFiles = (this.pendingFiles || []).filter(function (_, i) { return i !== idx; });
},
focusComposer() {
try {
var el = this.$refs && this.$refs.wrInput;
if (el) {
el.removeAttribute('disabled');
el.readOnly = false;
el.focus();
}
} catch (e) {}
},
canSend() {
return !this.busy && this.room && (((this.draft || '').trim()) || (this.pendingFiles || []).length);
var hasText = !!(this.draft || '').trim();
var hasFiles = !!(this.pendingFiles || []).length;
return !!(hasText || hasFiles);
},
async ensureRoom() {
if (this.room && this.room.id) return true;
try {
await this.loadRooms();
var wr = this.warroomRoom();
if (wr) {
await this.openRoom(wr.id);
} else {
// fallback: known warroom id 1
await this.openRoom(1);
}
} catch (e) {}
return !!(this.room && this.room.id);
},
onEnter(e) {
if (e && e.shiftKey) return; // Shift+Enter = nieuwe regel
if (this.mentionOpen) {
var sug = this.mentionSuggestions();
if (sug && sug.length) {
if (e) e.preventDefault();
this.pickMention(sug[this.mentionIndex] || sug[0]);
return;
}
}
if (e) e.preventDefault();
this.send();
},
async send() {
var text = (this.draft || '').trim();
var files = (this.pendingFiles || []).slice();
if (!this.room || this.busy) return;
if (!text && !files.length) return;
// Never get stuck behind a stale busy flag
if (this._sendLock) return;
this._sendLock = true;
this.busy = true;
this.mentionOpen = false;
this.emojiOpen = false;
var okRoom = await this.ensureRoom();
if (!okRoom) {
this.busy = false;
this._sendLock = false;
this.status = 'Room niet geladen — klik ↻';
if (window.Cockpit) Cockpit.toast(this.status, 'error');
return;
}
// Instant UI — no waiting for network/LLM
var tempId = 'local-' + Date.now();
var replySnapshot = this.replyTo ? {
id: this.replyTo.id,
agent_key: this.replyTo.agent_key,
username: this.replyTo.username,
sender_type: this.replyTo.sender_type,
excerpt: this.replyTo.excerpt,
label: this.replyTo.label,
} : null;
var replyToId = replySnapshot ? replySnapshot.id : null;
var optimisticMeta = {};
if (files.length) optimisticMeta.attachments = files;
if (replySnapshot) optimisticMeta.reply_to = replySnapshot;
var optimistic = {
id: tempId,
room_id: this.room.id,
sender_type: 'user',
content: text || (files.length ? '(bijlage)' : ''),
username: (this.me && this.me.username) || 'Jij',
sender_avatar_url: (this.me && this.me.avatar_url) || null,
metadata: optimisticMeta,
created_at: new Date().toISOString(),
};
this.draft = '';
this.pendingFiles = [];
this.replyTo = null;
this.replyHighlightId = replyToId;
this.pauseA2A(90000);
this.typing = null;
this.status = 'Verzenden…';
this.upsertMessage(optimistic);
this.status = '';
this.stickToBottom = true;
this.$nextTick(function () {
this.scrollLog(true);
this.focusComposer();
}.bind(this));
try {
var resp = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages/stream', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
body: JSON.stringify({ content: text, attachments: files }),
body: JSON.stringify({ content: text, attachments: files, reply_to_id: replyToId }),
});
if (!resp.ok) {
var err = await resp.json().catch(function () { return {}; });
throw new Error(err.detail || ('HTTP ' + resp.status));
}
await this.consumeStream(resp);
this.status = '';
this.scheduleContinue(2000);
// Drop temp bubble if server user msg arrived with real id
this.messages = (this.messages || []).filter(function (m) {
return m.id !== tempId;
});
// Herlaad recente geschiedenis (merge) zodat reply-parent niet "kwijt" raakt in de UI
await this.refreshMessages({ silent: true });
if (replyToId) {
var self = this;
this.$nextTick(function () {
self.scrollToMessage(replyToId);
setTimeout(function () { self.replyHighlightId = null; }, 4000);
});
}
if (this.autoDebate) this.scheduleContinue(60000);
} catch (e) {
this.draft = text;
this.pendingFiles = files;
// Keep optimistic message; restore draft only on hard fail
this.status = e.message || 'Verzenden mislukt';
if (window.Cockpit) Cockpit.toast(this.status, 'error');
}
this.typing = null;
this.busy = false;
this._sendLock = false;
this.$nextTick(function () {
this.paintAvatars();
this.scrollLog();
this.scrollLog(true);
this.focusComposer();
}.bind(this));
},
async consumeStream(resp) {
var reader = resp.body.getReader();
var decoder = new TextDecoder();
@@ -422,6 +588,7 @@ window.WarRoomHub = (function () {
}
var type = ev.event || '';
if (type === 'user' && ev.message) {
this.messages = (this.messages || []).filter(function (m) { return String(m.id).indexOf('local-') !== 0; });
this.upsertMessage(ev.message);
this.status = 'Agent typt…';
this.$nextTick(function () { this.scrollLog(); }.bind(this));
@@ -434,7 +601,9 @@ window.WarRoomHub = (function () {
text: '',
};
this.status = (this.typing.display_name || 'Agent') + ' typt…';
this.$nextTick(function () { this.scrollLog(); }.bind(this));
// alleen meescrollen als je al onderaan zit
if (this.stickToBottom) this.$nextTick(function () { this.scrollLog(); }.bind(this));
else this.showJumpLatest = true;
} else if (type === 'typing') {
if (!this.typing || this.typing.agent_key !== ev.agent_key) {
this.typing = {
@@ -446,11 +615,20 @@ window.WarRoomHub = (function () {
this.typing.text = (this.typing.text || '') + (ev.delta || '');
// force alpine refresh
this.typing = Object.assign({}, this.typing);
this.$nextTick(function () { this.scrollLog(); }.bind(this));
// geen jump tijdens teruglezen; max 1x / 600ms als je onderaan zit
if (this.stickToBottom) {
var now = Date.now();
if (now - (this._typingScrollAt || 0) > 600) {
this._typingScrollAt = now;
this.$nextTick(function () { this.scrollLog(); }.bind(this));
}
} else {
this.showJumpLatest = true;
}
} else if (type === 'agent' && ev.message) {
this.typing = null;
this.upsertMessage(ev.message);
if (ev.message.content) this.speakText(ev.message.content, ev.message.agent_key);
if (this.voiceRoomOpen && this.speakReplies && ev.message.content) this.speakText(ev.message.content, ev.message.agent_key);
this.$nextTick(function () {
this.paintAvatars();
this.scrollLog();
@@ -468,54 +646,142 @@ window.WarRoomHub = (function () {
},
onKey(e) {
if (!this.mentionOpen) return;
var sug = this.mentionSuggestions();
if (this.mentionOpen && sug.length) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.mentionIndex = (this.mentionIndex + 1) % sug.length;
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.mentionIndex = (this.mentionIndex - 1 + sug.length) % sug.length;
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
this.pickMention(sug[this.mentionIndex] || sug[0]);
return;
}
if (e.key === 'Escape') {
this.mentionOpen = false;
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
if (!sug.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
this.send();
this.mentionIndex = (this.mentionIndex + 1) % sug.length;
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.mentionIndex = (this.mentionIndex - 1 + sug.length) % sug.length;
return;
}
if (e.key === 'Tab') {
e.preventDefault();
this.pickMention(sug[this.mentionIndex] || sug[0]);
return;
}
if (e.key === 'Escape') {
this.mentionOpen = false;
}
},
scheduleContinue(delayMs) {
if (!this.autoDebate || !this.room || this.room.kind !== 'warroom') return;
if (this._continueTimer) clearTimeout(this._continueTimer);
if (!this.autoDebate) return;
var self = this;
this._continueTimer = setTimeout(function () { self.runContinue(2); }, delayMs || 2500);
// default: rustig tempo (~50s), niet elke paar seconden
var wait = (typeof delayMs === 'number' && delayMs >= 0) ? delayMs : 50000;
this._continueTimer = setTimeout(function () { self.runContinue(1); }, wait);
},
toggleA2A() {
this.autoDebate = !this.autoDebate;
try { localStorage.setItem('flk-warroom-a2a', this.autoDebate ? '1' : '0'); } catch (e) {}
if (this._continueTimer) clearTimeout(this._continueTimer);
if (this.autoDebate) {
this.status = 'A2A aan — agents mogen onderling verder praten';
this.scheduleContinue(8000);
if (window.Cockpit) Cockpit.toast('A2A aan', 'ok');
} else {
this.status = 'A2A uit — alleen reacties op jouw berichten';
this.a2aPausedUntil = 0;
if (window.Cockpit) Cockpit.toast('A2A uit', 'ok');
}
},
pauseA2A(ms) {
this.a2aPausedUntil = Date.now() + (ms || 45000);
if (this._continueTimer) clearTimeout(this._continueTimer);
},
setReply(m) {
if (!m || !m.id || String(m.id).indexOf('local-') === 0) return;
this.replyTo = {
id: m.id,
agent_key: m.agent_key || null,
username: m.username || null,
sender_type: m.sender_type,
excerpt: ((m.content || '').trim().slice(0, 160)) + (((m.content || '').length > 160) ? '…' : ''),
label: this.labelForMessage(m),
};
this.replyHighlightId = m.id;
this.pauseA2A(90000);
this.$nextTick(function () {
this.scrollToMessage(m.id);
this.focusComposer();
}.bind(this));
},
scrollToMessage(id) {
if (id == null) return;
var root = (this.$refs && this.$refs.wrLog) || document;
var el = root.querySelector && root.querySelector('[data-msg-id="' + id + '"]');
if (el && el.scrollIntoView) {
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {
el.scrollIntoView(true);
}
}
},
async refreshMessages(opts) {
opts = opts || {};
if (!this.room || !this.room.id) return;
try {
var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages?limit=200', {
credentials: 'same-origin',
}).then(function (x) { return x.json(); });
var items = this.normalizeList(r.items || []);
if (items.length) {
this.messages = items;
this.lastId = this.maxId(items);
}
if (!opts.silent) {
this.$nextTick(function () {
this.paintAvatars();
this.scrollLog();
}.bind(this));
} else {
this.$nextTick(function () { this.paintAvatars(); }.bind(this));
}
} catch (e) { /* quiet */ }
},
clearReply() {
this.replyTo = null;
this.replyHighlightId = null;
},
replyPreview(m) {
var meta = (m && m.metadata) || {};
return meta.reply_to || null;
},
async runContinue(turns) {
if (!this.autoDebate || !this.room || this.room.kind !== 'warroom') return;
if (this.busy || this._continueBusy) return;
// only continue if last message is from an agent (they are mid-debate)
var last = (this.messages || [])[(this.messages || []).length - 1];
if (!last || last.sender_type !== 'agent') return;
if (this.busy || this._continueBusy || this._sendLock) {
this.scheduleContinue(20000);
return;
}
if (Date.now() < (this.a2aPausedUntil || 0)) {
this.scheduleContinue(Math.max(10000, this.a2aPausedUntil - Date.now()));
return;
}
// niet doorkletsen terwijl jij typt
if ((this.draft || '').trim().length > 0 || this.replyTo) {
this.scheduleContinue(25000);
return;
}
this._continueBusy = true;
this.status = 'Agents overleggen verder…';
this.status = 'A2A — agents reageren…';
try {
var resp = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/continue?turns=' + (turns || 2), {
var resp = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/continue?turns=' + (turns || 1), {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'text/event-stream', 'Content-Type': 'application/json' },
@@ -525,16 +791,208 @@ window.WarRoomHub = (function () {
this.busy = true;
await this.consumeStream(resp);
this.busy = false;
this.status = '';
// keep the debate alive a bit while the tab is open
this.scheduleContinue(12000);
this.status = 'A2A live';
// keep conversation alive
this.scheduleContinue(55000);
} catch (e) {
this.busy = false;
this.status = '';
this.status = 'A2A hervatten…';
this.scheduleContinue(45000);
}
this._continueBusy = false;
},
async loadCeoInbox() {
try {
var r = await fetch('/api/agents/warroom/ceo-inbox?limit=40', { credentials: 'same-origin' })
.then(function (x) { return x.json(); });
this.ceoInbox = r.items || [];
this.ceoUnread = r.unread || 0;
} catch (e) {
this.ceoInbox = this.ceoInbox || [];
}
},
openCeoInbox() {
this.ceoInboxOpen = true;
this.loadCeoInbox();
},
closeCeoInbox() {
this.ceoInboxOpen = false;
},
async markCeoRead(id) {
try {
await fetch('/api/agents/warroom/ceo-inbox/read', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(id ? { ids: [id] } : { all: true }),
});
await this.loadCeoInbox();
} catch (e) {}
},
async markAllCeoRead() {
await this.markCeoRead(null);
if (window.Cockpit) Cockpit.toast('CEO-inbox gelezen', 'ok');
},
isCeoDirect(m) {
if (!m) return false;
if (m.ceo_inbox_id || (m.metadata && m.metadata.ceo_direct)) return true;
var c = (m.content || '').toLowerCase();
return c.indexOf('@aissa') >= 0 || c.indexOf('@ceo') >= 0 || c.indexOf('@ervoorz') >= 0;
},
formatMsgTime(m) {
var raw = (m && (m.created_at || m.createdAt || m.ts)) || '';
if (!raw) return '';
var d = new Date(raw);
if (isNaN(d.getTime())) return '';
return d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
},
formatMsgDate(m) {
var raw = (m && (m.created_at || m.createdAt || m.ts)) || '';
if (!raw) return '';
var d = new Date(raw);
if (isNaN(d.getTime())) return '';
var today = new Date();
var yest = new Date(); yest.setDate(today.getDate() - 1);
var same = function (a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
};
if (same(d, today)) return 'Vandaag';
if (same(d, yest)) return 'Gisteren';
return d.toLocaleDateString('nl-NL', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
},
dayKey(m) {
var raw = (m && (m.created_at || m.createdAt || m.ts)) || '';
if (!raw) return '';
var d = new Date(raw);
if (isNaN(d.getTime())) return '';
return d.getFullYear() + '-' + (d.getMonth()+1) + '-' + d.getDate();
},
showDaySep(idx) {
var list = this.visibleMessages();
if (!list.length || idx < 0 || idx >= list.length) return false;
if (idx === 0) return !!this.dayKey(list[idx]);
return this.dayKey(list[idx]) !== this.dayKey(list[idx - 1]);
},
visibleMessages() {
if (this.screenCleared) return [];
var q = (this.chatSearch || '').trim().toLowerCase();
var list = this.messages || [];
if (!q) return list;
return list.filter(function (m) {
var hay = [
m.content || '',
m.agent_key || '',
m.username || '',
m.display_name || '',
m.sender_type || '',
].join(' ').toLowerCase();
return hay.indexOf(q) >= 0;
});
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
this.toolsOpen = false;
if (this.searchOpen) {
var self = this;
this.$nextTick(function () {
var el = self.$refs && self.$refs.wrSearch;
if (el) el.focus();
});
} else {
this.chatSearch = '';
}
},
clearScreen() {
this.screenCleared = true;
this.status = 'Scherm geleegd (berichten blijven bewaard)';
if (window.Cockpit) Cockpit.toast('Chat-scherm geleegd', 'ok');
},
restoreScreen() {
this.screenCleared = false;
this.status = '';
this.$nextTick(function () { this.scrollLog(); }.bind(this));
},
async clearChatHistory() {
if (!this.room) return;
if (!window.confirm('Alle berichten in dit kanaal permanent van het scherm wissen (soft-delete)?')) return;
try {
var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages', {
method: 'DELETE',
credentials: 'same-origin',
}).then(function (x) { return x.json().then(function (d) { return { ok: x.ok, d: d }; }); });
if (!r.ok) throw new Error((r.d && (r.d.detail || r.d.message)) || 'Wissen mislukt');
this.messages = [];
this.screenCleared = false;
this.lastId = 0;
this.status = 'Geschiedenis gewist (' + ((r.d && r.d.cleared) || 0) + ')';
if (window.Cockpit) Cockpit.toast('Chatgeschichte gewist', 'ok');
} catch (e) {
if (window.Cockpit) Cockpit.toast(e.message || 'Wissen mislukt', 'error');
}
},
downloadChat(fmt) {
fmt = fmt || 'txt';
var list = this.messages || [];
if (!list.length) {
if (window.Cockpit) Cockpit.toast('Geen berichten om te downloaden', 'info');
return;
}
var title = (this.room && (this.room.title || this.room.kind)) || 'war-room';
var stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
var filename = 'foodlinkk-' + String(title).replace(/[^\w\-]+/g, '_') + '-' + stamp;
var body = '';
var mime = 'text/plain;charset=utf-8';
if (fmt === 'json') {
body = JSON.stringify({ room: this.room, exported_at: new Date().toISOString(), messages: list }, null, 2);
filename += '.json';
mime = 'application/json;charset=utf-8';
} else if (fmt === 'md') {
body = '# ' + title + '\n\n_Geëxporteerd: ' + new Date().toLocaleString('nl-NL') + '_\n\n';
list.forEach(function (m) {
var who = this.labelForMessage(m) + (m.agent_key ? (' (@' + m.agent_key + ')') : '');
var when = this.formatMsgDate(m) + ' ' + this.formatMsgTime(m);
body += '### ' + who + ' · ' + when.trim() + '\n\n' + (m.content || '') + '\n\n---\n\n';
}.bind(this));
filename += '.md';
mime = 'text/markdown;charset=utf-8';
} else {
body = title + '\nGeëxporteerd: ' + new Date().toLocaleString('nl-NL') + '\n' + ('='.repeat(48)) + '\n\n';
list.forEach(function (m) {
var who = this.labelForMessage(m) + (m.agent_key ? (' (@' + m.agent_key + ')') : '');
var when = (this.formatMsgDate(m) + ' ' + this.formatMsgTime(m)).trim();
body += '[' + when + '] ' + who + ':\n' + (m.content || '') + '\n\n';
}.bind(this));
filename += '.txt';
}
var blob = new Blob([body], { type: mime });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 500);
this.toolsOpen = false;
if (window.Cockpit) Cockpit.toast('Download gestart (' + fmt + ')', 'ok');
},
async deleteMessage(m) {
if (!this.room || !m || !m.id) return;
if (!window.confirm('Bericht verwijderen?')) return;
@@ -554,8 +1012,10 @@ window.WarRoomHub = (function () {
removeDeletedIds(ids) {
if (!ids || !ids.length) return;
var set = {};
ids.forEach(function (id) { set[id] = true; });
this.messages = (this.messages || []).filter(function (m) { return !set[m.id]; });
ids.forEach(function (id) { set[String(id)] = true; });
this.messages = (this.messages || []).filter(function (m) {
return !set[String(m.id)];
});
},
@@ -566,9 +1026,9 @@ window.WarRoomHub = (function () {
return;
}
if (!(this.voiceRoomAgents || []).length) {
this.voiceRoomAgents = ['herman'];
this.voiceRoomAgents = ['herman', 'bizdev', 'finance', 'retail', 'marketing'];
}
this.speakReplies = true;
// voorlezen blijft uit tot gebruiker 🔊 aanzet
this.voiceRoomOpen = true;
this.callOpen = false;
this.status = 'Voice Room open — houd 🎤 in om te praten';
+34 -4
View File
@@ -4,6 +4,8 @@ function exportIntelApp() {
tabIndicatorTop: 0,
tabIndicatorH: 40,
busy: false,
overlayDismissed: false,
syncAbort: null,
syncMsg: '',
syncMonitor: null,
syncMonitorOpen: true,
@@ -291,9 +293,30 @@ function exportIntelApp() {
},
isWorking() {
if (this.overlayDismissed) return false;
return this.busy || this.crmPushBusy || this.mapLoading || this.drawerLoading;
},
dismissOverlay() {
this.overlayDismissed = true;
},
async cancelSync() {
try {
if (this.syncAbort) {
try { this.syncAbort.abort(); } catch (e) {}
}
await fetch('/api/export-intel/sync/cancel', { method: 'POST' });
this.syncMsg = 'Sync wordt geannuleerd…';
this.busy = false;
this.overlayDismissed = true;
if (window.Cockpit) Cockpit.toast('Sync annuleren…', 'info');
await this.pollSyncStatus();
} catch (e) {
this.syncMsg = 'Annuleren mislukt: ' + e.message;
}
},
progressLabel() {
if (this.busy && this.syncMsg) return this.syncMsg;
if (this.crmPushBusy) return 'CRM import bezig…';
@@ -792,7 +815,9 @@ function exportIntelApp() {
return;
}
this.busy = true;
this.overlayDismissed = false;
this.syncMsg = kind === 'world' ? 'Wereld-sync gestart…' : 'Sync gestart…';
this.syncAbort = (typeof AbortController !== 'undefined') ? new AbortController() : null;
try {
var body = {
country_iso2: this.country || null,
@@ -806,20 +831,25 @@ function exportIntelApp() {
return;
}
var endpoint = kind === 'region' ? 'region' : kind;
var r = await fetch('/api/export-intel/sync/' + endpoint, {
var fetchOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((x) => x.json());
};
if (this.syncAbort) fetchOpts.signal = this.syncAbort.signal;
var r = await fetch('/api/export-intel/sync/' + endpoint, fetchOpts).then((x) => x.json());
if (r.error) this.syncMsg = 'Fout: ' + r.error;
else if (r.cancelled || (r.result && r.result.cancelled)) this.syncMsg = 'Sync geannuleerd';
else if (kind === 'world') this.syncMsg = 'Wereld-sync voltooid — kaart wordt ververst';
else if (kind === 'all' || kind === 'region') this.syncMsg = 'Regio-sync voltooid';
else this.syncMsg = 'Sync voltooid';
await this.refresh();
if (!(r.cancelled || (r.result && r.result.cancelled))) await this.refresh();
} catch (e) {
this.syncMsg = 'Sync mislukt: ' + e.message;
if (e && e.name === 'AbortError') this.syncMsg = 'Sync geannuleerd';
else this.syncMsg = 'Sync mislukt: ' + e.message;
}
this.busy = false;
this.syncAbort = null;
},
tabCount(id) {
+140 -34
View File
@@ -4,12 +4,12 @@
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=18" />
<script src="/static/js/agent-avatars.js?v=4"></script>
<link rel="stylesheet" href="/static/css/agent-characters.css?v=8" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=40" />
<script src="/static/js/agent-avatars.js?v=5"></script>
{% endblock %}
{% block content %}
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
<div class="hm-neo" :class="{ 'tab-warroom-active': tab==='warroom' }" x-data="agentsSoulHub()" x-init="init()">
<header class="hm-neo-header page-header" x-show="tab!=='warroom'" x-cloak>
<h1>Agents · Soul Registry</h1>
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
@@ -32,22 +32,22 @@
<div class="vtabs-layout" style="--vt-color:#ffd700" x-ref="vtabsWrap">
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
<div class="vtabs-nav-header"><strong>Agents</strong></div>
<button class="vtab-btn" :class="{active: tab==='warroom'}" @click="setTab('warroom', $el)" data-tab="warroom">
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 6v6c0 5 3.5 8.5 8 10 4.5-1.5 8-5 8-10V6l-8-4Z"/><path d="m9 12 2 2 4-4"/></svg></span><span>War Room</span>
</button>
<button class="vtab-btn" :class="{active: tab==='souls'}" @click="setTab('souls', $el)">
<span class="vtab-icon">🧬</span><span>Souls</span>
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg></span><span>Souls</span>
</button>
<button class="vtab-btn" :class="{active: tab==='mesh'}" @click="setTab('mesh', $el)">
<span class="vtab-icon">🕸️</span><span>Netwerk</span>
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4"/><path d="m6.7 17.4 3.6-2.4"/><path d="m17.3 17.4-3.6-2.4"/><circle cx="12" cy="12" r="2"/></svg></span><span>Netwerk</span>
</button>
<button class="vtab-btn" :class="{active: tab==='terminals'}" @click="setTab('terminals', $el)" data-tab="terminals">
<span class="vtab-icon">🖥️</span><span>Terminals</span>
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/><path d="m7 8 3 3-3 3"/><path d="M13 14h3"/></svg></span><span>Terminals</span>
</button>
<button class="vtab-btn" :class="{active: tab==='approvals'}" @click="setTab('approvals', $el)">
<span class="vtab-icon">✓</span><span>Goedkeuringen</span>
<span class="vtab-icon vtab-icon-svg" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg></span><span>Goedkeuringen</span>
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
</button>
<button class="vtab-btn" :class="{active: tab==='warroom'}" @click="setTab('warroom', $el)" data-tab="warroom">
<span class="vtab-icon">⚔️</span><span>War Room</span>
</button>
</nav>
<div class="vtabs-content">
@@ -275,11 +275,19 @@
<section x-show="tab==='warroom'" x-cloak>
<div x-data="warRoomHub()" x-init="init()" class="wr-hub-root">
<div class="panel" style="padding:0.75rem">
<div class="wr-layout">
<div class="wr-layout" :class="{'roster-collapsed': rosterCollapsed}">
<div class="wr-mobile-bar">
<button type="button" class="btn btn-sm" @click="rosterCollapsed = !rosterCollapsed"
x-text="rosterCollapsed ? '👥 Agents' : '▾ Agents'"></button>
<button type="button" class="btn btn-sm wr-a2a-btn"
:class="autoDebate ? 'is-on' : 'is-off'"
@click="toggleA2A()"
x-text="autoDebate ? 'A2A · aan' : 'A2A · uit'"></button>
</div>
<aside class="wr-roster">
<h3>Kanalen</h3>
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
<span>⚔️</span><strong>#war-room</strong>
<span class="wr-chan-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 6v6c0 5 3.5 8.5 8 10 4.5-1.5 8-5 8-10V6l-8-4Z"/><path d="m9 12 2 2 4-4"/></svg></span><strong>#war-room</strong>
</button>
<h3 style="margin-top:0.85rem">Agents</h3>
<template x-for="s in souls" :key="'wr-'+s.agent_key">
@@ -301,22 +309,62 @@
</aside>
<div class="wr-main">
<div class="wr-head">
<div>
<div class="wr-head-title">
<h2 x-text="room ? (room.title || ('#'+room.kind)) : 'War Room'"></h2>
<small class="muted" x-show="status" x-text="status"></small>
<small class="wr-msg-count muted" x-show="messages.length"
x-text="(screenCleared ? '0' : visibleMessages().length) + ' / ' + messages.length + ' berichten'"></small>
</div>
<div class="wr-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.3rem;cursor:pointer;margin:0" title="Agents praten onderling door"><input type="checkbox" x-model="autoDebate" @change="autoDebate && scheduleContinue(1500)" style="margin:0" /> A2A</label>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" :disabled="!room" title="Spraakmeeting met agents">🎙️ Voice Room</button><button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<button type="button" class="btn btn-sm" @click="popOut()" title="Open op tweede scherm">Pop-out ↗</button>
<button type="button" class="btn btn-sm" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
<button type="button" class="btn btn-sm wr-tool-btn" :class="{active: searchOpen}" @click="toggleSearch()" title="Zoeken in chat">🔍 Zoek</button>
<div class="wr-tools-wrap">
<button type="button" class="btn btn-sm wr-tool-btn" @click="toolsOpen=!toolsOpen" title="Meer acties">⋯</button>
<div class="wr-tools-menu" x-show="toolsOpen" @click.outside="toolsOpen=false" x-cloak>
<button type="button" @click="downloadChat('txt')">⬇ Download .txt</button>
<button type="button" @click="downloadChat('md')">⬇ Download .md</button>
<button type="button" @click="downloadChat('json')">⬇ Download .json</button>
<hr />
<button type="button" @click="clearScreen(); toolsOpen=false">🧹 Scherm legen</button>
<button type="button" x-show="screenCleared" @click="restoreScreen(); toolsOpen=false">↩ Scherm herstellen</button>
<button type="button" class="wr-danger" @click="clearChatHistory(); toolsOpen=false">🗑 Geschiedenis wissen</button>
</div>
</div>
<button type="button" class="btn btn-sm wr-a2a-btn"
:class="autoDebate ? 'is-on' : 'is-off'"
@click="toggleA2A()"
:title="autoDebate ? 'A2A staat aan — klik om uit te zetten' : 'A2A staat uit — klik om aan te zetten'">
<span x-text="autoDebate ? 'A2A · aan' : 'A2A · uit'"></span>
</button>
<button type="button" class="btn btn-sm wr-ceo-btn" @click="openCeoInbox()" title="Direct aan jou (@aissa)">
📬 CEO-inbox
<span class="wr-ceo-badge" x-show="ceoUnread > 0" x-text="ceoUnread"></span>
</button>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" title="Spraakmeeting met agents">🎙️ Voice</button>
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<button type="button" class="btn btn-sm" @click="popOut()" title="Open op tweede scherm">↗</button>
<button type="button" class="btn btn-sm" @click="room && openRoom(room.id)" :disabled="busy" title="Vernieuwen">↻</button>
</div>
</div>
<div class="wr-log" x-ref="wrLog">
<p class="wr-empty" x-show="!messages.length && !typing">Nog geen berichten — tag agents met @marketing of deel een bestand.</p>
<template x-for="m in messages" :key="m.id">
<div class="wr-searchbar" x-show="searchOpen" x-cloak>
<input type="search" class="form-input" x-ref="wrSearch" x-model="chatSearch"
placeholder="Zoek in berichten, @agent, tekst…" />
<button type="button" class="btn btn-sm" @click="chatSearch=''" x-show="chatSearch">Wis</button>
<button type="button" class="btn btn-sm" @click="toggleSearch()">Sluit</button>
</div>
<div class="wr-log-wrap"><div class="wr-log" x-ref="wrLog" @scroll.passive="onLogScroll()">
<p class="wr-empty" x-show="screenCleared && !typing">
Scherm is geleegd.
<button type="button" class="btn btn-sm" @click="restoreScreen()">Herstellen</button>
</p>
<p class="wr-empty" x-show="!screenCleared && !visibleMessages().length && !typing && messages.length">Geen treffers voor deze zoekopdracht.</p>
<p class="wr-empty" x-show="!screenCleared && !messages.length && !typing">Nog geen berichten — tag agents met @marketing of deel een bestand.</p>
<template x-for="(m, mi) in visibleMessages()" :key="'msg-'+m.id">
<div class="wr-msg-wrap" :data-msg-id="m.id">
<div class="wr-day-sep" x-show="showDaySep(mi)">
<span x-text="formatMsgDate(m)"></span>
</div>
<div class="wr-bubble"
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system', 'is-ceo-direct': isCeoDirect(m), 'is-reply-target': replyHighlightId == m.id}">
<div class="wr-avatar" x-show="m.sender_type!=='system'">
<template x-if="m.sender_type==='agent'">
<div class="agent-char-wrap"
@@ -333,10 +381,17 @@
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta">
<span x-text="labelForMessage(m)"></span>
<span x-show="m.agent_key" x-text="' · @'+m.agent_key"></span>
<button type="button" class="wr-msg-del" title="Verwijderen"
@click.stop="deleteMessage(m)">🗑</button>
<span class="wr-who" x-text="labelForMessage(m)"></span>
<span class="wr-handle" x-show="m.agent_key" x-text="'@'+m.agent_key"></span>
<time class="wr-time" x-text="formatMsgTime(m)" :title="formatMsgDate(m) + ' ' + formatMsgTime(m)"></time>
<button type="button" class="wr-msg-reply" title="Beantwoorden"
@click.stop="setReply(m)">↩ Reply</button>
<button type="button" class="wr-msg-del" title="Bericht verwijderen"
@click.stop.prevent="deleteMessage(m)">🗑</button>
</div>
<div class="wr-reply-quote" x-show="replyPreview(m)" x-cloak>
<span class="wr-reply-quote-who" x-text="'↩ ' + ((replyPreview(m)||{}).agent_key ? ('@'+(replyPreview(m).agent_key)) : ((replyPreview(m)||{}).username || 'bericht'))"></span>
<span class="wr-reply-quote-text" x-text="(replyPreview(m)||{}).excerpt"></span>
</div>
<div class="wr-bubble-text" x-text="m.content"></div>
<div class="wr-attach-list" x-show="attachmentsOf(m).length">
@@ -349,6 +404,7 @@
</div>
</div>
</div>
</div>
</template>
<div class="wr-bubble is-agent is-typing" x-show="typing" x-cloak>
<div class="wr-avatar">
@@ -365,7 +421,13 @@
</div>
</div>
</div>
<div class="wr-composer">
<button type="button" class="wr-jump-latest" x-show="showJumpLatest" x-cloak
@click="jumpToLatest()" title="Naar nieuwste berichten">
↓ Nieuwste
</button>
</div>
<div class="wr-composer" @click="focusComposer()">
<div class="wr-composer-tools">
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy" title="Houd in om te praten">🎤</button>
<button type="button" class="btn btn-sm" @click="emojiOpen=!emojiOpen" :disabled="busy">😊</button>
@@ -388,6 +450,13 @@
</span>
</template>
</div>
<div class="wr-reply-bar" x-show="replyTo" x-cloak>
<div class="wr-reply-bar-text">
<strong x-text="'↩ Reply aan ' + (replyTo && (replyTo.agent_key ? ('@'+replyTo.agent_key) : (replyTo.label || 'bericht')))"></strong>
<span x-text="replyTo && replyTo.excerpt"></span>
</div>
<button type="button" class="btn btn-sm" @click="clearReply()" title="Annuleer reply">×</button>
</div>
<div class="wr-composer-wrap">
<div class="wr-mention-pop" x-show="mentionOpen && mentionSuggestions().length" x-cloak>
<template x-for="(s, si) in mentionSuggestions()" :key="'m-'+s.agent_key">
@@ -400,15 +469,51 @@
</template>
</div>
<textarea class="form-input" rows="2" x-ref="wrInput" x-model="draft"
@input="onDraftInput()" @keydown="onKey($event)"
placeholder="Typ @bizdev of @finance… agent antwoordt hier in de War Room"
:disabled="!room"></textarea>
@input="onDraftInput()" @keydown="onKey($event)" @keydown.enter.prevent="onEnter($event)"
placeholder="Typ hier… bv. @herman of @retail"
></textarea>
</div>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend()">Stuur</button>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend() || _sendLock">Stuur</button>
</div>
<div class="wr-voice-overlay" :class="{ 'is-open': voiceRoomOpen }" x-show="voiceRoomOpen" x-cloak @keydown.escape.window="closeVoiceRoom()">
<!-- CEO inbox (@aissa) -->
<div class="wr-ceo-overlay" x-show="ceoInboxOpen" x-cloak @keydown.escape.window="closeCeoInbox()">
<div class="wr-ceo-panel" @click.stop>
<div class="wr-ceo-head">
<div>
<h3>📬 CEO-inbox · @aissa</h3>
<small class="muted">Direct van agents aan jou (@aissa) — ook ongelezen van toen je offline was</small>
</div>
<div class="wr-ceo-head-actions">
<button type="button" class="btn btn-sm" @click="markAllCeoRead()" :disabled="!ceoUnread">Alles gelezen</button>
<button type="button" class="btn btn-sm" @click="loadCeoInbox()">↻</button>
<button type="button" class="btn btn-sm" @click="closeCeoInbox()">Sluit</button>
</div>
</div>
<div class="wr-ceo-list">
<p class="wr-empty" x-show="!(ceoInbox||[]).length">Nog geen directe berichten. Agents gebruiken @aissa als ze jou echt nodig hebben.</p>
<template x-for="item in ceoInbox" :key="'ceo-'+item.id">
<article class="wr-ceo-item" :class="{ unread: !item.read_at }">
<div class="wr-ceo-item-meta">
<strong x-text="item.agent_display_name || ('@'+(item.agent_key||'agent'))"></strong>
<span x-text="'@'+(item.agent_key||'')"></span>
<time x-text="formatMsgTime({created_at: item.created_at})"></time>
<span class="wr-ceo-unread-dot" x-show="!item.read_at">nieuw</span>
</div>
<div class="wr-ceo-item-body" x-text="item.content"></div>
<div class="wr-ceo-item-actions">
<button type="button" class="btn btn-sm" x-show="!item.read_at" @click="markCeoRead(item.id)">Markeer gelezen</button>
</div>
</article>
</template>
</div>
</div>
</div>
<template x-if="voiceRoomOpen">
<div class="wr-voice-overlay is-open" @keydown.escape.window="closeVoiceRoom()">
<div class="wr-voice-panel" @click.stop>
<div class="wr-voice-head">
<div>
@@ -468,6 +573,7 @@
</div>
</div>
</div>
</template>
</div>
</div>
@@ -477,7 +583,7 @@
</div>
</div>
<div class="agent-events-backdrop" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
<div class="agent-events-backdrop" :class="{ 'is-open': eventsOpen }" x-show="eventsOpen" x-cloak @click="closeEvents()"></div>
<aside class="agent-events-drawer" :class="{ open: eventsOpen }" x-cloak>
<header class="agent-events-head">
<div>
@@ -516,7 +622,7 @@
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js?v=11"></script>
<script src="/static/js/agents-terminals.js?v=6"></script>
<script src="/static/js/agents-warroom.js?v=18"></script>
<script src="/static/js/agents-warroom.js?v=40"></script>
<script>
function agentsSoulHub() {
return {
+11 -9
View File
@@ -9,8 +9,8 @@
<link rel="stylesheet" href="/static/css/flk-themes.css?v=1" />
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=18" />
<script defer src="/static/js/agents-warroom.js?v=18"></script>
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=31" />
<script defer src="/static/js/agents-warroom.js?v=31"></script>
<script defer src="/static/js/alpine.min.js?v=2"></script>
</head>
<body class="wr-popup-body">
@@ -47,7 +47,7 @@
</div>
<div class="wr-head-actions">
<label class="btn btn-sm" style="display:inline-flex;align-items:center;gap:0.3rem;cursor:pointer;margin:0" title="Agents praten onderling door"><input type="checkbox" x-model="autoDebate" @change="autoDebate && scheduleContinue(1500)" style="margin:0" /> A2A</label>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" :disabled="!room" title="Spraakmeeting met agents">🎙️ Voice Room</button><button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<button type="button" class="btn btn-sm btn-pulse" @click="openVoiceRoom()" title="Spraakmeeting met agents">🎙️ Voice Room</button><button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy || !room" title="Snel praten">🎤</button>
<a class="btn btn-sm" href="/agents?tab=warroom" target="_blank">In cockpit</a>
<button type="button" class="btn btn-sm" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
</div>
@@ -99,7 +99,7 @@
</div>
</div>
</div>
<div class="wr-composer">
<div class="wr-composer" @click="focusComposer()">
<div class="wr-composer-tools">
<button type="button" class="btn btn-sm" :class="{'is-recording': voiceRecording}" @mousedown.prevent="startVoiceTalk($event)" @mouseup.prevent="stopVoiceTalk($event)" @touchstart.prevent="startVoiceTalk($event)" @touchend.prevent="stopVoiceTalk($event)" :disabled="voiceBusy || busy" title="Houd in om te praten">🎤</button>
<button type="button" class="btn btn-sm" @click="emojiOpen=!emojiOpen" :disabled="busy">😊</button>
@@ -134,15 +134,16 @@
</template>
</div>
<textarea class="form-input" rows="2" x-ref="wrInput" x-model="draft"
@input="onDraftInput()" @keydown="onKey($event)"
placeholder="Typ @bizdev… antwoord komt in deze War Room"
:disabled="!room"></textarea>
@input="onDraftInput()" @keydown="onKey($event)" @keydown.enter.prevent="onEnter($event)"
placeholder="Typ hier… bv. @herman"
></textarea>
</div>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend()">Stuur</button>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="!canSend() || _sendLock">Stuur</button>
</div>
<div class="wr-voice-overlay" :class="{ 'is-open': voiceRoomOpen }" x-show="voiceRoomOpen" x-cloak @keydown.escape.window="closeVoiceRoom()">
<template x-if="voiceRoomOpen">
<div class="wr-voice-overlay is-open" @keydown.escape.window="closeVoiceRoom()">
<div class="wr-voice-panel" @click.stop>
<div class="wr-voice-head">
<div>
@@ -202,6 +203,7 @@
</div>
</div>
</div>
</template>
</div>
</div>
+129 -28
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<html lang="nl">
<script>(function(){try{var t=localStorage.getItem('flk-theme');if(t)document.documentElement.setAttribute('data-theme',t);var m=document.querySelector('meta[name=theme-color]');}catch(e){}})();</script>
<script>(function(){try{var t=localStorage.getItem('flk-theme');if(t)document.documentElement.setAttribute('data-theme',t);var s=localStorage.getItem('flk-sidebar-theme');if(s)document.documentElement.setAttribute('data-sidebar',s);var b=localStorage.getItem('flk-page-bg');if(b)document.documentElement.setAttribute('data-bg',b);var ss=localStorage.getItem('flk-sidebar-style');if(ss==='plain')document.documentElement.setAttribute('data-sidebar-style','plain');else document.documentElement.removeAttribute('data-sidebar-style');}catch(e){}})();</script>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
@@ -22,20 +22,20 @@
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" sizes="180x180" />
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" sizes="192x192" />
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=4" />
<link rel="stylesheet" href="/static/css/flk-themes.css?v=1" />
<link rel="stylesheet" href="/static/css/flk-themes.css?v=8" />
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=6" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=10" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
<script defer src="/static/js/herman-assistant.js?v=17"></script>
<script defer src="/static/js/alpine.min.js?v=2"></script>
<script defer src="/static/js/htmx.min.js?v=2"></script>
{% block extra_head %}{% endblock %}
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=12" />
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=13" />
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=16" />
<!-- mobile last: wins over sidebar/dashboard cascade -->
<link rel="stylesheet" href="/static/css/mobile.css?v=7" />
<link rel="stylesheet" href="/static/css/mobile.css?v=12" />
<link rel="stylesheet" href="/static/css/user-avatar.css?v=3" />
</head>
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
@@ -60,36 +60,36 @@
<div class="persona-nav">
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
<div class="app-sidebar-label">Foodlinkk B2B</div>
<a href="/export-intel" data-perm="export" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon">🌍</span><span class="sidebar-nav-label">Wereldexport</span></a>
<a href="/clients" data-perm="clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon">👥</span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
<a href="/deals" data-perm="deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
<a href="/products" data-perm="products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
<a href="/suppliers" data-perm="suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
<a href="/reports" data-perm="reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
<a href="/export-intel" data-perm="export" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="sidebar-nav-label">Wereldexport</span></a>
<a href="/clients" data-perm="clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
<a href="/deals" data-perm="deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 6"/><path d="m3 13 1.68-1.68a2.41 2.41 0 0 1 3.41 0L12 16"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
<a href="/products" data-perm="products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m3.3 7 7.7 4.7L18.7 7"/><path d="m7.5 4.3 9 5.2"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
<a href="/suppliers" data-perm="suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7 5V8l-7 5V4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M17 18h1"/><path d="M12 18h1"/><path d="M7 18h1"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
<a href="/reports" data-perm="reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
</div>
<div class="app-sidebar-group" x-show="brand === 'cucina'">
<div class="app-sidebar-label">Cucina B2C</div>
<a href="/" data-perm="dashboard" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon">🏠</span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
<a href="/revenue-cockpit" data-perm="revenue" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon">💰</span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
<a href="/retail" data-perm="retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon">🛒</span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
<a href="/marketing" data-perm="marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon">📣</span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
<a href="/packaging" data-perm="packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
<a href="/beurs" data-perm="beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon">🎪</span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
<a href="/documents" data-perm="documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon">📄</span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
<a href="/projects" data-perm="projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon">📁</span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
<a href="/hermes" data-perm="hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon">✈️</span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
<a href="/analytics" data-perm="analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon">📈</span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
<a href="/" data-perm="dashboard" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
<a href="/revenue-cockpit" data-perm="revenue" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
<a href="/retail" data-perm="retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
<a href="/marketing" data-perm="marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m3 11 18-5v12L3 13v-2z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
<a href="/packaging" data-perm="packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
<a href="/beurs" data-perm="beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 21 14 3"/><path d="M20.5 21 10 3"/><path d="M15.5 21 12 15l-3.5 6"/><path d="M2 21h20"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
<a href="/documents" data-perm="documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
<a href="/projects" data-perm="projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
<a href="/hermes" data-perm="hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
<a href="/analytics" data-perm="analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
<a href="/ops" data-perm="ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon">⚙️</span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
<a href="/agents" data-perm="agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon">🤖</span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
<a href="/browser" data-perm="browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon">🌐</span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
<a href="/studio" data-perm="studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon">🎨</span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
<a href="/voice" data-perm="voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon">🎙️</span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
<a href="/settings" data-perm="settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon">🔧</span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
<a href="/ops" data-perm="ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><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="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
<a href="/agents?tab=warroom" data-perm="agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
<a href="/browser" data-perm="browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
<a href="/studio" data-perm="studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
<a href="/voice" data-perm="voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
<a href="/settings" data-perm="settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/></svg></span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
</div>
</div>
</aside>
@@ -536,23 +536,124 @@ window.FlkThemes = {
{ id: 'aurora', name: 'Aurora', desc: 'Blauw-violet met cyaan highlights' },
{ id: 'neon', name: 'Neon Tide', desc: 'Donker neon cyaan + magenta' },
{ id: 'glacier', name: 'Glacier', desc: 'IJsblauw en helder sky-cyan' },
{ id: 'cobalt', name: 'Cobalt Pulse', desc: 'Kobaltblauw met aqua en amber' }
{ id: 'cobalt', name: 'Cobalt Pulse', desc: 'Kobaltblauw met aqua en amber' },
{ id: 'reef', name: 'Reef', desc: 'Teal-diep met helder mint' },
{ id: 'plasma', name: 'Plasma', desc: 'Paars-nacht met cyaan vonken' },
{ id: 'lagoon', name: 'Lagoon', desc: 'Tropisch blauwgroen water' },
{ id: 'indigo', name: 'Indigo Night', desc: 'Indigo met lavendel accents' },
{ id: 'skyline', name: 'Skyline', desc: 'Stad-nacht blauw + sky' },
{ id: 'mintvolt', name: 'Mint Volt', desc: 'Donker groen met mint-energie' },
{ id: 'sapphire', name: 'Sapphire', desc: 'Klassiek saffierblauw' },
{ id: 'arctic', name: 'Arctic Glow', desc: 'Koud grijsblauw met ice-cyan' },
{ id: 'hyperion', name: 'Hyperion', desc: 'Cyaan met lime-flits' },
{ id: 'meridian', name: 'Meridian', desc: 'Violet-meridiaan met aqua' }
],
sidebars: [
{ id: 'aqua', name: 'Deep Aqua', desc: 'Diep aqua, strak en koel' },
{ id: 'azure', name: 'Royal Azure', desc: 'Diep koningsblauw' },
{ id: 'turquoise', name: 'Abyss Teal', desc: 'Diep teal, premium' },
{ id: 'cerulean', name: 'Ocean Depth', desc: 'Diep oceaanblauw' },
{ id: 'electric', name: 'Electric Cyan', desc: 'Donker elektrisch cyaan' },
{ id: 'violet', name: 'Indigo Night', desc: 'Diep indigo-violet (geen roze)' },
{ id: 'emerald', name: 'Jade Depth', desc: 'Diep jade smaragd' },
{ id: 'midnight', name: 'Midnight Navy', desc: 'Diep marinezwart-blauw' },
{ id: 'steel', name: 'Steel Blue', desc: 'Strak staalblauw' },
{ id: 'nordic', name: 'Nordic Frost', desc: 'Koel nordic blauw' }
],
backgrounds: [
{ id: 'navy', name: 'Deep Navy', desc: 'Diep marineblauw' },
{ id: 'royal', name: 'Royal Blue', desc: 'Koningsblauw' },
{ id: 'azure', name: 'Azure Sea', desc: 'Helder azuur' },
{ id: 'sky', name: 'Sky Blue', desc: 'Heldere luchtblauw' },
{ id: 'cerulean', name: 'Cerulean', desc: 'Ceruleum' },
{ id: 'teal', name: 'Deep Teal', desc: 'Diep teal' },
{ id: 'lagoon', name: 'Lagoon Blue', desc: 'Lagune blauwgroen' },
{ id: 'cyan', name: 'Cyan Depth', desc: 'Cyaan diepte' },
{ id: 'indigo', name: 'Indigo Field', desc: 'Indigo veld' },
{ id: 'violet', name: 'Violet Dusk', desc: 'Violet schemer' },
{ id: 'sapphire', name: 'Sapphire', desc: 'Saffierblauw' },
{ id: 'midnight', name: 'Midnight', desc: 'Nachtblauw (niet zwart)' },
{ id: 'emerald', name: 'Emerald Sea', desc: 'Smaragd zee' },
{ id: 'slate', name: 'Slate Blue', desc: 'Leisteenblauw' },
{ id: 'graphite', name: 'Graphite Blue', desc: 'Grafietblauw' }
],
current: function () {
try { return localStorage.getItem('flk-theme') || 'ocean'; } catch (e) { return 'ocean'; }
},
currentSidebar: function () {
try { return localStorage.getItem('flk-sidebar-theme') || ''; } catch (e) { return ''; }
},
apply: function (id) {
var ok = this.list.some(function (t) { return t.id === id; });
if (!ok) id = 'ocean';
document.documentElement.setAttribute('data-theme', id);
try { localStorage.setItem('flk-theme', id); } catch (e) {}
return id;
},
applySidebar: function (id) {
if (!id) {
document.documentElement.removeAttribute('data-sidebar');
try { localStorage.removeItem('flk-sidebar-theme'); } catch (e) {}
return '';
}
var migrate = { coral: 'midnight', fuchsia: 'nordic', lime: 'steel' };
if (migrate[id]) id = migrate[id];
var ok = this.sidebars.some(function (t) { return t.id === id; });
if (!ok) {
document.documentElement.removeAttribute('data-sidebar');
try { localStorage.removeItem('flk-sidebar-theme'); } catch (e) {}
return '';
}
document.documentElement.setAttribute('data-sidebar', id);
try { localStorage.setItem('flk-sidebar-theme', id); } catch (e) {}
return id;
},
currentSidebarStyle: function () {
try { return localStorage.getItem('flk-sidebar-style') || 'gradient'; } catch (e) { return 'gradient'; }
},
applySidebarStyle: function (mode) {
if (mode !== 'plain') mode = 'gradient';
if (mode === 'plain') {
document.documentElement.setAttribute('data-sidebar-style', 'plain');
} else {
document.documentElement.removeAttribute('data-sidebar-style');
}
try { localStorage.setItem('flk-sidebar-style', mode); } catch (e) {}
return mode;
},
currentBg: function () {
try { return localStorage.getItem('flk-page-bg') || ''; } catch (e) { return ''; }
},
applyBg: function (id) {
if (!id) {
document.documentElement.removeAttribute('data-bg');
try { localStorage.removeItem('flk-page-bg'); } catch (e) {}
return '';
}
var ok = (this.backgrounds || []).some(function (t) { return t.id === id; });
if (!ok) {
document.documentElement.removeAttribute('data-bg');
try { localStorage.removeItem('flk-page-bg'); } catch (e) {}
return '';
}
document.documentElement.setAttribute('data-bg', id);
try { localStorage.setItem('flk-page-bg', id); } catch (e) {}
return id;
}
};
if (!document.documentElement.getAttribute('data-theme')) {
window.FlkThemes.apply(window.FlkThemes.current());
}
(function () {
var s = window.FlkThemes.currentSidebar();
if (s) window.FlkThemes.applySidebar(s);
if (window.FlkThemes.applySidebarStyle) window.FlkThemes.applySidebarStyle(window.FlkThemes.currentSidebarStyle());
var b = window.FlkThemes.currentBg();
if (b) window.FlkThemes.applyBg(b);
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+11 -6
View File
@@ -4,24 +4,28 @@
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v=1" />
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.css?v=1" />
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.Default.css?v=1" />
<link rel="stylesheet" href="/static/css/export-intel.css?v=32" />
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=20" />
<link rel="stylesheet" href="/static/css/export-intel.css?v=23" />
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=21" />
<script src="/static/vendor/leaflet/leaflet.js?v=1"></script>
<script src="/static/vendor/leaflet.markercluster/leaflet.markercluster.js?v=1"></script>
<script src="/static/js/export-intel-map.js?v=21"></script>
<script src="/static/js/export-intel.js?v=32"></script>
<script src="/static/js/export-intel.js?v=48"></script>
<script src="/static/js/export-intel-profile.js?v=7"></script>
{% endblock %}
{% block content %}
<div class="ei-page" x-data="exportIntelApp()" x-init="init()">
<div class="ei-progress-overlay" x-show="isWorking()" x-cloak>
<div class="ei-progress-overlay" x-show="isWorking()" x-cloak>
<div class="ei-progress-card">
<div class="ei-progress-spinner"></div>
<p x-text="progressLabel()"></p>
<div class="ei-progress-bar"><div class="ei-progress-bar-fill" :style="'width:' + (syncMonitorPercent() || 100) + '%'"></div></div>
<div class="ei-progress-bar"><div class="ei-progress-bar-fill" :style="'width:' + (syncMonitorPercent() || 8) + '%'"></div></div>
<div class="ei-progress-actions">
<button type="button" class="btn btn-sm ei-btn-cancel" @click="cancelSync()" x-show="busy || (syncMonitor && syncMonitor.any_running)">⏹ Annuleer sync</button>
<button type="button" class="btn btn-sm" @click="dismissOverlay()" title="Overlay sluiten — sync loopt door in monitor">Verberg</button>
</div>
</div>
</div>
<header class="ei-header page-header">
<header class="ei-header page-header">
<div>
<h1>Wereldexport <span class="live-badge">Live</span></h1>
<p class="ei-subtitle">Foodlinkk B2B · wereldwijde markten · halal vlees · cateraars · distributeurs</p>
@@ -49,6 +53,7 @@
<div class="ei-sync-monitor-head">
<strong>Sync voortgang</strong>
<span x-show="syncMonitor && syncMonitor.any_running" class="ei-sync-live">● Live</span>
<button type="button" class="btn btn-sm ei-btn-cancel" style="margin-left:auto" @click="cancelSync()" x-show="syncMonitor && syncMonitor.any_running">⏹ Annuleer</button>
</div>
<template x-if="syncMonitor && syncMonitor.active_jobs && syncMonitor.active_jobs.length">
<div class="ei-sync-jobs">
+136 -5
View File
@@ -289,7 +289,7 @@
<div class="settings-panel-head">
<div>
<h2>UI Themes</h2>
<p class="page-tip">Kies een kleurrijk cyaan/blauw thema voor de hele cockpit. Direct toegepast op alle pagina's.</p>
<p class="page-tip">UI-theme, pagina-achtergrond en sidebar-kleur zijn los van elkaar te kiezen.</p>
</div>
</div>
<div class="flk-theme-grid">
@@ -301,6 +301,48 @@
</button>
</template>
</div>
<div class="flk-theme-section">
<h3>Pagina achtergrond</h3>
<p class="page-tip">15 echte kleuren voor alle subpagina’s (niet zwart). Los van UI-theme en sidebar.</p>
<div class="flk-theme-grid">
<button type="button" class="flk-theme-card" :class="{ 'is-active': !bg }" @click="selectBg('')">
<div class="flk-theme-swatch" style="background:linear-gradient(135deg,#07131f,#123049)"></div>
<strong>Standaard</strong>
<small>Volgt UI-theme achtergrond</small>
</button>
<template x-for="b in backgrounds" :key="'bg-'+b.id">
<button type="button" class="flk-theme-card" :class="{ 'is-active': bg === b.id }" @click="selectBg(b.id)">
<div class="flk-theme-swatch" :style="bgSwatch(b.id)"></div>
<strong x-text="b.name"></strong>
<small x-text="b.desc"></small>
</button>
</template>
</div>
</div>
<div class="flk-theme-section">
<h3>Sidebar Themes</h3>
<p class="page-tip">Los van het UI-theme — 10 diepe, strakke kleuren (geen rood/roze). Kies gradient of effen.</p>
<div class="flk-style-toggle" role="group" aria-label="Sidebar stijl">
<button type="button" :class="{ 'is-active': sidebarStyle === 'gradient' }" @click="selectSidebarStyle('gradient')">Gradient</button>
<button type="button" :class="{ 'is-active': sidebarStyle === 'plain' }" @click="selectSidebarStyle('plain')">Effen kleur</button>
</div>
<div class="flk-theme-grid">
<button type="button" class="flk-theme-card" :class="{ 'is-active': !sidebar }" @click="selectSidebar('')">
<div class="flk-theme-swatch" style="background:linear-gradient(180deg,#080c12,#121820)"></div>
<strong>Standaard</strong>
<small>Geen aparte sidebar-kleur (volgt UI)</small>
</button>
<template x-for="s in sidebars" :key="'sb-'+s.id">
<button type="button" class="flk-theme-card" :class="{ 'is-active': sidebar === s.id }" @click="selectSidebar(s.id)">
<div class="flk-theme-swatch" :data-sb="s.id" :data-sb-style="sidebarStyle" :style="sidebarSwatch(s.id)"></div>
<strong x-text="s.name"></strong>
<small x-text="s.desc"></small>
</button>
</template>
</div>
</div>
</section>
{% elif active_tab == 'general' %}
@@ -318,14 +360,42 @@
function flkThemesPage() {
return {
themes: (window.FlkThemes && window.FlkThemes.list) || [],
sidebars: (window.FlkThemes && window.FlkThemes.sidebars) || [],
backgrounds: (window.FlkThemes && window.FlkThemes.backgrounds) || [],
current: 'ocean',
sidebar: '',
sidebarStyle: 'gradient',
bg: '',
init() {
this.themes = (window.FlkThemes && window.FlkThemes.list) || [];
this.sidebars = (window.FlkThemes && window.FlkThemes.sidebars) || [];
this.backgrounds = (window.FlkThemes && window.FlkThemes.backgrounds) || [];
this.current = (window.FlkThemes && window.FlkThemes.current()) || 'ocean';
if (window.FlkThemes) window.FlkThemes.apply(this.current);
this.sidebar = (window.FlkThemes && window.FlkThemes.currentSidebar()) || '';
this.sidebarStyle = (window.FlkThemes && window.FlkThemes.currentSidebarStyle && window.FlkThemes.currentSidebarStyle()) || 'gradient';
this.bg = (window.FlkThemes && window.FlkThemes.currentBg && window.FlkThemes.currentBg()) || '';
if (window.FlkThemes) {
window.FlkThemes.apply(this.current);
window.FlkThemes.applySidebar(this.sidebar);
if (window.FlkThemes.applySidebarStyle) window.FlkThemes.applySidebarStyle(this.sidebarStyle);
if (window.FlkThemes.applyBg) window.FlkThemes.applyBg(this.bg);
}
},
select(id) {
this.current = window.FlkThemes.apply(id);
if (window.Cockpit) Cockpit.toast('Theme: ' + id, 'ok');
if (window.Cockpit) Cockpit.toast('UI theme: ' + id, 'ok');
},
selectSidebar(id) {
this.sidebar = window.FlkThemes.applySidebar(id || '');
if (window.Cockpit) Cockpit.toast(id ? ('Sidebar: ' + id) : 'Sidebar: standaard', 'ok');
},
selectSidebarStyle(mode) {
this.sidebarStyle = window.FlkThemes.applySidebarStyle(mode || 'gradient');
if (window.Cockpit) Cockpit.toast(mode === 'plain' ? 'Sidebar: effen kleur' : 'Sidebar: gradient', 'ok');
},
selectBg(id) {
this.bg = window.FlkThemes.applyBg(id || '');
if (window.Cockpit) Cockpit.toast(id ? ('Achtergrond: ' + id) : 'Achtergrond: standaard', 'ok');
},
swatch(id) {
var map = {
@@ -333,9 +403,70 @@ function flkThemesPage() {
aurora: 'linear-gradient(135deg,#0a1020,#1a2550 45%,#a78bfa 75%,#22d3ee)',
neon: 'linear-gradient(135deg,#050b14,#10243f 40%,#06b6d4 70%,#e879f9)',
glacier: 'linear-gradient(135deg,#0b1522,#1a2f4a 45%,#38bdf8 80%,#7dd3fc)',
cobalt: 'linear-gradient(135deg,#08101c,#172642 40%,#3b82f6 70%,#22d3ee)'
cobalt: 'linear-gradient(135deg,#08101c,#172642 40%,#3b82f6 70%,#22d3ee)',
reef: 'linear-gradient(135deg,#04161a,#11353f 40%,#2dd4bf)',
plasma: 'linear-gradient(135deg,#0c0618,#26114a 40%,#c084fc 75%,#22d3ee)',
lagoon: 'linear-gradient(135deg,#031a1f,#0c3a45 40%,#22d3ee)',
indigo: 'linear-gradient(135deg,#07091a,#1a2250 40%,#818cf8 75%,#22d3ee)',
skyline: 'linear-gradient(135deg,#06101c,#142d4d 40%,#38bdf8)',
mintvolt: 'linear-gradient(135deg,#04140f,#10352a 40%,#34d399 70%,#22d3ee)',
sapphire: 'linear-gradient(135deg,#050d1f,#122650 40%,#3b82f6 70%,#38bdf8)',
arctic: 'linear-gradient(135deg,#0a121c,#1c2a3d 45%,#67e8f9)',
hyperion: 'linear-gradient(135deg,#041018,#0e2d42 40%,#22d3ee 70%,#a3e635)',
meridian: 'linear-gradient(135deg,#0a0716,#221645 40%,#c084fc 70%,#22d3ee)'
};
return 'background:' + (map[id] || map.ocean);
return 'background: ' + (map[id] || map.ocean) + ' !important;';
},
sidebarSwatch(id) {
var style = this.sidebarStyle || 'gradient';
var solids = {
aqua: '#0a4f5c',
azure: '#163a7a',
turquoise: '#0a453f',
cerulean: '#0c4a6e',
electric: '#0a3d4a',
violet: '#2a1f6b',
emerald: '#0a4a38',
midnight: '#0d1f3c',
steel: '#1a2d42',
nordic: '#0e3a4f',
};
var grads = {
aqua: 'linear-gradient(180deg, #062a32 0%, #0a4f5c 55%, #0d6b7a 100%)',
azure: 'linear-gradient(180deg, #0a1f45 0%, #163a7a 55%, #1e4f9e 100%)',
turquoise: 'linear-gradient(180deg, #052925 0%, #0a453f 55%, #0f5f56 100%)',
cerulean: 'linear-gradient(180deg, #06263a 0%, #0c4a6e 55%, #126087 100%)',
electric: 'linear-gradient(180deg, #041820 0%, #0a3d4a 50%, #0e5a6b 100%)',
violet: 'linear-gradient(180deg, #150e3a 0%, #2a1f6b 55%, #3b2f8a 100%)',
emerald: 'linear-gradient(180deg, #05291e 0%, #0a4a38 55%, #0f6349 100%)',
midnight: 'linear-gradient(180deg, #060e1c 0%, #0d1f3c 55%, #153056 100%)',
steel: 'linear-gradient(180deg, #0d1622 0%, #1a2d42 55%, #243b55 100%)',
nordic: 'linear-gradient(180deg, #071f2c 0%, #0e3a4f 55%, #15506a 100%)',
};
var bg = (style === 'plain')
? (solids[id] || solids.electric)
: (grads[id] || grads.electric);
return 'background: ' + bg + ' !important;';
},
bgSwatch(id) {
var map = {
navy: 'linear-gradient(135deg,#0d2b5c,#134078)',
royal: 'linear-gradient(135deg,#1a3a8f,#274bb8)',
azure: 'linear-gradient(135deg,#0a4f8a,#0d6cb8)',
sky: 'linear-gradient(135deg,#0e5f9e,#1880c9)',
cerulean: 'linear-gradient(135deg,#0c6a9e,#1490c8)',
teal: 'linear-gradient(135deg,#0c5c68,#128090)',
lagoon: 'linear-gradient(135deg,#0a6b7a,#0f8fa3)',
cyan: 'linear-gradient(135deg,#0a5568,#0e7a94)',
indigo: 'linear-gradient(135deg,#2f2a7a,#4338ca)',
violet: 'linear-gradient(135deg,#4a1d7a,#6d28d9)',
sapphire: 'linear-gradient(135deg,#123a8c,#1d4ed8)',
midnight: 'linear-gradient(135deg,#1a2f55,#274878)',
emerald: 'linear-gradient(135deg,#0d5c45,#059669)',
slate: 'linear-gradient(135deg,#243447,#334155)',
graphite: 'linear-gradient(135deg,#1e2a3a,#2c3b52)'
};
return 'background: ' + (map[id] || map.navy) + ' !important;';
}
};
}
+4 -1
View File
@@ -93,11 +93,14 @@ services:
HERMES_PREVIEW_BASE: http://10.4.7.27:8080
NAS_CLIENTS_ROOT: /data/nas-clients
NAS_SHARE_ROOT: /data/nas-share
NAS_AGENTS_ROOT: /data/nas-share/Agents
NAS_AGENTS_SMB: //10.4.7.11/share/Agents
NAS_AGENTS_UNC: //10.4.7.11/share/Agents
ports:
- "8610:8600"
volumes:
- /home/aissa/nas-clients:/data/nas-clients
- /mnt/synology:/data/nas-share:ro
- /mnt/synology:/data/nas-share
- ./cockpit/static/uploads:/app/static/uploads
depends_on:
- tools-api
+18
View File
@@ -0,0 +1,18 @@
-- CEO direct inbox (@ervoorz / @aissa)
CREATE TABLE IF NOT EXISTS warroom_ceo_inbox (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT REFERENCES warroom_messages(id) ON DELETE CASCADE,
room_id BIGINT REFERENCES warroom_rooms(id) ON DELETE CASCADE,
agent_key VARCHAR(64),
content TEXT NOT NULL,
excerpt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
read_at TIMESTAMPTZ,
recipient TEXT NOT NULL DEFAULT 'aissa'
);
CREATE INDEX IF NOT EXISTS warroom_ceo_inbox_unread
ON warroom_ceo_inbox (recipient, read_at, created_at DESC);
CREATE INDEX IF NOT EXISTS warroom_ceo_inbox_created
ON warroom_ceo_inbox (created_at DESC);
+14
View File
@@ -1372,7 +1372,9 @@ def api_sync_places(payload: SyncRequestIn) -> dict[str, Any]:
job_id = start_job("places", f"Google Places · {len(countries)} landen", total=len(countries))
results: dict[str, Any] = {}
try:
from app.export_intel_sync_progress import SyncCancelled, raise_if_cancelled
for i, c in enumerate(countries, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Google Places {i}/{len(countries)}: {c}", country=c, current=i, total=len(countries))
results[c] = sync_places_country(c)
time.sleep(1)
@@ -1385,6 +1387,18 @@ def api_sync_places(payload: SyncRequestIn) -> dict[str, Any]:
return {"status": "completed", "phase": "B", "result": results}
@router.post("/sync/cancel")
def api_sync_cancel() -> dict[str, Any]:
"""Request cancellation of the currently running sync job(s)."""
from app.export_intel_sync_progress import request_cancel, get_monitor_snapshot
out = request_cancel("user")
snap = get_monitor_snapshot()
out["any_running"] = snap.get("any_running")
out["active_jobs"] = [{"id": j.get("id"), "label": j.get("label"), "status": j.get("status")} for j in snap.get("active_jobs", [])]
return out
@router.get("/sync/status")
def sync_status() -> dict[str, Any]:
from app.export_intel_sync_progress import get_monitor_snapshot
+13
View File
@@ -26,6 +26,8 @@ TED_RSS_URLS = [
from app.export_territory_seeds import BBOX_ONLY_ISO2
from app.export_intel_sync_progress import (
SyncCancelled,
raise_if_cancelled,
finish_job,
get_active_job,
job_step,
@@ -593,6 +595,7 @@ def sync_all_priority(max_priority: int = 2, region_code: Optional[str] = None)
cust: dict[str, Any] = {}
contacts: dict[str, Any] = {}
for i, c in enumerate(iso_list, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Land {i}/{len(iso_list)}: {c}", country=c, step="osm", current=i, total=len(iso_list))
try:
materialize_caterer_presence(c)
@@ -610,6 +613,10 @@ def sync_all_priority(max_priority: int = 2, region_code: Optional[str] = None)
report["steps"]["google_places"] = {"skipped": True, "reason": "gratis-modus: geen Google Places"}
finish_job(job_id, result={"country_count": len(iso_list), "region": region_code})
except SyncCancelled as exc:
finish_job(job_id, error=str(exc))
report["cancelled"] = True
return report
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
@@ -644,6 +651,7 @@ def sync_missing_countries(max_priority: int = 3) -> dict[str, Any]:
contacts: dict[str, Any] = {}
try:
for i, c in enumerate(iso_list, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Leeg land {i}/{len(iso_list)}: {c}", country=c, current=i, total=len(iso_list))
try:
materialize_caterer_presence(c)
@@ -661,6 +669,10 @@ def sync_missing_countries(max_priority: int = 3) -> dict[str, Any]:
"contacts": contacts,
}
finish_job(job_id, result={"country_count": len(iso_list)})
except SyncCancelled as exc:
finish_job(job_id, error=str(exc))
report["cancelled"] = True
return report
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
@@ -743,6 +755,7 @@ def sync_customers(country_iso2: Optional[str] = None, region_code: Optional[str
results: dict[str, Any] = {}
try:
for i, c in enumerate(countries, start=1):
raise_if_cancelled()
job_step(job_id, message=f"Horeca {i}/{len(countries)}: {c}", country=c, current=i, total=len(countries))
try:
results[c] = sync_osm_country(c, kinds=["customers"])
+41 -3
View File
@@ -11,6 +11,39 @@ STATUS_PATH = Path("/tmp/export-intel-sync-status.json")
LOG_PATH = Path("/tmp/export-intel-sync-progress.jsonl")
LOCK_PATH = Path("/tmp/export-intel-sync.lock")
CANCEL_PATH = Path("/tmp/export-intel-sync-cancel.flag")
class SyncCancelled(Exception):
"""Raised when a running sync job is cancelled by the user."""
def request_cancel(reason: str = "user") -> dict[str, Any]:
CANCEL_PATH.write_text(json.dumps({"ts": _now(), "reason": reason}))
log_event("cancel_requested", reason=reason)
# Mark running jobs as cancelling
status = _load_status()
for job in status.get("jobs", {}).values():
if job.get("status") == "running":
job["status"] = "cancelling"
job["message"] = "Annuleren…"
job["updated_at"] = _now()
_save_status(status)
return {"ok": True, "cancel_requested": True}
def clear_cancel() -> None:
CANCEL_PATH.unlink(missing_ok=True)
def is_cancel_requested() -> bool:
return CANCEL_PATH.exists()
def raise_if_cancelled() -> None:
if is_cancel_requested():
raise SyncCancelled("Sync geannuleerd door gebruiker")
_active_job_id: Optional[str] = None
@@ -68,6 +101,7 @@ def _clear_lock() -> None:
def start_job(job_type: str, label: str, total: int = 0, meta: Optional[dict[str, Any]] = None) -> str:
clear_cancel()
job_id = f"{job_type}-{_now():.0f}"
status = _load_status()
job = {
@@ -138,7 +172,10 @@ def finish_job(job_id: str, result: Optional[dict[str, Any]] = None, error: Opti
job = status["jobs"].get(job_id)
if not job:
return
job["status"] = "error" if error else "completed"
if error and "geannuleerd" in str(error).lower():
job["status"] = "cancelled"
else:
job["status"] = "error" if error else "completed"
job["updated_at"] = _now()
job["finished_at"] = _now()
job["percent"] = 100 if not error else job.get("percent", 0)
@@ -151,9 +188,10 @@ def finish_job(job_id: str, result: Optional[dict[str, Any]] = None, error: Opti
log_event("job_finish", job_id=job_id, status=job["status"], error=error)
if get_active_job() == job_id:
set_active_job(None)
running = [j for j in status["jobs"].values() if j.get("status") == "running"]
running = [j for j in status["jobs"].values() if j.get("status") in ("running", "cancelling")]
if not running:
_clear_lock()
clear_cancel()
def tail_log(limit: int = 40) -> list[dict[str, Any]]:
@@ -172,7 +210,7 @@ def tail_log(limit: int = 40) -> list[dict[str, Any]]:
def get_monitor_snapshot(db_counts: Optional[dict[str, Any]] = None) -> dict[str, Any]:
status = _load_status()
jobs = status.get("jobs", {})
active = [j for j in jobs.values() if j.get("status") == "running"]
active = [j for j in jobs.values() if j.get("status") in ("running", "cancelling")]
recent = [jobs[jid] for jid in status.get("history", [])[:8] if jid in jobs and jobs[jid].get("status") != "running"]
lock = _read_json(LOCK_PATH, None) if LOCK_PATH.exists() else None
return {