SysOps: approved_backup — 2026-08-16 11:01 UTC

This commit is contained in:
sysops
2026-08-16 11:01:58 +00:00
parent 22a17ed048
commit 1dd607b060
46 changed files with 3734 additions and 147 deletions
+292
View File
@@ -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
+6
View File
@@ -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:
+137
View File
@@ -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")
+5 -2
View File
@@ -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}
+10 -2
View File
@@ -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:
+119 -1
View File
@@ -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),
}
+231
View File
@@ -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}
+9 -1
View File
@@ -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,
},
)
+589
View File
@@ -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}
@@ -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
+48 -16
View File
@@ -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",
+83
View File
@@ -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."
)
+100 -2
View File
@@ -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,
+9 -8
View File
@@ -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
+62 -1
View File
@@ -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) {
+175
View File
@@ -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); }
}
+7 -1
View File
@@ -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;
}
}
+99
View File
@@ -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;
}
+61 -16
View File
@@ -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; }
}
+44
View File
@@ -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;
}
}
+147
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 160" role="img" aria-label="Foodlinkk">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#f59e0b"/>
</linearGradient>
</defs>
<rect x="8" y="24" width="112" height="112" rx="28" fill="#0a0e14" stroke="url(#g)" stroke-width="4"/>
<text x="64" y="102" text-anchor="middle" font-family="system-ui,Segoe UI,sans-serif" font-size="72" font-weight="700" fill="#fbbf24">F</text>
<circle cx="100" cy="44" r="10" fill="#22c55e"/>
<text x="140" y="88" font-family="system-ui,Segoe UI,sans-serif" font-size="52" font-weight="700" fill="#f8fafc" letter-spacing="-0.02em">Foodlinkk</text>
<text x="142" y="122" font-family="system-ui,Segoe UI,sans-serif" font-size="16" font-weight="500" fill="#94a3b8" letter-spacing="0.18em">COMMAND CENTER</text>
</svg>

After

Width:  |  Height:  |  Size: 925 B

+9 -4
View File
@@ -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);
});
}
+426
View File
@@ -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 });
});
}
});
+4
View File
@@ -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) {
+7
View File
@@ -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(', ');
+5 -1
View File
@@ -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) {
+38 -7
View File
@@ -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" }]
}
]
}
+58 -7
View File
@@ -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;
})
);
}
});
+69
View File
@@ -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;
})
);
}
});
+119 -9
View File
@@ -5,17 +5,23 @@
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
<script src="/static/js/agent-avatars.js?v=3"></script>
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=3" />
<script src="/static/js/agent-avatars.js?v=4"></script>
{% endblock %}
{% block content %}
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
<header class="hm-neo-header page-header">
<header class="hm-neo-header page-header" x-show="tab!=='warroom'" x-cloak>
<h1>Agents · Soul Registry</h1>
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
</header>
<header class="hm-neo-header page-header wr-page-header" x-show="tab==='warroom'" x-cloak>
<h1>War Room</h1>
<p class="hm-sub">Teamchat · @mentions · 1:1 DM · pop-out voor tweede scherm</p>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem">
<div id="viz-toolbar" class="viz-toolbar" x-show="tab!=='warroom'" x-cloak></div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem"
x-show="tab!=='warroom'" x-cloak>
<div class="hm-chart-head"><h4>Agent activiteit (events)</h4><small>5 visualisatie-modi</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents-viz"></canvas></div>
@@ -39,6 +45,9 @@
<span class="vtab-icon">✓</span><span>Goedkeuringen</span>
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
</button>
<button class="vtab-btn" :class="{active: tab==='warroom'}" @click="setTab('warroom', $el)" data-tab="warroom">
<span class="vtab-icon">⚔️</span><span>War Room</span>
</button>
</nav>
<div class="vtabs-content">
@@ -262,6 +271,82 @@
<p x-show="!items.length" class="muted">Geen approval requests.</p>
</div>
</section>
<section x-show="tab==='warroom'" x-cloak x-data="warRoomHub()" x-init="init()">
<div class="panel" style="padding:0.75rem">
<div class="wr-layout">
<aside class="wr-roster">
<h3>Kanalen</h3>
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
<span>⚔️</span><strong>#war-room</strong>
</button>
<h3 style="margin-top:0.85rem">Agents</h3>
<template x-for="s in souls" :key="'wr-'+s.agent_key">
<button type="button" class="wr-agent-btn"
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
@click="openDm(s.agent_key)"
@dblclick="insertMention(s.agent_key)">
<div class="agent-char-wrap"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<div class="wr-agent-meta">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</div>
</button>
</template>
</aside>
<div class="wr-main">
<div class="wr-head">
<div>
<h2 x-text="room ? (room.title || ('#'+room.kind)) : 'War Room'"></h2>
<small class="muted" x-show="status" x-text="status"></small>
</div>
<div class="wr-head-actions">
<button type="button" class="btn btn-sm" @click="popOut()" title="Open op tweede scherm">Pop-out ↗</button>
<button type="button" class="btn btn-sm btn-pulse" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
</div>
</div>
<div class="wr-log" x-ref="wrLog">
<p class="wr-empty" x-show="!messages.length">Nog geen berichten — tag agents met @marketing of open een DM.</p>
<template x-for="m in messages" :key="m.id">
<div class="wr-bubble"
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
<div class="wr-avatar" x-show="m.sender_type!=='system'">
<template x-if="m.sender_type==='agent'">
<div class="agent-char-wrap"
:data-agent-avatar="m.agent_key"
data-agent-color="#1e3a5f"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, m.agent_key))"></div>
</template>
<template x-if="m.sender_type==='user' && (m.sender_avatar_url || me.avatar_url)">
<img :src="m.sender_avatar_url || me.avatar_url" alt="" />
</template>
<template x-if="m.sender_type==='user' && !(m.sender_avatar_url || me.avatar_url)">
<span class="wr-avatar-fallback" x-text="(m.username||me.username||'?').slice(0,2).toUpperCase()"></span>
</template>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta">
<span x-text="labelForMessage(m)"></span>
<span x-show="m.agent_key" x-text="' · @'+m.agent_key"></span>
<span x-show="m.metadata && m.metadata.in_reply_to" x-text="' · reageert op @'+m.metadata.in_reply_to"></span>
</div>
<div class="wr-bubble-text" x-text="m.content"></div>
</div>
</div>
</template>
</div>
<div class="wr-composer">
<textarea class="form-input" rows="2" x-model="draft" @keydown="onKey($event)"
placeholder="Bericht… gebruik @agent om iemand te taggen" :disabled="busy || !room"></textarea>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="busy || !room || !draft.trim()">Stuur</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@@ -304,6 +389,7 @@
<script src="/static/js/page-viz.js?v=1"></script>
<script src="/static/js/agents-mesh.js?v=11"></script>
<script src="/static/js/agents-terminals.js?v=6"></script>
<script src="/static/js/agents-warroom.js?v=4"></script>
<script>
function agentsSoulHub() {
return {
@@ -384,7 +470,14 @@ function agentsSoulHub() {
this.syncMeshMode();
await this.load();
await this.loadPendingCount();
this._poll = setInterval(() => { this.load(true); this.loadPendingCount(); }, 20000);
this._poll = setInterval(() => {
if (this.tab === 'warroom') {
this.loadPendingCount();
return;
}
this.load(true);
this.loadPendingCount();
}, 20000);
if (window.PageViz) {
window.PageViz.init('agents', (mode) => this.renderViz(mode));
}
@@ -399,7 +492,7 @@ function agentsSoulHub() {
},
});
}
if (window.AgentsTerminals) AgentsTerminals.startLiveStream();
if (window.AgentsTerminals && this.tab !== 'warroom') AgentsTerminals.startLiveStream();
if (this.tab === 'mesh') this.mountMesh();
if (this.tab === 'terminals') this.startTerminals();
});
@@ -412,6 +505,7 @@ function agentsSoulHub() {
this.tabIndicatorH = btnRect.height || 40;
},
setTab(next, el) {
const prev = this.tab;
this.tab = next;
this.updateTabIndicator(el);
const url = new URL(window.location.href);
@@ -419,6 +513,15 @@ function agentsSoulHub() {
window.history.replaceState({}, '', url.toString());
if (next === 'mesh') this.mountMesh();
if (next === 'terminals') this.startTerminals();
if (next === 'warroom') {
if (window.AgentsTerminals && AgentsTerminals.stopLiveStream) AgentsTerminals.stopLiveStream();
} else if (prev === 'warroom') {
if (window.AgentsTerminals && AgentsTerminals.startLiveStream) AgentsTerminals.startLiveStream();
this.$nextTick(() => {
this.renderViz(window.__vizMode || 'neo-bars');
window.AgentAvatars && AgentAvatars.paintAll(this.$root);
});
}
if (next === 'approvals') this.loadPendingCount();
},
async loadPendingCount() {
@@ -511,16 +614,23 @@ function agentsSoulHub() {
},
async load(quiet) {
const r = await fetch('/api/agents/souls').then(x => x.json());
this.souls = r.items || [];
const items = r.items || [];
// Avoid Alpine thrash: only replace souls when something meaningful changed
const sig = items.map(s => [s.agent_key, s.event_count, s.events_6h, s.current_status, s.activity, s.current_task].join(':')).join('|');
if (sig !== this._soulsSig) {
this._soulsSig = sig;
this.souls = items;
}
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
if (this.tab === 'warroom') return; // no chart / global avatar repaint on War Room
this.$nextTick(() => {
(this.souls || []).forEach((s) => {
const el = document.querySelector('[data-agent-avatar="' + s.agent_key + '"]');
const el = document.querySelector('.agent-grid [data-agent-avatar="' + s.agent_key + '"], .hm-neo [data-agent-avatar="' + s.agent_key + '"]');
if (el && window.AgentAvatars) AgentAvatars.paint(el, s.agent_key, s.avatar_color);
});
});
this.renderViz(window.__vizMode || 'neo-bars');
this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
if (!quiet) this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
},
renderViz(mode) {
if (!window.VizEngine) return;
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>War Room · Foodlinkk</title>
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=3" />
<script defer src="/static/js/alpine.min.js?v=2"></script>
</head>
<body class="wr-popup-body">
<div class="wr-popup-shell" x-data="warRoomPopup()" x-init="init()">
<div class="wr-layout">
<aside class="wr-roster">
<h3>Kanalen</h3>
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
<span>⚔️</span><strong>#war-room</strong>
</button>
<h3 style="margin-top:0.85rem">Agents</h3>
<template x-for="s in souls" :key="'p-'+s.agent_key">
<button type="button" class="wr-agent-btn"
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
@click="openDm(s.agent_key)">
<div class="agent-char-wrap"
:data-agent-avatar="s.agent_key"
:data-agent-color="s.avatar_color || '#1e3a5f'"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
<div class="wr-agent-meta">
<strong x-text="s.display_name"></strong>
<small x-text="'@'+s.agent_key"></small>
</div>
</button>
</template>
</aside>
<div class="wr-main">
<div class="wr-head">
<div>
<h2 x-text="room ? (room.title || 'War Room') : 'War Room'"></h2>
<small class="muted" x-show="status" x-text="status"></small>
</div>
<div class="wr-head-actions">
<a class="btn btn-sm" href="/agents?tab=warroom" target="_blank">In cockpit</a>
<button type="button" class="btn btn-sm btn-pulse" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
</div>
</div>
<div class="wr-log" x-ref="wrLog">
<p class="wr-empty" x-show="!messages.length">Nog geen berichten.</p>
<template x-for="m in messages" :key="m.id">
<div class="wr-bubble"
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
<div class="wr-avatar" x-show="m.sender_type!=='system'">
<template x-if="m.sender_type==='agent'">
<div class="agent-char-wrap"
:data-agent-avatar="m.agent_key"
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, m.agent_key))"></div>
</template>
<template x-if="m.sender_type==='user' && (m.sender_avatar_url || me.avatar_url)">
<img :src="m.sender_avatar_url || me.avatar_url" alt="" />
</template>
<template x-if="m.sender_type==='user' && !(m.sender_avatar_url || me.avatar_url)">
<span class="wr-avatar-fallback" x-text="(m.username||me.username||'?').slice(0,2).toUpperCase()"></span>
</template>
</div>
<div class="wr-bubble-body">
<div class="wr-bubble-meta"><span x-text="labelForMessage(m)"></span></div>
<div class="wr-bubble-text" x-text="m.content"></div>
</div>
</div>
</template>
</div>
<div class="wr-composer">
<textarea class="form-input" rows="2" x-model="draft" @keydown="onKey($event)"
placeholder="Bericht… @agent" :disabled="busy || !room"></textarea>
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="busy || !room || !draft.trim()">Stuur</button>
</div>
</div>
</div>
</div>
<script src="/static/js/cockpit.js?v=3"></script>
<script src="/static/js/agent-avatars.js?v=4"></script>
<script src="/static/js/agents-warroom.js?v=4"></script>
</body>
</html>
+196 -48
View File
@@ -12,22 +12,30 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
<link rel="manifest" href="/static/manifest.json?v=2" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="manifest" href="/static/manifest.json?v=3" />
<meta name="application-name" content="Foodlinkk" />
<meta name="format-detection" content="telephone=no" />
<link rel="icon" href="/static/icons/icon-192.png" type="image/png" />
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" sizes="180x180" />
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" sizes="192x192" />
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=4" />
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=6" />
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
<script defer src="/static/js/herman-assistant.js?v=16"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
<script defer src="/static/js/herman-assistant.js?v=17"></script>
<script defer src="/static/js/agents-warroom.js?v=4"></script>
<script defer src="/static/js/alpine.min.js?v=2"></script>
<script defer src="/static/js/htmx.min.js?v=2"></script>
{% block extra_head %}{% endblock %}
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=11" />
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=12" />
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=16" />
<!-- mobile last: wins over sidebar/dashboard cascade -->
<link rel="stylesheet" href="/static/css/mobile.css?v=7" />
<link rel="stylesheet" href="/static/css/user-avatar.css?v=3" />
</head>
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
<div class="app-shell">
@@ -51,36 +59,36 @@
<div class="persona-nav">
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
<div class="app-sidebar-label">Foodlinkk B2B</div>
<a href="/export-intel" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon">🌍</span><span class="sidebar-nav-label">Wereldexport</span></a>
<a href="/clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon">👥</span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
<a href="/export-intel" data-perm="export" class="nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}"><span class="sidebar-nav-icon">🌍</span><span class="sidebar-nav-label">Wereldexport</span></a>
<a href="/clients" data-perm="clients" class="nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}"><span class="sidebar-nav-icon">👥</span><span class="sidebar-nav-label" data-i18n="nav.clients">Klanten</span></a>
<a href="/deals" data-perm="deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
<a href="/products" data-perm="products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
<a href="/suppliers" data-perm="suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
<a href="/reports" data-perm="reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
</div>
<div class="app-sidebar-group" x-show="brand === 'cucina'">
<div class="app-sidebar-label">Cucina B2C</div>
<a href="/" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon">🏠</span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
<a href="/revenue-cockpit" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon">💰</span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
<a href="/retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon">🛒</span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
<a href="/marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon">📣</span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
<a href="/packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
<a href="/beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon">🎪</span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
<a href="/documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon">📄</span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
<a href="/projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon">📁</span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
<a href="/hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon">✈️</span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
<a href="/analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon">📈</span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
<a href="/" data-perm="dashboard" class="nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}"><span class="sidebar-nav-icon">🏠</span><span class="sidebar-nav-label" data-i18n="nav.ceo_home">Overzicht</span></a>
<a href="/revenue-cockpit" data-perm="revenue" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}"><span class="sidebar-nav-icon">💰</span><span class="sidebar-nav-label" data-i18n="nav.revenue_cockpit">Revenue Cockpit</span></a>
<a href="/retail" data-perm="retail" class="nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}"><span class="sidebar-nav-icon">🛒</span><span class="sidebar-nav-label" data-i18n="nav.retail">Retail NL</span></a>
<a href="/marketing" data-perm="marketing" class="nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}"><span class="sidebar-nav-icon">📣</span><span class="sidebar-nav-label" data-i18n="nav.marketing">Marketing</span></a>
<a href="/packaging" data-perm="packaging" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.packaging">Packaging</span></a>
<a href="/beurs" data-perm="beurs" class="nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}"><span class="sidebar-nav-icon">🎪</span><span class="sidebar-nav-label" data-i18n="nav.beurs">Beurs Live</span></a>
<a href="/documents" data-perm="documents" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}"><span class="sidebar-nav-icon">📄</span><span class="sidebar-nav-label" data-i18n="nav.documents">Documenten</span></a>
<a href="/projects" data-perm="projects" class="nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}"><span class="sidebar-nav-icon">📁</span><span class="sidebar-nav-label" data-i18n="nav.projects">Projecten</span></a>
<a href="/hermes" data-perm="hermes" class="nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}"><span class="sidebar-nav-icon">✈️</span><span class="sidebar-nav-label" data-i18n="nav.telegram">Telegram</span></a>
<a href="/analytics" data-perm="analytics" class="nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}"><span class="sidebar-nav-icon">📈</span><span class="sidebar-nav-label" data-i18n="nav.analytics">Analytics</span></a>
</div>
<div class="app-sidebar-group">
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
<a href="/ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon">⚙️</span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
<a href="/agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon">🤖</span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
<a href="/browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon">🌐</span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
<a href="/studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon">🎨</span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
<a href="/voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon">🎙️</span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
<a href="/settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon">🔧</span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
<a href="/ops" data-perm="ops" class="nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}"><span class="sidebar-nav-icon">⚙️</span><span class="sidebar-nav-label" data-i18n="nav.ops">IT Ops</span></a>
<a href="/agents" data-perm="agents" class="nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}"><span class="sidebar-nav-icon">🤖</span><span class="sidebar-nav-label" data-i18n="nav.agents">Agents</span></a>
<a href="/browser" data-perm="browser" class="nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}"><span class="sidebar-nav-icon">🌐</span><span class="sidebar-nav-label" data-i18n="nav.browser">Browser</span></a>
<a href="/studio" data-perm="studio" class="nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}"><span class="sidebar-nav-icon">🎨</span><span class="sidebar-nav-label" data-i18n="nav.studio">Studio</span></a>
<a href="/voice" data-perm="voice" class="nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}"><span class="sidebar-nav-icon">🎙️</span><span class="sidebar-nav-label" data-i18n="nav.voice">Voice</span></a>
<a href="/settings" data-perm="settings" class="nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}"><span class="sidebar-nav-icon">🔧</span><span class="sidebar-nav-label" data-i18n="nav.settings">Instellingen</span></a>
</div>
</div>
</aside>
@@ -100,15 +108,39 @@
<option value="en">EN</option>
</select>
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
{% set cu = request.state.user if request is defined else none %}
<div class="app-user-chip"{% if not cu %} x-show="authUser" x-cloak{% endif %}
x-data="userChip()" x-init="bootAvatar()">
<button type="button" class="user-avatar-btn" title="Profielfoto wijzigen" @click="pickAvatar()">
{% if cu and cu.avatar_url %}
<img class="user-avatar-img" src="{{ cu.avatar_url }}"
:src="photoUrl || '{{ cu.avatar_url }}'" alt="" width="36" height="36" />
{% else %}
<img class="user-avatar-img" x-show="photoUrl" :src="photoUrl" alt="" width="36" height="36" />
<span class="user-avatar-fallback" x-show="!photoUrl" x-text="initials">?</span>
{% endif %}
</button>
<input type="file" x-ref="avatarFile" accept="image/png,image/jpeg,image/webp,image/gif"
class="user-avatar-input" style="display:none!important;position:absolute;width:0;height:0;opacity:0" tabindex="-1" aria-hidden="true" @change="onFile($event)" />
<div class="user-avatar-meta">
<span class="user-avatar-name" x-text="name">{% if cu %}{{ cu.display_name or cu.username }}{% endif %}</span>
<span class="user-avatar-role" x-text="role">{% if cu %}{{ cu.role }}{% endif %}</span>
</div>
<a class="btn btn-sm btn-secondary user-logout-btn" href="/logout">Uitloggen</a>
</div>
</header>
<main class="main-topnav">{% block content %}{% endblock %}</main>
</div>
</div>
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
<div class="pwa-install-bar" x-show="showInstall" x-cloak
:class="{ 'is-ios': isIos, 'is-android': isAndroid }"
style="display:none">
<p>Installeer Foodlinkk op je beginscherm</p>
<p class="pwa-install-hint is-ios-hint">iPhone/iPad (Safari): Deel ↗ → <strong>Zet op beginscherm</strong>. Open via <strong>http://10.4.7.18:8610</strong> — geen certificaatmelding.</p>
<p class="pwa-install-hint is-android-hint">Android (Chrome): menu ⋮ → <strong>Toevoegen aan startscherm</strong>. Open via <strong>http://10.4.7.18:8610</strong>.</p>
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Uitleg</button>
<button type="button" class="btn btn-sm" @click="dismissPwa()">Later</button>
</div>
<!-- Herman chat — altijd vast onderin zichtbaar -->
@@ -136,6 +168,8 @@
<div class="ha-head-actions">
<a x-show="!micSecureOk()" class="ha-text-btn" :href="httpsChatUrl()" target="_blank" rel="noopener" title="Audio/microfoon in HTTPS-tab">Audio</a>
<button type="button" class="ha-text-btn" @click="clearChat()" title="Nieuwe chat" :disabled="busy || (!turns.length && !manualText)">Wissen</button>
<button type="button" class="ha-text-btn" @click="openWarRoomPopup()" title="War Room op tweede scherm">War Room</button>
<a href="/agents?tab=warroom">Agents</a>
<a href="/herman">Volledig</a>
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
</div>
@@ -224,7 +258,7 @@
</div>
<script src="/static/js/i18n.js?v=5"></script>
<script src="/static/js/cockpit.js?v=2"></script>
<script src="/static/js/cockpit.js?v=3"></script>
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/global-viz.js?v=1"></script>
<script src="/static/js/live-pulse.js?v=3"></script>
@@ -250,6 +284,47 @@
/* legacy persona helper → brand */
window.FoodlinkkPersona = window.FoodlinkkBrand;
function userChip() {
return {
photoUrl: {% if cu and cu.avatar_url %}'{{ cu.avatar_url }}'{% else %}null{% endif %},
name: {% if cu %}'{{ (cu.display_name or cu.username)|e }}'{% else %}''{% endif %},
role: {% if cu %}'{{ cu.role }}'{% else %}''{% endif %},
initials: {% if cu %}'{{ ((cu.display_name or cu.username or '?')[0:1])|upper }}'{% else %}'?'{% endif %},
bootAvatar() {
// sync from parent shell when /api/auth/me returns
window.addEventListener('flk-auth-ready', (ev) => {
const u = ev.detail;
if (!u) return;
this.photoUrl = u.avatar_url || null;
this.name = u.display_name || u.username || '';
this.role = u.role || '';
const n = (this.name || '?').trim();
const parts = n.split(/\s+/).filter(Boolean);
this.initials = parts.length >= 2 ? (parts[0][0] + parts[1][0]).toUpperCase() : n.slice(0, 1).toUpperCase();
});
},
pickAvatar() { this.$refs.avatarFile && this.$refs.avatarFile.click(); },
async onFile(ev) {
const file = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/auth/me/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || 'Upload mislukt');
this.photoUrl = (data.avatar_url || (data.user && data.user.avatar_url)) + '?t=' + Date.now();
if (window.Cockpit && Cockpit.toast) Cockpit.toast('Profielfoto bijgewerkt', 'success');
window.dispatchEvent(new CustomEvent('flk-auth-ready', { detail: data.user }));
} catch (e) {
if (window.Cockpit && Cockpit.toast) Cockpit.toast(e.message || 'Upload mislukt', 'error');
else alert(e.message || 'Upload mislukt');
}
}
};
}
function appShell() {
return {
locale: 'nl',
@@ -258,21 +333,24 @@
navOpen: false,
sidebarWidth: 280,
showInstall: false,
authUser: null,
authPages: [],
_deferredPrompt: null,
clockDate: '—',
clockTime: '—',
_offset: 0,
isIos: false,
async initApp() {
this.brand = window.FoodlinkkBrand.detect(location.pathname);
if (!localStorage.getItem('flk-cache-v4')) {
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
this.isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// One-time migration off legacy no-op SW
if (!localStorage.getItem('flk-pwa-v6')) {
if (window.caches) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
localStorage.setItem('flk-pwa-v6', '1');
localStorage.setItem('flk-cache-v4', '1');
}
if (window.I18n) await window.I18n.init();
@@ -280,6 +358,59 @@
this.initSidebarWidth();
this.initClock();
this.initPwa();
await this.initAuth();
},
async initAuth() {
try {
const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (r.status === 401) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
if (!r.ok) return;
const data = await r.json();
this.authUser = Object.assign({}, data.user || null, { _avTick: Date.now() });
this.authPages = (data.user && data.user.permissions && data.user.permissions.pages) || [];
this.applyNavPermissions();
window.dispatchEvent(new CustomEvent('flk-auth-ready', { detail: this.authUser }));
} catch (e) { /* ignore */ }
},
canPage(key) {
if (!this.authUser) return true;
if (this.authUser.role === 'admin') return true;
return this.authPages.indexOf(key) !== -1;
},
applyNavPermissions() {
if (!this.authUser || this.authUser.role === 'admin') return;
document.querySelectorAll('.app-sidebar a.nav-pill[data-perm]').forEach((a) => {
const key = a.getAttribute('data-perm');
a.style.display = this.canPage(key) ? '' : 'none';
});
},
avatarInitials() {
const u = this.authUser;
if (!u) return '?';
const n = (u.display_name || u.username || '?').trim();
const parts = n.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return n.slice(0, 1).toUpperCase();
},
async uploadMyAvatar(ev) {
const file = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/api/auth/me/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || 'Upload mislukt');
this.authUser = Object.assign({}, data.user, { _avTick: Date.now() });
if (window.Cockpit && Cockpit.toast) Cockpit.toast('Profielfoto bijgewerkt', 'success');
} catch (e) {
if (window.Cockpit && Cockpit.toast) Cockpit.toast(e.message || 'Upload mislukt', 'error');
else alert(e.message || 'Upload mislukt');
}
},
initSidebarWidth() {
var saved = parseInt(localStorage.getItem('flk-sidebar-width') || '280', 10);
@@ -340,32 +471,49 @@
tick();
setInterval(tick, 1000);
},
isAndroid: false,
initPwa() {
if (window.matchMedia('(display-mode: standalone)').matches) return;
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.showInstall = !localStorage.getItem('pwa-dismiss');
const standalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
if (standalone) return;
this.isAndroid = /Android/i.test(navigator.userAgent);
// SW alleen proberen op secure context (https/localhost). Op http in LAN faalt dit stil — app blijft gewoon werken.
if (window.isSecureContext && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(function (err) {
console.warn('SW register failed', err);
});
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
this._deferredPrompt = e;
this.showInstall = true;
if (!localStorage.getItem('pwa-dismiss')) this.showInstall = true;
});
if (!localStorage.getItem('pwa-dismiss') && (this.isIos || this.isAndroid)) {
this.showInstall = true;
}
},
dismissPwa() {
this.showInstall = false;
localStorage.setItem('pwa-dismiss', '1');
},
async installPwa() {
if (this._deferredPrompt) {
this._deferredPrompt.prompt();
await this._deferredPrompt.userChoice;
this._deferredPrompt = null;
this.showInstall = false;
this.dismissPwa();
return;
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
if (this.isIos) {
alert('Open http://10.4.7.18:8610 in Safari\n\n1. Tik op Deel (↗)\n2. Kies «Zet op beginscherm»\n3. Bevestig met «Zet erop»\n\nGeen HTTPS nodig — geen certificaatmelding.');
} else {
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
alert('Open http://10.4.7.18:8610 in Chrome\n\n1. Tik op ⋮ (rechtsboven)\n2. «Toevoegen aan startscherm» of «App installeren»\n3. Bevestig\n\nGeen HTTPS nodig — geen certificaatmelding.');
}
this.showInstall = false;
localStorage.setItem('pwa-dismiss', '1');
this.dismissPwa();
}
};
}
+1 -1
View File
@@ -2,7 +2,7 @@
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0a0e14" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>Inloggen · Foodlinkk</title>
<link rel="icon" href="/static/icons/icon-192.png" type="image/png" />
<link rel="stylesheet" href="/static/css/login.css?v=1" />
</head>
<body class="login-body">
<main class="login-shell">
<section class="login-card">
<img class="login-logo" src="/static/img/foodlinkk-logo.svg" alt="Foodlinkk" width="320" height="80" />
<p class="login-sub">Meld je aan bij de Command Center cockpit</p>
{% if error %}
<div class="login-error" role="alert">{{ error }}</div>
{% endif %}
<form class="login-form" method="post" action="/login" autocomplete="on">
<input type="hidden" name="next" value="{{ next or '/' }}" />
<label class="login-label">Gebruikersnaam
<input class="login-input" type="text" name="username" required autofocus
value="{{ username or '' }}" autocomplete="username" />
</label>
<label class="login-label">Wachtwoord
<input class="login-input" type="password" name="password" required
autocomplete="current-password" />
</label>
<button class="login-btn" type="submit">Inloggen</button>
</form>
<p class="login-foot">Foodlinkk · Cucina · intern netwerk</p>
</section>
</main>
</body>
</html>
+111 -18
View File
@@ -2,6 +2,48 @@
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
<style>
.reclame-kpi-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 640px) {
.reclame-kpi-row { grid-template-columns: 1fr; }
}
button.reclame-kpi-btn.kpi-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
font: inherit;
color: inherit;
text-align: left;
appearance: none;
-webkit-appearance: none;
background: var(--bg-elevated, #0f172a);
border: 1px solid rgba(56,189,248,0.55);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
button.reclame-kpi-btn.kpi-card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(56,189,248,0.18);
border-color: rgba(56,189,248,0.9);
}
button.reclame-kpi-btn.kpi-card:focus-visible {
outline: 2px solid rgba(56,189,248,0.75);
outline-offset: 2px;
}
button.reclame-kpi-btn.kpi-card.is-active {
border-color: #38bdf8;
background: rgba(56,189,248,0.08);
box-shadow: 0 0 0 1px rgba(56,189,248,0.25);
}
button.reclame-kpi-btn.kpi-card span { color: var(--text-secondary, #94a3b8); font-size: 0.85rem; }
button.reclame-kpi-btn.kpi-card strong { color: var(--text-primary, #f8fafc); font-size: 1.15rem; }
</style>
{% endblock %}
{% block content %}
<div x-data="marketingHub()" x-init="init()">
@@ -133,17 +175,23 @@
<p class="hint">Live folders van <a href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Reclamefolder.nl</a> — klik om de folder te openen.</p>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="busy">↻ Ophalen van Reclamefolder.nl</button>
<button type="button" class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="reclameBusy" x-text="reclameBusy ? '⏳ Bezig met ophalen…' : '↻ Ophalen van Reclamefolder.nl'"></button>
<a class="btn btn-sm" href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Bron site →</a>
</div>
</div>
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem">
<div class="kpi-card"><span>Ketens</span><strong x-text="reclameStats.chains||0"></strong></div>
<div class="kpi-card kpi-green"><span>Folders</span><strong x-text="filteredPromos.length"></strong></div>
<div class="kpi-card"><span>Totaal geladen</span><strong x-text="promos.length"></strong></div>
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem" role="group" aria-label="Reclame snelfilters">
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'ketens' }" @click="onReclameKpi('ketens')" title="Open ketens-filter">
<span>Ketens</span><strong x-text="reclameStats.chains||0"></strong>
</button>
<button type="button" class="kpi-card kpi-green clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'folders' }" @click="onReclameKpi('folders')" title="Toon folders">
<span>Folders</span><strong x-text="filteredPromos.length"></strong>
</button>
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'totaal' }" @click="onReclameKpi('totaal')" title="Toon alles / filters wissen">
<span>Totaal geladen</span><strong x-text="promos.length"></strong>
</button>
</div>
<div class="form-grid reclame-filters" style="margin-bottom:1rem">
<select class="form-input" x-model="reclameFilter.chain" @change="loadPromos()">
<select class="form-input" x-ref="chainFilter" x-model="reclameFilter.chain" @change="loadPromos()">
<option value="">Alle ketens</option>
<template x-for="c in reclameChains" :key="c">
<option :value="c" x-text="c"></option>
@@ -177,7 +225,7 @@
</a>
</template>
</div>
<p x-show="!filteredPromos.length && !busy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
<p x-show="!filteredPromos.length && !reclameBusy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
<h3 style="margin-top:1.5rem">Nieuwe folder registreren</h3>
<form class="form-grid" @submit.prevent="addPromo">
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
@@ -397,10 +445,11 @@ function marketingHub() {
return {
tab: new URLSearchParams(location.search).get('tab') || 'live',
tabIndicatorTop: 0, tabIndicatorH: 44,
busy: false, dashboard: {}, socialAnalytics: '',
busy: false, reclameBusy: false, dashboard: {}, socialAnalytics: '',
regRows: { regelgeving: [], cbs: [], markt: [] },
promos: [], bookmarks: [], bookmarkIds: {},
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
reclameKpi: 'folders',
reclameStats: { chains: 0 },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
@@ -457,7 +506,30 @@ function marketingHub() {
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
this.tabIndicatorH = btn.offsetHeight;
},
onReclameKpi(which) {
this.reclameKpi = which;
if (which === 'ketens') {
this.$nextTick(() => {
const el = this.$refs.chainFilter;
if (el) {
el.focus();
try { el.showPicker && el.showPicker(); } catch (e) {}
}
});
return;
}
if (which === 'folders') {
this.reclameFilter = { chain: '', q: '', folder_type: 'folder', valid_days: '' };
this.loadPromos();
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
return;
}
this.reclameFilter = { chain: '', q: '', folder_type: '', valid_days: '' };
this.loadPromos();
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
},
async loadReclameChains() {
try {
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
this.reclameChains = d.chains || [];
@@ -471,10 +543,15 @@ function marketingHub() {
if (this.reclameFilter.q) params.set('q', this.reclameFilter.q);
if (this.reclameFilter.folder_type) params.set('folder_type', this.reclameFilter.folder_type);
if (this.reclameFilter.valid_days) params.set('valid_days', this.reclameFilter.valid_days);
let items = (await fetch('/api/retail/promo-campaigns?' + params).then(r => r.json())).items || [];
const resp = await fetch('/api/retail/promo-campaigns?' + params);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
let items = (await resp.json()).items || [];
items = items.filter(p => p.source === 'reclamefolder.nl' || (p.folder_path || '').startsWith('http') || (p.metadata && (typeof p.metadata === 'object' ? p.metadata.edition_id : String(p.metadata).includes('edition_id'))));
this.promos = items;
if (!items.length && !this.reclameFilter.q && !this.reclameFilter.chain) await this.refreshReclamefolders(false);
// Alleen auto-sync bij eerste lege load, niet tijdens handmatige sync (voorkomt nesting)
if (!items.length && !this.reclameBusy && !this.reclameFilter.q && !this.reclameFilter.chain && !this.reclameFilter.folder_type && !this.reclameFilter.valid_days) {
await this.refreshReclamefolders(false);
}
} catch (e) { this.promos = []; }
},
promoUrl(p) {
@@ -489,15 +566,26 @@ function marketingHub() {
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
},
async refreshReclamefolders(toast = true) {
this.busy = true;
if (this.reclameBusy) return;
this.reclameBusy = true;
try {
const d = await fetch('/api/retail/reclamefolder/refresh', { method: 'POST' }).then(r => r.json());
const resp = await fetch('/api/retail/reclamefolder/refresh', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: '{}',
});
const d = await resp.json().catch(() => ({}));
if (!resp.ok || d.ok === false) {
throw new Error(d.detail || d.error || ('HTTP ' + resp.status));
}
await this.loadPromos();
await this.loadReclameChains();
if (toast) Cockpit.toast((d.synced || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
if (typeof d.synced === 'number') this.reclameStats.synced = d.synced;
if (toast) Cockpit.toast((d.synced || this.promos.length || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
} catch (e) {
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt', 'error');
} finally { this.busy = false; }
console.error('reclamefolder refresh', e);
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt: ' + (e.message || e), 'error');
} finally { this.reclameBusy = false; }
},
async addPromo() {
await fetch('/api/retail/promo-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.promoForm) });
@@ -545,13 +633,18 @@ function marketingHub() {
},
async refreshRss() { this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadDashboard(); }finally{ this.busy=false; } },
async refreshAll(toast) {
this.busy = true;
const silent = toast === false;
if (!silent) this.busy = true;
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadDashboard();
if (this.tab === 'reclame' && !this.reclameBusy) {
await this.loadPromos();
await this.loadReclameChains();
}
this.renderViz(window.__vizMode || 'neo-bars');
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { this.busy = false; }
if (!silent) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { if (!silent) this.busy = false; }
},
async addMention() { await Cockpit.api('/mentions',{method:'POST',body:JSON.stringify(this.mentionForm)}); location.reload(); },
async deleteMention(id) { if(!Cockpit.confirmDelete()) return; await Cockpit.api('/mentions/'+id,{method:'DELETE'}); location.reload(); },
+2 -2
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}Revenue Cockpit · Cucina{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=12" />
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=13" />
{% endblock %}
{% block content %}
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
@@ -336,5 +336,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/revenue-cockpit.js?v=12"></script>
<script src="/static/js/revenue-cockpit.js?v=13"></script>
{% endblock %}
+254
View File
@@ -12,6 +12,9 @@
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
<a href="/settings?tab=social" class="settings-tab {% if active_tab == 'social' %}active{% endif %}">Social API's</a>
<a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a>
{% if can_manage_users %}
<a href="/settings?tab=users" class="settings-tab {% if active_tab == 'users' %}active{% endif %}">Gebruikers</a>
{% endif %}
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
</nav>
@@ -138,6 +141,111 @@
</template>
</div>
</section>
{% elif active_tab == 'users' and can_manage_users %}
<section class="panel settings-panel hm-neo" x-data="usersAdminPage()" x-init="load()">
<div class="settings-panel-head">
<div>
<h2>Gebruikers &amp; rechten</h2>
<p class="page-tip">Maak accounts aan en bepaal welke cockpit-pagina's zichtbaar zijn. Admins hebben altijd volledige toegang.</p>
</div>
<button type="button" class="btn btn-primary" @click="openCreate()">+ Nieuwe gebruiker</button>
</div>
<div class="email-account-list">
<template x-for="u in users" :key="u.id">
<article class="email-account-card" :class="{ 'is-active': u.is_active }">
<div class="email-account-card-head">
<div style="display:flex;align-items:center;gap:0.75rem;min-width:0">
<img class="user-card-avatar" x-show="u.avatar_url" :src="u.avatar_url" alt="" />
<span class="user-card-avatar fallback" x-show="!u.avatar_url" x-text="initials(u)"></span>
<div style="min-width:0">
<strong x-text="u.display_name"></strong>
<span class="muted" x-text="'@' + u.username"></span>
</div>
</div>
<span class="badge" :class="u.role === 'admin' ? 'badge-positive' : 'badge-neutral'" x-text="u.role"></span>
</div>
<div class="email-account-meta">
<span x-text="u.is_active ? 'Actief' : 'Uitgeschakeld'"></span>
<span x-text="u.role === 'admin' ? 'Alle pagina\'s' : ((u.permissions.pages||[]).length + ' pagina\'s')"></span>
<span x-show="u.last_login_at" x-text="'Laatste login: ' + (u.last_login_at || '')"></span>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary btn-sm" @click="edit(u)">Bewerken</button>
<button type="button" class="btn btn-reject btn-sm" @click="remove(u)" x-show="u.id !== meId">Verwijder</button>
</div>
</article>
</template>
<p class="empty-state" x-show="!users.length && !loading">Nog geen gebruikers.</p>
</div>
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
<div class="modal-card settings-modal" style="max-width:560px">
<h3 x-text="form.id ? 'Gebruiker bewerken' : 'Nieuwe gebruiker'"></h3>
<div class="user-form-avatar-row">
<img class="user-form-avatar-preview" x-show="form.avatar_url || form.avatarPreview"
:src="form.avatarPreview || form.avatar_url" alt="" />
<span class="user-form-avatar-preview fallback" x-show="!form.avatar_url && !form.avatarPreview"
x-text="formInitials()"></span>
<div>
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif"
@change="onAvatarPick($event)" />
<p class="page-tip" style="margin:0.35rem 0 0">JPG/PNG/WEBP, max 3 MB. Bij nieuwe user: eerst opslaan, daarna foto.</p>
<button type="button" class="btn btn-sm btn-secondary" style="margin-top:0.35rem"
x-show="form.id && form.avatar_url" @click="removeAvatar()">Foto verwijderen</button>
</div>
</div>
<label class="form-label">Gebruikersnaam
<input class="form-input" x-model="form.username" :disabled="!!form.id" placeholder="bijv. sandra" autocomplete="off" />
</label>
<label class="form-label">Weergavenaam
<input class="form-input" x-model="form.display_name" placeholder="Sandra" />
</label>
<label class="form-label">Wachtwoord <span class="muted" x-show="form.id">(leeg = ongewijzigd)</span>
<input class="form-input" type="password" x-model="form.password" autocomplete="new-password" />
</label>
<label class="form-label">Rol
<select class="form-input" x-model="form.role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
<label class="form-check" x-show="form.id">
<input type="checkbox" x-model="form.is_active" /> Account actief
</label>
<label class="form-check">
<input type="checkbox" x-model="form.manage_users" :disabled="form.role === 'admin'" /> Mag gebruikers beheren
</label>
<div x-show="form.role !== 'admin'" style="margin-top:0.75rem">
<strong style="font-size:0.85rem">Pagina-rechten</strong>
<div class="perm-grid" style="margin-top:0.5rem;grid-template-columns:1fr 1fr">
<template x-for="p in pages" :key="p.key">
<label class="form-check">
<input type="checkbox" :value="p.key" :checked="form.pages.includes(p.key)"
@change="togglePage(p.key, $event.target.checked)" />
<span x-text="p.label"></span>
</label>
</template>
</div>
<div class="btn-group" style="margin-top:0.5rem">
<button type="button" class="btn btn-sm btn-secondary" @click="selectAllPages()">Alles</button>
<button type="button" class="btn btn-sm btn-secondary" @click="form.pages=[]">Geen</button>
</div>
</div>
<p class="page-tip" x-show="form.role === 'admin'">Admins hebben automatisch toegang tot alle pagina's en gebruikersbeheer.</p>
<div class="btn-group" style="margin-top:1rem">
<button type="button" class="btn btn-primary" @click="save()" :disabled="saving" x-text="saving ? 'Bezig…' : 'Opslaan'"></button>
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleer</button>
</div>
</div>
</div>
</section>
{% elif active_tab == 'social' %}
<section class="panel settings-panel" x-data="socialSettingsPage()" x-init="load()">
<div class="settings-panel-head">
@@ -271,6 +379,152 @@ function socialSettingsPage() {
};
}
function usersAdminPage() {
return {
loading: false,
saving: false,
users: [],
pages: [],
meId: null,
formOpen: false,
form: { id: null, username: '', display_name: '', password: '', role: 'user', is_active: true, manage_users: false, pages: [] },
async load() {
this.loading = true;
try {
const me = await Cockpit.api('/api/auth/me');
this.meId = me.user && me.user.id;
const r = await Cockpit.api('/api/settings/users');
this.users = r.users || [];
this.pages = r.pages || [];
} catch (e) {
Cockpit.toast((e && e.message) || 'Laden mislukt', 'error');
} finally {
this.loading = false;
}
},
openCreate() {
this.form = { id: null, username: '', display_name: '', password: '', role: 'user', is_active: true, manage_users: false, pages: ['dashboard'], avatar_url: null, avatarPreview: null, avatarFile: null };
this.formOpen = true;
},
edit(u) {
this.form = {
id: u.id,
username: u.username,
display_name: u.display_name || '',
password: '',
role: u.role || 'user',
is_active: !!u.is_active,
manage_users: !!(u.permissions && u.permissions.manage_users),
pages: (u.permissions && u.permissions.pages) ? [...u.permissions.pages] : [],
avatar_url: u.avatar_url || null,
avatarPreview: null,
avatarFile: null,
};
this.formOpen = true;
},
initials(u) {
const n = ((u && (u.display_name || u.username)) || '?').trim();
const parts = n.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return n.slice(0, 2).toUpperCase();
},
formInitials() {
return this.initials({ display_name: this.form.display_name, username: this.form.username });
},
onAvatarPick(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
this.form.avatarFile = file;
this.form.avatarPreview = URL.createObjectURL(file);
},
async uploadAvatarFor(userId, file) {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/settings/users/' + userId + '/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || 'Foto upload mislukt');
return data.user;
},
async removeAvatar() {
if (!this.form.id) return;
if (!confirm('Profielfoto verwijderen?')) return;
try {
await Cockpit.api('/api/settings/users/' + this.form.id + '/avatar', { method: 'DELETE' });
this.form.avatar_url = null;
this.form.avatarPreview = null;
this.form.avatarFile = null;
Cockpit.toast('Foto verwijderd', 'success');
await this.load();
} catch (e) {
Cockpit.toast((e && e.message) || 'Mislukt', 'error');
}
},
togglePage(key, on) {
if (on && !this.form.pages.includes(key)) this.form.pages.push(key);
if (!on) this.form.pages = this.form.pages.filter(k => k !== key);
},
selectAllPages() { this.form.pages = this.pages.map(p => p.key); },
async save() {
this.saving = true;
try {
const permissions = {
pages: this.form.role === 'admin' ? this.pages.map(p => p.key) : this.form.pages,
manage_users: this.form.role === 'admin' ? true : !!this.form.manage_users,
};
let saved = null;
if (this.form.id) {
const body = {
display_name: this.form.display_name,
role: this.form.role,
is_active: this.form.is_active,
permissions,
};
if (this.form.password) body.password = this.form.password;
saved = await Cockpit.api('/api/settings/users/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
saved = saved.user || saved;
} else {
if (!this.form.username || !this.form.password) {
Cockpit.toast('Gebruikersnaam en wachtwoord verplicht', 'error');
return;
}
saved = await Cockpit.api('/api/settings/users', {
method: 'POST',
body: JSON.stringify({
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name || this.form.username,
role: this.form.role,
permissions,
}),
});
saved = saved.user || saved;
}
if (this.form.avatarFile && saved && saved.id) {
await this.uploadAvatarFor(saved.id, this.form.avatarFile);
}
this.formOpen = false;
Cockpit.toast('Gebruiker opgeslagen', 'success');
await this.load();
} catch (e) {
Cockpit.toast((e && e.message) || 'Opslaan mislukt', 'error');
} finally {
this.saving = false;
}
},
async remove(u) {
if (!confirm('Verwijder gebruiker ' + u.username + '?')) return;
try {
await Cockpit.api('/api/settings/users/' + u.id, { method: 'DELETE' });
Cockpit.toast('Verwijderd', 'success');
await this.load();
} catch (e) {
Cockpit.toast((e && e.message) || 'Verwijderen mislukt', 'error');
}
},
};
}
function permissionsPage() {
return {
perms: [], grantedCount: 0, total: 0,
+1
View File
@@ -98,6 +98,7 @@ services:
volumes:
- /home/aissa/nas-clients:/data/nas-clients
- /mnt/synology:/data/nas-share:ro
- ./cockpit/static/uploads:/app/static/uploads
depends_on:
- tools-api
- whisper
+18
View File
@@ -0,0 +1,18 @@
-- Cockpit login users + page permissions
CREATE TABLE IF NOT EXISTS cockpit_users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
permissions JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_cockpit_users_username ON cockpit_users (username);
COMMENT ON TABLE cockpit_users IS 'Foodlinkk Cockpit UI login accounts';
COMMENT ON COLUMN cockpit_users.permissions IS 'JSON: {"pages":["dashboard","revenue",...], "manage_users": true}';
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE cockpit_users
ADD COLUMN IF NOT EXISTS avatar_url TEXT;
COMMENT ON COLUMN cockpit_users.avatar_url IS 'Public URL under /static/uploads/avatars/…';
+38
View File
@@ -0,0 +1,38 @@
-- War Room + 1:1 agent chat rooms/messages
CREATE TABLE IF NOT EXISTS warroom_rooms (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('warroom', 'dm')),
agent_key VARCHAR(64) REFERENCES agent_souls(agent_key) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT warroom_rooms_kind_agent CHECK (
(kind = 'warroom' AND agent_key IS NULL) OR
(kind = 'dm' AND agent_key IS NOT NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS warroom_rooms_one_warroom
ON warroom_rooms ((kind)) WHERE kind = 'warroom';
CREATE UNIQUE INDEX IF NOT EXISTS warroom_rooms_dm_agent
ON warroom_rooms (agent_key) WHERE kind = 'dm';
CREATE TABLE IF NOT EXISTS warroom_messages (
id BIGSERIAL PRIMARY KEY,
room_id BIGINT NOT NULL REFERENCES warroom_rooms(id) ON DELETE CASCADE,
sender_type TEXT NOT NULL CHECK (sender_type IN ('user', 'agent', 'system')),
agent_key VARCHAR(64),
username TEXT,
sender_avatar_url TEXT,
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS warroom_messages_room_created
ON warroom_messages (room_id, created_at);
INSERT INTO warroom_rooms (kind, agent_key, title)
SELECT 'warroom', NULL, 'War Room'
WHERE NOT EXISTS (SELECT 1 FROM warroom_rooms WHERE kind = 'warroom');