diff --git a/cockpit/app/auth_users.py b/cockpit/app/auth_users.py new file mode 100644 index 0000000..26345a8 --- /dev/null +++ b/cockpit/app/auth_users.py @@ -0,0 +1,292 @@ +"""Cockpit user accounts: password hashing, CRUD, permissions.""" +from __future__ import annotations + +import hashlib +import json +import secrets +from typing import Any, Optional + +from app.db import execute, fetch_all, fetch_one + +# Nav / feature keys shown in Settings → Gebruikers +PAGE_CATALOG: list[dict[str, str]] = [ + {"key": "dashboard", "label": "Overzicht (CEO)"}, + {"key": "revenue", "label": "Revenue Cockpit"}, + {"key": "retail", "label": "Retail NL"}, + {"key": "marketing", "label": "Marketing"}, + {"key": "packaging", "label": "Packaging"}, + {"key": "beurs", "label": "Beurs Live"}, + {"key": "documents", "label": "Documenten"}, + {"key": "projects", "label": "Projecten"}, + {"key": "hermes", "label": "Telegram / Hermes"}, + {"key": "analytics", "label": "Analytics"}, + {"key": "export", "label": "Wereldexport"}, + {"key": "clients", "label": "Klanten"}, + {"key": "deals", "label": "Deals"}, + {"key": "products", "label": "Producten"}, + {"key": "suppliers", "label": "Leveranciers"}, + {"key": "reports", "label": "Rapporten"}, + {"key": "ops", "label": "IT Ops"}, + {"key": "agents", "label": "Agents"}, + {"key": "browser", "label": "Browser"}, + {"key": "studio", "label": "Studio"}, + {"key": "voice", "label": "Voice"}, + {"key": "settings", "label": "Instellingen"}, +] + +# path prefix → permission key (order: longest prefix first) +PATH_RULES: list[tuple[str, str]] = [ + ("/revenue-cockpit", "revenue"), + ("/export-intel", "export"), + ("/packaging", "packaging"), + ("/marketing", "marketing"), + ("/documents", "documents"), + ("/analytics", "analytics"), + ("/suppliers", "suppliers"), + ("/products", "products"), + ("/projects", "projects"), + ("/settings", "settings"), + ("/clients", "clients"), + ("/browser", "browser"), + ("/monitor", "browser"), + ("/hermes", "hermes"), + ("/retail", "retail"), + ("/reports", "reports"), + ("/studio", "studio"), + ("/agents", "agents"), + ("/beurs", "beurs"), + ("/deals", "deals"), + ("/voice", "voice"), + ("/ops", "ops"), + ("/", "dashboard"), +] + +ADMIN_SEED_USERNAME = "aissa" +ADMIN_SEED_PASSWORD = "Foodlinkk#2026" + + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + rounds = 200_000 + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds) + return f"pbkdf2_sha256${rounds}${salt}${dk.hex()}" + + +def verify_password(password: str, password_hash: str) -> bool: + try: + algo, rounds_s, salt, digest = password_hash.split("$", 3) + if algo != "pbkdf2_sha256": + return False + rounds = int(rounds_s) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds) + return secrets.compare_digest(dk.hex(), digest) + except Exception: + return False + + +def _normalize_perms(raw: Any) -> dict[str, Any]: + if raw is None: + return {"pages": [], "manage_users": False} + if isinstance(raw, str): + try: + raw = json.loads(raw) + except Exception: + return {"pages": [], "manage_users": False} + if not isinstance(raw, dict): + return {"pages": [], "manage_users": False} + pages = raw.get("pages") or [] + if not isinstance(pages, list): + pages = [] + return { + "pages": [str(p) for p in pages], + "manage_users": bool(raw.get("manage_users")), + } + + +def public_user(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + perms = _normalize_perms(row.get("permissions")) + role = row.get("role") or "user" + if role == "admin": + perms = { + "pages": [p["key"] for p in PAGE_CATALOG], + "manage_users": True, + } + return { + "id": row["id"], + "username": row["username"], + "display_name": row.get("display_name") or row["username"], + "role": role, + "is_active": bool(row.get("is_active", True)), + "avatar_url": row.get("avatar_url") or None, + "permissions": perms, + "created_at": row["created_at"].isoformat() if row.get("created_at") else None, + "last_login_at": row["last_login_at"].isoformat() if row.get("last_login_at") else None, + } + + +def ensure_admin_seed() -> None: + """Create default admin aissa if table empty / user missing.""" + row = fetch_one("SELECT id FROM cockpit_users WHERE username = %s", (ADMIN_SEED_USERNAME,)) + if row: + return + execute( + """ + INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions) + VALUES (%s, %s, %s, 'admin', %s::jsonb) + ON CONFLICT (username) DO NOTHING + """, + ( + ADMIN_SEED_USERNAME, + hash_password(ADMIN_SEED_PASSWORD), + "Aissa", + json.dumps({"pages": [p["key"] for p in PAGE_CATALOG], "manage_users": True}), + ), + ) + + +def authenticate(username: str, password: str) -> Optional[dict[str, Any]]: + row = fetch_one( + "SELECT * FROM cockpit_users WHERE lower(username) = lower(%s)", + (username.strip(),), + ) + if not row or not row.get("is_active"): + return None + if not verify_password(password, row["password_hash"]): + return None + execute( + "UPDATE cockpit_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s", + (row["id"],), + ) + return public_user(row) + + +def get_user_by_id(user_id: int) -> Optional[dict[str, Any]]: + row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,)) + return public_user(row) + + +def list_users() -> list[dict[str, Any]]: + rows = fetch_all("SELECT * FROM cockpit_users ORDER BY username ASC") + return [public_user(r) for r in rows if r] + + +def create_user( + username: str, + password: str, + display_name: str | None, + role: str, + permissions: dict[str, Any], +) -> dict[str, Any]: + username = username.strip() + if not username or not password: + raise ValueError("Gebruikersnaam en wachtwoord zijn verplicht") + if role not in ("admin", "user"): + role = "user" + perms = _normalize_perms(permissions) + if role == "admin": + perms["manage_users"] = True + perms["pages"] = [p["key"] for p in PAGE_CATALOG] + row = fetch_one( + """ + INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions) + VALUES (%s, %s, %s, %s, %s::jsonb) + RETURNING * + """, + (username, hash_password(password), (display_name or username).strip(), role, json.dumps(perms)), + ) + return public_user(row) # type: ignore + + +def update_user( + user_id: int, + *, + display_name: str | None = None, + role: str | None = None, + is_active: bool | None = None, + permissions: dict[str, Any] | None = None, + password: str | None = None, +) -> dict[str, Any]: + row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,)) + if not row: + raise ValueError("Gebruiker niet gevonden") + new_name = display_name if display_name is not None else row.get("display_name") + new_role = role if role in ("admin", "user") else row["role"] + new_active = row["is_active"] if is_active is None else bool(is_active) + new_perms = _normalize_perms(permissions if permissions is not None else row.get("permissions")) + if new_role == "admin": + new_perms["manage_users"] = True + new_perms["pages"] = [p["key"] for p in PAGE_CATALOG] + pw_hash = row["password_hash"] + if password: + pw_hash = hash_password(password) + updated = fetch_one( + """ + UPDATE cockpit_users + SET display_name = %s, role = %s, is_active = %s, permissions = %s::jsonb, + password_hash = %s, updated_at = NOW() + WHERE id = %s + RETURNING * + """, + (new_name, new_role, new_active, json.dumps(new_perms), pw_hash, user_id), + ) + return public_user(updated) # type: ignore + + +def delete_user(user_id: int, *, actor_id: int) -> None: + if user_id == actor_id: + raise ValueError("Je kunt jezelf niet verwijderen") + row = fetch_one("SELECT role FROM cockpit_users WHERE id = %s", (user_id,)) + if not row: + raise ValueError("Gebruiker niet gevonden") + if row["role"] == "admin": + admins = fetch_one( + "SELECT COUNT(*)::int AS n FROM cockpit_users WHERE role = 'admin' AND is_active = TRUE" + ) + if admins and admins["n"] <= 1: + raise ValueError("Laatste admin kan niet verwijderd worden") + execute("DELETE FROM cockpit_users WHERE id = %s", (user_id,)) + + +def path_allowed(user: dict[str, Any] | None, path: str) -> bool: + if not user: + return False + if user.get("role") == "admin": + return True + pages = set((user.get("permissions") or {}).get("pages") or []) + # normalize + p = path.split("?")[0] or "/" + if p != "/" and p.endswith("/"): + p = p.rstrip("/") + for prefix, key in PATH_RULES: + if prefix == "/": + if p == "/" or p == "": + return "dashboard" in pages + continue + if p == prefix or p.startswith(prefix + "/"): + return key in pages + return "dashboard" in pages + + +def can_manage_users(user: dict[str, Any] | None) -> bool: + if not user: + return False + if user.get("role") == "admin": + return True + return bool((user.get("permissions") or {}).get("manage_users")) + + +def set_avatar(user_id: int, avatar_url: str | None) -> dict[str, Any]: + row = fetch_one( + """ + UPDATE cockpit_users + SET avatar_url = %s, updated_at = NOW() + WHERE id = %s + RETURNING * + """, + (avatar_url, user_id), + ) + if not row: + raise ValueError("Gebruiker niet gevonden") + return public_user(row) # type: ignore diff --git a/cockpit/app/config.py b/cockpit/app/config.py index dfdd64b..7dfd89b 100644 --- a/cockpit/app/config.py +++ b/cockpit/app/config.py @@ -24,10 +24,16 @@ class Settings: WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base") WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl") HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755") + # Bind cockpit chat aan Hermes/Telegram memory (chat_id als OpenAI "user") + HERMES_CEO_CHAT_ID: str = os.getenv("HERMES_CEO_CHAT_ID", "8859782446") + HERMES_CTO_CHAT_ID: str = os.getenv("HERMES_CTO_CHAT_ID", "789036463") + HERMES_MEMORY_LIMIT: int = int(os.getenv("HERMES_MEMORY_LIMIT", "24")) HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798") CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma") CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000")) MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000") + SESSION_SECRET: str = os.getenv("SESSION_SECRET", "foodlinkk-cockpit-session-change-me-2026") + SESSION_MAX_AGE: int = int(os.getenv("SESSION_MAX_AGE", str(60 * 60 * 24 * 14))) # 14 days @property def database_dsn(self) -> str: diff --git a/cockpit/app/main.py b/cockpit/app/main.py index abbd629..e99538d 100644 --- a/cockpit/app/main.py +++ b/cockpit/app/main.py @@ -3,6 +3,8 @@ import json from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from starlette.middleware.sessions import SessionMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.responses import Response @@ -38,6 +40,9 @@ from app.routes import ( from app.routes.revenue_cockpit import api as revenue_cockpit_api from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api from app.routes.settings_api import settings_router +from app.routes import auth_routes +from app import auth_users +from app.config import settings as app_settings from app.routes.agents_api import router as agents_api_router from app.routes.marketing_api import router as marketing_api_router from app.routes.projects_api import router as projects_api_router @@ -59,6 +64,131 @@ class NoCacheStaticFiles(StaticFiles): app = FastAPI(title="Foodlinkk Command Center", version="2.5.0") + +PUBLIC_PREFIXES = ( + "/login", + "/logout", + "/static/", + "/sw.js", + "/manifest.webmanifest", + "/static/manifest.json", + "/api/auth/login", +) +PUBLIC_EXACT = {"/favicon.ico", "/robots.txt"} + + +def _is_public(path: str) -> bool: + if path in PUBLIC_EXACT: + return True + for pref in PUBLIC_PREFIXES: + if path == pref or path.startswith(pref): + return True + return False + + +class AuthGateMiddleware: + """Require login; must run *inside* SessionMiddleware.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + from starlette.requests import Request + + request = Request(scope, receive=receive) + path = request.url.path + user = None + try: + uid = request.session.get("user_id") + except Exception: + uid = None + if uid: + try: + user = auth_users.get_user_by_id(int(uid)) + except Exception: + user = None + if not user or not user.get("is_active"): + try: + request.session.clear() + except Exception: + pass + user = None + request.state.user = user + + async def call_next(): + await self.app(scope, receive, send) + + if _is_public(path): + await self.app(scope, receive, send) + return + + if not user: + if path.startswith("/api/") or path.startswith("/ws"): + response = JSONResponse({"detail": "Niet ingelogd"}, status_code=401) + else: + nxt = path + if request.url.query: + nxt = f"{path}?{request.url.query}" + from urllib.parse import quote + response = RedirectResponse(f"/login?next={quote(nxt)}", status_code=303) + await response(scope, receive, send) + return + + if ( + not path.startswith("/api/") + and not path.startswith("/ws") + and not path.startswith("/static") + and not auth_users.path_allowed(user, path) + ): + response = RedirectResponse("/", status_code=303) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +# Order: first added = innermost. Session must be outermost so request.session works. +app.add_middleware(AuthGateMiddleware) +app.add_middleware( + SessionMiddleware, + secret_key=app_settings.SESSION_SECRET, + session_cookie="flk_session", + same_site="lax", + https_only=False, + max_age=app_settings.SESSION_MAX_AGE, +) + + + + +@app.api_route("/sw.js", methods=["GET", "HEAD"], include_in_schema=False) +async def service_worker(): + """Root-scoped service worker for installable PWA (Android + iOS Safari).""" + sw_path = BASE_DIR / "static" / "sw.js" + return FileResponse( + sw_path, + media_type="application/javascript; charset=utf-8", + headers={ + "Service-Worker-Allowed": "/", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + ) + + +@app.api_route("/manifest.webmanifest", methods=["GET", "HEAD"], include_in_schema=False) +async def web_manifest(): + man_path = BASE_DIR / "static" / "manifest.json" + return FileResponse( + man_path, + media_type="application/manifest+json", + headers={"Cache-Control": "no-cache"}, + ) + + app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static") for r in ( @@ -77,6 +207,7 @@ for r in ( reports.router, voice.router, settings.router, + auth_routes.router, browser.router, herman.router, studio.router, @@ -96,6 +227,8 @@ for r in ( agents_api_router, marketing_api_router, projects_api_router, + auth_routes.api_router, + auth_routes.users_api, ): app.include_router(r) @@ -103,6 +236,10 @@ for r in ( @app.on_event("startup") def on_startup() -> None: init_pool() + try: + auth_users.ensure_admin_seed() + except Exception as e: + print(f"auth seed warning: {e}") @app.on_event("shutdown") diff --git a/cockpit/app/routes/admin_api.py b/cockpit/app/routes/admin_api.py index ca3e9a1..2a4540b 100644 --- a/cockpit/app/routes/admin_api.py +++ b/cockpit/app/routes/admin_api.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Any, Optional import httpx -from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi import Request, APIRouter, File, Form, HTTPException, UploadFile from pydantic import BaseModel, Field from app.config import settings @@ -1019,14 +1019,17 @@ async def ai_generate_post_draft(body: AIPostDraftBody): @herman_api.post("/chat") -async def herman_chat_api(body: HermanChatBody): +async def herman_chat_api(request: Request, body: HermanChatBody): if not body.message.strip(): raise HTTPException(400, "message required") + user = getattr(request.state, "user", None) or {} result = await herman_service.chat( body.message.strip(), channel=(body.channel or "cockpit").strip(), session_id=body.session_id, confirm_action_id=body.confirm_action_id, + username=(user.get("username") if isinstance(user, dict) else None), + role=(user.get("role") if isinstance(user, dict) else None), ) return {"ok": True, **result} diff --git a/cockpit/app/routes/agents.py b/cockpit/app/routes/agents.py index cba4fa9..04b3b8c 100644 --- a/cockpit/app/routes/agents.py +++ b/cockpit/app/routes/agents.py @@ -1,7 +1,7 @@ from pathlib import Path from fastapi import APIRouter, Form, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from app.db import execute, fetch_all @@ -22,7 +22,7 @@ AGENT_STATUSES = [ @router.get("") async def agents_page(request: Request): active_tab = request.query_params.get("tab", "souls") - if active_tab not in {"souls", "mesh", "terminals", "approvals"}: + if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}: active_tab = "souls" events: list = [] try: @@ -52,6 +52,14 @@ async def agents_page(request: Request): ) + +@router.get("/warroom/popup", response_class=HTMLResponse) +async def agents_warroom_popup(request: Request): + return templates.TemplateResponse( + "agents_warroom_popup.html", + {"request": request, "page_title": "War Room"}, + ) + @router.post("/approve/{event_id}") async def approve_event(event_id: int, next_url: str = Form("/")): try: diff --git a/cockpit/app/routes/agents_api.py b/cockpit/app/routes/agents_api.py index e0dada0..974edf8 100644 --- a/cockpit/app/routes/agents_api.py +++ b/cockpit/app/routes/agents_api.py @@ -5,7 +5,7 @@ import asyncio import json from typing import Any, Optional -from fastapi import APIRouter, HTTPException +from fastapi import File, UploadFile, APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field @@ -545,3 +545,121 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return {"ok": True, "request": req} + + +# --- War Room chat --- +class WarroomMessageBody(BaseModel): + content: str = Field("", max_length=8000) + attachments: list[dict] = Field(default_factory=list) + + +@router.get("/warroom/rooms") +async def warroom_list_rooms() -> dict[str, Any]: + from app.services import agent_chat + agent_chat.ensure_warroom() + return {"items": agent_chat.list_rooms()} + + +@router.post("/warroom/rooms/dm/{agent_key}") +async def warroom_open_dm(agent_key: str) -> dict[str, Any]: + from app.services import agent_chat + try: + room = agent_chat.get_or_create_dm(agent_key) + except ValueError as exc: + raise HTTPException(404, str(exc)) from exc + return {"room": room} + + +@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)} + + +@router.post("/warroom/rooms/{room_id}/messages") +async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]: + from app.services import agent_chat + user = getattr(request.state, "user", None) or {} + try: + result = await agent_chat.post_user_message( + room_id, + body.content, + username=(user.get("username") if isinstance(user, dict) else None), + 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, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return result + + +@router.post("/warroom/rooms/{room_id}/messages/stream") +async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody): + """SSE stream: user → typing tokens → agent message(s) → done.""" + 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.post_user_message_stream( + room_id, + body.content, + username=(user.get("username") if isinstance(user, dict) else None), + 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, + ): + 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.post("/warroom/upload") +async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]: + """Upload a file to share in War Room (images/docs).""" + from pathlib import Path as P + import uuid + import re + + raw_name = (file.filename or "upload.bin").strip() + safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin" + ext = P(safe).suffix.lower() + allowed = { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv", + ".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md", + } + if ext not in allowed: + raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}") + data = await file.read() + if len(data) > 12 * 1024 * 1024: + raise HTTPException(400, "Max 12MB per bestand") + dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom" + dest_dir.mkdir(parents=True, exist_ok=True) + fname = f"{uuid.uuid4().hex[:12]}_{safe}" + (dest_dir / fname).write_bytes(data) + url = f"/static/uploads/warroom/{fname}" + return { + "ok": True, + "url": url, + "name": raw_name[:180], + "mime": file.content_type or "application/octet-stream", + "size": len(data), + } diff --git a/cockpit/app/routes/auth_routes.py b/cockpit/app/routes/auth_routes.py new file mode 100644 index 0000000..41624e6 --- /dev/null +++ b/cockpit/app/routes/auth_routes.py @@ -0,0 +1,231 @@ +"""Login / logout / me + user admin API.""" +from __future__ import annotations + +from pathlib import Path +import uuid +from typing import Any, Optional + +from fastapi import APIRouter, File, HTTPException, Request, UploadFile +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field + +from app import auth_users + +router = APIRouter(tags=["auth"]) +api_router = APIRouter(prefix="/api/auth", tags=["auth"]) +users_api = APIRouter(prefix="/api/settings/users", tags=["users"]) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars" +AVATAR_DIR.mkdir(parents=True, exist_ok=True) +ALLOWED_AVATAR = {".jpg", ".jpeg", ".png", ".webp", ".gif"} + + +async def _save_avatar_file(user_id: int, file: UploadFile) -> str: + raw_name = (file.filename or "avatar.jpg").lower() + ext = Path(raw_name).suffix + if ext not in ALLOWED_AVATAR: + raise HTTPException(status_code=400, detail="Alleen JPG, PNG, WEBP of GIF") + data = await file.read() + if len(data) > 3 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Max 3 MB") + # strip old files for this user + for old in AVATAR_DIR.glob(f"user_{user_id}_*"): + try: + old.unlink() + except OSError: + pass + fname = f"user_{user_id}_{uuid.uuid4().hex[:10]}{ext}" + path = AVATAR_DIR / fname + path.write_bytes(data) + return f"/static/uploads/avatars/{fname}" + + + +class LoginBody(BaseModel): + username: str + password: str + + +class UserCreateBody(BaseModel): + username: str + password: str + display_name: Optional[str] = None + role: str = "user" + permissions: dict[str, Any] = Field(default_factory=dict) + + +class UserUpdateBody(BaseModel): + display_name: Optional[str] = None + role: Optional[str] = None + is_active: Optional[bool] = None + password: Optional[str] = None + permissions: Optional[dict[str, Any]] = None + + +def _session_user(request: Request) -> dict[str, Any] | None: + return getattr(request.state, "user", None) + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, next: str = "/"): + if _session_user(request): + return RedirectResponse(next or "/", status_code=303) + return templates.TemplateResponse( + "login.html", + {"request": request, "page_title": "Inloggen", "next": next or "/", "error": None}, + ) + + +@router.post("/login") +async def login_submit(request: Request): + form = await request.form() + username = str(form.get("username") or "").strip() + password = str(form.get("password") or "") + next_url = str(form.get("next") or "/") + if not next_url.startswith("/"): + next_url = "/" + user = auth_users.authenticate(username, password) + if not user: + return templates.TemplateResponse( + "login.html", + { + "request": request, + "page_title": "Inloggen", + "next": next_url, + "error": "Ongeldige gebruikersnaam of wachtwoord", + "username": username, + }, + status_code=401, + ) + request.session.clear() + request.session["user_id"] = user["id"] + request.session["username"] = user["username"] + return RedirectResponse(next_url, status_code=303) + + +@router.post("/logout") +@router.get("/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +@api_router.post("/login") +async def api_login(request: Request, body: LoginBody): + user = auth_users.authenticate(body.username, body.password) + if not user: + raise HTTPException(status_code=401, detail="Ongeldige login") + request.session.clear() + request.session["user_id"] = user["id"] + request.session["username"] = user["username"] + return {"ok": True, "user": user} + + +@api_router.post("/logout") +async def api_logout(request: Request): + request.session.clear() + return {"ok": True} + + +@api_router.get("/me") +async def api_me(request: Request): + user = _session_user(request) + if not user: + raise HTTPException(status_code=401, detail="Niet ingelogd") + return { + "user": user, + "pages": auth_users.PAGE_CATALOG, + "can_manage_users": auth_users.can_manage_users(user), + } + + +def _require_user_admin(request: Request) -> dict[str, Any]: + user = _session_user(request) + if not user: + raise HTTPException(status_code=401, detail="Niet ingelogd") + if not auth_users.can_manage_users(user): + raise HTTPException(status_code=403, detail="Geen rechten om gebruikers te beheren") + return user + + +@users_api.get("") +async def users_list(request: Request): + _require_user_admin(request) + return {"users": auth_users.list_users(), "pages": auth_users.PAGE_CATALOG} + + +@users_api.post("") +async def users_create(request: Request, body: UserCreateBody): + _require_user_admin(request) + try: + user = auth_users.create_user( + body.username, body.password, body.display_name, body.role, body.permissions or {} + ) + except Exception as e: + msg = str(e) + if "unique" in msg.lower() or "duplicate" in msg.lower(): + raise HTTPException(status_code=400, detail="Gebruikersnaam bestaat al") from e + raise HTTPException(status_code=400, detail=msg) from e + return {"user": user} + + +@users_api.put("/{user_id}") +async def users_update(request: Request, user_id: int, body: UserUpdateBody): + _require_user_admin(request) + try: + user = auth_users.update_user( + user_id, + display_name=body.display_name, + role=body.role, + is_active=body.is_active, + permissions=body.permissions, + password=body.password or None, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"user": user} + + +@users_api.delete("/{user_id}") +async def users_delete(request: Request, user_id: int): + actor = _require_user_admin(request) + try: + auth_users.delete_user(user_id, actor_id=actor["id"]) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"ok": True} + +@api_router.post("/me/avatar") +async def upload_my_avatar(request: Request, file: UploadFile = File(...)): + user = _session_user(request) + if not user: + raise HTTPException(status_code=401, detail="Niet ingelogd") + url = await _save_avatar_file(user["id"], file) + updated = auth_users.set_avatar(user["id"], url) + request.session["username"] = updated["username"] + return {"user": updated, "avatar_url": url} + + +@users_api.post("/{user_id}/avatar") +async def upload_user_avatar(request: Request, user_id: int, file: UploadFile = File(...)): + _require_user_admin(request) + url = await _save_avatar_file(user_id, file) + updated = auth_users.set_avatar(user_id, url) + return {"user": updated, "avatar_url": url} + + +@users_api.delete("/{user_id}/avatar") +async def delete_user_avatar(request: Request, user_id: int): + actor = _require_user_admin(request) + for old in AVATAR_DIR.glob(f"user_{user_id}_*"): + try: + old.unlink() + except OSError: + pass + updated = auth_users.set_avatar(user_id, None) + return {"user": updated} + diff --git a/cockpit/app/routes/settings.py b/cockpit/app/routes/settings.py index 9b0603d..3588c8e 100644 --- a/cockpit/app/routes/settings.py +++ b/cockpit/app/routes/settings.py @@ -1,7 +1,10 @@ from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from pathlib import Path +from app import auth_users + router = APIRouter(tags=["settings"]) BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -10,15 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) @router.get("/settings") async def settings_page(request: Request, tab: str = "email"): - allowed = ("email", "general", "permissions", "social") + allowed = ("email", "general", "permissions", "social", "users") active = tab if tab in allowed else "email" if tab == "ai": active = "email" + user = getattr(request.state, "user", None) + if active == "users" and not auth_users.can_manage_users(user): + return RedirectResponse("/settings?tab=email", status_code=303) return templates.TemplateResponse( "settings.html", { "request": request, "page_title": "Settings", "active_tab": active, + "can_manage_users": auth_users.can_manage_users(user), + "current_user": user, }, ) diff --git a/cockpit/app/services/agent_chat.py b/cockpit/app/services/agent_chat.py new file mode 100644 index 0000000..c0df2ee --- /dev/null +++ b/cockpit/app/services/agent_chat.py @@ -0,0 +1,589 @@ +"""War Room + 1:1 agent chat — Hermes/mimo only, soul prompts, A2A rounds.""" +from __future__ import annotations + +import asyncio + +import logging +import re +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.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_REPLIES_PER_ROUND = 3 +HISTORY_LIMIT = 16 +WARROOM_MAX_TOKENS = 512 +WARROOM_TIMEOUT = 55.0 + + +def _iso(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + out = dict(row) + for k, v in list(out.items()): + if hasattr(v, "isoformat"): + out[k] = v.isoformat() + if isinstance(out.get("metadata"), str): + try: + import json + out["metadata"] = json.loads(out["metadata"]) + except Exception: + out["metadata"] = {} + return out + + +def ensure_warroom() -> dict[str, Any]: + row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'warroom' LIMIT 1") + if row: + return _iso(row) # type: ignore + row = fetch_one( + """INSERT INTO warroom_rooms (kind, title) VALUES ('warroom', 'War Room') + RETURNING *""" + ) + return _iso(row) # type: ignore + + +def get_or_create_dm(agent_key: str) -> dict[str, Any]: + key = normalize_agent_key(agent_key) or (agent_key or "").strip().lower() + soul = agent_souls.get_soul(key) + if not soul: + raise ValueError(f"Onbekende agent: {agent_key}") + row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'dm' AND agent_key = %s", (key,)) + if row: + return _iso(row) # type: ignore + title = f"DM · {soul.get('display_name') or key}" + row = fetch_one( + """INSERT INTO warroom_rooms (kind, agent_key, title) + VALUES ('dm', %s, %s) RETURNING *""", + (key, title), + ) + return _iso(row) # type: ignore + + +def list_rooms() -> list[dict[str, Any]]: + ensure_warroom() + rows = fetch_all( + """SELECT r.*, + (SELECT COUNT(*) FROM warroom_messages m WHERE m.room_id = r.id) AS message_count, + (SELECT content FROM warroom_messages m WHERE m.room_id = r.id + ORDER BY m.created_at DESC LIMIT 1) AS last_message + FROM warroom_rooms r + ORDER BY CASE WHEN r.kind = 'warroom' THEN 0 ELSE 1 END, r.title""" + ) + return [_iso(r) for r in rows] # type: ignore + + +def get_room(room_id: int) -> dict[str, Any] | None: + return _iso(fetch_one("SELECT * FROM warroom_rooms WHERE id = %s", (room_id,))) + + +def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None) -> list[dict[str, Any]]: + if after_id: + rows = fetch_all( + """SELECT * FROM warroom_messages + WHERE room_id = %s AND id > %s + 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 + ORDER BY created_at DESC, id DESC LIMIT %s + ) t ORDER BY created_at ASC, id ASC""", + (room_id, limit), + ) + return [_iso(r) for r in rows] # type: ignore + + +def _insert_message( + room_id: int, + *, + sender_type: str, + content: str, + agent_key: str | None = None, + username: str | None = None, + sender_avatar_url: str | None = None, + metadata: dict | None = None, +) -> dict[str, Any]: + import json + + row = fetch_one( + """INSERT INTO warroom_messages + (room_id, sender_type, agent_key, username, sender_avatar_url, content, metadata) + VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) + RETURNING *""", + ( + room_id, + sender_type, + agent_key, + username, + sender_avatar_url, + content, + json.dumps(metadata or {}), + ), + ) + execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,)) + return _iso(row) # type: ignore + + +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)} + + +def parse_mentions(text: str) -> list[str]: + keys = _active_agent_keys() + found: list[str] = [] + for m in MENTION_RE.findall(text or ""): + k = normalize_agent_key(m) or m.lower() + if k in keys and k not in found: + found.append(k) + return found + + +def _history_as_chat(messages: list[dict[str, Any]], *, limit: int = 20) -> list[dict[str, str]]: + out: list[dict[str, str]] = [] + for m in messages[-limit:]: + st = m.get("sender_type") + content = (m.get("content") or "").strip() + if not content: + continue + if st == "user": + name = m.get("username") or "User" + out.append({"role": "user", "content": f"{name}: {content}"}) + elif st == "agent": + key = m.get("agent_key") or "agent" + out.append({"role": "assistant", "content": f"@{key}: {content}"}) + else: + out.append({"role": "system", "content": content}) + return out + + +async def _hermes_reply( + messages: list[dict[str, str]], + *, + hermes_user: str, + timeout: float | None = None, +) -> tuple[str, dict[str, Any]]: + """Force Hermes/mimo — never DeepSeek/OpenRouter fallback.""" + prov = llm_router.resolve_provider(None) + ptype = (prov.get("provider_type") or "").lower() + if ptype != "hermes": + hermes = llm_router._env_hermes_provider() + if not hermes: + raise RuntimeError("Hermes/mimo niet geconfigureerd (HERMES_API_KEY / :8642).") + raise RuntimeError( + f"War Room vereist Hermes/mimo, huidige provider is '{ptype or 'onbekend'}'. " + "Zet HERMAN_LLM_BACKEND=hermes." + ) + reply, meta = await llm_router.chat_messages( + messages, + timeout=float(timeout if timeout is not None else WARROOM_TIMEOUT), + user=hermes_user, + max_tokens=WARROOM_MAX_TOKENS, + temperature=0.35, + ) + meta = dict(meta or {}) + if meta.get("provider_type") != "hermes": + raise RuntimeError("Antwoord kwam niet van Hermes/mimo — afgebroken.") + return reply, meta + + +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] + extra = ( + "War Room: antwoord in max 4 korte zinnen (NL). Alleen @mention als écht nodig." + if room_kind == "warroom" + else "1:1 DM: bondig in het Nederlands (max 5 zinnen)." + ) + return ( + f"Je bent {name} (@{key}), {role} bij Foodlinkk.\n" + f"Focus: {resp or '—'}\n" + f"{soul_md}\n\n{extra}" + ) + + +async def _agent_speak( + room: dict[str, Any], + agent_key: str, + *, + trigger: str, + hermes_user: str, + in_reply_to: str | None = None, +) -> dict[str, Any]: + soul = agent_souls.get_soul(agent_key) + if not soul: + return _insert_message( + int(room["id"]), + sender_type="system", + content=f"Agent @{agent_key} niet gevonden.", + metadata={"error": True}, + ) + hist = list_messages(int(room["id"]), limit=HISTORY_LIMIT) + messages: list[dict[str, str]] = [ + {"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")}, + ] + messages.extend(_history_as_chat(hist, limit=8)) + cue = trigger + if in_reply_to: + cue = f"(Reageer op @{in_reply_to}) {trigger}" + messages.append({"role": "user", "content": cue}) + try: + reply, meta = await _hermes_reply(messages, hermes_user=hermes_user) + except Exception as exc: + log.exception("agent speak failed %s", agent_key) + return _insert_message( + int(room["id"]), + sender_type="system", + content=f"@{agent_key}: Hermes/mimo fout — {exc}", + metadata={"error": True, "agent_key": agent_key}, + ) + return _insert_message( + int(room["id"]), + sender_type="agent", + agent_key=agent_key, + content=reply.strip() or "(geen antwoord)", + metadata={ + "llm": meta, + "in_reply_to": in_reply_to, + "model": "hermes-agent/mimo", + }, + ) + + +async def _orchestrate_warroom( + room: dict[str, Any], + user_text: str, + *, + hermes_user: str, + username: str | None, +) -> 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"] + + # Round 0: initial speakers in parallel (sneller bij meerdere @mentions) + keys = speakers[:MAX_REPLIES_PER_ROUND] + if len(keys) == 1: + produced.append( + await _agent_speak(room, keys[0], trigger=user_text, hermes_user=hermes_user) + ) + elif keys: + produced.extend( + await asyncio.gather( + *[_agent_speak(room, k, trigger=user_text, hermes_user=hermes_user) for k in keys] + ) + ) + + # A2A follow-up rounds from @mentions in agent replies + for _round in range(MAX_ROUNDS - 1): + next_speakers: list[tuple[str, str]] = [] # (agent, replied_to) + seen = {m.get("agent_key") for m in produced if m.get("sender_type") == "agent"} + for m in produced[-MAX_REPLIES_PER_ROUND:]: + if m.get("sender_type") != "agent": + continue + from_key = m.get("agent_key") or "" + for target in parse_mentions(m.get("content") or ""): + if target == from_key: + continue + if target in seen and (target, from_key) in [(a, b) for a, b in next_speakers]: + continue + next_speakers.append((target, from_key)) + # unique preserve order + 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)) + uniq = uniq[:MAX_REPLIES_PER_ROUND] + if not uniq: + break + for target, src in uniq: + msg = await _agent_speak( + room, + target, + trigger=( + f"War Room topic van {username or 'user'}: {user_text}\n" + f"@{src} noemde je. Reageer kort en concreet." + ), + hermes_user=hermes_user, + in_reply_to=src, + ) + produced.append(msg) + + return produced + + + + +def post_system_line(content: str, *, agent_key: str | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any]: + """Proactive system/agent bubble into #war-room (no LLM).""" + room = ensure_warroom() + return _insert_message( + int(room["id"]), + sender_type="system" if not agent_key else "agent", + agent_key=agent_key, + content=content[:2000], + metadata=metadata or {"proactive": True}, + ) + + +def announce_handoff(from_agent: str, to_agent: str, handoff_type: str = "partner", *, correlation_id: str | None = None) -> None: + """Best-effort War Room line for scheduler/API handoffs.""" + try: + src = (from_agent or "").strip() + dst = (to_agent or "").strip() + if not src or not dst: + return + label = (handoff_type or "partner").strip() or "partner" + text = f"Handoff: {src} → {dst} ({label})" + post_system_line( + text, + agent_key=src, + metadata={ + "proactive": True, + "kind": "handoff", + "from_agent": src, + "to_agent": dst, + "handoff_type": label, + "correlation_id": correlation_id, + }, + ) + except Exception: + log.exception("warroom announce_handoff failed") + +def _normalize_attachments(attachments: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for a in attachments or []: + if not isinstance(a, dict): + continue + url = str(a.get("url") or "").strip() + if not url.startswith("/static/uploads/warroom/"): + continue + out.append({ + "url": url, + "name": str(a.get("name") or "bestand")[:180], + "mime": str(a.get("mime") or "application/octet-stream")[:120], + "size": int(a.get("size") or 0), + }) + if len(out) >= 5: + break + return out + + +def _trigger_text(text: str, attachments: list[dict[str, Any]]) -> str: + if not attachments: + return text + names = ", ".join(a.get("name") or "bestand" for a in attachments) + if text: + return f"{text}\n\n[Bijlage(n): {names}]" + return f"[Deelde bestand(en): {names}]" + + +async def post_user_message( + room_id: int, + content: str, + *, + username: str | None = None, + role: str | None = None, + sender_avatar_url: str | None = None, + attachments: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + text = (content or "").strip() + files = _normalize_attachments(attachments) + if not text and not files: + raise ValueError("Leeg bericht") + room = get_room(room_id) + if not room: + raise ValueError("Room niet gevonden") + + hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom") + meta: dict[str, Any] = {"hermes_user": hermes_user} + if files: + meta["attachments"] = files + + user_msg = _insert_message( + room_id, + sender_type="user", + content=text or ("📎 " + ", ".join(a["name"] for a in files)), + username=username, + sender_avatar_url=sender_avatar_url, + metadata=meta, + ) + + trigger = _trigger_text(text, files) + agent_msgs: list[dict[str, Any]] = [] + kind = room.get("kind") + try: + if kind == "dm": + key = room.get("agent_key") + if not key: + raise ValueError("DM zonder agent") + agent_msgs.append( + await _agent_speak(room, str(key), trigger=trigger, hermes_user=hermes_user) + ) + else: + agent_msgs = await _orchestrate_warroom( + room, trigger, hermes_user=hermes_user, username=username + ) + except Exception as exc: + log.exception("warroom post failed") + agent_msgs.append( + _insert_message( + room_id, + sender_type="system", + content=f"Hermes/mimo fout: {exc}", + metadata={"error": True}, + ) + ) + + return { + "ok": True, + "room": room, + "user_message": user_msg, + "messages": [user_msg, *agent_msgs], + "hermes_user": hermes_user, + } + + +async def _agent_speak_stream( + room: dict[str, Any], + agent_key: str, + *, + trigger: str, + hermes_user: str, + in_reply_to: str | None = None, +): + """Yield ('start'|'token'|'done'|'error', payload).""" + soul = agent_souls.get_soul(agent_key) + if not soul: + msg = _insert_message( + int(room["id"]), + sender_type="system", + content=f"Agent @{agent_key} niet gevonden.", + metadata={"error": True}, + ) + yield ("error", msg) + return + + hist = list_messages(int(room["id"]), limit=HISTORY_LIMIT) + messages: list[dict[str, str]] = [ + {"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")}, + ] + messages.extend(_history_as_chat(hist, limit=8)) + cue = trigger + if in_reply_to: + cue = f"(Reageer op @{in_reply_to}) {trigger}" + messages.append({"role": "user", "content": cue}) + + yield ("start", {"agent_key": agent_key, "display_name": soul.get("display_name") or agent_key}) + chunks: list[str] = [] + try: + async for delta in llm_router.stream_chat_messages( + messages, + timeout=WARROOM_TIMEOUT, + user=hermes_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)" + 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}, + "in_reply_to": in_reply_to, + "model": "hermes-agent/mimo", + }, + ) + yield ("done", msg) + except Exception as exc: + log.exception("agent stream failed %s", agent_key) + msg = _insert_message( + int(room["id"]), + sender_type="system", + content=f"@{agent_key}: Hermes/mimo fout — {exc}", + metadata={"error": True, "agent_key": agent_key}, + ) + yield ("error", msg) + + +async def post_user_message_stream( + room_id: int, + content: str, + *, + username: str | None = None, + role: str | None = None, + sender_avatar_url: str | None = None, + attachments: list[dict[str, Any]] | None = None, +): + """Async generator of SSE-ready event dicts for live typing.""" + text = (content or "").strip() + files = _normalize_attachments(attachments) + if not text and not files: + raise ValueError("Leeg bericht") + room = get_room(room_id) + if not room: + raise ValueError("Room niet gevonden") + + hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom") + meta: dict[str, Any] = {"hermes_user": hermes_user} + if files: + meta["attachments"] = files + user_msg = _insert_message( + room_id, + sender_type="user", + content=text or ("📎 " + ", ".join(a["name"] for a in files)), + username=username, + sender_avatar_url=sender_avatar_url, + metadata=meta, + ) + yield {"event": "user", "message": user_msg} + + trigger = _trigger_text(text, files) + kind = room.get("kind") + speakers: list[str] = [] + if kind == "dm": + key = room.get("agent_key") + if key: + speakers = [str(key)] + else: + speakers = parse_mentions(trigger) or ["herman"] + speakers = speakers[:MAX_REPLIES_PER_ROUND] + + for key in speakers: + async for kind_ev, payload in _agent_speak_stream( + room, key, trigger=trigger, hermes_user=hermes_user + ): + 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} + + # light A2A: one follow-up round max from last agent reply mentions + # (non-streamed for simplicity / speed) + yield {"event": "done", "ok": True} diff --git a/cockpit/app/services/agent_integration.py b/cockpit/app/services/agent_integration.py index 32f175d..2f8b274 100644 --- a/cockpit/app/services/agent_integration.py +++ b/cockpit/app/services/agent_integration.py @@ -63,6 +63,11 @@ def create_handoff( "handoff_type": handoff_type, } _log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid) + try: + from app.services import agent_chat + agent_chat.announce_handoff(src, dst, handoff_type, correlation_id=cid) + except Exception: + pass return handoff diff --git a/cockpit/app/services/herman.py b/cockpit/app/services/herman.py index 3b10082..35e8303 100644 --- a/cockpit/app/services/herman.py +++ b/cockpit/app/services/herman.py @@ -120,18 +120,35 @@ def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dic -async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]: - from app.services import llm_router +async def _chat_via_llm_router( + message: str, + channel: str, + *, + username: str | None = None, + role: str | None = None, +) -> dict[str, Any]: + from app.services import hermes_memory, llm_router - system = ( - "Je bent Herman, de AI-assistent van Foodlinkk B2B (halal vlees, export, CRM). " - "Antwoord in het Nederlands, bondig, actiegericht. Geef concrete vervolgstappen waar nuttig." - ) - messages = [ - {"role": "system", "content": system}, - {"role": "user", "content": message}, - ] - reply, meta = await llm_router.chat_messages(messages, timeout=120.0) + hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel=channel) + from app.config import settings as _settings + display = "Aïssa" if str(hermes_user) == str(_settings.HERMES_CEO_CHAT_ID) else (username or "gebruiker") + system = hermes_memory.continuity_system(hermes_user, display_name=display) + + messages: list[dict[str, str]] = [{"role": "system", "content": system}] + feed_items = await hermes_memory.fetch_recent_feed(hermes_user) + hist = hermes_memory.feed_to_messages(feed_items) + if hist: + messages.append({ + "role": "system", + "content": ( + f"Recente Telegram-geschiedenis voor chat_id={hermes_user} " + f"({len(hist)} berichten, oud→nieuw). Behandel dit als jouw geheugencontext." + ), + }) + messages.extend(hist) + messages.append({"role": "user", "content": message}) + + reply, meta = await llm_router.chat_messages(messages, timeout=180.0, user=hermes_user) provider = meta.get("provider_label") or "LLM" model = meta.get("model") or "" await _log_event( @@ -139,7 +156,13 @@ async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]: "llm_chat", f"Herman via {provider}", message[:2000], - {"provider": meta, "channel": channel}, + { + "provider": meta, + "channel": channel, + "hermes_user": hermes_user, + "memory_messages": len(hist), + "username": username, + }, channel=channel, ) return { @@ -147,9 +170,16 @@ async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]: "agent_label": f"Herman · {provider}", "reply": reply, "delegated_agents": ["herman"], - "routing_reason": f"Cloud LLM · {provider}" + (f" · {model}" if model else ""), - "agent_steps": _agent_steps([], f"Via {provider}"), + "routing_reason": ( + f"Cloud LLM · {provider}" + + (f" · {model}" if model else "") + + f" · memory:{hermes_user}" + + (f" · feed:{len(hist)}" if hist else "") + ), + "agent_steps": _agent_steps([], f"Via {provider} + Hermes memory"), "llm_provider": meta, + "hermes_user": hermes_user, + "memory_messages": len(hist), } @@ -158,6 +188,8 @@ async def chat( channel: str = "cockpit", session_id: str | None = None, confirm_action_id: str | None = None, + username: str | None = None, + role: str | None = None, ) -> dict[str, Any]: use_voice_router = ( channel in voice_export_actions.VOICE_CHANNELS @@ -382,7 +414,7 @@ async def chat( # Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator. if backend in ("hermes", "router", "openrouter", "llm"): try: - return await _chat_via_llm_router(message, channel) + return await _chat_via_llm_router(message, channel, username=username, role=role) except Exception as exc: hint = ( "Controleer HERMES_API_KEY / Hermes Agent (:8642)." @@ -427,7 +459,7 @@ async def chat( except Exception as exc: if backend == "auto": try: - return await _chat_via_llm_router(message, channel) + return await _chat_via_llm_router(message, channel, username=username, role=role) except Exception as llm_exc: return { "agent": "herman", diff --git a/cockpit/app/services/hermes_memory.py b/cockpit/app/services/hermes_memory.py new file mode 100644 index 0000000..3c4223b --- /dev/null +++ b/cockpit/app/services/hermes_memory.py @@ -0,0 +1,83 @@ +"""Koppel cockpit Herman-chat aan Hermes/Telegram memory.""" +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from app.config import settings + +log = logging.getLogger("cockpit.hermes_memory") + +_USERNAME_MAP = { + "aissa": "ceo", + "aïssa": "ceo", + "aissa zarioh": "ceo", + "mo": "cto", + "mohamed": "cto", +} + + +def resolve_hermes_user( + *, + username: str | None = None, + role: str | None = None, + channel: str | None = None, +) -> str: + """OpenAI-compatible `user` field = Telegram chat_id for Hermes memory.""" + uname = (username or "").strip().lower() + r = (role or "").strip().lower() + mapped = _USERNAME_MAP.get(uname) + if mapped == "ceo" or r in ("ceo", "admin") or uname in ("aissa", "aïssa"): + return str(settings.HERMES_CEO_CHAT_ID or "8859782446") + if mapped == "cto" or r == "cto" or uname in ("mo", "mohamed"): + return str(settings.HERMES_CTO_CHAT_ID or "789036463") + return str(settings.HERMES_CEO_CHAT_ID or "8859782446") + + +async def fetch_recent_feed(chat_id: str | int, limit: int | None = None) -> list[dict[str, Any]]: + limit = limit or int(getattr(settings, "HERMES_MEMORY_LIMIT", 24) or 24) + base = (settings.HERMES_FEED_URL or "").rstrip("/") + if not base: + return [] + try: + async with httpx.AsyncClient(timeout=12.0) as client: + r = await client.get(f"{base}/feed", params={"chat_id": int(chat_id), "limit": limit}) + if r.status_code != 200: + return [] + items = (r.json() or {}).get("items") or [] + return list(reversed(items)) + except Exception as exc: + log.warning("hermes feed fetch failed: %s", exc) + return [] + + +def feed_to_messages(items: list[dict[str, Any]], *, max_chars: int = 12000) -> list[dict[str, str]]: + out: list[dict[str, str]] = [] + total = 0 + for it in items: + text = (it.get("content_text") or "").strip() + if not text: + continue + role_raw = (it.get("role") or "").lower() + role = "assistant" if role_raw in ("assistant", "hermes", "herman") else "user" + if role == "assistant" and (it.get("direction") or "") == "in": + role = "user" + entry = {"role": role, "content": text[:2000]} + total += len(entry["content"]) + if total > max_chars: + break + out.append(entry) + return out + + +def continuity_system(chat_id: str, display_name: str | None = None) -> str: + name = display_name or ("Aïssa" if str(chat_id) == str(settings.HERMES_CEO_CHAT_ID) else "de gebruiker") + return ( + "Je bent Herman / Hermes, dezelfde AI Co-CEO als in Telegram. " + f"Deze cockpit-chat is gekoppeld aan Telegram-sessie chat_id={chat_id} ({name}). " + "Gebruik je bestaande Hermes-memory en recente Telegram-geschiedenis. " + "Doe alsof dit dezelfde doorlopende conversatie is — geen nieuw persoon, geen amnesie. " + "Antwoord in het Nederlands, bondig en actiegericht." + ) diff --git a/cockpit/app/services/llm_router.py b/cockpit/app/services/llm_router.py index 19b6daf..0a25c03 100644 --- a/cockpit/app/services/llm_router.py +++ b/cockpit/app/services/llm_router.py @@ -255,6 +255,9 @@ async def chat_messages( provider_id: int | None = None, model: str | None = None, timeout: float = 120.0, + user: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, ) -> tuple[str, dict[str, Any]]: """Returns (reply_text, meta dict with provider info).""" prov = resolve_provider(provider_id) @@ -309,9 +312,14 @@ async def chat_messages( payload: dict[str, Any] = { "model": use_model, "messages": messages, - "temperature": float(extra.get("temperature", 0.4)), - "max_tokens": int(extra.get("max_tokens", 2048)), + "temperature": float(temperature if temperature is not None else extra.get("temperature", 0.4)), + "max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 2048)), } + # Hermes: `user` = Telegram chat_id → koppelt API-chat aan bestaande memory/sessie + if user: + payload["user"] = str(user) + elif ptype == "hermes": + payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446") async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post(url, headers=headers, json=payload) @@ -332,9 +340,99 @@ async def chat_messages( "provider_type": ptype, "provider_label": prov.get("label"), "model": use_model, + "hermes_user": payload.get("user"), } + +async def stream_chat_messages( + messages: list[dict[str, str]], + *, + provider_id: int | None = None, + model: str | None = None, + timeout: float = 90.0, + user: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, +): + """Yield text deltas from Hermes/OpenAI-compatible streaming chat.""" + prov = resolve_provider(provider_id) + ptype = (prov.get("provider_type") or "ollama").lower() + use_model = model or prov.get("model") or settings.OLLAMA_MODEL + if ptype != "hermes": + # non-stream fallback: one chunk + reply, _meta = await chat_messages( + messages, + provider_id=provider_id, + model=model, + timeout=timeout, + user=user, + max_tokens=max_tokens, + temperature=temperature, + ) + if reply: + yield reply + return + + api_key = (prov.get("api_key") or "").strip() + if not api_key: + raise RuntimeError("Geen Hermes API key") + base = (prov.get("api_base_url") or "").strip().rstrip("/") + if not base: + raise RuntimeError("Geen Hermes API URL") + extra = prov.get("extra_config") or {} + if isinstance(extra, str): + try: + extra = json.loads(extra) + except Exception: + extra = {} + url = f"{base}/chat/completions" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "text/event-stream", + } + payload: dict[str, Any] = { + "model": use_model, + "messages": messages, + "temperature": float(temperature if temperature is not None else extra.get("temperature", 0.35)), + "max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 512)), + "stream": True, + } + if user: + payload["user"] = str(user) + else: + payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446") + + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream("POST", url, headers=headers, json=payload) as resp: + if resp.status_code >= 400: + body = (await resp.aread()).decode("utf-8", errors="replace")[:500] + raise RuntimeError(f"Hermes stream fout ({resp.status_code}): {body}") + async for line in resp.aiter_lines(): + if not line: + continue + if line.startswith(":"): + continue + if not line.startswith("data:"): + continue + data = line[5:].strip() + if not data or data == "[DONE]": + if data == "[DONE]": + break + continue + try: + obj = json.loads(data) + except Exception: + continue + choices = obj.get("choices") or [] + if not choices: + continue + delta = (choices[0].get("delta") or {}).get("content") + if delta: + yield delta + + async def generate( prompt: str, system: str | None = None, diff --git a/cockpit/requirements.txt b/cockpit/requirements.txt index 075b369..4c322b6 100644 --- a/cockpit/requirements.txt +++ b/cockpit/requirements.txt @@ -1,11 +1,12 @@ -fastapi==0.115.6 -uvicorn[standard]==0.32.1 -jinja2==3.1.4 -python-multipart==0.0.12 -psycopg2-binary==2.9.9 -httpx==0.27.2 -websockets==13.1 beautifulsoup4==4.12.3 -textblob==0.18.0.post0 +fastapi==0.115.6 +httpx==0.27.2 +itsdangerous>=2.1.0 +jinja2==3.1.4 openpyxl>=3.1.0 +psycopg2-binary==2.9.9 +python-multipart==0.0.12 +textblob==0.18.0.post0 tweepy==4.15.0 +uvicorn[standard]==0.32.1 +websockets==13.1 diff --git a/cockpit/revenue-cockpit.js b/cockpit/revenue-cockpit.js index 6146c10..6f59e81 100644 --- a/cockpit/revenue-cockpit.js +++ b/cockpit/revenue-cockpit.js @@ -3,7 +3,9 @@ function revenueCockpit() { loading: false, saving: false, savedAt: '', + _pollStop: null, viewMode: 'cockpit', + visualMode: 'table', goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' }, projects: [], dbProjects: [], @@ -44,8 +46,20 @@ function revenueCockpit() { async init() { localStorage.setItem('foodlinkk-persona', 'ceo'); + const saved = localStorage.getItem('rc-visual-mode'); + if (saved === 'table' || saved === 'bars' || saved === 'cards') { + this.visualMode = saved; + } else if (window.matchMedia('(max-width: 768px)').matches) { + this.visualMode = 'cards'; + } await this.loadLive(); await this.loadDbQuiet(); + if (window.CockpitLive) { + this._pollStop = CockpitLive.startPolling(async () => { + await this.loadLive(); + await this.loadDbQuiet(); + }, 45000); + } }, projectKey(p) { @@ -119,6 +133,53 @@ function revenueCockpit() { return t.length <= n ? t : t.slice(0, n).trim() + '…'; }, + + setVisualMode(mode) { + if (mode !== 'table' && mode !== 'bars' && mode !== 'cards') return; + this.visualMode = mode; + localStorage.setItem('rc-visual-mode', mode); + }, + + styleLabel(style) { + const map = { green: 'Live', red: 'Inactief', orange: 'Oranje', yellow: 'Strategisch', blue: 'Blauw', white: 'Neutraal' }; + return map[style] || 'Neutraal'; + }, + + rankedRows() { + const rows = this.visualRows().slice(); + rows.sort((a, b) => (Number(b.margin_month) || 0) - (Number(a.margin_month) || 0)); + return rows; + }, + + cardRows() { + return this.rankedRows().filter((r) => r.margin_month != null || (r.next_steps || '').trim()); + }, + + async setCockpitStyle(style) { + const p = this.selectedProject; + if (!p) return; + if (!p.db_id) { + p.row_style = style; + if (window.Cockpit) Cockpit.toast('Lokaal gezet — sync DB om te bewaren', 'info'); + return; + } + try { + await fetch('/api/revenue-cockpit/projects/' + p.db_id, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ row_style: style }), + }); + p.row_style = style; + const pi = this.projects.findIndex((x) => this.projectKey(x) === this.projectKey(p)); + if (pi >= 0) this.projects[pi].row_style = style; + const di = this.dbProjects.findIndex((x) => x.id === p.db_id); + if (di >= 0) this.dbProjects[di].row_style = style; + if (window.Cockpit) Cockpit.toast('Status → ' + this.styleLabel(style), 'success'); + } catch (e) { + if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error'); + } + }, + styleColor(style) { const map = { green: '#22c55e', red: '#ef4444', orange: '#f59e0b', @@ -167,7 +228,7 @@ function revenueCockpit() { donutStyle(p) { const pct = this.sharePct(p); const color = this.styleColor(p.row_style); - return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, rgba(148,163,184,0.15) ' + pct + '% 100%)'; + return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)'; }, toggleExpand(p) { diff --git a/cockpit/static/css/agents-warroom.css b/cockpit/static/css/agents-warroom.css new file mode 100644 index 0000000..426c704 --- /dev/null +++ b/cockpit/static/css/agents-warroom.css @@ -0,0 +1,175 @@ +/* Foodlinkk War Room */ +.wr-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 0.75rem; + min-height: 62vh; +} +.wr-roster { + border-right: 1px solid rgba(255,255,255,0.08); + padding-right: 0.5rem; + max-height: 70vh; + overflow: auto; +} +.wr-roster h3 { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.7; + margin: 0 0 0.4rem; +} +.wr-room-btn, .wr-agent-btn { + width: 100%; + display: flex; + align-items: center; + gap: 0.55rem; + text-align: left; + background: transparent; + border: 1px solid transparent; + color: inherit; + padding: 0.4rem 0.45rem; + border-radius: 8px; + cursor: pointer; +} +.wr-room-btn:hover, .wr-agent-btn:hover, +.wr-room-btn.active, .wr-agent-btn.active { + background: rgba(255, 215, 0, 0.08); + 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-main { display: flex; flex-direction: column; min-width: 0; min-height: 62vh; } +.wr-head { + display: flex; justify-content: space-between; align-items: flex-start; + gap: 0.75rem; margin-bottom: 0.5rem; +} +.wr-head h2 { margin: 0; font-size: 1.05rem; letter-spacing: 0.04em; } +.wr-head-actions { display: flex; gap: 0.35rem; } + +.wr-log { + flex: 1; + 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; +} +.wr-empty { opacity: 0.6; margin: 0.5rem 0; font-size: 0.9rem; } + +.wr-bubble { + display: flex; gap: 0.55rem; margin-bottom: 0.7rem; + contain: layout style; +} +.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 img, .wr-avatar .agent-char-wrap { + width: 36px; height: 36px; border-radius: 50%; object-fit: cover; +} +.wr-avatar .agent-char-wrap svg { display: block; width: 36px; height: 36px; } +.wr-avatar-fallback { + display: grid; place-items: center; width: 36px; height: 36px; + border-radius: 50%; background: #1e3a5f; font-size: 0.7rem; font-weight: 700; +} +.wr-bubble-body { + max-width: min(720px, 82%); + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; + padding: 0.45rem 0.65rem; +} +.wr-bubble.is-user .wr-bubble-body { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.28); +} +.wr-bubble.is-agent .wr-bubble-body { + 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-text { + white-space: pre-wrap; + word-break: break-word; + font-size: 0.92rem; + line-height: 1.35; +} +.wr-typing-cursor::after { + 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-card { + display: block; max-width: 220px; 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 span { + display: block; padding: 0.3rem 0.45rem; font-size: 0.72rem; opacity: 0.85; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.wr-composer { + margin-top: 0.55rem; + display: grid; + grid-template-columns: 1fr auto; + gap: 0.45rem; + align-items: end; +} +.wr-composer-tools { + grid-column: 1 / -1; + display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; +} +.wr-pending { + display: flex; flex-wrap: wrap; gap: 0.35rem; 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; + 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; + 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; +} +.wr-emoji-pop button:hover { transform: scale(1.15); } + +.wr-page-header { margin-bottom: 0.75rem; } +.wr-page-header h1 { font-size: 1.35rem; } + +.wr-popup-body { + margin: 0; min-height: 100vh; + 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; } + +@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); } +} diff --git a/cockpit/static/css/app-sidebar-neo.css b/cockpit/static/css/app-sidebar-neo.css index 59ffb89..58e4416 100644 --- a/cockpit/static/css/app-sidebar-neo.css +++ b/cockpit/static/css/app-sidebar-neo.css @@ -230,5 +230,11 @@ body.sidebar-resizing iframe { } @media (max-width: 768px) { - .app-sidebar-resizer { display: none; } + .app-sidebar-resizer { display: none !important; } + .app-main { margin-left: 0 !important; } + .app-sidebar { + z-index: 1000; + overflow-y: auto; + overflow-x: hidden; + } } diff --git a/cockpit/static/css/login.css b/cockpit/static/css/login.css new file mode 100644 index 0000000..ea37d6d --- /dev/null +++ b/cockpit/static/css/login.css @@ -0,0 +1,99 @@ +:root { + --bg0: #06090e; + --bg1: #0c121b; + --card: #111821; + --line: rgba(148, 163, 184, 0.18); + --text: #f1f5f9; + --muted: #94a3b8; + --gold: #fbbf24; + --green: #22c55e; + --danger: #f87171; +} +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +.login-body { + min-height: 100%; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + color: var(--text); + background: + radial-gradient(900px 420px at 15% -10%, rgba(251, 191, 36, 0.14), transparent 55%), + radial-gradient(700px 380px at 100% 10%, rgba(34, 197, 94, 0.1), transparent 50%), + linear-gradient(160deg, var(--bg0), var(--bg1) 55%, #0a1018); +} +.login-shell { + min-height: 100%; + display: grid; + place-items: center; + padding: max(1.25rem, env(safe-area-inset-top)) 1rem max(1.25rem, env(safe-area-inset-bottom)); +} +.login-card { + width: min(420px, 100%); + background: rgba(17, 24, 33, 0.92); + border: 1px solid var(--line); + border-radius: 18px; + padding: 1.75rem 1.5rem 1.35rem; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45); +} +.login-logo { + display: block; + width: min(280px, 100%); + height: auto; + margin: 0 auto 0.75rem; +} +.login-sub { + margin: 0 0 1.25rem; + text-align: center; + color: var(--muted); + font-size: 0.92rem; +} +.login-error { + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.35); + color: #fecaca; + border-radius: 10px; + padding: 0.65rem 0.75rem; + margin-bottom: 1rem; + font-size: 0.88rem; +} +.login-form { display: grid; gap: 0.85rem; } +.login-label { + display: grid; + gap: 0.35rem; + font-size: 0.8rem; + color: var(--muted); + font-weight: 600; + letter-spacing: 0.02em; +} +.login-input { + width: 100%; + min-height: 46px; + border-radius: 10px; + border: 1px solid var(--line); + background: #0a0f16; + color: var(--text); + padding: 0.65rem 0.8rem; + font-size: 16px; +} +.login-input:focus { + outline: none; + border-color: rgba(251, 191, 36, 0.55); + box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.12); +} +.login-btn { + margin-top: 0.35rem; + min-height: 48px; + border: 0; + border-radius: 10px; + background: linear-gradient(135deg, #fbbf24, #f59e0b); + color: #111827; + font-weight: 700; + font-size: 0.95rem; + cursor: pointer; +} +.login-btn:hover { filter: brightness(1.05); } +.login-foot { + margin: 1.1rem 0 0; + text-align: center; + color: #64748b; + font-size: 0.75rem; +} diff --git a/cockpit/static/css/mobile.css b/cockpit/static/css/mobile.css index 62dc3ef..1768ffb 100644 --- a/cockpit/static/css/mobile.css +++ b/cockpit/static/css/mobile.css @@ -1,5 +1,6 @@ /** * Foodlinkk Command Center — mobile (Android + iOS) + * Must load LAST so it wins over app-sidebar-neo / herman-dashboard. */ :root { --safe-top: env(safe-area-inset-top, 0px); @@ -32,8 +33,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); } position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); - z-index: 998; - backdrop-filter: blur(2px); + z-index: 999; + backdrop-filter: none; + -webkit-backdrop-filter: none; } @media (max-width: 1024px) { @@ -87,43 +89,59 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); } .regs-columns[style*="repeat(3"] { grid-template-columns: 1fr !important; } .regs-columns { flex-direction: column !important; display: flex !important; } .pwa-install-bar { display: flex; } + + /* Scherper op tablet/mobiel: minder soft blur */ + .hm-panel, .hm-chart-panel, .live-digest, .briefing-card, + .ha-panel, .panel { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } } @media (max-width: 768px) { - .mobile-menu-btn { display: inline-flex; } + .mobile-menu-btn { display: inline-flex !important; } .mobile-nav-overlay { display: block; } - body:not(.nav-open) .mobile-nav-overlay { display: none; } + body:not(.nav-open) .mobile-nav-overlay { display: none !important; } + .app-shell { grid-template-columns: 1fr !important; display: block !important; } .app-main { margin-left: 0 !important; + width: 100% !important; + max-width: 100vw !important; } + + /* Hard overrides — must beat app-sidebar-neo.css */ .app-sidebar { position: fixed !important; - top: 0; left: 0; bottom: 0; - width: min(280px, 88vw); + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + width: min(280px, 88vw) !important; height: 100dvh !important; - z-index: 999; - transform: translateX(-105%); - transition: transform 0.3s ease; + z-index: 1000 !important; + transform: translateX(-105%) !important; + transition: transform 0.3s ease !important; box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4); - padding-top: max(1rem, var(--safe-top)); + padding-top: max(1rem, var(--safe-top)) !important; flex-direction: column !important; flex-wrap: nowrap !important; - overflow-y: auto; - overflow-x: hidden; + overflow-y: auto !important; + overflow-x: hidden !important; -webkit-overflow-scrolling: touch; } + .app-sidebar-resizer { display: none !important; } .persona-nav { flex: none; min-height: auto; overflow: visible; padding-bottom: 0; } - body.nav-open .app-sidebar { transform: translateX(0); } + body.nav-open .app-sidebar { transform: translateX(0) !important; } body.nav-open { overflow: hidden; } + .app-topbar { position: sticky; top: 0; z-index: 100; padding-top: max(0.5rem, var(--safe-top)); @@ -133,16 +151,43 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); } .retail-actions .btn, .herman-briefing-actions .btn { min-width: 100%; flex: 1 1 100%; } + + .herman-briefing-actions { + flex-direction: column; + } + .herman-briefing-actions .btn { + width: 100%; + } +} + +@media (max-width: 480px) { + .kpi-row, .hm-neo-kpi-row, .hub-kpi-row, .analytics-kpis { + grid-template-columns: 1fr !important; + } + .page-header h1 { font-size: 1.2rem !important; } } .pwa-install-bar { display: none; - position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; + position: fixed; bottom: 0; left: 0; right: 0; z-index: 10100; padding: 0.75rem 1rem; padding-bottom: max(0.75rem, var(--safe-bottom)); - background: rgba(8, 12, 18, 0.95); + background: rgba(8, 12, 18, 0.97); border-top: 1px solid rgba(148, 163, 184, 0.15); align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; } .pwa-install-bar p { margin: 0; font-size: 0.85rem; color: #cbd5e1; flex: 1; } -@media (display-mode: standalone) { .pwa-install-bar { display: none !important; } } +.pwa-install-hint { + display: none; + width: 100%; + margin: 0; + font-size: 0.78rem; + color: #94a3b8; + line-height: 1.35; +} +.pwa-install-bar.is-ios .pwa-install-hint.is-ios-hint { display: block; } +.pwa-install-bar.is-android .pwa-install-hint.is-android-hint { display: block; } +.pwa-install-bar:not(.is-ios):not(.is-android) .pwa-install-hint.is-android-hint { display: block; } +@media (display-mode: standalone), (display-mode: fullscreen) { + .pwa-install-bar { display: none !important; } +} diff --git a/cockpit/static/css/revenue-cockpit.css b/cockpit/static/css/revenue-cockpit.css index 096c4d6..0f8d5d3 100644 --- a/cockpit/static/css/revenue-cockpit.css +++ b/cockpit/static/css/revenue-cockpit.css @@ -956,3 +956,47 @@ .rc-visual-toggle { order: 5; flex: 1 1 100%; } .rc-style-chips { order: 6; flex: 1 1 100%; } } + +/* === MOBILE V2 === */ +@media (max-width: 768px) { + .rc-sheet-wrap, + .rc-visual-wrap { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; + max-width: 100%; + } + .rc-sheet { + min-width: 720px !important; /* scroll inside wrap, don't blow up page */ + } + .rc-visual-table { + min-width: 560px !important; + } + .rc-page { + overflow-x: hidden; + } + .rc-header { + flex-direction: column; + align-items: stretch !important; + } + .rc-header-actions { + width: 100%; + } + .rc-header-actions .rc-btn, + .rc-header-actions .rc-dot-btn, + .rc-view-toggle { + min-height: 44px; + } + .rc-summary-strip { + flex-direction: column; + align-items: stretch; + } + .rc-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + .rc-filter-chips button, + .rc-toggle-btn { + min-height: 40px; + } +} diff --git a/cockpit/static/css/user-avatar.css b/cockpit/static/css/user-avatar.css new file mode 100644 index 0000000..8c8b292 --- /dev/null +++ b/cockpit/static/css/user-avatar.css @@ -0,0 +1,147 @@ + +/* Topbar identity chip */ +.app-user-chip { + display: inline-flex; + align-items: center; + gap: 0.55rem; + margin-left: 0.65rem; + padding: 0.2rem 0.35rem 0.2rem 0.2rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.75); + border: 1px solid rgba(148, 163, 184, 0.18); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.app-user-chip input[type="file"], +.user-avatar-input { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + border: 0 !important; + opacity: 0 !important; + pointer-events: none !important; +} + +.user-avatar-btn { + appearance: none; + border: 0; + padding: 0; + margin: 0; + background: transparent; + cursor: pointer; + border-radius: 50%; + line-height: 0; + flex-shrink: 0; +} + +.user-avatar-btn:focus-visible { + outline: 2px solid rgba(251, 191, 36, 0.65); + outline-offset: 2px; +} + +.user-avatar-img, +.user-avatar-fallback { + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(251, 191, 36, 0.55); + background: linear-gradient(145deg, #1e293b, #0f172a); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); +} + +.user-avatar-img { + display: block; +} + +.user-avatar-fallback { + display: none; /* Alpine x-show zet dit aan indien geen foto */ + align-items: center; + justify-content: center; + color: #fbbf24; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* Als Alpine display:flex/inline-flex zet via x-show */ +.user-avatar-fallback[style*="flex"] { + display: inline-flex !important; +} + +.user-avatar-meta { + display: flex; + flex-direction: column; + min-width: 0; + padding-right: 0.15rem; + line-height: 1.15; +} + +.user-avatar-name { + font-size: 0.8rem; + font-weight: 600; + color: #e2e8f0; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-avatar-role { + font-size: 0.65rem; + color: #94a3b8; + text-transform: capitalize; +} + +.user-logout-btn { + min-height: 30px !important; + padding: 0.2rem 0.65rem !important; + font-size: 0.7rem !important; + border-radius: 999px !important; + margin-right: 0.15rem; +} + +@media (max-width: 640px) { + .user-avatar-meta { display: none; } + .app-user-chip { + padding: 0.15rem; + gap: 0.35rem; + } +} + +/* Settings list */ +.user-card-avatar, +.user-form-avatar-preview { + width: 48px; + height: 48px; + border-radius: 50% !important; + object-fit: cover; + border: 2px solid rgba(251, 191, 36, 0.4); + background: #1e293b; + flex-shrink: 0; +} +.user-card-avatar.fallback, +.user-form-avatar-preview.fallback { + display: inline-flex; + align-items: center; + justify-content: center; + color: #fbbf24; + font-weight: 700; +} +.user-form-avatar-preview { + width: 72px; + height: 72px; +} +.user-form-avatar-row { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 0.85rem; +} diff --git a/cockpit/static/icons/apple-touch-icon.png b/cockpit/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..e56f6a6 Binary files /dev/null and b/cockpit/static/icons/apple-touch-icon.png differ diff --git a/cockpit/static/icons/icon-192.png b/cockpit/static/icons/icon-192.png new file mode 100644 index 0000000..e51146b Binary files /dev/null and b/cockpit/static/icons/icon-192.png differ diff --git a/cockpit/static/icons/icon-512-maskable.png b/cockpit/static/icons/icon-512-maskable.png new file mode 100644 index 0000000..b3671f0 Binary files /dev/null and b/cockpit/static/icons/icon-512-maskable.png differ diff --git a/cockpit/static/icons/icon-512.png b/cockpit/static/icons/icon-512.png new file mode 100644 index 0000000..1152823 Binary files /dev/null and b/cockpit/static/icons/icon-512.png differ diff --git a/cockpit/static/img/foodlinkk-logo.svg b/cockpit/static/img/foodlinkk-logo.svg new file mode 100644 index 0000000..2bd8039 --- /dev/null +++ b/cockpit/static/img/foodlinkk-logo.svg @@ -0,0 +1,13 @@ + diff --git a/cockpit/static/js/agent-avatars.js b/cockpit/static/js/agent-avatars.js index e89b5bc..8c50ba7 100644 --- a/cockpit/static/js/agent-avatars.js +++ b/cockpit/static/js/agent-avatars.js @@ -94,15 +94,20 @@ window.AgentAvatars = (function () { ); } - function paint(el, key, color) { + function paint(el, key, color, force) { if (!el) return; - el.innerHTML = svgAvatar((key || '').toLowerCase(), color); + var k = (key || '').toLowerCase(); + var c = color || ''; + var sig = k + '|' + c; + if (!force && el.getAttribute('data-avatar-painted') === sig && el.querySelector('svg')) return; + el.innerHTML = svgAvatar(k, color); + el.setAttribute('data-avatar-painted', sig); } - function paintAll(root) { + function paintAll(root, force) { root = root || document; root.querySelectorAll('[data-agent-avatar]').forEach(function (el) { - paint(el, el.getAttribute('data-agent-avatar'), el.getAttribute('data-agent-color')); + paint(el, el.getAttribute('data-agent-avatar'), el.getAttribute('data-agent-color'), force); }); } diff --git a/cockpit/static/js/agents-warroom.js b/cockpit/static/js/agents-warroom.js new file mode 100644 index 0000000..24db270 --- /dev/null +++ b/cockpit/static/js/agents-warroom.js @@ -0,0 +1,426 @@ +/** Foodlinkk War Room — tab + popup (stream, emoji, files) */ +window.WarRoomHub = (function () { + var EMOJIS = ['😀','😂','🙂','😉','😍','🤝','👍','👎','🙏','🔥','✅','❌','⚠️','💡','📎','📦','🚀','💬','🧠','☕','💪','🎯','📊','🧾','🇳🇱','🌍']; + + function openPopup(roomQuery) { + var url = '/agents/warroom/popup' + (roomQuery || ''); + var w = window.open(url, 'flk-warroom', 'width=980,height=780,menubar=no,toolbar=no,location=no,status=no'); + if (w) try { w.focus(); } catch (e) {} + return w; + } + + function warRoomController(opts) { + opts = opts || {}; + return { + popupMode: !!opts.popupMode, + souls: [], + rooms: [], + room: null, + messages: [], + draft: '', + busy: false, + me: { username: '', avatar_url: '', role: '' }, + lastId: 0, + _poll: null, + _polling: false, + status: '', + emojiOpen: false, + emojis: EMOJIS, + pendingFiles: [], + typing: null, // { agent_key, display_name, text } + + async init() { + await Promise.all([this.loadMe(), this.loadSouls(), this.loadRooms()]); + var params = new URLSearchParams(window.location.search || ''); + var want = params.get('room'); + if (want) await this.openRoom(parseInt(want, 10)); + else { + var wr = (this.rooms || []).find(function (r) { return r.kind === 'warroom'; }); + if (wr) await this.openRoom(wr.id); + else if (this.rooms.length) await this.openRoom(this.rooms[0].id); + } + this.startPoll(); + }, + + destroy() { + if (this._poll) clearInterval(this._poll); + }, + + async loadMe() { + try { + var r = await fetch('/api/auth/me').then(function (x) { return x.json(); }); + var u = r.user || r || {}; + this.me = { + username: u.username || '', + avatar_url: u.avatar_url || '', + role: u.role || '', + }; + } catch (e) { + this.me = { username: '', avatar_url: '', role: '' }; + } + }, + + async loadSouls() { + try { + var r = await fetch('/api/agents/souls').then(function (x) { return x.json(); }); + this.souls = (r.items || r.souls || r || []).filter(function (s) { return s.is_active !== false; }); + } catch (e) { + this.souls = []; + } + this.$nextTick(function () { this.paintAvatars(); }.bind(this)); + }, + + async loadRooms() { + try { + var r = await fetch('/api/agents/warroom/rooms').then(function (x) { return x.json(); }); + this.rooms = r.items || []; + } catch (e) { + this.rooms = []; + } + }, + + warroomRoom() { + return (this.rooms || []).find(function (r) { return r.kind === 'warroom'; }) || null; + }, + + async openWarroom() { + var wr = this.warroomRoom(); + if (wr) return this.openRoom(wr.id); + await this.loadRooms(); + wr = this.warroomRoom(); + if (wr) return this.openRoom(wr.id); + }, + + async openDm(agentKey) { + if (this.busy) return; + this.busy = true; + try { + var r = await Cockpit.api('/api/agents/warroom/rooms/dm/' + encodeURIComponent(agentKey), { + method: 'POST', + body: '{}', + }); + await this.loadRooms(); + if (r.room) await this.openRoom(r.room.id); + } catch (e) { + if (window.Cockpit) Cockpit.toast(e.message || 'DM openen mislukt', 'error'); + } + this.busy = false; + }, + + async openRoom(id) { + if (!id) return; + this.busy = true; + this.status = ''; + this.typing = null; + try { + var r = await fetch('/api/agents/warroom/rooms/' + id + '/messages?limit=120').then(function (x) { + return x.json(); + }); + this.room = r.room; + this.messages = this.normalizeList(r.items || []); + this.lastId = this.maxId(this.messages); + this.$nextTick(function () { + this.paintAvatars(); + this.scrollLog(); + }.bind(this)); + } catch (e) { + this.status = e.message || 'Laden mislukt'; + } + this.busy = false; + }, + + normalizeList(items) { + return (items || []).map(function (m) { + if (m && typeof m.metadata === 'string') { + try { m.metadata = JSON.parse(m.metadata); } catch (e) { m.metadata = {}; } + } + return m; + }); + }, + + maxId(items) { + var n = 0; + (items || []).forEach(function (m) { + var id = Number(m && m.id); + if (!isNaN(id) && id > n) n = id; + }); + return n; + }, + + paintAvatars() { + if (!window.AgentAvatars) return; + var root = this.$root || document; + root.querySelectorAll('.wr-roster [data-agent-avatar], .wr-log [data-agent-avatar]').forEach(function (el) { + AgentAvatars.paint(el, el.getAttribute('data-agent-avatar'), el.getAttribute('data-agent-color')); + }); + }, + + upsertMessage(msg) { + if (!msg || msg.id == null) return false; + var list = this.messages || []; + var idx = list.findIndex(function (m) { return String(m.id) === String(msg.id); }); + if (idx >= 0) { + list[idx] = msg; + this.messages = list.slice(); + } else { + this.messages = list.concat([msg]); + } + var idn = Number(msg.id); + if (!isNaN(idn) && idn > (this.lastId || 0)) this.lastId = idn; + return true; + }, + + mergeMessages(items) { + var changed = false; + var self = this; + this.normalizeList(items || []).forEach(function (m) { + if (self.upsertMessage(m)) changed = true; + }); + return changed; + }, + + scrollLog() { + var el = this.$refs && this.$refs.wrLog; + if (el) el.scrollTop = el.scrollHeight; + }, + + startPoll() { + var self = this; + if (this._poll) clearInterval(this._poll); + this._poll = setInterval(function () { self.pollNew(); }, 4000); + }, + + async pollNew() { + // Cruciaal: niet pollen tijdens send/stream → voorkomt dubbele user-bubbles + if (!this.room || this._polling || this.busy) return; + this._polling = true; + try { + var url = + '/api/agents/warroom/rooms/' + + this.room.id + + '/messages?limit=50&after_id=' + + (this.lastId || 0); + var r = await fetch(url).then(function (x) { return x.json(); }); + if (this.mergeMessages(r.items || [])) { + this.$nextTick(function () { + this.paintAvatars(); + this.scrollLog(); + }.bind(this)); + } + } catch (e) { /* quiet */ } + this._polling = false; + }, + + attachmentsOf(m) { + var meta = (m && m.metadata) || {}; + return meta.attachments || []; + }, + + isImage(att) { + var mime = (att && att.mime) || ''; + var name = ((att && att.name) || '').toLowerCase(); + return mime.indexOf('image/') === 0 || /\.(png|jpe?g|gif|webp)$/.test(name); + }, + + insertEmoji(em) { + this.draft = (this.draft || '') + em; + this.emojiOpen = false; + }, + + insertMention(key) { + this.draft = (this.draft || '') + '@' + key + ' '; + }, + + async onPickFiles(ev) { + var files = Array.prototype.slice.call((ev.target && ev.target.files) || []); + if (ev.target) ev.target.value = ''; + for (var i = 0; i < files.length; i++) { + await this.uploadFile(files[i]); + } + }, + + async uploadFile(file) { + if (!file) return; + this.status = 'Uploaden…'; + try { + var fd = new FormData(); + fd.append('file', file); + var resp = await fetch('/api/agents/warroom/upload', { method: 'POST', body: fd, credentials: 'same-origin' }); + var data = await resp.json().catch(function () { return {}; }); + if (!resp.ok) throw new Error(data.detail || data.message || 'Upload mislukt'); + this.pendingFiles = (this.pendingFiles || []).concat([{ + url: data.url, + name: data.name || file.name, + mime: data.mime || file.type, + size: data.size || file.size || 0, + }]); + this.status = ''; + } catch (e) { + this.status = e.message || 'Upload mislukt'; + if (window.Cockpit) Cockpit.toast(this.status, 'error'); + } + }, + + removePending(idx) { + this.pendingFiles = (this.pendingFiles || []).filter(function (_, i) { return i !== idx; }); + }, + + canSend() { + return !this.busy && this.room && (((this.draft || '').trim()) || (this.pendingFiles || []).length); + }, + + async send() { + var text = (this.draft || '').trim(); + var files = (this.pendingFiles || []).slice(); + if (!this.room || this.busy) return; + if (!text && !files.length) return; + + this.busy = true; + this.emojiOpen = false; + this.draft = ''; + this.pendingFiles = []; + this.typing = null; + this.status = 'Verzenden…'; + + 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 }), + }); + 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 = ''; + } catch (e) { + this.draft = text; + this.pendingFiles = files; + this.status = e.message || 'Verzenden mislukt'; + if (window.Cockpit) Cockpit.toast(this.status, 'error'); + } + this.typing = null; + this.busy = false; + this.$nextTick(function () { + this.paintAvatars(); + this.scrollLog(); + }.bind(this)); + }, + + async consumeStream(resp) { + var reader = resp.body.getReader(); + var decoder = new TextDecoder(); + var buf = ''; + while (true) { + var chunk = await reader.read(); + if (chunk.done) break; + buf += decoder.decode(chunk.value, { stream: true }); + var parts = buf.split('\n\n'); + buf = parts.pop() || ''; + for (var i = 0; i < parts.length; i++) { + this.handleSseBlock(parts[i]); + } + } + if (buf.trim()) this.handleSseBlock(buf); + }, + + handleSseBlock(block) { + var lines = (block || '').split('\n'); + var dataLines = []; + for (var i = 0; i < lines.length; i++) { + if (lines[i].indexOf('data:') === 0) dataLines.push(lines[i].slice(5).trim()); + } + if (!dataLines.length) return; + var raw = dataLines.join('\n'); + var ev; + try { + ev = JSON.parse(raw); + } catch (e) { + return; + } + var type = ev.event || ''; + if (type === 'user' && ev.message) { + this.upsertMessage(ev.message); + this.status = 'Agent typt…'; + this.$nextTick(function () { this.scrollLog(); }.bind(this)); + } else if (type === 'typing_start') { + this.typing = { + agent_key: ev.agent_key, + display_name: ev.display_name || ev.agent_key, + text: '', + }; + this.status = (this.typing.display_name || 'Agent') + ' typt…'; + this.$nextTick(function () { this.scrollLog(); }.bind(this)); + } else if (type === 'typing') { + if (!this.typing || this.typing.agent_key !== ev.agent_key) { + this.typing = { + agent_key: ev.agent_key, + display_name: (this.soulFor(ev.agent_key) || {}).display_name || ev.agent_key, + text: '', + }; + } + this.typing.text = (this.typing.text || '') + (ev.delta || ''); + // force alpine refresh + this.typing = Object.assign({}, this.typing); + this.$nextTick(function () { this.scrollLog(); }.bind(this)); + } else if (type === 'agent' && ev.message) { + this.typing = null; + this.upsertMessage(ev.message); + this.$nextTick(function () { + this.paintAvatars(); + this.scrollLog(); + }.bind(this)); + } else if (type === 'system' && ev.message) { + this.typing = null; + this.upsertMessage(ev.message); + } else if (type === 'error') { + this.typing = null; + this.status = ev.detail || 'Streamfout'; + if (window.Cockpit) Cockpit.toast(this.status, 'error'); + } else if (type === 'done') { + this.typing = null; + } + }, + + onKey(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.send(); + } + }, + + popOut() { + var q = this.room ? '?room=' + this.room.id : ''; + openPopup(q); + }, + + soulFor(key) { + return (this.souls || []).find(function (s) { return s.agent_key === key; }) || null; + }, + + labelForMessage(m) { + if (m.sender_type === 'user') return m.username || this.me.username || 'Jij'; + if (m.sender_type === 'agent') { + var s = this.soulFor(m.agent_key); + return (s && s.display_name) || m.agent_key || 'Agent'; + } + return 'Systeem'; + }, + }; + } + + 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 }); + }); + } +}); diff --git a/cockpit/static/js/cockpit.js b/cockpit/static/js/cockpit.js index e7f597a..1ae7b04 100644 --- a/cockpit/static/js/cockpit.js +++ b/cockpit/static/js/cockpit.js @@ -30,6 +30,10 @@ window.Cockpit = (function () { const fetchOpts = Object.assign({}, options, { headers: headers }); if (options.signal) fetchOpts.signal = options.signal; const res = await fetch(url, fetchOpts); + if (res.status === 401 && !String(url).includes('/api/auth/login')) { + location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); + throw new Error('Niet ingelogd'); + } let data = null; try { data = await res.json(); } catch (e) { data = null; } if (!res.ok) { diff --git a/cockpit/static/js/herman-assistant.js b/cockpit/static/js/herman-assistant.js index 4eba916..65708a1 100644 --- a/cockpit/static/js/herman-assistant.js +++ b/cockpit/static/js/herman-assistant.js @@ -638,6 +638,13 @@ window.HermanAssistant = (function () { this.busy = false; }, + openWarRoomPopup: function () { + if (window.WarRoomHub && WarRoomHub.openPopup) { + WarRoomHub.openPopup(); + return; + } + window.open('/agents/warroom/popup', 'flk-warroom', 'width=980,height=780,menubar=no,toolbar=no,location=no,status=no'); + }, formatDelegated: function (meta) { var d = (meta && meta.delegated) || []; return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', '); diff --git a/cockpit/static/js/revenue-cockpit.js b/cockpit/static/js/revenue-cockpit.js index 485175b..6f59e81 100644 --- a/cockpit/static/js/revenue-cockpit.js +++ b/cockpit/static/js/revenue-cockpit.js @@ -47,7 +47,11 @@ function revenueCockpit() { async init() { localStorage.setItem('foodlinkk-persona', 'ceo'); const saved = localStorage.getItem('rc-visual-mode'); - if (saved === 'table' || saved === 'bars' || saved === 'cards') this.visualMode = saved; + if (saved === 'table' || saved === 'bars' || saved === 'cards') { + this.visualMode = saved; + } else if (window.matchMedia('(max-width: 768px)').matches) { + this.visualMode = 'cards'; + } await this.loadLive(); await this.loadDbQuiet(); if (window.CockpitLive) { diff --git a/cockpit/static/manifest.json b/cockpit/static/manifest.json index 91dc70d..fe184df 100644 --- a/cockpit/static/manifest.json +++ b/cockpit/static/manifest.json @@ -1,26 +1,57 @@ { - "name": "Cucina · Foodlinkk Command Center", + "id": "/", + "name": "Foodlinkk Command Center", "short_name": "Foodlinkk", - "description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail", - "start_url": "/", + "description": "CEO dashboard · Revenue Cockpit · CRM · Retail · Cucina & Foodlinkk", + "start_url": "/?source=pwa", "scope": "/", "display": "standalone", + "display_override": ["standalone", "fullscreen", "browser"], "orientation": "any", "background_color": "#0a0e14", "theme_color": "#0a0e14", "lang": "nl", + "dir": "ltr", + "categories": ["business", "productivity", "finance"], + "prefer_related_applications": false, "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, { "src": "/static/icons/app-icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" + } + ], + "shortcuts": [ + { + "name": "Overzicht", + "short_name": "Home", + "url": "/?source=pwa", + "icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }] }, { - "src": "/static/icons/app-icon.svg", - "sizes": "512x512", - "type": "image/svg+xml", - "purpose": "maskable" + "name": "Revenue Cockpit", + "short_name": "Revenue", + "url": "/revenue-cockpit?source=pwa", + "icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }] } ] } diff --git a/cockpit/static/sw.js b/cockpit/static/sw.js index a433563..73fa272 100644 --- a/cockpit/static/sw.js +++ b/cockpit/static/sw.js @@ -1,18 +1,69 @@ -/* PWA cache disabled — clears legacy caches and unregisters itself */ +/* Foodlinkk PWA service worker v6 */ +const CACHE = 'foodlinkk-shell-v6'; +const PRECACHE = [ + '/', + '/static/manifest.json', + '/static/css/mobile.css?v=6', + '/static/css/app-sidebar-neo.css?v=12', + '/static/css/palantir-theme.css?v=4', + '/static/icons/icon-192.png', + '/static/icons/icon-512.png', + '/static/icons/apple-touch-icon.png', +]; + self.addEventListener('install', (event) => { self.skipWaiting(); event.waitUntil( - caches.keys().then((keys) => Promise.all(keys.map((k) => caches.delete(k)))) + caches.open(CACHE).then((cache) => cache.addAll(PRECACHE).catch(() => undefined)) ); }); self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys() - .then((keys) => Promise.all(keys.map((k) => caches.delete(k)))) - .then(() => self.clients.claim()) + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) ); }); -/* Do not intercept fetches — always use network */ -self.addEventListener('fetch', () => {}); +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + // API / live data: always network + if (url.pathname.startsWith('/api') || url.pathname.includes('/ws')) return; + + // Navigations: network-first, fallback to cache + if (req.mode === 'navigate') { + event.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put('/', copy)).catch(() => undefined); + return res; + }) + .catch(() => caches.match('/') || caches.match(req)) + ); + return; + } + + // Static assets: stale-while-revalidate + if (url.pathname.startsWith('/static/')) { + event.respondWith( + caches.match(req).then((cached) => { + const net = fetch(req) + .then((res) => { + if (res && res.ok) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => undefined); + } + return res; + }) + .catch(() => cached); + return cached || net; + }) + ); + } +}); diff --git a/cockpit/static/sw.root.js b/cockpit/static/sw.root.js new file mode 100644 index 0000000..73fa272 --- /dev/null +++ b/cockpit/static/sw.root.js @@ -0,0 +1,69 @@ +/* Foodlinkk PWA service worker v6 */ +const CACHE = 'foodlinkk-shell-v6'; +const PRECACHE = [ + '/', + '/static/manifest.json', + '/static/css/mobile.css?v=6', + '/static/css/app-sidebar-neo.css?v=12', + '/static/css/palantir-theme.css?v=4', + '/static/icons/icon-192.png', + '/static/icons/icon-512.png', + '/static/icons/apple-touch-icon.png', +]; + +self.addEventListener('install', (event) => { + self.skipWaiting(); + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(PRECACHE).catch(() => undefined)) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + // API / live data: always network + if (url.pathname.startsWith('/api') || url.pathname.includes('/ws')) return; + + // Navigations: network-first, fallback to cache + if (req.mode === 'navigate') { + event.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put('/', copy)).catch(() => undefined); + return res; + }) + .catch(() => caches.match('/') || caches.match(req)) + ); + return; + } + + // Static assets: stale-while-revalidate + if (url.pathname.startsWith('/static/')) { + event.respondWith( + caches.match(req).then((cached) => { + const net = fetch(req) + .then((res) => { + if (res && res.ok) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => undefined); + } + return res; + }) + .catch(() => cached); + return cached || net; + }) + ); + } +}); diff --git a/cockpit/templates/agents.html b/cockpit/templates/agents.html index 7fc582d..12bed3d 100644 --- a/cockpit/templates/agents.html +++ b/cockpit/templates/agents.html @@ -5,17 +5,23 @@ - + + {% endblock %} {% block content %}
Animated team · duidelijke rollen · soul.md · live activiteit
Teamchat · @mentions · 1:1 DM · pop-out voor tweede scherm
+Geen approval requests.
Nog geen berichten — tag agents met @marketing of open een DM.
+ +Nog geen berichten.
+ +