SysOps: approved_backup — 2026-08-16 11:01 UTC
This commit is contained in:
@@ -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
|
||||||
@@ -24,10 +24,16 @@ class Settings:
|
|||||||
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
|
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
|
||||||
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
|
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
|
||||||
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
|
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")
|
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
|
||||||
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
|
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
|
||||||
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
|
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
|
||||||
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
|
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
|
@property
|
||||||
def database_dsn(self) -> str:
|
def database_dsn(self) -> str:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.responses import Response
|
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.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.admin_api import admin_router, ai_router, herman_api, voice_api
|
||||||
from app.routes.settings_api import settings_router
|
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.agents_api import router as agents_api_router
|
||||||
from app.routes.marketing_api import router as marketing_api_router
|
from app.routes.marketing_api import router as marketing_api_router
|
||||||
from app.routes.projects_api import router as projects_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")
|
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")
|
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
|
||||||
for r in (
|
for r in (
|
||||||
@@ -77,6 +207,7 @@ for r in (
|
|||||||
reports.router,
|
reports.router,
|
||||||
voice.router,
|
voice.router,
|
||||||
settings.router,
|
settings.router,
|
||||||
|
auth_routes.router,
|
||||||
browser.router,
|
browser.router,
|
||||||
herman.router,
|
herman.router,
|
||||||
studio.router,
|
studio.router,
|
||||||
@@ -96,6 +227,8 @@ for r in (
|
|||||||
agents_api_router,
|
agents_api_router,
|
||||||
marketing_api_router,
|
marketing_api_router,
|
||||||
projects_api_router,
|
projects_api_router,
|
||||||
|
auth_routes.api_router,
|
||||||
|
auth_routes.users_api,
|
||||||
):
|
):
|
||||||
app.include_router(r)
|
app.include_router(r)
|
||||||
|
|
||||||
@@ -103,6 +236,10 @@ for r in (
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup() -> None:
|
def on_startup() -> None:
|
||||||
init_pool()
|
init_pool()
|
||||||
|
try:
|
||||||
|
auth_users.ensure_admin_seed()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"auth seed warning: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import datetime
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import httpx
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -1019,14 +1019,17 @@ async def ai_generate_post_draft(body: AIPostDraftBody):
|
|||||||
|
|
||||||
|
|
||||||
@herman_api.post("/chat")
|
@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():
|
if not body.message.strip():
|
||||||
raise HTTPException(400, "message required")
|
raise HTTPException(400, "message required")
|
||||||
|
user = getattr(request.state, "user", None) or {}
|
||||||
result = await herman_service.chat(
|
result = await herman_service.chat(
|
||||||
body.message.strip(),
|
body.message.strip(),
|
||||||
channel=(body.channel or "cockpit").strip(),
|
channel=(body.channel or "cockpit").strip(),
|
||||||
session_id=body.session_id,
|
session_id=body.session_id,
|
||||||
confirm_action_id=body.confirm_action_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}
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.db import execute, fetch_all
|
from app.db import execute, fetch_all
|
||||||
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def agents_page(request: Request):
|
async def agents_page(request: Request):
|
||||||
active_tab = request.query_params.get("tab", "souls")
|
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"
|
active_tab = "souls"
|
||||||
events: list = []
|
events: list = []
|
||||||
try:
|
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}")
|
@router.post("/approve/{event_id}")
|
||||||
async def approve_event(event_id: int, next_url: str = Form("/")):
|
async def approve_event(event_id: int, next_url: str = Form("/")):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
return {"ok": True, "request": req}
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app import auth_users
|
||||||
|
|
||||||
router = APIRouter(tags=["settings"])
|
router = APIRouter(tags=["settings"])
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
@@ -10,15 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|||||||
|
|
||||||
@router.get("/settings")
|
@router.get("/settings")
|
||||||
async def settings_page(request: Request, tab: str = "email"):
|
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"
|
active = tab if tab in allowed else "email"
|
||||||
if tab == "ai":
|
if tab == "ai":
|
||||||
active = "email"
|
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(
|
return templates.TemplateResponse(
|
||||||
"settings.html",
|
"settings.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"page_title": "Settings",
|
"page_title": "Settings",
|
||||||
"active_tab": active,
|
"active_tab": active,
|
||||||
|
"can_manage_users": auth_users.can_manage_users(user),
|
||||||
|
"current_user": user,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
"handoff_type": handoff_type,
|
||||||
}
|
}
|
||||||
_log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
|
_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
|
return handoff
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
async def _chat_via_llm_router(
|
||||||
from app.services import 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 = (
|
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel=channel)
|
||||||
"Je bent Herman, de AI-assistent van Foodlinkk B2B (halal vlees, export, CRM). "
|
from app.config import settings as _settings
|
||||||
"Antwoord in het Nederlands, bondig, actiegericht. Geef concrete vervolgstappen waar nuttig."
|
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 = [
|
|
||||||
{"role": "system", "content": system},
|
messages: list[dict[str, str]] = [{"role": "system", "content": system}]
|
||||||
{"role": "user", "content": message},
|
feed_items = await hermes_memory.fetch_recent_feed(hermes_user)
|
||||||
]
|
hist = hermes_memory.feed_to_messages(feed_items)
|
||||||
reply, meta = await llm_router.chat_messages(messages, timeout=120.0)
|
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"
|
provider = meta.get("provider_label") or "LLM"
|
||||||
model = meta.get("model") or ""
|
model = meta.get("model") or ""
|
||||||
await _log_event(
|
await _log_event(
|
||||||
@@ -139,7 +156,13 @@ async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]:
|
|||||||
"llm_chat",
|
"llm_chat",
|
||||||
f"Herman via {provider}",
|
f"Herman via {provider}",
|
||||||
message[:2000],
|
message[:2000],
|
||||||
{"provider": meta, "channel": channel},
|
{
|
||||||
|
"provider": meta,
|
||||||
|
"channel": channel,
|
||||||
|
"hermes_user": hermes_user,
|
||||||
|
"memory_messages": len(hist),
|
||||||
|
"username": username,
|
||||||
|
},
|
||||||
channel=channel,
|
channel=channel,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -147,9 +170,16 @@ async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]:
|
|||||||
"agent_label": f"Herman · {provider}",
|
"agent_label": f"Herman · {provider}",
|
||||||
"reply": reply,
|
"reply": reply,
|
||||||
"delegated_agents": ["herman"],
|
"delegated_agents": ["herman"],
|
||||||
"routing_reason": f"Cloud LLM · {provider}" + (f" · {model}" if model else ""),
|
"routing_reason": (
|
||||||
"agent_steps": _agent_steps([], f"Via {provider}"),
|
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,
|
"llm_provider": meta,
|
||||||
|
"hermes_user": hermes_user,
|
||||||
|
"memory_messages": len(hist),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -158,6 +188,8 @@ async def chat(
|
|||||||
channel: str = "cockpit",
|
channel: str = "cockpit",
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
confirm_action_id: str | None = None,
|
confirm_action_id: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
use_voice_router = (
|
use_voice_router = (
|
||||||
channel in voice_export_actions.VOICE_CHANNELS
|
channel in voice_export_actions.VOICE_CHANNELS
|
||||||
@@ -382,7 +414,7 @@ async def chat(
|
|||||||
# Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
|
# Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
|
||||||
if backend in ("hermes", "router", "openrouter", "llm"):
|
if backend in ("hermes", "router", "openrouter", "llm"):
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
hint = (
|
hint = (
|
||||||
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
|
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
|
||||||
@@ -427,7 +459,7 @@ async def chat(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if backend == "auto":
|
if backend == "auto":
|
||||||
try:
|
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:
|
except Exception as llm_exc:
|
||||||
return {
|
return {
|
||||||
"agent": "herman",
|
"agent": "herman",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
)
|
||||||
@@ -255,6 +255,9 @@ async def chat_messages(
|
|||||||
provider_id: int | None = None,
|
provider_id: int | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
timeout: float = 120.0,
|
timeout: float = 120.0,
|
||||||
|
user: str | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
) -> tuple[str, dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
"""Returns (reply_text, meta dict with provider info)."""
|
"""Returns (reply_text, meta dict with provider info)."""
|
||||||
prov = resolve_provider(provider_id)
|
prov = resolve_provider(provider_id)
|
||||||
@@ -309,9 +312,14 @@ async def chat_messages(
|
|||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"model": use_model,
|
"model": use_model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"temperature": float(extra.get("temperature", 0.4)),
|
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.4)),
|
||||||
"max_tokens": int(extra.get("max_tokens", 2048)),
|
"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:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
resp = await client.post(url, headers=headers, json=payload)
|
resp = await client.post(url, headers=headers, json=payload)
|
||||||
@@ -332,9 +340,99 @@ async def chat_messages(
|
|||||||
"provider_type": ptype,
|
"provider_type": ptype,
|
||||||
"provider_label": prov.get("label"),
|
"provider_label": prov.get("label"),
|
||||||
"model": use_model,
|
"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(
|
async def generate(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
system: str | None = None,
|
system: str | None = None,
|
||||||
|
|||||||
@@ -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
|
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
|
openpyxl>=3.1.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
python-multipart==0.0.12
|
||||||
|
textblob==0.18.0.post0
|
||||||
tweepy==4.15.0
|
tweepy==4.15.0
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
websockets==13.1
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ function revenueCockpit() {
|
|||||||
loading: false,
|
loading: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
savedAt: '',
|
savedAt: '',
|
||||||
|
_pollStop: null,
|
||||||
viewMode: 'cockpit',
|
viewMode: 'cockpit',
|
||||||
|
visualMode: 'table',
|
||||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||||
projects: [],
|
projects: [],
|
||||||
dbProjects: [],
|
dbProjects: [],
|
||||||
@@ -44,8 +46,20 @@ function revenueCockpit() {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
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.loadLive();
|
||||||
await this.loadDbQuiet();
|
await this.loadDbQuiet();
|
||||||
|
if (window.CockpitLive) {
|
||||||
|
this._pollStop = CockpitLive.startPolling(async () => {
|
||||||
|
await this.loadLive();
|
||||||
|
await this.loadDbQuiet();
|
||||||
|
}, 45000);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
projectKey(p) {
|
projectKey(p) {
|
||||||
@@ -119,6 +133,53 @@ function revenueCockpit() {
|
|||||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
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) {
|
styleColor(style) {
|
||||||
const map = {
|
const map = {
|
||||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||||
@@ -167,7 +228,7 @@ function revenueCockpit() {
|
|||||||
donutStyle(p) {
|
donutStyle(p) {
|
||||||
const pct = this.sharePct(p);
|
const pct = this.sharePct(p);
|
||||||
const color = this.styleColor(p.row_style);
|
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) {
|
toggleExpand(p) {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -230,5 +230,11 @@ body.sidebar-resizing iframe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Foodlinkk Command Center — mobile (Android + iOS)
|
* Foodlinkk Command Center — mobile (Android + iOS)
|
||||||
|
* Must load LAST so it wins over app-sidebar-neo / herman-dashboard.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
@@ -32,8 +33,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
z-index: 998;
|
z-index: 999;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@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[style*="repeat(3"] { grid-template-columns: 1fr !important; }
|
||||||
.regs-columns { flex-direction: column !important; display: flex !important; }
|
.regs-columns { flex-direction: column !important; display: flex !important; }
|
||||||
.pwa-install-bar { display: flex; }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.mobile-menu-btn { display: inline-flex; }
|
.mobile-menu-btn { display: inline-flex !important; }
|
||||||
.mobile-nav-overlay { display: block; }
|
.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 {
|
.app-shell {
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr !important;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
.app-main {
|
.app-main {
|
||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hard overrides — must beat app-sidebar-neo.css */
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 0; left: 0; bottom: 0;
|
top: 0 !important;
|
||||||
width: min(280px, 88vw);
|
left: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: min(280px, 88vw) !important;
|
||||||
height: 100dvh !important;
|
height: 100dvh !important;
|
||||||
z-index: 999;
|
z-index: 1000 !important;
|
||||||
transform: translateX(-105%);
|
transform: translateX(-105%) !important;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease !important;
|
||||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4);
|
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-direction: column !important;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden !important;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
.app-sidebar-resizer { display: none !important; }
|
||||||
.persona-nav {
|
.persona-nav {
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
padding-bottom: 0;
|
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; }
|
body.nav-open { overflow: hidden; }
|
||||||
|
|
||||||
.app-topbar {
|
.app-topbar {
|
||||||
position: sticky; top: 0; z-index: 100;
|
position: sticky; top: 0; z-index: 100;
|
||||||
padding-top: max(0.5rem, var(--safe-top));
|
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 {
|
.retail-actions .btn, .herman-briefing-actions .btn {
|
||||||
min-width: 100%; flex: 1 1 100%;
|
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 {
|
.pwa-install-bar {
|
||||||
display: none;
|
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: 0.75rem 1rem;
|
||||||
padding-bottom: max(0.75rem, var(--safe-bottom));
|
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);
|
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap;
|
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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -956,3 +956,47 @@
|
|||||||
.rc-visual-toggle { order: 5; flex: 1 1 100%; }
|
.rc-visual-toggle { order: 5; flex: 1 1 100%; }
|
||||||
.rc-style-chips { order: 6; 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -94,15 +94,20 @@ window.AgentAvatars = (function () {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function paint(el, key, color) {
|
function paint(el, key, color, force) {
|
||||||
if (!el) return;
|
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 = root || document;
|
||||||
root.querySelectorAll('[data-agent-avatar]').forEach(function (el) {
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -30,6 +30,10 @@ window.Cockpit = (function () {
|
|||||||
const fetchOpts = Object.assign({}, options, { headers: headers });
|
const fetchOpts = Object.assign({}, options, { headers: headers });
|
||||||
if (options.signal) fetchOpts.signal = options.signal;
|
if (options.signal) fetchOpts.signal = options.signal;
|
||||||
const res = await fetch(url, fetchOpts);
|
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;
|
let data = null;
|
||||||
try { data = await res.json(); } catch (e) { data = null; }
|
try { data = await res.json(); } catch (e) { data = null; }
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -638,6 +638,13 @@ window.HermanAssistant = (function () {
|
|||||||
this.busy = false;
|
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) {
|
formatDelegated: function (meta) {
|
||||||
var d = (meta && meta.delegated) || [];
|
var d = (meta && meta.delegated) || [];
|
||||||
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
|
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ function revenueCockpit() {
|
|||||||
async init() {
|
async init() {
|
||||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||||
const saved = localStorage.getItem('rc-visual-mode');
|
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.loadLive();
|
||||||
await this.loadDbQuiet();
|
await this.loadDbQuiet();
|
||||||
if (window.CockpitLive) {
|
if (window.CockpitLive) {
|
||||||
|
|||||||
@@ -1,26 +1,57 @@
|
|||||||
{
|
{
|
||||||
"name": "Cucina · Foodlinkk Command Center",
|
"id": "/",
|
||||||
|
"name": "Foodlinkk Command Center",
|
||||||
"short_name": "Foodlinkk",
|
"short_name": "Foodlinkk",
|
||||||
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
|
"description": "CEO dashboard · Revenue Cockpit · CRM · Retail · Cucina & Foodlinkk",
|
||||||
"start_url": "/",
|
"start_url": "/?source=pwa",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"display_override": ["standalone", "fullscreen", "browser"],
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#0a0e14",
|
"background_color": "#0a0e14",
|
||||||
"theme_color": "#0a0e14",
|
"theme_color": "#0a0e14",
|
||||||
"lang": "nl",
|
"lang": "nl",
|
||||||
|
"dir": "ltr",
|
||||||
|
"categories": ["business", "productivity", "finance"],
|
||||||
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"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",
|
"src": "/static/icons/app-icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"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",
|
"name": "Revenue Cockpit",
|
||||||
"sizes": "512x512",
|
"short_name": "Revenue",
|
||||||
"type": "image/svg+xml",
|
"url": "/revenue-cockpit?source=pwa",
|
||||||
"purpose": "maskable"
|
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-7
@@ -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.addEventListener('install', (event) => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
event.waitUntil(
|
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) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys()
|
caches.keys().then((keys) =>
|
||||||
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
.then(() => self.clients.claim())
|
).then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Do not intercept fetches — always use network */
|
self.addEventListener('fetch', (event) => {
|
||||||
self.addEventListener('fetch', () => {});
|
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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,17 +5,23 @@
|
|||||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
<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/agents-mesh.css?v=8" />
|
||||||
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
|
<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 %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
|
<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>
|
<h1>Agents · Soul Registry</h1>
|
||||||
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
|
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
|
||||||
</header>
|
</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 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">
|
<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="hm-chart-head"><h4>Agent activiteit (events)</h4><small>5 visualisatie-modi</small></div>
|
||||||
<div class="page-viz-body">
|
<div class="page-viz-body">
|
||||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents-viz"></canvas></div>
|
<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-icon">✓</span><span>Goedkeuringen</span>
|
||||||
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
|
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div class="vtabs-content">
|
<div class="vtabs-content">
|
||||||
@@ -262,6 +271,82 @@
|
|||||||
<p x-show="!items.length" class="muted">Geen approval requests.</p>
|
<p x-show="!items.length" class="muted">Geen approval requests.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,6 +389,7 @@
|
|||||||
<script src="/static/js/page-viz.js?v=1"></script>
|
<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-mesh.js?v=11"></script>
|
||||||
<script src="/static/js/agents-terminals.js?v=6"></script>
|
<script src="/static/js/agents-terminals.js?v=6"></script>
|
||||||
|
<script src="/static/js/agents-warroom.js?v=4"></script>
|
||||||
<script>
|
<script>
|
||||||
function agentsSoulHub() {
|
function agentsSoulHub() {
|
||||||
return {
|
return {
|
||||||
@@ -384,7 +470,14 @@ function agentsSoulHub() {
|
|||||||
this.syncMeshMode();
|
this.syncMeshMode();
|
||||||
await this.load();
|
await this.load();
|
||||||
await this.loadPendingCount();
|
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) {
|
if (window.PageViz) {
|
||||||
window.PageViz.init('agents', (mode) => this.renderViz(mode));
|
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 === 'mesh') this.mountMesh();
|
||||||
if (this.tab === 'terminals') this.startTerminals();
|
if (this.tab === 'terminals') this.startTerminals();
|
||||||
});
|
});
|
||||||
@@ -412,6 +505,7 @@ function agentsSoulHub() {
|
|||||||
this.tabIndicatorH = btnRect.height || 40;
|
this.tabIndicatorH = btnRect.height || 40;
|
||||||
},
|
},
|
||||||
setTab(next, el) {
|
setTab(next, el) {
|
||||||
|
const prev = this.tab;
|
||||||
this.tab = next;
|
this.tab = next;
|
||||||
this.updateTabIndicator(el);
|
this.updateTabIndicator(el);
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
@@ -419,6 +513,15 @@ function agentsSoulHub() {
|
|||||||
window.history.replaceState({}, '', url.toString());
|
window.history.replaceState({}, '', url.toString());
|
||||||
if (next === 'mesh') this.mountMesh();
|
if (next === 'mesh') this.mountMesh();
|
||||||
if (next === 'terminals') this.startTerminals();
|
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();
|
if (next === 'approvals') this.loadPendingCount();
|
||||||
},
|
},
|
||||||
async loadPendingCount() {
|
async loadPendingCount() {
|
||||||
@@ -511,16 +614,23 @@ function agentsSoulHub() {
|
|||||||
},
|
},
|
||||||
async load(quiet) {
|
async load(quiet) {
|
||||||
const r = await fetch('/api/agents/souls').then(x => x.json());
|
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.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.$nextTick(() => {
|
||||||
(this.souls || []).forEach((s) => {
|
(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);
|
if (el && window.AgentAvatars) AgentAvatars.paint(el, s.agent_key, s.avatar_color);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
this.renderViz(window.__vizMode || 'neo-bars');
|
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) {
|
renderViz(mode) {
|
||||||
if (!window.VizEngine) return;
|
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
@@ -12,22 +12,30 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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="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="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/palantir-theme.css?v=4" />
|
||||||
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
|
<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/topnav-neo.css?v=6" />
|
||||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
|
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
|
||||||
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
|
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
|
||||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
|
<script defer src="/static/js/herman-assistant.js?v=17"></script>
|
||||||
<script defer src="/static/js/herman-assistant.js?v=16"></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/alpine.min.js?v=2"></script>
|
||||||
<script defer src="/static/js/htmx.min.js?v=2"></script>
|
<script defer src="/static/js/htmx.min.js?v=2"></script>
|
||||||
{% block extra_head %}{% endblock %}
|
{% 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/cockpit-typography.css?v=1" />
|
||||||
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=16" />
|
<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>
|
</head>
|
||||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
|
<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">
|
<div class="app-shell">
|
||||||
@@ -51,36 +59,36 @@
|
|||||||
<div class="persona-nav">
|
<div class="persona-nav">
|
||||||
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
|
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
|
||||||
<div class="app-sidebar-label">Foodlinkk B2B</div>
|
<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="/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" 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="/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" 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="/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" 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="/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" 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="/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" 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="/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>
|
||||||
|
|
||||||
<div class="app-sidebar-group" x-show="brand === 'cucina'">
|
<div class="app-sidebar-group" x-show="brand === 'cucina'">
|
||||||
<div class="app-sidebar-label">Cucina B2C</div>
|
<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="/" 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" 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="/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" 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="/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" 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="/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" 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="/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" 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="/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" 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="/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" 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="/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" 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="/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" 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="/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>
|
||||||
|
|
||||||
<div class="app-sidebar-group">
|
<div class="app-sidebar-group">
|
||||||
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
|
<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="/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" 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="/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" 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="/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" 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="/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" 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="/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" 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="/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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -100,15 +108,39 @@
|
|||||||
<option value="en">EN</option>
|
<option value="en">EN</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
|
<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>
|
</header>
|
||||||
<main class="main-topnav">{% block content %}{% endblock %}</main>
|
<main class="main-topnav">{% block content %}{% endblock %}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
|
<div class="pwa-install-bar" x-show="showInstall" x-cloak
|
||||||
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
|
:class="{ 'is-ios': isIos, 'is-android': isAndroid }"
|
||||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
|
style="display:none">
|
||||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Herman chat — altijd vast onderin zichtbaar -->
|
<!-- Herman chat — altijd vast onderin zichtbaar -->
|
||||||
@@ -136,6 +168,8 @@
|
|||||||
<div class="ha-head-actions">
|
<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>
|
<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="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>
|
<a href="/herman">Volledig</a>
|
||||||
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
|
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +258,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/i18n.js?v=5"></script>
|
<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/viz-engine.js?v=3"></script>
|
||||||
<script src="/static/js/global-viz.js?v=1"></script>
|
<script src="/static/js/global-viz.js?v=1"></script>
|
||||||
<script src="/static/js/live-pulse.js?v=3"></script>
|
<script src="/static/js/live-pulse.js?v=3"></script>
|
||||||
@@ -250,6 +284,47 @@
|
|||||||
/* legacy persona helper → brand */
|
/* legacy persona helper → brand */
|
||||||
window.FoodlinkkPersona = window.FoodlinkkBrand;
|
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() {
|
function appShell() {
|
||||||
return {
|
return {
|
||||||
locale: 'nl',
|
locale: 'nl',
|
||||||
@@ -258,21 +333,24 @@
|
|||||||
navOpen: false,
|
navOpen: false,
|
||||||
sidebarWidth: 280,
|
sidebarWidth: 280,
|
||||||
showInstall: false,
|
showInstall: false,
|
||||||
|
authUser: null,
|
||||||
|
authPages: [],
|
||||||
_deferredPrompt: null,
|
_deferredPrompt: null,
|
||||||
clockDate: '—',
|
clockDate: '—',
|
||||||
clockTime: '—',
|
clockTime: '—',
|
||||||
_offset: 0,
|
_offset: 0,
|
||||||
|
isIos: false,
|
||||||
async initApp() {
|
async initApp() {
|
||||||
this.brand = window.FoodlinkkBrand.detect(location.pathname);
|
this.brand = window.FoodlinkkBrand.detect(location.pathname);
|
||||||
if (!localStorage.getItem('flk-cache-v4')) {
|
this.isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||||
if ('serviceWorker' in navigator) {
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
const regs = await navigator.serviceWorker.getRegistrations();
|
// One-time migration off legacy no-op SW
|
||||||
await Promise.all(regs.map((r) => r.unregister()));
|
if (!localStorage.getItem('flk-pwa-v6')) {
|
||||||
}
|
|
||||||
if (window.caches) {
|
if (window.caches) {
|
||||||
const keys = await caches.keys();
|
const keys = await caches.keys();
|
||||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||||
}
|
}
|
||||||
|
localStorage.setItem('flk-pwa-v6', '1');
|
||||||
localStorage.setItem('flk-cache-v4', '1');
|
localStorage.setItem('flk-cache-v4', '1');
|
||||||
}
|
}
|
||||||
if (window.I18n) await window.I18n.init();
|
if (window.I18n) await window.I18n.init();
|
||||||
@@ -280,6 +358,59 @@
|
|||||||
this.initSidebarWidth();
|
this.initSidebarWidth();
|
||||||
this.initClock();
|
this.initClock();
|
||||||
this.initPwa();
|
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() {
|
initSidebarWidth() {
|
||||||
var saved = parseInt(localStorage.getItem('flk-sidebar-width') || '280', 10);
|
var saved = parseInt(localStorage.getItem('flk-sidebar-width') || '280', 10);
|
||||||
@@ -340,32 +471,49 @@
|
|||||||
tick();
|
tick();
|
||||||
setInterval(tick, 1000);
|
setInterval(tick, 1000);
|
||||||
},
|
},
|
||||||
|
isAndroid: false,
|
||||||
initPwa() {
|
initPwa() {
|
||||||
if (window.matchMedia('(display-mode: standalone)').matches) return;
|
const standalone = window.matchMedia('(display-mode: standalone)').matches
|
||||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
|| window.navigator.standalone === true;
|
||||||
this.showInstall = !localStorage.getItem('pwa-dismiss');
|
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) => {
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this._deferredPrompt = e;
|
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() {
|
async installPwa() {
|
||||||
if (this._deferredPrompt) {
|
if (this._deferredPrompt) {
|
||||||
this._deferredPrompt.prompt();
|
this._deferredPrompt.prompt();
|
||||||
await this._deferredPrompt.userChoice;
|
await this._deferredPrompt.userChoice;
|
||||||
this._deferredPrompt = null;
|
this._deferredPrompt = null;
|
||||||
this.showInstall = false;
|
this.dismissPwa();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
if (this.isIos) {
|
||||||
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
|
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 {
|
} 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;
|
this.dismissPwa();
|
||||||
localStorage.setItem('pwa-dismiss', '1');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<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/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 %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="herman-shell hm-neo">
|
<div class="herman-shell hm-neo">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,6 +2,48 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<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" />
|
<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 %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div x-data="marketingHub()" x-init="init()">
|
<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>
|
<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>
|
||||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
|
<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>
|
<a class="btn btn-sm" href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Bron site →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem">
|
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem" role="group" aria-label="Reclame snelfilters">
|
||||||
<div class="kpi-card"><span>Ketens</span><strong x-text="reclameStats.chains||0"></strong></div>
|
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'ketens' }" @click="onReclameKpi('ketens')" title="Open ketens-filter">
|
||||||
<div class="kpi-card kpi-green"><span>Folders</span><strong x-text="filteredPromos.length"></strong></div>
|
<span>Ketens</span><strong x-text="reclameStats.chains||0"></strong>
|
||||||
<div class="kpi-card"><span>Totaal geladen</span><strong x-text="promos.length"></strong></div>
|
</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>
|
||||||
<div class="form-grid reclame-filters" style="margin-bottom:1rem">
|
<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>
|
<option value="">Alle ketens</option>
|
||||||
<template x-for="c in reclameChains" :key="c">
|
<template x-for="c in reclameChains" :key="c">
|
||||||
<option :value="c" x-text="c"></option>
|
<option :value="c" x-text="c"></option>
|
||||||
@@ -177,7 +225,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
<h3 style="margin-top:1.5rem">Nieuwe folder registreren</h3>
|
||||||
<form class="form-grid" @submit.prevent="addPromo">
|
<form class="form-grid" @submit.prevent="addPromo">
|
||||||
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
|
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
|
||||||
@@ -397,10 +445,11 @@ function marketingHub() {
|
|||||||
return {
|
return {
|
||||||
tab: new URLSearchParams(location.search).get('tab') || 'live',
|
tab: new URLSearchParams(location.search).get('tab') || 'live',
|
||||||
tabIndicatorTop: 0, tabIndicatorH: 44,
|
tabIndicatorTop: 0, tabIndicatorH: 44,
|
||||||
busy: false, dashboard: {}, socialAnalytics: '',
|
busy: false, reclameBusy: false, dashboard: {}, socialAnalytics: '',
|
||||||
regRows: { regelgeving: [], cbs: [], markt: [] },
|
regRows: { regelgeving: [], cbs: [], markt: [] },
|
||||||
promos: [], bookmarks: [], bookmarkIds: {},
|
promos: [], bookmarks: [], bookmarkIds: {},
|
||||||
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
|
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
|
||||||
|
reclameKpi: 'folders',
|
||||||
reclameStats: { chains: 0 },
|
reclameStats: { chains: 0 },
|
||||||
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
|
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
|
||||||
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
|
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
|
||||||
@@ -457,7 +506,30 @@ function marketingHub() {
|
|||||||
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
|
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
|
||||||
this.tabIndicatorH = btn.offsetHeight;
|
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() {
|
async loadReclameChains() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
|
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
|
||||||
this.reclameChains = d.chains || [];
|
this.reclameChains = d.chains || [];
|
||||||
@@ -471,10 +543,15 @@ function marketingHub() {
|
|||||||
if (this.reclameFilter.q) params.set('q', this.reclameFilter.q);
|
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.folder_type) params.set('folder_type', this.reclameFilter.folder_type);
|
||||||
if (this.reclameFilter.valid_days) params.set('valid_days', this.reclameFilter.valid_days);
|
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'))));
|
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;
|
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 = []; }
|
} catch (e) { this.promos = []; }
|
||||||
},
|
},
|
||||||
promoUrl(p) {
|
promoUrl(p) {
|
||||||
@@ -489,15 +566,26 @@ function marketingHub() {
|
|||||||
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
|
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
|
||||||
},
|
},
|
||||||
async refreshReclamefolders(toast = true) {
|
async refreshReclamefolders(toast = true) {
|
||||||
this.busy = true;
|
if (this.reclameBusy) return;
|
||||||
|
this.reclameBusy = true;
|
||||||
try {
|
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.loadPromos();
|
||||||
await this.loadReclameChains();
|
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) {
|
} catch (e) {
|
||||||
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt', 'error');
|
console.error('reclamefolder refresh', e);
|
||||||
} finally { this.busy = false; }
|
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt: ' + (e.message || e), 'error');
|
||||||
|
} finally { this.reclameBusy = false; }
|
||||||
},
|
},
|
||||||
async addPromo() {
|
async addPromo() {
|
||||||
await fetch('/api/retail/promo-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.promoForm) });
|
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 refreshRss() { this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadDashboard(); }finally{ this.busy=false; } },
|
||||||
async refreshAll(toast) {
|
async refreshAll(toast) {
|
||||||
this.busy = true;
|
const silent = toast === false;
|
||||||
|
if (!silent) this.busy = true;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
||||||
await this.loadDashboard();
|
await this.loadDashboard();
|
||||||
|
if (this.tab === 'reclame' && !this.reclameBusy) {
|
||||||
|
await this.loadPromos();
|
||||||
|
await this.loadReclameChains();
|
||||||
|
}
|
||||||
this.renderViz(window.__vizMode || 'neo-bars');
|
this.renderViz(window.__vizMode || 'neo-bars');
|
||||||
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
|
if (!silent) Cockpit.toast('Marketing data bijgewerkt', 'success');
|
||||||
} finally { this.busy = false; }
|
} finally { if (!silent) this.busy = false; }
|
||||||
},
|
},
|
||||||
async addMention() { await Cockpit.api('/mentions',{method:'POST',body:JSON.stringify(this.mentionForm)}); location.reload(); },
|
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(); },
|
async deleteMention(id) { if(!Cockpit.confirmDelete()) return; await Cockpit.api('/mentions/'+id,{method:'DELETE'}); location.reload(); },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
||||||
{% block extra_head %}
|
{% 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 %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
||||||
@@ -336,5 +336,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/revenue-cockpit.js?v=12"></script>
|
<script src="/static/js/revenue-cockpit.js?v=13"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
|
<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=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>
|
<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>
|
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -138,6 +141,111 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 & 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' %}
|
{% elif active_tab == 'social' %}
|
||||||
<section class="panel settings-panel" x-data="socialSettingsPage()" x-init="load()">
|
<section class="panel settings-panel" x-data="socialSettingsPage()" x-init="load()">
|
||||||
<div class="settings-panel-head">
|
<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() {
|
function permissionsPage() {
|
||||||
return {
|
return {
|
||||||
perms: [], grantedCount: 0, total: 0,
|
perms: [], grantedCount: 0, total: 0,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /home/aissa/nas-clients:/data/nas-clients
|
- /home/aissa/nas-clients:/data/nas-clients
|
||||||
- /mnt/synology:/data/nas-share:ro
|
- /mnt/synology:/data/nas-share:ro
|
||||||
|
- ./cockpit/static/uploads:/app/static/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
- tools-api
|
- tools-api
|
||||||
- whisper
|
- whisper
|
||||||
|
|||||||
@@ -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}';
|
||||||
@@ -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/…';
|
||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user