From 910ba465d71cf8a0724d90d4482a5f7b8c466b42 Mon Sep 17 00:00:00 2001 From: Aissa Date: Sun, 16 Aug 2026 15:49:16 +0000 Subject: [PATCH] Add War Room voice meeting, fix chat overlay, and ship 5 cyan UI themes. Voice Room lets humans talk with agents via Whisper/TTS; chat typing no longer blocked by a full-screen overlay, attachments/avatars are larger, and Settings exposes five vivid mid-dark cyan/blue themes for the whole cockpit. --- cockpit/app/config.py | 1 + cockpit/app/routes/agents_api.py | 182 +++++++- cockpit/app/routes/settings.py | 2 +- cockpit/app/services/agent_chat.py | 491 ++++++++++++++++++-- cockpit/app/services/agent_souls.py | 12 +- cockpit/app/services/briefing.py | 7 +- cockpit/app/services/warroom_digest.py | 180 +++++++ cockpit/static/css/agents-warroom.css | 344 +++++++++++--- cockpit/static/css/flk-themes.css | 180 +++++++ cockpit/static/js/agents-warroom.js | 475 ++++++++++++++++++- cockpit/templates/agents.html | 149 +++++- cockpit/templates/agents_warroom_popup.html | 147 +++++- cockpit/templates/base.html | 31 +- cockpit/templates/settings.html | 48 ++ migrations/044_warroom_message_deleted.sql | 7 + scripts/herman_warroom_digest.py | 45 ++ 16 files changed, 2162 insertions(+), 139 deletions(-) create mode 100644 cockpit/app/services/warroom_digest.py create mode 100644 cockpit/static/css/flk-themes.css create mode 100644 migrations/044_warroom_message_deleted.sql create mode 100755 scripts/herman_warroom_digest.py diff --git a/cockpit/app/config.py b/cockpit/app/config.py index 7dfd89b..21ce289 100644 --- a/cockpit/app/config.py +++ b/cockpit/app/config.py @@ -20,6 +20,7 @@ class Settings: HERMES_API_BASE: str = os.getenv("HERMES_API_BASE", "http://10.4.7.10:8642/v1") HERMES_API_KEY: str = os.getenv("HERMES_API_KEY", "") HERMES_API_MODEL: str = os.getenv("HERMES_API_MODEL", "hermes-agent") + WARROOM_MEET_BASE: str = os.getenv("WARROOM_MEET_BASE", "https://meet.jit.si") WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions") WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base") WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl") diff --git a/cockpit/app/routes/agents_api.py b/cockpit/app/routes/agents_api.py index 974edf8..e93c80f 100644 --- a/cockpit/app/routes/agents_api.py +++ b/cockpit/app/routes/agents_api.py @@ -573,10 +573,11 @@ async def warroom_open_dm(agent_key: str) -> dict[str, Any]: @router.get("/warroom/rooms/{room_id}/messages") async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]: from app.services import agent_chat - room = agent_chat.get_room(room_id) - if not room: - raise HTTPException(404, "Room niet gevonden") - return {"room": room, "items": agent_chat.list_messages(room_id, limit=min(limit, 200), after_id=after_id)} + + agent_chat.ensure_warroom() + 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} @router.post("/warroom/rooms/{room_id}/messages") @@ -663,3 +664,176 @@ async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]: "mime": file.content_type or "application/octet-stream", "size": len(data), } + + + + + + +@router.post("/warroom/digest") +async def warroom_daily_digest(hours: int = 24, deliver: bool = True) -> dict[str, Any]: + """Generate Herman War Room day report and optionally push to Aïssa + Mo on Telegram.""" + from app.services import warroom_digest + + try: + return await warroom_digest.run_daily_digest(hours=max(1, min(hours, 72)), deliver=deliver) + except Exception as exc: + raise HTTPException(500, str(exc)) from exc + + +@router.post("/warroom/rooms/{room_id}/continue") +async def warroom_continue(request: Request, room_id: int, turns: int = 2): + """Autonomous A2A continuation — agents keep debating with cockpit data.""" + import json + from app.services import agent_chat + + user = getattr(request.state, "user", None) or {} + + async def gen(): + try: + async for ev in agent_chat.continue_warroom_stream( + room_id, + username=(user.get("username") if isinstance(user, dict) else None), + role=(user.get("role") if isinstance(user, dict) else None), + max_turns=max(1, min(int(turns or 2), 4)), + ): + yield f"data: {json.dumps(ev, default=str)}\n\n" + except ValueError as exc: + yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n" + except Exception as exc: + yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n" + + return StreamingResponse( + gen(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + + +@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.""" + from app.services import agent_chat + + room = agent_chat.get_room(room_id) + if not room: + raise HTTPException(404, "Room niet gevonden") + row = agent_chat.delete_message(room_id, message_id) + if not row: + raise HTTPException(404, "Bericht niet gevonden") + return {"ok": True, "id": message_id} + + + +@router.post("/warroom/rooms/{room_id}/voice-room") +async def warroom_voice_room(request: Request, room_id: int) -> dict[str, Any]: + """Open/announce a Foodlinkk Voice Room (humans + agents via STT/TTS).""" + from app.services import agent_chat + + room = agent_chat.get_room(room_id) + if not room: + raise HTTPException(404, "Room niet gevonden") + body = {} + try: + body = await request.json() + except Exception: + body = {} + action = str(body.get("action") or "join").lower() + agents = body.get("agents") or ["herman", "retail", "bizdev", "halal", "finance"] + if not isinstance(agents, list): + agents = ["herman"] + agents = [str(a).strip().lower() for a in agents if str(a).strip()][:12] + user = getattr(request.state, "user", None) or {} + who = user.get("username") if isinstance(user, dict) else "user" + + if action == "leave": + agent_chat.post_system_line( + f"🎙️ {who} verliet de Voice Room", + agent_key="herman", + metadata={"kind": "voice_room", "action": "leave", "opened_by": who}, + ) + return {"ok": True, "action": "leave"} + + agent_chat.post_system_line( + f"🎙️ Voice Room open — {who} + agents: " + ", ".join("@" + a for a in agents), + agent_key="herman", + metadata={ + "kind": "voice_room", + "action": "join", + "opened_by": who, + "agents": agents, + }, + ) + return {"ok": True, "action": "join", "agents": agents, "host": who} + + +@router.post("/warroom/rooms/{room_id}/call") +async def warroom_start_call(request: Request, room_id: int) -> dict[str, Any]: + """Open a simple no-account video room in an external HTTPS window. + + Cockpit stays on HTTP. Mic/camera need HTTPS in a separate tab. + Default provider is Freifunk meet (no 8x8 login / no moderator gate). + """ + import os + from urllib.parse import quote + from app.db import execute, fetch_one + from app.services import agent_chat + + room = agent_chat.get_room(room_id) + if not room: + raise HTTPException(404, "Room niet gevonden") + + base = (os.getenv("WARROOM_MEET_BASE") or "https://meet.ffmuc.net").rstrip("/") + slug = f"FoodlinkkWR{room_id}" + if room.get("kind") == "dm" and room.get("agent_key"): + slug = f"FoodlinkkDM{room.get('agent_key')}{room_id}" + url = f"{base}/{quote(slug)}" + + user = getattr(request.state, "user", None) or {} + who = user.get("username") if isinstance(user, dict) else "user" + + recent = fetch_one( + """SELECT id, metadata FROM warroom_messages + WHERE room_id = %s AND deleted_at IS NULL + AND metadata->>'kind' = 'call' + AND created_at > NOW() - INTERVAL '20 minutes' + ORDER BY id DESC LIMIT 1""", + (room_id,), + ) + reused = bool(recent and (recent.get("metadata") or {}).get("call_url") == url) + + if not reused: + execute( + """UPDATE warroom_messages + SET deleted_at = NOW(), content = '', + metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb + WHERE room_id = %s AND deleted_at IS NULL + AND ( + metadata->>'kind' = 'call' + OR content ILIKE %s + OR content ILIKE %s + )""", + (room_id, "%War Room-call%", "%Call open%"), + ) + agent_chat.post_system_line( + f"📹 Call open — geen account nodig: {url}", + agent_key="herman", + metadata={"kind": "call", "call_url": url, "room_slug": slug, "opened_by": who}, + ) + + return { + "ok": True, + "url": url, + "slug": slug, + "provider": "ffmuc", + "embed": False, + "mode": "external", + "reused": reused, + "hint": "Cockpit blijft op HTTP. Gesprek opent in een apart venster — geen login.", + } + diff --git a/cockpit/app/routes/settings.py b/cockpit/app/routes/settings.py index 3588c8e..fe9f3f2 100644 --- a/cockpit/app/routes/settings.py +++ b/cockpit/app/routes/settings.py @@ -13,7 +13,7 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) @router.get("/settings") async def settings_page(request: Request, tab: str = "email"): - allowed = ("email", "general", "permissions", "social", "users") + allowed = ("email", "general", "themes", "permissions", "social", "users") active = tab if tab in allowed else "email" if tab == "ai": active = "email" diff --git a/cockpit/app/services/agent_chat.py b/cockpit/app/services/agent_chat.py index c0df2ee..aa30f56 100644 --- a/cockpit/app/services/agent_chat.py +++ b/cockpit/app/services/agent_chat.py @@ -15,11 +15,105 @@ from app.services.agent_names import normalize_agent_key log = logging.getLogger("cockpit.agent_chat") MENTION_RE = re.compile(r"@([a-zA-Z][a-zA-Z0-9_-]{1,63})") -MAX_ROUNDS = 2 +MAX_ROUNDS = 4 MAX_REPLIES_PER_ROUND = 3 HISTORY_LIMIT = 16 WARROOM_MAX_TOKENS = 512 WARROOM_TIMEOUT = 55.0 +A2A_CONTINUE_MAX = 3 +COCKPIT_SNAPSHOT_TTL = 60.0 + + + +_snapshot_cache: dict[str, Any] = {"at": 0.0, "text": ""} + + +def cockpit_snapshot(*, max_chars: int = 2200) -> str: + """Compact live cockpit facts for War Room agents — accuracy first.""" + import time + + now = time.time() + if _snapshot_cache.get("text") and (now - float(_snapshot_cache.get("at") or 0)) < COCKPIT_SNAPSHOT_TTL: + return str(_snapshot_cache["text"]) + + lines: list[str] = [ + "COCKPIT FEITEN (alleen dit mag je citeren; verzin niets erbuiten):", + "SCHEIDING: CRM-klanten/deals ≠ retail-supermarktlocaties. Nooit CRM-namen als AH-filiaal presenteren.", + ] + + # CRM + try: + row = fetch_one( + """SELECT + (SELECT COUNT(*) FROM clients) AS clients, + (SELECT COUNT(*) FROM deals) AS deals, + (SELECT COALESCE(SUM(value),0) FROM deals WHERE stage NOT IN ('won','lost')) AS pipeline + """ + ) or {} + lines.append( + f"- CRM: {row.get('clients', 0)} klanten · {row.get('deals', 0)} deals · " + f"pipeline €{float(row.get('pipeline') or 0):,.0f}" + ) + deals = fetch_all( + """SELECT d.title, d.stage, d.value, c.name AS client_name + FROM deals d + LEFT JOIN clients c ON c.id = d.client_id + WHERE d.stage NOT IN ('won','lost') + ORDER BY d.updated_at DESC NULLS LAST + LIMIT 6""" + ) or [] + if deals: + bits = [] + for d in deals: + bits.append( + f"{d.get('client_name') or d.get('title') or '?'}[{d.get('stage')}" + f"/€{float(d.get('value') or 0):,.0f}]" + ) + lines.append("- Open deals: " + "; ".join(bits)) + clients = fetch_all("SELECT name, stage FROM clients ORDER BY updated_at DESC NULLS LAST LIMIT 6") or [] + if clients: + lines.append( + "- CRM-klanten: " + + ", ".join(f"{c.get('name')}({c.get('stage')})" for c in clients) + ) + except Exception as exc: + log.warning("snapshot CRM failed: %s", exc) + lines.append("- CRM: (niet beschikbaar)") + + # Real AH / retail opportunities — exclude test rows + try: + ah = fetch_all( + """ + SELECT s.id, s.name, s.address, s.postcode, s.city, + ros.halal_opportunity_score AS score, + COALESCE(s.partnership_status, ros.factors->>'partnership_status', 'none') AS status + FROM retail_opportunity_scores ros + JOIN supermarkets s ON s.id = ros.supermarket_id + WHERE s.chain ILIKE '%albert%heijn%' + AND s.name NOT ILIKE '%masala%' + AND s.name NOT ILIKE '%TEST%' + AND COALESCE(s.data_source,'') NOT ILIKE '%test_exclude%' + ORDER BY ros.halal_opportunity_score DESC NULLS LAST, s.city, s.postcode + LIMIT 8 + """ + ) or [] + if ah: + lines.append("- Top Albert Heijn (halal-kans score, echte filialen):") + for s in ah: + lines.append( + f" · AH {s.get('address')}, {s.get('postcode')} {s.get('city')} " + f"— score {float(s.get('score') or 0):.1f} · status {s.get('status')}" + ) + else: + lines.append("- Top Albert Heijn: geen geldige scores (geen testrecords).") + except Exception as exc: + log.warning("snapshot AH failed: %s", exc) + lines.append("- Top Albert Heijn: (niet beschikbaar)") + + text = "\n".join(lines)[:max_chars] + _snapshot_cache["at"] = now + _snapshot_cache["text"] = text + return text def _iso(row: dict[str, Any] | None) -> dict[str, Any] | None: @@ -87,14 +181,15 @@ def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None if after_id: rows = fetch_all( """SELECT * FROM warroom_messages - WHERE room_id = %s AND id > %s + WHERE room_id = %s AND id > %s AND deleted_at IS NULL ORDER BY created_at ASC, id ASC LIMIT %s""", (room_id, after_id, limit), ) else: rows = fetch_all( """SELECT * FROM ( - SELECT * FROM warroom_messages WHERE room_id = %s + SELECT * FROM warroom_messages + WHERE room_id = %s AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT %s ) t ORDER BY created_at ASC, id ASC""", (room_id, limit), @@ -102,6 +197,34 @@ def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None return [_iso(r) for r in rows] # type: ignore +def delete_message(room_id: int, message_id: int) -> dict[str, Any] | None: + """Soft-delete a warroom message. Returns the row or None if missing/already deleted.""" + row = fetch_one( + """UPDATE warroom_messages + SET deleted_at = NOW(), + content = '', + metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true}'::jsonb + WHERE id = %s AND room_id = %s AND deleted_at IS NULL + RETURNING *""", + (message_id, room_id), + ) + if row: + execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,)) + return _iso(row) if row else None # type: ignore + + +def list_deleted_ids(room_id: int) -> list[int]: + """Recently soft-deleted message ids (for live UI sync across clients).""" + rows = fetch_all( + """SELECT id FROM warroom_messages + WHERE room_id = %s AND deleted_at IS NOT NULL + AND deleted_at > NOW() - INTERVAL '6 hours' + ORDER BY deleted_at DESC LIMIT 200""", + (room_id,), + ) + return [int(r["id"]) for r in rows] + + def _insert_message( room_id: int, *, @@ -195,24 +318,174 @@ async def _hermes_reply( return reply, meta +def _live_roster_line() -> str: + keys = sorted(_active_agent_keys()) + return ", ".join(keys) if keys else "(geen)" + + 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 "")[:900] - resp = (soul.get("responsibilities") or "")[:400] + soul_md = (soul.get("soul_md") or "")[:700] + resp = (soul.get("responsibilities") or "")[:350] + roster = _live_roster_line() + hard = ( + "HARDE FEITEN (overschrijven alle eerdere aannames/geheugen):\n" + f"- LIVE ROSTER ONLINE: {roster}.\n" + "- Alle agents hierboven zijn NU actief in de War Room / cockpit.\n" + "- Verboden zinnen: 'staat uit', 'niet actief', 'niet beschikbaar', 'weer inschakelen', " + "'weer activeren', 'alleen Halal is actief', 'agent is offline'.\n" + "- Als je twijfelt: behandel elke agent op de roster als online en antwoord inhoudelijk.\n" + ) + snap = cockpit_snapshot() + accuracy = ( + "NAUWKEURIGHEID (hard):\n" + "- Antwoord ALLEEN met feiten uit COCKPIT FEITEN hieronder of uit recente War Room-berichten.\n" + "- Weet je het niet 100% zeker: zeg expliciet 'Dat staat zo niet in de cockpit' en @mention de specialist " + "(@retail / @bizdev / @finance). Verzin GEEN locaties, scores of klantnamen.\n" + "- CRM-klanten (Reesha, MELEDI, …) zijn GEEN Albert Heijn-filialen.\n" + "- Negeer testdata / namen met Masala, TEST, of test_exclude.\n" + "- Geen aannames uit Telegram-geheugen als die botsen met COCKPIT FEITEN.\n" + ) + if (key or "").lower() == "herman": + accuracy += ( + "- 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 = ( - "War Room: antwoord in max 4 korte zinnen (NL). Alleen @mention als écht nodig." + 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 "1:1 DM: bondig in het Nederlands (max 5 zinnen)." + else 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}" ) +_OFFLINE_CLAIM_RE = re.compile( + r"(staat\s+uit|niet\s+actief|niet\s+beschikbaar|weer\s+inschakel|weer\s+activeer|" + r"agent\s+is\s+niet|alleen\s+halal|offline\s+in\s+cockpit|uit\s+in\s+cockpit|" + r"geen\s+actieve\s+\w*\s*agent)", + re.I, +) + + +_BAD_TEST_NAME_RE = re.compile(r"chai\s*n\s*masala|masala\s*regio", re.I) + + +def _sanitize_warroom_reply(agent_key: str, reply: str) -> str: + text = _clean_agent_reply(agent_key, reply) + if _OFFLINE_CLAIM_RE.search(text or ""): + roster = _live_roster_line() + return ( + f"Correctie: alle War Room-agents zijn online ({roster}). " + "Zeg kort wat je wél kunt doen — geen 'staat uit'-praat." + ) + if _BAD_TEST_NAME_RE.search(text or ""): + return ( + "Correctie: 'Chai N Masala' is testdata, geen echte Albert Heijn. " + "Voor AH-locaties alleen echte filialen uit de cockpit-supermarktlijst gebruiken " + "(adres + postcode + halal-score). @retail kan de actuele top-lijst geven." + ) + return text + + +_ALIAS_MAP = { + "herman": "herman", + "bizdev": "bizdev", + "biz": "bizdev", + "business": "bizdev", + "finance": "finance", + "financien": "finance", + "financiën": "finance", + "cfo": "finance", + "marketing": "marketing", + "retail": "retail", + "product": "product", + "sourcing": "sourcing", + "sysops": "sysops", + "hr": "hr", + "halal": "halal", + "design": "design", + "browser": "browser", + "email": "email", + "knowledge": "knowledge", + "packaging": "packaging", + "research": "research", + "sales": "sales", +} + + +def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]: + """@mentions + soft name matches (bizdev/finance zonder @).""" + active = _active_agent_keys() + found: list[str] = [] + for key in parse_mentions(text): + if key in active and key not in found: + found.append(key) + low = (text or "").lower() + # Expliciete @mentions winnen — voorkomt dat 'Herman' in een zin extra speakers toevoegt + if not found: + for alias, key in _ALIAS_MAP.items(): + if key not in active or key in found: + continue + if re.search(rf"(? str: + text = (reply or "").strip() + # strip @handle / Name / **Name** prefixes (incl. wrong Herman: from memory bleed) + for _ in range(4): + text2 = re.sub( + r"^\*{0,2}\s*@?[A-Za-z][\w .:-]{0,40}?\*{0,2}\s*:\s*", + "", + text, + count=1, + ).strip() + if text2 == text: + break + text = text2 + return text or "(geen antwoord)" + + +def _agent_hermes_user(base_user: str, agent_key: str) -> str: + """Separate Hermes session per agent — avoids Herman-memory bleed into BizDev/Finance.""" + base = (base_user or "warroom").strip() or "warroom" + key = (agent_key or "agent").strip().lower() or "agent" + return f"{base}:wr:{key}" + + async def _agent_speak( room: dict[str, Any], agent_key: str, @@ -234,12 +507,32 @@ async def _agent_speak( {"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")}, ] messages.extend(_history_as_chat(hist, limit=8)) - cue = trigger + roster = _live_roster_line() + trig_l = (trigger or "").lower() + addressed = f"@{agent_key}".lower() in trig_l + if addressed: + address_line = ( + f"Je bent DIRECT aangesproken als @{agent_key}. " + "Antwoord ZELF in de War Room — niet doorverwijzen naar een andere agent." + ) + elif in_reply_to: + address_line = f"Reageer op @{in_reply_to}." + else: + address_line = f"Beantwoord als @{agent_key} in de War Room." + cue = ( + f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" + f"[{address_line}]\n" + f"{trigger}" + ) if in_reply_to: - cue = f"(Reageer op @{in_reply_to}) {trigger}" + cue = ( + f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" + f"(Reageer op @{in_reply_to}) {trigger}" + ) messages.append({"role": "user", "content": cue}) + agent_user = _agent_hermes_user(hermes_user, agent_key) try: - reply, meta = await _hermes_reply(messages, hermes_user=hermes_user) + reply, meta = await _hermes_reply(messages, hermes_user=agent_user) except Exception as exc: log.exception("agent speak failed %s", agent_key) return _insert_message( @@ -252,7 +545,7 @@ async def _agent_speak( int(room["id"]), sender_type="agent", agent_key=agent_key, - content=reply.strip() or "(geen antwoord)", + content=_sanitize_warroom_reply(agent_key, reply), metadata={ "llm": meta, "in_reply_to": in_reply_to, @@ -261,6 +554,33 @@ async def _agent_speak( ) + +def _next_a2a_speakers( + produced: list[dict[str, Any]], + *, + exclude: set[str] | None = None, +) -> list[tuple[str, str]]: + """From recent agent messages, find @targets to speak next.""" + exclude = exclude or set() + next_speakers: list[tuple[str, str]] = [] + recent = [m for m in produced if m.get("sender_type") == "agent"][-MAX_REPLIES_PER_ROUND:] + for m in recent: + from_key = str(m.get("agent_key") or "") + for target in parse_mentions(m.get("content") or ""): + if not target or target == from_key or target in exclude: + continue + next_speakers.append((target, from_key)) + # If nobody @mentioned, Herman can nudge a relevant peer once + uniq: list[tuple[str, str]] = [] + used: set[str] = set() + for t, src in next_speakers: + if t in used: + continue + used.add(t) + uniq.append((t, src)) + return uniq[:MAX_REPLIES_PER_ROUND] + + async def _orchestrate_warroom( room: dict[str, Any], user_text: str, @@ -270,11 +590,7 @@ async def _orchestrate_warroom( ) -> list[dict[str, Any]]: """Herman facilitates; mentioned agents reply; limited A2A rounds.""" produced: list[dict[str, Any]] = [] - mentions = parse_mentions(user_text) - speakers = mentions[:] - if not speakers: - # Herman opens, then may suggest others — still let Herman speak first - speakers = ["herman"] + speakers = resolve_speakers(user_text, default_herman=True) # Round 0: initial speakers in parallel (sneller bij meerdere @mentions) keys = speakers[:MAX_REPLIES_PER_ROUND] @@ -487,10 +803,30 @@ async def _agent_speak_stream( {"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")}, ] messages.extend(_history_as_chat(hist, limit=8)) - cue = trigger + roster = _live_roster_line() + trig_l = (trigger or "").lower() + addressed = f"@{agent_key}".lower() in trig_l + if addressed: + address_line = ( + f"Je bent DIRECT aangesproken als @{agent_key}. " + "Antwoord ZELF in de War Room — niet doorverwijzen naar een andere agent." + ) + elif in_reply_to: + address_line = f"Reageer op @{in_reply_to}." + else: + address_line = f"Beantwoord als @{agent_key} in de War Room." + cue = ( + f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" + f"[{address_line}]\n" + f"{trigger}" + ) if in_reply_to: - cue = f"(Reageer op @{in_reply_to}) {trigger}" + cue = ( + f"[LIVE ROSTER ONLINE: {roster}. Nooit zeggen dat een agent uit/offline staat.]\n" + f"(Reageer op @{in_reply_to}) {trigger}" + ) messages.append({"role": "user", "content": cue}) + agent_user = _agent_hermes_user(hermes_user, agent_key) yield ("start", {"agent_key": agent_key, "display_name": soul.get("display_name") or agent_key}) chunks: list[str] = [] @@ -498,20 +834,20 @@ async def _agent_speak_stream( async for delta in llm_router.stream_chat_messages( messages, timeout=WARROOM_TIMEOUT, - user=hermes_user, + user=agent_user, max_tokens=WARROOM_MAX_TOKENS, temperature=0.35, ): chunks.append(delta) yield ("token", {"agent_key": agent_key, "delta": delta}) - reply = "".join(chunks).strip() or "(geen antwoord)" + reply = _sanitize_warroom_reply(agent_key, "".join(chunks)) msg = _insert_message( int(room["id"]), sender_type="agent", agent_key=agent_key, content=reply, metadata={ - "llm": {"provider_type": "hermes", "hermes_user": hermes_user, "streamed": True}, + "llm": {"provider_type": "hermes", "hermes_user": agent_user, "streamed": True}, "in_reply_to": in_reply_to, "model": "hermes-agent/mimo", }, @@ -568,22 +904,121 @@ async def post_user_message_stream( if key: speakers = [str(key)] else: - speakers = parse_mentions(trigger) or ["herman"] - speakers = speakers[:MAX_REPLIES_PER_ROUND] + speakers = resolve_speakers(trigger, default_herman=True) - for key in speakers: + produced: list[dict[str, Any]] = [] + + async def _emit_speak(key: str, *, trig: str, reply_to: str | None = None): async for kind_ev, payload in _agent_speak_stream( - room, key, trigger=trigger, hermes_user=hermes_user + room, key, trigger=trig, hermes_user=hermes_user, in_reply_to=reply_to ): if kind_ev == "start": yield {"event": "typing_start", **payload} elif kind_ev == "token": yield {"event": "typing", **payload} elif kind_ev == "done": + produced.append(payload) + yield {"event": "agent", "message": payload} + elif kind_ev == "error": + produced.append(payload) + yield {"event": "system", "message": payload} + + for key in speakers: + async for ev in _emit_speak(key, trig=trigger): + 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 + + yield {"event": "done", "ok": True, "a2a": True} + + +async def continue_warroom_stream( + room_id: int, + *, + username: str | None = None, + role: str | None = None, + max_turns: int = A2A_CONTINUE_MAX, +): + """Autonomous continuation: agents keep talking using last topic + cockpit data.""" + room = get_room(room_id) + if not room: + raise ValueError("Room niet gevonden") + if room.get("kind") == "dm": + raise ValueError("Continue alleen in #war-room") + + 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") + + # Find last user topic + topic = "" + for m in reversed(recent): + if m.get("sender_type") == "user": + topic = (m.get("content") or "").strip() + break + if not topic: + topic = "Blijf het Foodlinkk cockpit-dossier bespreken (pipeline, deals, retail, finance)." + + produced = [m for m in recent if m.get("sender_type") == "agent"][-6:] + yield {"event": "continue_start", "topic": topic[:200]} + + for turn in range(max(1, min(int(max_turns), 5))): + 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()] + 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." + ) + 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." + ) + + async for kind_ev, payload in _agent_speak_stream( + room, target, trigger=trig, hermes_user=hermes_user, in_reply_to=src + ): + if kind_ev == "start": + yield {"event": "typing_start", **payload} + elif kind_ev == "token": + yield {"event": "typing", **payload} + elif kind_ev == "done": + produced.append(payload) yield {"event": "agent", "message": payload} elif kind_ev == "error": yield {"event": "system", "message": payload} + return - # light A2A: one follow-up round max from last agent reply mentions - # (non-streamed for simplicity / speed) - yield {"event": "done", "ok": True} + yield {"event": "done", "ok": True, "continued": True} diff --git a/cockpit/app/services/agent_souls.py b/cockpit/app/services/agent_souls.py index 8abd763..8c51bb5 100644 --- a/cockpit/app/services/agent_souls.py +++ b/cockpit/app/services/agent_souls.py @@ -126,10 +126,14 @@ def list_souls() -> list[dict[str, Any]]: soul["current_status"] = st.get("current_status") # Working = recent activity (not just DB is_active flag) soul["working"] = events_6h > 0 - soul["activity"] = ( - "busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5) - else ("active" if events_6h > 0 else ("idle" if event_count > 0 else "offline")) - ) + # is_active souls are never "offline" in the registry (War Room / mesh ready) + if not soul.get("is_active", True): + soul["activity"] = "offline" + else: + soul["activity"] = ( + "busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5) + else ("active" if events_6h > 0 else "idle") + ) out.append(soul) return out diff --git a/cockpit/app/services/briefing.py b/cockpit/app/services/briefing.py index 7f7c8c3..cf5bf65 100644 --- a/cockpit/app/services/briefing.py +++ b/cockpit/app/services/briefing.py @@ -243,10 +243,13 @@ def _collect_retail_intel(data: dict[str, Any]) -> None: try: data["top_opportunities"] = fetch_all( - """SELECT s.name, s.chain, s.city, ros.halal_opportunity_score + """SELECT s.name, s.chain, s.city, s.address, s.postcode, ros.halal_opportunity_score FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id - ORDER BY ros.halal_opportunity_score DESC LIMIT 5""" + WHERE s.name NOT ILIKE '%masala%' + AND s.name NOT ILIKE 'TEST%' + AND COALESCE(s.data_source,'') NOT ILIKE '%test_exclude%' + ORDER BY ros.halal_opportunity_score DESC LIMIT 8""" ) except Exception: data["top_opportunities"] = [] diff --git a/cockpit/app/services/warroom_digest.py b/cockpit/app/services/warroom_digest.py new file mode 100644 index 0000000..191cad8 --- /dev/null +++ b/cockpit/app/services/warroom_digest.py @@ -0,0 +1,180 @@ +"""Daily Herman War Room digest → Telegram (Aïssa + Mo).""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +import httpx + +from app.config import settings +from app.db import execute, fetch_all, fetch_one +from app.services import llm_router + +log = logging.getLogger("cockpit.warroom_digest") + + +def _recipients() -> list[tuple[str, str]]: + """(chat_id, display_name)""" + return [ + (str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"), "Aïssa"), + (str(getattr(settings, "HERMES_CTO_CHAT_ID", None) or "789036463"), "Mo"), + ] + + +def collect_warroom_day(*, hours: int = 24, limit: int = 120) -> list[dict[str, Any]]: + hours = max(1, min(int(hours), 72)) + rows = fetch_all( + """ + SELECT m.id, m.sender_type, m.agent_key, m.username, m.content, m.created_at, r.kind, r.title + FROM warroom_messages m + JOIN warroom_rooms r ON r.id = m.room_id + WHERE m.created_at >= NOW() - make_interval(hours => %s) + AND r.kind = 'warroom' + ORDER BY m.created_at ASC + LIMIT %s + """, + (hours, limit), + ) + out = [] + for r in rows or []: + item = dict(r) + if item.get("created_at") and hasattr(item["created_at"], "isoformat"): + item["created_at"] = item["created_at"].isoformat() + out.append(item) + return out + + +def _transcript(messages: list[dict[str, Any]], *, max_chars: int = 6000) -> str: + lines: list[str] = [] + for m in messages: + st = m.get("sender_type") + if st == "user": + who = m.get("username") or "user" + elif st == "agent": + who = m.get("agent_key") or "agent" + else: + who = "system" + content = (m.get("content") or "").strip().replace("\n", " ") + if not content: + continue + lines.append(f"- {who}: {content[:280]}") + text = "\n".join(lines) + return text[-max_chars:] + + +async def generate_digest_text(messages: list[dict[str, Any]]) -> str: + day = datetime.now(timezone.utc).strftime("%Y-%m-%d") + if not messages: + return ( + f"📋 War Room dagrapport {day}\n\n" + "Geen activiteit in #war-room vandaag. Agents stonden klaar; geen overleg gelogd." + ) + transcript = _transcript(messages) + system = ( + "Je bent Herman, Co-CEO van Foodlinkk. Schrijf een beknopte Nederlandse " + "Telegram-samenvatting van het War Room-overleg voor Aïssa (CEO) en Mo (CTO).\n" + "Structuur:\n" + f"1) Kop: 📋 War Room dagrapport {day}\n" + "2) 3–6 bullets: wat agents bespraken (pipeline, deals, acties, blockers)\n" + "3) Sectie 'Acties / open punten' met concrete next steps\n" + "4) 1 zin afsluiting\n" + "Max ~350 woorden. Geen @zelf-prefix. Geen 'agents staan uit'." + ) + user = f"War Room transcript (nieuwste onderaan):\n{transcript}" + reply, _meta = await llm_router.chat_messages( + [{"role": "system", "content": system}, {"role": "user", "content": user}], + timeout=90.0, + user=str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446"), + max_tokens=900, + temperature=0.3, + ) + text = (reply or "").strip() + if not text.startswith("📋"): + text = f"📋 War Room dagrapport {day}\n\n{text}" + return text[:3500] + + +async def _deliver_telegram(chat_id: str, name: str, digest: str) -> dict[str, Any]: + """Ask Hermes agent to deliver the digest into that Telegram chat.""" + system = ( + f"Je bent Herman. Bezorg het volgende War Room-dagrapport NU via Telegram " + f"aan {name} (chat_id={chat_id}). Stuur de tekst zo letterlijk mogelijk. " + "Bevestig kort of verzonden." + ) + try: + reply, meta = await llm_router.chat_messages( + [ + {"role": "system", "content": system}, + {"role": "user", "content": digest}, + ], + timeout=120.0, + user=str(chat_id), + max_tokens=200, + temperature=0.1, + ) + return { + "ok": True, + "chat_id": chat_id, + "name": name, + "confirm": (reply or "")[:240], + "provider": (meta or {}).get("provider_type"), + } + except Exception as exc: + log.exception("warroom digest deliver failed %s", chat_id) + return {"ok": False, "chat_id": chat_id, "name": name, "error": str(exc)} + + +def _persist(digest: str, message_count: int, deliveries: list[dict[str, Any]]) -> None: + try: + execute( + """ + INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb) + """, + ( + "herman", + "warroom_digest", + "warroom_daily_digest", + "War Room dagrapport verstuurd", + digest[:4000], + "completed", + "telegram", + __import__("json").dumps( + { + "message_count": message_count, + "deliveries": deliveries, + } + ), + ), + ) + except Exception: + log.exception("persist digest event failed") + + try: + from app.services import agent_chat + + agent_chat.post_system_line( + "📤 Dagrapport verstuurd naar Aïssa + Mo via Telegram.\n\n" + digest[:1500], + agent_key="herman", + metadata={"kind": "warroom_digest", "deliveries": deliveries}, + ) + except Exception: + log.exception("post digest to warroom failed") + + +async def run_daily_digest(*, hours: int = 24, deliver: bool = True) -> dict[str, Any]: + messages = collect_warroom_day(hours=hours) + digest = await generate_digest_text(messages) + deliveries: list[dict[str, Any]] = [] + if deliver: + for chat_id, name in _recipients(): + deliveries.append(await _deliver_telegram(chat_id, name, digest)) + _persist(digest, len(messages), deliveries) + return { + "ok": True, + "message_count": len(messages), + "digest": digest, + "deliveries": deliveries, + "generated_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/cockpit/static/css/agents-warroom.css b/cockpit/static/css/agents-warroom.css index 426c704..c40d701 100644 --- a/cockpit/static/css/agents-warroom.css +++ b/cockpit/static/css/agents-warroom.css @@ -1,33 +1,38 @@ -/* Foodlinkk War Room */ +/* Foodlinkk War Room — compact, one viewport */ .wr-layout { display: grid; - grid-template-columns: 220px 1fr; - gap: 0.75rem; - min-height: 62vh; + grid-template-columns: 200px minmax(0, 1fr); + gap: 0.6rem; + height: calc(100vh - 150px); + max-height: calc(100vh - 150px); } +.wr-page-header { margin-bottom: 0.4rem; } +.wr-page-header h1 { font-size: 1.2rem; margin: 0; } +.wr-page-header .hm-sub { margin: 0.15rem 0 0; font-size: 0.85rem; } + .wr-roster { border-right: 1px solid rgba(255,255,255,0.08); - padding-right: 0.5rem; - max-height: 70vh; + padding-right: 0.4rem; overflow: auto; + min-height: 0; } .wr-roster h3 { - font-size: 0.72rem; + font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.7; - margin: 0 0 0.4rem; + margin: 0 0 0.35rem; } .wr-room-btn, .wr-agent-btn { width: 100%; display: flex; align-items: center; - gap: 0.55rem; + gap: 0.45rem; text-align: left; background: transparent; border: 1px solid transparent; color: inherit; - padding: 0.4rem 0.45rem; + padding: 0.32rem 0.4rem; border-radius: 8px; cursor: pointer; } @@ -37,51 +42,55 @@ border-color: rgba(255, 215, 0, 0.25); } .wr-agent-meta { display: flex; flex-direction: column; min-width: 0; } -.wr-agent-meta strong { font-size: 0.85rem; } -.wr-agent-meta small { opacity: 0.65; font-size: 0.7rem; } -.wr-roster .agent-char-wrap { width: 34px; height: 34px; flex: 0 0 34px; } +.wr-agent-meta strong { font-size: 0.8rem; } +.wr-agent-meta small { opacity: 0.65; font-size: 0.68rem; } +.wr-roster .agent-char-wrap { width: 40px; height: 40px; flex: 0 0 40px; } -.wr-main { display: flex; flex-direction: column; min-width: 0; min-height: 62vh; } +.wr-main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; +} .wr-head { display: flex; justify-content: space-between; align-items: flex-start; - gap: 0.75rem; margin-bottom: 0.5rem; + gap: 0.5rem; margin-bottom: 0.35rem; flex: 0 0 auto; } -.wr-head h2 { margin: 0; font-size: 1.05rem; letter-spacing: 0.04em; } -.wr-head-actions { display: flex; gap: 0.35rem; } +.wr-head h2 { margin: 0; font-size: 1rem; letter-spacing: 0.04em; } +.wr-head-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; } .wr-log { - flex: 1; + flex: 1 1 auto; + min-height: 0; overflow: auto; background: rgba(0,0,0,0.22); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; - padding: 0.75rem; - contain: content; - min-height: 320px; + padding: 0.55rem 0.65rem; } -.wr-empty { opacity: 0.6; margin: 0.5rem 0; font-size: 0.9rem; } +.wr-empty { opacity: 0.6; margin: 0.35rem 0; font-size: 0.85rem; } .wr-bubble { - display: flex; gap: 0.55rem; margin-bottom: 0.7rem; - contain: layout style; + display: flex; gap: 0.45rem; margin-bottom: 0.45rem; } .wr-bubble.is-user { flex-direction: row-reverse; } .wr-bubble.is-system { opacity: 0.85; } -.wr-avatar { width: 36px; height: 36px; flex: 0 0 36px; } +.wr-avatar { width: 44px; height: 44px; flex: 0 0 44px; } .wr-avatar img, .wr-avatar .agent-char-wrap { - width: 36px; height: 36px; border-radius: 50%; object-fit: cover; + width: 44px; height: 44px; border-radius: 50%; object-fit: cover; } -.wr-avatar .agent-char-wrap svg { display: block; width: 36px; height: 36px; } +.wr-avatar .agent-char-wrap svg { display: block; width: 44px; height: 44px; } .wr-avatar-fallback { - display: grid; place-items: center; width: 36px; height: 36px; - border-radius: 50%; background: #1e3a5f; font-size: 0.7rem; font-weight: 700; + display: grid; place-items: center; width: 44px; height: 44px; + border-radius: 50%; background: #1e3a5f; font-size: 0.65rem; font-weight: 700; } .wr-bubble-body { - max-width: min(720px, 82%); + max-width: min(680px, 85%); background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); - border-radius: 12px; - padding: 0.45rem 0.65rem; + border-radius: 10px; + padding: 0.35rem 0.55rem; } .wr-bubble.is-user .wr-bubble-body { background: rgba(34, 197, 94, 0.12); @@ -91,85 +100,288 @@ background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.22); } -.wr-bubble.is-typing .wr-bubble-body { - border-style: dashed; - opacity: 0.95; -} -.wr-bubble-meta { - font-size: 0.7rem; opacity: 0.7; margin-bottom: 0.15rem; -} +.wr-bubble.is-typing .wr-bubble-body { border-style: dashed; } +.wr-bubble-meta { font-size: 0.68rem; opacity: 0.7; margin-bottom: 0.1rem; } .wr-bubble-text { white-space: pre-wrap; word-break: break-word; - font-size: 0.92rem; - line-height: 1.35; + font-size: 0.88rem; + line-height: 1.3; } .wr-typing-cursor::after { - content: '▍ + content: "▌"; animation: wrBlink 1s steps(1) infinite; margin-left: 1px; } @keyframes wrBlink { 50% { opacity: 0; } } -.wr-attach-list { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.4rem; } +.wr-attach-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.3rem; } .wr-attach-card { - display: block; max-width: 220px; text-decoration: none; color: inherit; + display: block; max-width: 300px; text-decoration: none; color: inherit; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; overflow: hidden; background: rgba(0,0,0,0.2); } -.wr-attach-card img { display: block; width: 100%; max-height: 140px; object-fit: cover; } +.wr-attach-card img { display: block; width: 100%; max-height: 220px; object-fit: cover; } .wr-attach-card span { - display: block; padding: 0.3rem 0.45rem; font-size: 0.72rem; opacity: 0.85; + display: block; padding: 0.25rem 0.4rem; font-size: 0.7rem; opacity: 0.85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .wr-composer { - margin-top: 0.55rem; + margin-top: 0.4rem; display: grid; grid-template-columns: 1fr auto; - gap: 0.45rem; + gap: 0.35rem; align-items: end; + flex: 0 0 auto; } .wr-composer-tools { grid-column: 1 / -1; - display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; + display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; } .wr-pending { - display: flex; flex-wrap: wrap; gap: 0.35rem; grid-column: 1 / -1; + display: flex; flex-wrap: wrap; gap: 0.3rem; grid-column: 1 / -1; } .wr-pending-chip { - display: inline-flex; align-items: center; gap: 0.3rem; - font-size: 0.72rem; padding: 0.2rem 0.45rem; + display: inline-flex; align-items: center; gap: 0.25rem; + font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 999px; background: rgba(255,255,255,0.08); } .wr-pending-chip button { border: 0; background: transparent; color: inherit; cursor: pointer; opacity: 0.7; } .wr-emoji-pop { - display: flex; flex-wrap: wrap; gap: 0.2rem; - max-width: 280px; padding: 0.35rem; + display: flex; flex-wrap: wrap; gap: 0.15rem; + max-width: 280px; padding: 0.3rem; background: #0f172a; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; } .wr-emoji-pop button { - border: 0; background: transparent; cursor: pointer; font-size: 1.15rem; line-height: 1; - padding: 0.15rem; + border: 0; background: transparent; cursor: pointer; font-size: 1.1rem; line-height: 1; + padding: 0.12rem; } -.wr-emoji-pop button:hover { transform: scale(1.15); } -.wr-page-header { margin-bottom: 0.75rem; } -.wr-page-header h1 { font-size: 1.35rem; } +/* Call overlay */ +.wr-call-overlay { + position: fixed; inset: 0; z-index: 11000; + background: rgba(3, 8, 18, 0.82); + display: none; align-items: center; justify-content: center; + padding: 1rem; pointer-events: none; +} +.wr-call-panel { + width: min(1100px, 96vw); + height: min(780px, 92vh); + background: #0b1220; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 14px; + display: flex; flex-direction: column; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0,0,0,0.45); +} +.wr-call-head { + display: flex; justify-content: space-between; align-items: center; + gap: 0.75rem; padding: 0.65rem 0.85rem; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +.wr-call-head h3 { margin: 0; font-size: 1rem; } +.wr-call-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; } +.wr-call-frame { + flex: 1; border: 0; width: 100%; background: #000; +} +.wr-call-hint { + margin: 0; padding: 0.45rem 0.85rem; font-size: 0.78rem; opacity: 0.75; + border-top: 1px solid rgba(255,255,255,0.06); +} .wr-popup-body { - margin: 0; min-height: 100vh; + margin: 0; height: 100vh; overflow: hidden; background: #070b14; color: #e8eef8; } -.wr-popup-shell { padding: 0.75rem; } -.wr-popup-shell .wr-layout { min-height: calc(100vh - 1.5rem); } -.wr-popup-shell .wr-main { min-height: calc(100vh - 1.5rem); } -.wr-popup-shell .wr-log { min-height: 50vh; } +.wr-popup-shell { padding: 0.55rem; height: 100vh; box-sizing: border-box; } +.wr-popup-shell .wr-layout { + height: calc(100vh - 1.1rem); + max-height: calc(100vh - 1.1rem); +} @media (max-width: 900px) { - .wr-layout { grid-template-columns: 1fr; } - .wr-roster { max-height: 180px; border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.08); } + .wr-layout { + grid-template-columns: 1fr; + height: auto; + max-height: none; + } + .wr-roster { max-height: 140px; border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.08); } + .wr-main { height: calc(100vh - 280px); } +} + + +.wr-composer-wrap { position: relative; grid-column: 1 / 2; } +.wr-mention-pop { + position: absolute; + left: 0; right: 0; bottom: calc(100% + 4px); + max-height: 220px; overflow: auto; + background: #0f172a; + border: 1px solid rgba(255,215,0,0.35); + border-radius: 10px; + z-index: 20; + box-shadow: 0 10px 30px rgba(0,0,0,0.4); +} +.wr-mention-item { + width: 100%; display: flex; align-items: center; gap: 0.45rem; + text-align: left; border: 0; background: transparent; color: inherit; + padding: 0.4rem 0.55rem; cursor: pointer; +} +.wr-mention-item:hover, .wr-mention-item.active { + background: rgba(255, 215, 0, 0.12); +} +.wr-mention-item strong { font-size: 0.85rem; } +.wr-mention-item small { opacity: 0.65; margin-left: 0.25rem; } +.wr-agent-btn .wr-dm-hint { + margin-left: auto; font-size: 0.65rem; opacity: 0.45; +} + +.wr-bubble-meta { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; } +.wr-msg-del { + margin-left: auto; + opacity: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.75rem; + padding: 0 0.2rem; + line-height: 1; +} +.wr-bubble:hover .wr-msg-del { opacity: 0.55; } +.wr-msg-del:hover { opacity: 1 !important; color: #e74c3c; } +.wr-call-join { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1.5rem; + text-align: center; + min-height: 280px; +} +.wr-call-title { margin: 0; font-size: 1.15rem; font-weight: 600; } +.wr-call-join-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; } +.wr-call-link { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + opacity: 0.75; + padding: 0.4rem 0.6rem; + border-radius: 6px; + background: rgba(255,255,255,0.06); +} + +/* Call overlay must escape wr-main overflow clipping */ +.wr-main { position: relative; } +.wr-call-overlay { + position: fixed !important; + inset: 0 !important; + z-index: 11000 !important; +} + +.btn.is-recording, button.is-recording { + background: #c0392b !important; + color: #fff !important; + animation: wr-mic-pulse 1s ease-in-out infinite; +} +@keyframes wr-mic-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(192,57,43,0.5); } + 50% { box-shadow: 0 0 0 8px rgba(192,57,43,0); } +} + +.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 */ + align-items: center; justify-content: center; + padding: 1rem; + pointer-events: none; +} +.wr-voice-overlay.is-open { + display: flex !important; + pointer-events: auto; +} +.wr-voice-panel { + width: min(920px, 96vw); + max-height: 92vh; + overflow: auto; + background: #0b1220; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 16px; + padding: 1rem 1.1rem 1.15rem; + box-shadow: 0 24px 70px rgba(0,0,0,0.5); + color: #e8eef8; +} +.wr-voice-head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; } +.wr-voice-head h3 { margin: 0; font-size: 1.15rem; } +.wr-voice-head-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; } +.wr-voice-status { + margin: 0.85rem 0 0.75rem; + padding: 0.55rem 0.75rem; + border-radius: 10px; + background: rgba(255,255,255,0.05); + font-size: 0.9rem; + min-height: 1.4em; +} +.wr-voice-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.65rem; +} +.wr-voice-card { + display: flex; flex-direction: column; align-items: center; gap: 0.25rem; + padding: 0.75rem 0.5rem; + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.03); + text-align: center; + cursor: pointer; + transition: border-color .15s, box-shadow .15s, opacity .15s; +} +.wr-voice-card.is-human { cursor: default; border-color: rgba(56, 189, 248, 0.35); } +.wr-voice-card.is-live { + border-color: rgba(34, 197, 94, 0.7); + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.25); +} +.wr-voice-card.is-off { opacity: 0.45; } +.wr-voice-avatar { + width: 52px; height: 52px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + overflow: hidden; background: #1e293b; +} +.wr-voice-avatar-human { + font-weight: 700; font-size: 0.85rem; background: #0ea5e9; color: #041018; +} +.wr-voice-avatar .agent-char-wrap, .wr-voice-avatar.agent-char-wrap { width: 52px; height: 52px; } +.wr-voice-card strong { font-size: 0.82rem; } +.wr-voice-card small { font-size: 0.7rem; } +.wr-voice-hint { font-size: 0.8rem; opacity: 0.8; margin: 0.75rem 0 0.25rem; } +.wr-voice-ptt { display: flex; justify-content: center; margin: 1rem 0 0.5rem; } +.wr-ptt-btn { + min-width: min(320px, 90vw); + min-height: 64px; + border: 0; border-radius: 999px; + font-size: 1.05rem; font-weight: 600; + background: linear-gradient(135deg, #0ea5e9, #2563eb); + color: #fff; cursor: pointer; + box-shadow: 0 10px 30px rgba(14,165,233,0.35); +} +.wr-ptt-btn.is-recording { + background: linear-gradient(135deg, #dc2626, #b91c1c); + animation: wr-mic-pulse 1s ease-in-out infinite; +} +.wr-ptt-btn.is-busy { opacity: 0.7; cursor: wait; } +.wr-voice-foot { + display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; + flex-wrap: wrap; margin-top: 0.85rem; padding-top: 0.75rem; + border-top: 1px solid rgba(255,255,255,0.08); + font-size: 0.78rem; } diff --git a/cockpit/static/css/flk-themes.css b/cockpit/static/css/flk-themes.css new file mode 100644 index 0000000..1b30dd9 --- /dev/null +++ b/cockpit/static/css/flk-themes.css @@ -0,0 +1,180 @@ +/* Foodlinkk UI themes — colorful cyan/blue family, mid-dark, vivid accents */ +/* Applied via (localStorage key: flk-theme) */ + +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); +} + +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); +} + +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); +} + +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); +} + +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); +} + +/* 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] .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)); +} + +/* Settings theme cards */ +.flk-theme-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} +.flk-theme-card { + border: 1px solid var(--border-subtle); + border-radius: 14px; + padding: 0.85rem; + background: var(--bg-elevated); + cursor: pointer; + text-align: left; + color: inherit; + transition: border-color .15s, transform .15s, box-shadow .15s; +} +.flk-theme-card:hover { transform: translateY(-2px); border-color: var(--accent-cyan); } +.flk-theme-card.is-active { + border-color: var(--accent-cyan); + 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); +} +.flk-theme-card strong { display: block; margin-bottom: 0.15rem; } +.flk-theme-card small { opacity: 0.75; line-height: 1.35; display: block; } diff --git a/cockpit/static/js/agents-warroom.js b/cockpit/static/js/agents-warroom.js index 24db270..0be8ffb 100644 --- a/cockpit/static/js/agents-warroom.js +++ b/cockpit/static/js/agents-warroom.js @@ -25,11 +25,30 @@ window.WarRoomHub = (function () { _polling: false, status: '', emojiOpen: false, + mentionOpen: false, + mentionQuery: '', + mentionIndex: 0, emojis: EMOJIS, pendingFiles: [], typing: null, // { agent_key, display_name, text } + callOpen: false, + callBusy: false, + callUrl: '', + callSlug: '', + voiceRecording: false, + voiceBusy: false, + speakReplies: true, + voiceRoomOpen: false, + voiceRoomAgents: ['herman'], + voiceSpeakingKey: '', + voiceLastHeard: '', + _voiceRec: null, + _voiceStream: null, + _voiceChunks: null, async init() { + this.voiceRoomOpen = false; + this.busy = false; await Promise.all([this.loadMe(), this.loadSouls(), this.loadRooms()]); var params = new URLSearchParams(window.location.search || ''); var want = params.get('room'); @@ -44,6 +63,7 @@ window.WarRoomHub = (function () { destroy() { if (this._poll) clearInterval(this._poll); + if (this._continueTimer) clearTimeout(this._continueTimer); }, async loadMe() { @@ -123,6 +143,10 @@ window.WarRoomHub = (function () { this.paintAvatars(); this.scrollLog(); }.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); + } } catch (e) { this.status = e.message || 'Laden mislukt'; } @@ -201,6 +225,7 @@ window.WarRoomHub = (function () { '/messages?limit=50&after_id=' + (this.lastId || 0); var r = await fetch(url).then(function (x) { return x.json(); }); + this.removeDeletedIds(r.deleted_ids || []); if (this.mergeMessages(r.items || [])) { this.$nextTick(function () { this.paintAvatars(); @@ -228,9 +253,63 @@ window.WarRoomHub = (function () { }, insertMention(key) { - this.draft = (this.draft || '') + '@' + key + ' '; + var d = this.draft || ''; + var at = d.lastIndexOf('@'); + if (at >= 0 && !/\s/.test(d.slice(at + 1))) { + // replace partial @query + this.draft = d.slice(0, at) + '@' + key + ' '; + } else { + this.draft = d + (d && !/\s$/.test(d) ? ' ' : '') + '@' + key + ' '; + } + this.mentionOpen = false; + this.mentionQuery = ''; + this.mentionIndex = 0; + this.$nextTick(function () { + var el = this.$refs && this.$refs.wrInput; + if (el) { el.focus(); el.setSelectionRange(this.draft.length, this.draft.length); } + }.bind(this)); }, + mentionSuggestions() { + if (!this.mentionOpen) return []; + var q = (this.mentionQuery || '').toLowerCase(); + return (this.souls || []).filter(function (s) { + if (!s || !s.agent_key) return false; + if (!q) return true; + var key = String(s.agent_key).toLowerCase(); + var name = String(s.display_name || '').toLowerCase(); + return key.indexOf(q) === 0 || key.indexOf(q) >= 0 || name.indexOf(q) >= 0; + }).slice(0, 8); + }, + + onDraftInput() { + var d = this.draft || ''; + var m = /(^|\s)@([a-zA-Z0-9_-]*)$/.exec(d); + if (m) { + this.mentionOpen = true; + this.mentionQuery = m[2] || ''; + this.mentionIndex = 0; + } else { + this.mentionOpen = false; + this.mentionQuery = ''; + } + }, + + pickMention(s) { + if (!s) return; + this.insertMention(s.agent_key); + }, + + /** Roster: in #war-room → @tag; anders open DM */ + selectAgent(agentKey) { + if (this.room && this.room.kind === 'warroom') { + this.insertMention(agentKey); + return; + } + this.openDm(agentKey); + }, + + async onPickFiles(ev) { var files = Array.prototype.slice.call((ev.target && ev.target.files) || []); if (ev.target) ev.target.value = ''; @@ -295,6 +374,7 @@ window.WarRoomHub = (function () { } await this.consumeStream(resp); this.status = ''; + this.scheduleContinue(2000); } catch (e) { this.draft = text; this.pendingFiles = files; @@ -345,6 +425,8 @@ window.WarRoomHub = (function () { this.upsertMessage(ev.message); this.status = 'Agent typt…'; this.$nextTick(function () { this.scrollLog(); }.bind(this)); + } else if (type === 'continue_start') { + this.status = 'Agents praten verder over: ' + ((ev.topic || '').slice(0, 80)); } else if (type === 'typing_start') { this.typing = { agent_key: ev.agent_key, @@ -368,6 +450,7 @@ window.WarRoomHub = (function () { } 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); this.$nextTick(function () { this.paintAvatars(); this.scrollLog(); @@ -385,12 +468,379 @@ window.WarRoomHub = (function () { }, onKey(e) { + 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) { e.preventDefault(); this.send(); } }, + + + scheduleContinue(delayMs) { + if (!this.autoDebate || !this.room || this.room.kind !== 'warroom') return; + if (this._continueTimer) clearTimeout(this._continueTimer); + var self = this; + this._continueTimer = setTimeout(function () { self.runContinue(2); }, delayMs || 2500); + }, + + 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; + + this._continueBusy = true; + this.status = 'Agents overleggen verder…'; + try { + var resp = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/continue?turns=' + (turns || 2), { + method: 'POST', + credentials: 'same-origin', + headers: { Accept: 'text/event-stream', 'Content-Type': 'application/json' }, + body: '{}', + }); + if (!resp.ok) throw new Error('Continue mislukt'); + 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); + } catch (e) { + this.busy = false; + this.status = ''; + } + this._continueBusy = false; + }, + + async deleteMessage(m) { + if (!this.room || !m || !m.id) return; + if (!window.confirm('Bericht verwijderen?')) return; + try { + var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/messages/' + m.id, { + 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)) || 'Verwijderen mislukt'); + this.messages = (this.messages || []).filter(function (x) { return x.id !== m.id; }); + if (window.Cockpit) Cockpit.toast('Bericht verwijderd', 'ok'); + } catch (e) { + if (window.Cockpit) Cockpit.toast(e.message || 'Verwijderen mislukt', 'error'); + } + }, + + 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]; }); + }, + + + + openVoiceRoom() { + if (!this.room) { + if (window.Cockpit) Cockpit.toast('Geen room geladen', 'error'); + return; + } + if (!(this.voiceRoomAgents || []).length) { + this.voiceRoomAgents = ['herman']; + } + this.speakReplies = true; + this.voiceRoomOpen = true; + this.callOpen = false; + this.status = 'Voice Room open — houd 🎤 in om te praten'; + this._announceVoiceRoom('join'); + if (this.micNeedsHttps()) { + // still open UI; mic will prompt on first PTT + this.status = 'Voice Room open — voor mic: gebruik HTTPS (:8643)'; + } + }, + + closeVoiceRoom() { + if (this.voiceRecording) { + try { this.stopVoiceTalk(); } catch (e) {} + } + if (window.speechSynthesis) try { window.speechSynthesis.cancel(); } catch (e2) {} + this.voiceRoomOpen = false; + this.voiceSpeakingKey = ''; + this._announceVoiceRoom('leave'); + if (this.status && String(this.status).indexOf('Voice') === 0) this.status = ''; + }, + + async _announceVoiceRoom(action) { + if (!this.room) return; + try { + await fetch('/api/agents/warroom/rooms/' + this.room.id + '/voice-room', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: action || 'join', agents: this.voiceRoomAgents || [] }), + }); + await this.pollNew(); + } catch (e) { /* quiet */ } + }, + + toggleVoiceAgent(key) { + var list = (this.voiceRoomAgents || []).slice(); + var i = list.indexOf(key); + if (i >= 0) { + if (list.length <= 1) return; // keep at least one + list.splice(i, 1); + } else { + list.push(key); + } + this.voiceRoomAgents = list; + }, + + isVoiceAgent(key) { + return (this.voiceRoomAgents || []).indexOf(key) >= 0; + }, + + voiceAgentSouls() { + var souls = (this.souls || []).slice(); + if (!souls.length) { + return (this.voiceRoomAgents || ['herman']).map(function (k) { + return { agent_key: k, display_name: k }; + }); + } + var prefer = ['herman','retail','bizdev','halal','finance','marketing','legal']; + souls.sort(function (a, b) { + var ia = prefer.indexOf(a.agent_key); var ib = prefer.indexOf(b.agent_key); + if (ia < 0) ia = 99; if (ib < 0) ib = 99; + return ia - ib || String(a.display_name||'').localeCompare(String(b.display_name||'')); + }); + return souls.slice(0, 12); + }, + + secureWarroomUrl() { + try { + var u = new URL(window.location.href); + if (u.protocol === 'https:') { + if (u.pathname.indexOf('/agents') === 0 && u.search.indexOf('tab=') < 0) { + u.searchParams.set('tab', 'warroom'); + } + return u.toString(); + } + u.protocol = 'https:'; + if (u.port === '8610' || u.port === '8600') u.port = '8643'; + else if (!u.port || u.port === '80') u.port = '8643'; + if (u.pathname.indexOf('/warroom/popup') >= 0) { + /* keep popup path */ + } else { + u.pathname = '/agents'; + u.searchParams.set('tab', 'warroom'); + } + return u.toString(); + } catch (e) { + return 'https://10.4.7.18:8643/agents?tab=warroom'; + } + }, + + micNeedsHttps() { + return !(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + }, + + async ensureMic() { + if (!this.micNeedsHttps()) return true; + this.status = 'Microfoon vereist HTTPS — open veilige War Room'; + if (window.Cockpit) { + Cockpit.toast('Mic werkt niet op HTTP. Open HTTPS War Room.', 'error'); + } + var url = this.secureWarroomUrl(); + var go = window.confirm('Spraak met agents werkt via HTTPS.\n\nNu openen?\n' + url); + if (go) window.open(url, '_blank'); + return false; + }, + + async startVoiceTalk(ev) { + if (ev) { try { ev.preventDefault(); } catch (e0) {} } + if (this.voiceRecording || this.voiceBusy || this.busy) return; + if (!this.voiceRoomOpen) this.openVoiceRoom(); + if (!(await this.ensureMic())) return; + try { + this._voiceChunks = []; + this._voiceStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + var rec = new MediaRecorder(this._voiceStream); + this._voiceRec = rec; + var self = this; + rec.ondataavailable = function (e) { + if (e.data && e.data.size) self._voiceChunks.push(e.data); + }; + rec.start(200); + this.voiceRecording = true; + this.status = '🎤 Luisteren… laat los om te sturen'; + } catch (e) { + this.status = 'Microfoon geweigerd of niet beschikbaar'; + if (window.Cockpit) Cockpit.toast(this.status, 'error'); + this._stopVoiceStream(); + } + }, + + async stopVoiceTalk(ev) { + if (ev) { try { ev.preventDefault(); } catch (e0) {} } + if (!this.voiceRecording || !this._voiceRec) return; + var self = this; + this.voiceRecording = false; + this.voiceBusy = true; + this.status = 'Spraak omzetten…'; + await new Promise(function (resolve) { + self._voiceRec.onstop = function () { resolve(); }; + try { self._voiceRec.stop(); } catch (e1) { resolve(); } + }); + this._stopVoiceStream(); + try { + var blob = new Blob(this._voiceChunks || [], { type: 'audio/webm' }); + this._voiceChunks = []; + if (!blob.size) throw new Error('Geen audio opgenomen'); + var fd = new FormData(); + fd.append('file', blob, 'warroom-voice.webm'); + var r = await fetch('/api/voice/transcribe', { method: 'POST', body: fd, credentials: 'same-origin' }); + var j = await r.json().catch(function () { return {}; }); + if (!r.ok) throw new Error(j.detail || j.error || 'Transcriptie mislukt'); + var text = (j.text || '').trim(); + if (!text) throw new Error('Geen spraak herkend — probeer opnieuw'); + // Stuur naar War Room (agents antwoorden zoals bij typen) + this.voiceLastHeard = text; + var tags = (this.voiceRoomAgents || ['herman']).map(function (k) { return '@' + k; }); + // Mention selected voice-room agents so they all can answer + var missing = tags.filter(function (t) { return text.toLowerCase().indexOf(t) < 0; }); + if (missing.length) text = missing.join(' ') + ' ' + text; + this.draft = text; + this.status = 'Versturen naar Voice Room…'; + if (this.voiceRoomOpen) this.voiceSpeakingKey = ''; + await this.send(); + this.status = this.voiceRoomOpen ? 'Voice Room — wachten op agents…' : ''; + } catch (e) { + this.status = e.message || 'Voice mislukt'; + if (window.Cockpit) Cockpit.toast(this.status, 'error'); + } + this.voiceBusy = false; + }, + + _stopVoiceStream() { + try { if (this._voiceRec && this._voiceRec.state !== 'inactive') this._voiceRec.stop(); } catch (e) {} + this._voiceRec = null; + if (this._voiceStream) { + try { this._voiceStream.getTracks().forEach(function (t) { t.stop(); }); } catch (e2) {} + } + this._voiceStream = null; + }, + + speakText(text, agentKey) { + if (!this.speakReplies || !text || !window.speechSynthesis) return; + try { + window.speechSynthesis.cancel(); + var self = this; + this.voiceSpeakingKey = agentKey || 'agent'; + var u = new SpeechSynthesisUtterance(String(text).slice(0, 900)); + u.lang = 'nl-NL'; + u.rate = 1.02; + var voices = window.speechSynthesis.getVoices() || []; + var nl = voices.find(function (v) { return /nl/i.test(v.lang) && /google|microsoft|maarten|xander|neder/i.test(v.name); }) + || voices.find(function (v) { return /nl/i.test(v.lang); }); + if (nl) u.voice = nl; + u.onend = function () { + if (self.voiceSpeakingKey === (agentKey || 'agent')) self.voiceSpeakingKey = ''; + if (self.voiceRoomOpen) self.status = 'Voice Room — jouw beurt (houd 🎤 in)'; + }; + window.speechSynthesis.speak(u); + if (this.voiceRoomOpen) { + this.status = (agentKey || 'Agent') + ' spreekt…'; + } + } catch (e) { /* ignore */ } + }, + + + callHref() { + var id = (this.room && this.room.id) ? this.room.id : 1; + var slug = 'FoodlinkkWR' + id; + if (this.room && this.room.kind === 'dm' && this.room.agent_key) { + slug = 'FoodlinkkDM' + this.room.agent_key + id; + } + return 'https://meet.ffmuc.net/' + slug; + }, + + onCallClick() { + // Runs in the same user-gesture as the navigation — always show panel. + var url = this.callHref(); + this.callUrl = url; + this.callSlug = url.split('/').pop(); + this.callOpen = true; + this.status = 'Call open — nieuw tabblad of klik Deelnemen'; + this.announceCall(url); + }, + + async announceCall(url) { + if (!this.room || this.callBusy) return; + this.callBusy = true; + try { + var r = await fetch('/api/agents/warroom/rooms/' + this.room.id + '/call', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }).then(function (x) { return x.json().then(function (d) { return { ok: x.ok, d: d }; }); }); + if (r.ok && r.d && r.d.url) { + this.callUrl = r.d.url; + this.callSlug = r.d.slug || this.callSlug; + } + await this.pollNew(); + } catch (e) { + /* panel + tab already useful without announce */ + } + this.callBusy = false; + }, + + async startCall() { + // Fallback if something still calls startCall() + this.onCallClick(); + var url = this.callUrl || this.callHref(); + window.open(url, '_blank'); + }, + + closeCall() { + this.callOpen = false; + if (this.status && String(this.status).indexOf('Call') === 0) this.status = ''; + }, + + openCallExternal() { + var url = this.callUrl || this.callHref(); + window.open(url, '_blank'); + }, + + async copyCallLink() { + var link = (this.callUrl || this.callHref()).split('#')[0]; + try { + await navigator.clipboard.writeText(link); + if (window.Cockpit) Cockpit.toast('Link gekopieerd', 'ok'); + } catch (e) { + window.prompt('Kopieer deze call-link:', link); + } + }, + popOut() { var q = this.room ? '?room=' + this.room.id : ''; openPopup(q); @@ -414,13 +864,16 @@ window.WarRoomHub = (function () { return { openPopup: openPopup, controller: warRoomController }; })(); -document.addEventListener('alpine:init', function () { - if (window.Alpine && Alpine.data) { - Alpine.data('warRoomHub', function () { - return WarRoomHub.controller({ popupMode: false }); - }); - Alpine.data('warRoomPopup', function () { - return WarRoomHub.controller({ popupMode: true }); - }); - } -}); +function __flkRegisterWarRoomAlpine() { + if (!window.Alpine || typeof Alpine.data !== 'function') return; + if (Alpine.__flkWarRoomRegistered) return; + Alpine.data('warRoomHub', function () { + return WarRoomHub.controller({ popupMode: false }); + }); + Alpine.data('warRoomPopup', function () { + return WarRoomHub.controller({ popupMode: true }); + }); + Alpine.__flkWarRoomRegistered = true; +} +document.addEventListener('alpine:init', __flkRegisterWarRoomAlpine); +__flkRegisterWarRoomAlpine(); diff --git a/cockpit/templates/agents.html b/cockpit/templates/agents.html index 12bed3d..efad51a 100644 --- a/cockpit/templates/agents.html +++ b/cockpit/templates/agents.html @@ -5,7 +5,7 @@ - + {% endblock %} {% block content %} @@ -272,7 +272,8 @@ -
+
+
+ + {% elif active_tab == 'themes' %} +
+
+
+

UI Themes

+

Kies een kleurrijk cyaan/blauw thema voor de hele cockpit. Direct toegepast op alle pagina's.

+
+
+
+ +
+
+ {% elif active_tab == 'general' %}

General

@@ -293,6 +314,33 @@ {% endblock %} {% block scripts %} + +