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

This commit is contained in:
sysops
2026-08-16 11:01:58 +00:00
parent 22a17ed048
commit 1dd607b060
46 changed files with 3734 additions and 147 deletions
+292
View File
@@ -0,0 +1,292 @@
"""Cockpit user accounts: password hashing, CRUD, permissions."""
from __future__ import annotations
import hashlib
import json
import secrets
from typing import Any, Optional
from app.db import execute, fetch_all, fetch_one
# Nav / feature keys shown in Settings → Gebruikers
PAGE_CATALOG: list[dict[str, str]] = [
{"key": "dashboard", "label": "Overzicht (CEO)"},
{"key": "revenue", "label": "Revenue Cockpit"},
{"key": "retail", "label": "Retail NL"},
{"key": "marketing", "label": "Marketing"},
{"key": "packaging", "label": "Packaging"},
{"key": "beurs", "label": "Beurs Live"},
{"key": "documents", "label": "Documenten"},
{"key": "projects", "label": "Projecten"},
{"key": "hermes", "label": "Telegram / Hermes"},
{"key": "analytics", "label": "Analytics"},
{"key": "export", "label": "Wereldexport"},
{"key": "clients", "label": "Klanten"},
{"key": "deals", "label": "Deals"},
{"key": "products", "label": "Producten"},
{"key": "suppliers", "label": "Leveranciers"},
{"key": "reports", "label": "Rapporten"},
{"key": "ops", "label": "IT Ops"},
{"key": "agents", "label": "Agents"},
{"key": "browser", "label": "Browser"},
{"key": "studio", "label": "Studio"},
{"key": "voice", "label": "Voice"},
{"key": "settings", "label": "Instellingen"},
]
# path prefix → permission key (order: longest prefix first)
PATH_RULES: list[tuple[str, str]] = [
("/revenue-cockpit", "revenue"),
("/export-intel", "export"),
("/packaging", "packaging"),
("/marketing", "marketing"),
("/documents", "documents"),
("/analytics", "analytics"),
("/suppliers", "suppliers"),
("/products", "products"),
("/projects", "projects"),
("/settings", "settings"),
("/clients", "clients"),
("/browser", "browser"),
("/monitor", "browser"),
("/hermes", "hermes"),
("/retail", "retail"),
("/reports", "reports"),
("/studio", "studio"),
("/agents", "agents"),
("/beurs", "beurs"),
("/deals", "deals"),
("/voice", "voice"),
("/ops", "ops"),
("/", "dashboard"),
]
ADMIN_SEED_USERNAME = "aissa"
ADMIN_SEED_PASSWORD = "Foodlinkk#2026"
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
rounds = 200_000
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return f"pbkdf2_sha256${rounds}${salt}${dk.hex()}"
def verify_password(password: str, password_hash: str) -> bool:
try:
algo, rounds_s, salt, digest = password_hash.split("$", 3)
if algo != "pbkdf2_sha256":
return False
rounds = int(rounds_s)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), rounds)
return secrets.compare_digest(dk.hex(), digest)
except Exception:
return False
def _normalize_perms(raw: Any) -> dict[str, Any]:
if raw is None:
return {"pages": [], "manage_users": False}
if isinstance(raw, str):
try:
raw = json.loads(raw)
except Exception:
return {"pages": [], "manage_users": False}
if not isinstance(raw, dict):
return {"pages": [], "manage_users": False}
pages = raw.get("pages") or []
if not isinstance(pages, list):
pages = []
return {
"pages": [str(p) for p in pages],
"manage_users": bool(raw.get("manage_users")),
}
def public_user(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
perms = _normalize_perms(row.get("permissions"))
role = row.get("role") or "user"
if role == "admin":
perms = {
"pages": [p["key"] for p in PAGE_CATALOG],
"manage_users": True,
}
return {
"id": row["id"],
"username": row["username"],
"display_name": row.get("display_name") or row["username"],
"role": role,
"is_active": bool(row.get("is_active", True)),
"avatar_url": row.get("avatar_url") or None,
"permissions": perms,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"last_login_at": row["last_login_at"].isoformat() if row.get("last_login_at") else None,
}
def ensure_admin_seed() -> None:
"""Create default admin aissa if table empty / user missing."""
row = fetch_one("SELECT id FROM cockpit_users WHERE username = %s", (ADMIN_SEED_USERNAME,))
if row:
return
execute(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, 'admin', %s::jsonb)
ON CONFLICT (username) DO NOTHING
""",
(
ADMIN_SEED_USERNAME,
hash_password(ADMIN_SEED_PASSWORD),
"Aissa",
json.dumps({"pages": [p["key"] for p in PAGE_CATALOG], "manage_users": True}),
),
)
def authenticate(username: str, password: str) -> Optional[dict[str, Any]]:
row = fetch_one(
"SELECT * FROM cockpit_users WHERE lower(username) = lower(%s)",
(username.strip(),),
)
if not row or not row.get("is_active"):
return None
if not verify_password(password, row["password_hash"]):
return None
execute(
"UPDATE cockpit_users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(row["id"],),
)
return public_user(row)
def get_user_by_id(user_id: int) -> Optional[dict[str, Any]]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
return public_user(row)
def list_users() -> list[dict[str, Any]]:
rows = fetch_all("SELECT * FROM cockpit_users ORDER BY username ASC")
return [public_user(r) for r in rows if r]
def create_user(
username: str,
password: str,
display_name: str | None,
role: str,
permissions: dict[str, Any],
) -> dict[str, Any]:
username = username.strip()
if not username or not password:
raise ValueError("Gebruikersnaam en wachtwoord zijn verplicht")
if role not in ("admin", "user"):
role = "user"
perms = _normalize_perms(permissions)
if role == "admin":
perms["manage_users"] = True
perms["pages"] = [p["key"] for p in PAGE_CATALOG]
row = fetch_one(
"""
INSERT INTO cockpit_users (username, password_hash, display_name, role, permissions)
VALUES (%s, %s, %s, %s, %s::jsonb)
RETURNING *
""",
(username, hash_password(password), (display_name or username).strip(), role, json.dumps(perms)),
)
return public_user(row) # type: ignore
def update_user(
user_id: int,
*,
display_name: str | None = None,
role: str | None = None,
is_active: bool | None = None,
permissions: dict[str, Any] | None = None,
password: str | None = None,
) -> dict[str, Any]:
row = fetch_one("SELECT * FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
new_name = display_name if display_name is not None else row.get("display_name")
new_role = role if role in ("admin", "user") else row["role"]
new_active = row["is_active"] if is_active is None else bool(is_active)
new_perms = _normalize_perms(permissions if permissions is not None else row.get("permissions"))
if new_role == "admin":
new_perms["manage_users"] = True
new_perms["pages"] = [p["key"] for p in PAGE_CATALOG]
pw_hash = row["password_hash"]
if password:
pw_hash = hash_password(password)
updated = fetch_one(
"""
UPDATE cockpit_users
SET display_name = %s, role = %s, is_active = %s, permissions = %s::jsonb,
password_hash = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(new_name, new_role, new_active, json.dumps(new_perms), pw_hash, user_id),
)
return public_user(updated) # type: ignore
def delete_user(user_id: int, *, actor_id: int) -> None:
if user_id == actor_id:
raise ValueError("Je kunt jezelf niet verwijderen")
row = fetch_one("SELECT role FROM cockpit_users WHERE id = %s", (user_id,))
if not row:
raise ValueError("Gebruiker niet gevonden")
if row["role"] == "admin":
admins = fetch_one(
"SELECT COUNT(*)::int AS n FROM cockpit_users WHERE role = 'admin' AND is_active = TRUE"
)
if admins and admins["n"] <= 1:
raise ValueError("Laatste admin kan niet verwijderd worden")
execute("DELETE FROM cockpit_users WHERE id = %s", (user_id,))
def path_allowed(user: dict[str, Any] | None, path: str) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
pages = set((user.get("permissions") or {}).get("pages") or [])
# normalize
p = path.split("?")[0] or "/"
if p != "/" and p.endswith("/"):
p = p.rstrip("/")
for prefix, key in PATH_RULES:
if prefix == "/":
if p == "/" or p == "":
return "dashboard" in pages
continue
if p == prefix or p.startswith(prefix + "/"):
return key in pages
return "dashboard" in pages
def can_manage_users(user: dict[str, Any] | None) -> bool:
if not user:
return False
if user.get("role") == "admin":
return True
return bool((user.get("permissions") or {}).get("manage_users"))
def set_avatar(user_id: int, avatar_url: str | None) -> dict[str, Any]:
row = fetch_one(
"""
UPDATE cockpit_users
SET avatar_url = %s, updated_at = NOW()
WHERE id = %s
RETURNING *
""",
(avatar_url, user_id),
)
if not row:
raise ValueError("Gebruiker niet gevonden")
return public_user(row) # type: ignore
+6
View File
@@ -24,10 +24,16 @@ class Settings:
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
# Bind cockpit chat aan Hermes/Telegram memory (chat_id als OpenAI "user")
HERMES_CEO_CHAT_ID: str = os.getenv("HERMES_CEO_CHAT_ID", "8859782446")
HERMES_CTO_CHAT_ID: str = os.getenv("HERMES_CTO_CHAT_ID", "789036463")
HERMES_MEMORY_LIMIT: int = int(os.getenv("HERMES_MEMORY_LIMIT", "24"))
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
SESSION_SECRET: str = os.getenv("SESSION_SECRET", "foodlinkk-cockpit-session-change-me-2026")
SESSION_MAX_AGE: int = int(os.getenv("SESSION_MAX_AGE", str(60 * 60 * 24 * 14))) # 14 days
@property
def database_dsn(self) -> str:
+137
View File
@@ -3,6 +3,8 @@ import json
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.responses import Response
@@ -38,6 +40,9 @@ from app.routes import (
from app.routes.revenue_cockpit import api as revenue_cockpit_api
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
from app.routes.settings_api import settings_router
from app.routes import auth_routes
from app import auth_users
from app.config import settings as app_settings
from app.routes.agents_api import router as agents_api_router
from app.routes.marketing_api import router as marketing_api_router
from app.routes.projects_api import router as projects_api_router
@@ -59,6 +64,131 @@ class NoCacheStaticFiles(StaticFiles):
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
PUBLIC_PREFIXES = (
"/login",
"/logout",
"/static/",
"/sw.js",
"/manifest.webmanifest",
"/static/manifest.json",
"/api/auth/login",
)
PUBLIC_EXACT = {"/favicon.ico", "/robots.txt"}
def _is_public(path: str) -> bool:
if path in PUBLIC_EXACT:
return True
for pref in PUBLIC_PREFIXES:
if path == pref or path.startswith(pref):
return True
return False
class AuthGateMiddleware:
"""Require login; must run *inside* SessionMiddleware."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
from starlette.requests import Request
request = Request(scope, receive=receive)
path = request.url.path
user = None
try:
uid = request.session.get("user_id")
except Exception:
uid = None
if uid:
try:
user = auth_users.get_user_by_id(int(uid))
except Exception:
user = None
if not user or not user.get("is_active"):
try:
request.session.clear()
except Exception:
pass
user = None
request.state.user = user
async def call_next():
await self.app(scope, receive, send)
if _is_public(path):
await self.app(scope, receive, send)
return
if not user:
if path.startswith("/api/") or path.startswith("/ws"):
response = JSONResponse({"detail": "Niet ingelogd"}, status_code=401)
else:
nxt = path
if request.url.query:
nxt = f"{path}?{request.url.query}"
from urllib.parse import quote
response = RedirectResponse(f"/login?next={quote(nxt)}", status_code=303)
await response(scope, receive, send)
return
if (
not path.startswith("/api/")
and not path.startswith("/ws")
and not path.startswith("/static")
and not auth_users.path_allowed(user, path)
):
response = RedirectResponse("/", status_code=303)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
# Order: first added = innermost. Session must be outermost so request.session works.
app.add_middleware(AuthGateMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=app_settings.SESSION_SECRET,
session_cookie="flk_session",
same_site="lax",
https_only=False,
max_age=app_settings.SESSION_MAX_AGE,
)
@app.api_route("/sw.js", methods=["GET", "HEAD"], include_in_schema=False)
async def service_worker():
"""Root-scoped service worker for installable PWA (Android + iOS Safari)."""
sw_path = BASE_DIR / "static" / "sw.js"
return FileResponse(
sw_path,
media_type="application/javascript; charset=utf-8",
headers={
"Service-Worker-Allowed": "/",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)
@app.api_route("/manifest.webmanifest", methods=["GET", "HEAD"], include_in_schema=False)
async def web_manifest():
man_path = BASE_DIR / "static" / "manifest.json"
return FileResponse(
man_path,
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)
app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
@@ -77,6 +207,7 @@ for r in (
reports.router,
voice.router,
settings.router,
auth_routes.router,
browser.router,
herman.router,
studio.router,
@@ -96,6 +227,8 @@ for r in (
agents_api_router,
marketing_api_router,
projects_api_router,
auth_routes.api_router,
auth_routes.users_api,
):
app.include_router(r)
@@ -103,6 +236,10 @@ for r in (
@app.on_event("startup")
def on_startup() -> None:
init_pool()
try:
auth_users.ensure_admin_seed()
except Exception as e:
print(f"auth seed warning: {e}")
@app.on_event("shutdown")
+5 -2
View File
@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import Request, APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.config import settings
@@ -1019,14 +1019,17 @@ async def ai_generate_post_draft(body: AIPostDraftBody):
@herman_api.post("/chat")
async def herman_chat_api(body: HermanChatBody):
async def herman_chat_api(request: Request, body: HermanChatBody):
if not body.message.strip():
raise HTTPException(400, "message required")
user = getattr(request.state, "user", None) or {}
result = await herman_service.chat(
body.message.strip(),
channel=(body.channel or "cockpit").strip(),
session_id=body.session_id,
confirm_action_id=body.confirm_action_id,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
)
return {"ok": True, **result}
+10 -2
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import execute, fetch_all
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
active_tab = "souls"
events: list = []
try:
@@ -52,6 +52,14 @@ async def agents_page(request: Request):
)
@router.get("/warroom/popup", response_class=HTMLResponse)
async def agents_warroom_popup(request: Request):
return templates.TemplateResponse(
"agents_warroom_popup.html",
{"request": request, "page_title": "War Room"},
)
@router.post("/approve/{event_id}")
async def approve_event(event_id: int, next_url: str = Form("/")):
try:
+119 -1
View File
@@ -5,7 +5,7 @@ import asyncio
import json
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
@@ -545,3 +545,121 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req}
# --- War Room chat ---
class WarroomMessageBody(BaseModel):
content: str = Field("", max_length=8000)
attachments: list[dict] = Field(default_factory=list)
@router.get("/warroom/rooms")
async def warroom_list_rooms() -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
return {"items": agent_chat.list_rooms()}
@router.post("/warroom/rooms/dm/{agent_key}")
async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
from app.services import agent_chat
try:
room = agent_chat.get_or_create_dm(agent_key)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"room": room}
@router.get("/warroom/rooms/{room_id}/messages")
async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]:
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
return {"room": room, "items": agent_chat.list_messages(room_id, limit=min(limit, 200), after_id=after_id)}
@router.post("/warroom/rooms/{room_id}/messages")
async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]:
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
try:
result = await agent_chat.post_user_message(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.post("/warroom/rooms/{room_id}/messages/stream")
async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody):
"""SSE stream: user → typing tokens → agent message(s) → done."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.post_user_message_stream(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/warroom/upload")
async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]:
"""Upload a file to share in War Room (images/docs)."""
from pathlib import Path as P
import uuid
import re
raw_name = (file.filename or "upload.bin").strip()
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin"
ext = P(safe).suffix.lower()
allowed = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv",
".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md",
}
if ext not in allowed:
raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}")
data = await file.read()
if len(data) > 12 * 1024 * 1024:
raise HTTPException(400, "Max 12MB per bestand")
dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom"
dest_dir.mkdir(parents=True, exist_ok=True)
fname = f"{uuid.uuid4().hex[:12]}_{safe}"
(dest_dir / fname).write_bytes(data)
url = f"/static/uploads/warroom/{fname}"
return {
"ok": True,
"url": url,
"name": raw_name[:180],
"mime": file.content_type or "application/octet-stream",
"size": len(data),
}
+231
View File
@@ -0,0 +1,231 @@
"""Login / logout / me + user admin API."""
from __future__ import annotations
from pathlib import Path
import uuid
from typing import Any, Optional
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app import auth_users
router = APIRouter(tags=["auth"])
api_router = APIRouter(prefix="/api/auth", tags=["auth"])
users_api = APIRouter(prefix="/api/settings/users", tags=["users"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_AVATAR = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
async def _save_avatar_file(user_id: int, file: UploadFile) -> str:
raw_name = (file.filename or "avatar.jpg").lower()
ext = Path(raw_name).suffix
if ext not in ALLOWED_AVATAR:
raise HTTPException(status_code=400, detail="Alleen JPG, PNG, WEBP of GIF")
data = await file.read()
if len(data) > 3 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Max 3 MB")
# strip old files for this user
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
fname = f"user_{user_id}_{uuid.uuid4().hex[:10]}{ext}"
path = AVATAR_DIR / fname
path.write_bytes(data)
return f"/static/uploads/avatars/{fname}"
class LoginBody(BaseModel):
username: str
password: str
class UserCreateBody(BaseModel):
username: str
password: str
display_name: Optional[str] = None
role: str = "user"
permissions: dict[str, Any] = Field(default_factory=dict)
class UserUpdateBody(BaseModel):
display_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
permissions: Optional[dict[str, Any]] = None
def _session_user(request: Request) -> dict[str, Any] | None:
return getattr(request.state, "user", None)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
if _session_user(request):
return RedirectResponse(next or "/", status_code=303)
return templates.TemplateResponse(
"login.html",
{"request": request, "page_title": "Inloggen", "next": next or "/", "error": None},
)
@router.post("/login")
async def login_submit(request: Request):
form = await request.form()
username = str(form.get("username") or "").strip()
password = str(form.get("password") or "")
next_url = str(form.get("next") or "/")
if not next_url.startswith("/"):
next_url = "/"
user = auth_users.authenticate(username, password)
if not user:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"page_title": "Inloggen",
"next": next_url,
"error": "Ongeldige gebruikersnaam of wachtwoord",
"username": username,
},
status_code=401,
)
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return RedirectResponse(next_url, status_code=303)
@router.post("/logout")
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@api_router.post("/login")
async def api_login(request: Request, body: LoginBody):
user = auth_users.authenticate(body.username, body.password)
if not user:
raise HTTPException(status_code=401, detail="Ongeldige login")
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return {"ok": True, "user": user}
@api_router.post("/logout")
async def api_logout(request: Request):
request.session.clear()
return {"ok": True}
@api_router.get("/me")
async def api_me(request: Request):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
return {
"user": user,
"pages": auth_users.PAGE_CATALOG,
"can_manage_users": auth_users.can_manage_users(user),
}
def _require_user_admin(request: Request) -> dict[str, Any]:
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
if not auth_users.can_manage_users(user):
raise HTTPException(status_code=403, detail="Geen rechten om gebruikers te beheren")
return user
@users_api.get("")
async def users_list(request: Request):
_require_user_admin(request)
return {"users": auth_users.list_users(), "pages": auth_users.PAGE_CATALOG}
@users_api.post("")
async def users_create(request: Request, body: UserCreateBody):
_require_user_admin(request)
try:
user = auth_users.create_user(
body.username, body.password, body.display_name, body.role, body.permissions or {}
)
except Exception as e:
msg = str(e)
if "unique" in msg.lower() or "duplicate" in msg.lower():
raise HTTPException(status_code=400, detail="Gebruikersnaam bestaat al") from e
raise HTTPException(status_code=400, detail=msg) from e
return {"user": user}
@users_api.put("/{user_id}")
async def users_update(request: Request, user_id: int, body: UserUpdateBody):
_require_user_admin(request)
try:
user = auth_users.update_user(
user_id,
display_name=body.display_name,
role=body.role,
is_active=body.is_active,
permissions=body.permissions,
password=body.password or None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"user": user}
@users_api.delete("/{user_id}")
async def users_delete(request: Request, user_id: int):
actor = _require_user_admin(request)
try:
auth_users.delete_user(user_id, actor_id=actor["id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True}
@api_router.post("/me/avatar")
async def upload_my_avatar(request: Request, file: UploadFile = File(...)):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
url = await _save_avatar_file(user["id"], file)
updated = auth_users.set_avatar(user["id"], url)
request.session["username"] = updated["username"]
return {"user": updated, "avatar_url": url}
@users_api.post("/{user_id}/avatar")
async def upload_user_avatar(request: Request, user_id: int, file: UploadFile = File(...)):
_require_user_admin(request)
url = await _save_avatar_file(user_id, file)
updated = auth_users.set_avatar(user_id, url)
return {"user": updated, "avatar_url": url}
@users_api.delete("/{user_id}/avatar")
async def delete_user_avatar(request: Request, user_id: int):
actor = _require_user_admin(request)
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
updated = auth_users.set_avatar(user_id, None)
return {"user": updated}
+9 -1
View File
@@ -1,7 +1,10 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app import auth_users
router = APIRouter(tags=["settings"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -10,15 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social")
allowed = ("email", "general", "permissions", "social", "users")
active = tab if tab in allowed else "email"
if tab == "ai":
active = "email"
user = getattr(request.state, "user", None)
if active == "users" and not auth_users.can_manage_users(user):
return RedirectResponse("/settings?tab=email", status_code=303)
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"page_title": "Settings",
"active_tab": active,
"can_manage_users": auth_users.can_manage_users(user),
"current_user": user,
},
)
+589
View File
@@ -0,0 +1,589 @@
"""War Room + 1:1 agent chat — Hermes/mimo only, soul prompts, A2A rounds."""
from __future__ import annotations
import asyncio
import logging
import re
from typing import Any
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import agent_souls, hermes_memory, llm_router
from app.services.agent_names import normalize_agent_key
log = logging.getLogger("cockpit.agent_chat")
MENTION_RE = re.compile(r"@([a-zA-Z][a-zA-Z0-9_-]{1,63})")
MAX_ROUNDS = 2
MAX_REPLIES_PER_ROUND = 3
HISTORY_LIMIT = 16
WARROOM_MAX_TOKENS = 512
WARROOM_TIMEOUT = 55.0
def _iso(row: dict[str, Any] | None) -> dict[str, Any] | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
if isinstance(out.get("metadata"), str):
try:
import json
out["metadata"] = json.loads(out["metadata"])
except Exception:
out["metadata"] = {}
return out
def ensure_warroom() -> dict[str, Any]:
row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'warroom' LIMIT 1")
if row:
return _iso(row) # type: ignore
row = fetch_one(
"""INSERT INTO warroom_rooms (kind, title) VALUES ('warroom', 'War Room')
RETURNING *"""
)
return _iso(row) # type: ignore
def get_or_create_dm(agent_key: str) -> dict[str, Any]:
key = normalize_agent_key(agent_key) or (agent_key or "").strip().lower()
soul = agent_souls.get_soul(key)
if not soul:
raise ValueError(f"Onbekende agent: {agent_key}")
row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'dm' AND agent_key = %s", (key,))
if row:
return _iso(row) # type: ignore
title = f"DM · {soul.get('display_name') or key}"
row = fetch_one(
"""INSERT INTO warroom_rooms (kind, agent_key, title)
VALUES ('dm', %s, %s) RETURNING *""",
(key, title),
)
return _iso(row) # type: ignore
def list_rooms() -> list[dict[str, Any]]:
ensure_warroom()
rows = fetch_all(
"""SELECT r.*,
(SELECT COUNT(*) FROM warroom_messages m WHERE m.room_id = r.id) AS message_count,
(SELECT content FROM warroom_messages m WHERE m.room_id = r.id
ORDER BY m.created_at DESC LIMIT 1) AS last_message
FROM warroom_rooms r
ORDER BY CASE WHEN r.kind = 'warroom' THEN 0 ELSE 1 END, r.title"""
)
return [_iso(r) for r in rows] # type: ignore
def get_room(room_id: int) -> dict[str, Any] | None:
return _iso(fetch_one("SELECT * FROM warroom_rooms WHERE id = %s", (room_id,)))
def list_messages(room_id: int, *, limit: int = 100, after_id: int | None = None) -> list[dict[str, Any]]:
if after_id:
rows = fetch_all(
"""SELECT * FROM warroom_messages
WHERE room_id = %s AND id > %s
ORDER BY created_at ASC, id ASC LIMIT %s""",
(room_id, after_id, limit),
)
else:
rows = fetch_all(
"""SELECT * FROM (
SELECT * FROM warroom_messages WHERE room_id = %s
ORDER BY created_at DESC, id DESC LIMIT %s
) t ORDER BY created_at ASC, id ASC""",
(room_id, limit),
)
return [_iso(r) for r in rows] # type: ignore
def _insert_message(
room_id: int,
*,
sender_type: str,
content: str,
agent_key: str | None = None,
username: str | None = None,
sender_avatar_url: str | None = None,
metadata: dict | None = None,
) -> dict[str, Any]:
import json
row = fetch_one(
"""INSERT INTO warroom_messages
(room_id, sender_type, agent_key, username, sender_avatar_url, content, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING *""",
(
room_id,
sender_type,
agent_key,
username,
sender_avatar_url,
content,
json.dumps(metadata or {}),
),
)
execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,))
return _iso(row) # type: ignore
def _active_agent_keys() -> set[str]:
return {str(s.get("agent_key")) for s in agent_souls.list_souls() if s.get("is_active", True)}
def parse_mentions(text: str) -> list[str]:
keys = _active_agent_keys()
found: list[str] = []
for m in MENTION_RE.findall(text or ""):
k = normalize_agent_key(m) or m.lower()
if k in keys and k not in found:
found.append(k)
return found
def _history_as_chat(messages: list[dict[str, Any]], *, limit: int = 20) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
for m in messages[-limit:]:
st = m.get("sender_type")
content = (m.get("content") or "").strip()
if not content:
continue
if st == "user":
name = m.get("username") or "User"
out.append({"role": "user", "content": f"{name}: {content}"})
elif st == "agent":
key = m.get("agent_key") or "agent"
out.append({"role": "assistant", "content": f"@{key}: {content}"})
else:
out.append({"role": "system", "content": content})
return out
async def _hermes_reply(
messages: list[dict[str, str]],
*,
hermes_user: str,
timeout: float | None = None,
) -> tuple[str, dict[str, Any]]:
"""Force Hermes/mimo — never DeepSeek/OpenRouter fallback."""
prov = llm_router.resolve_provider(None)
ptype = (prov.get("provider_type") or "").lower()
if ptype != "hermes":
hermes = llm_router._env_hermes_provider()
if not hermes:
raise RuntimeError("Hermes/mimo niet geconfigureerd (HERMES_API_KEY / :8642).")
raise RuntimeError(
f"War Room vereist Hermes/mimo, huidige provider is '{ptype or 'onbekend'}'. "
"Zet HERMAN_LLM_BACKEND=hermes."
)
reply, meta = await llm_router.chat_messages(
messages,
timeout=float(timeout if timeout is not None else WARROOM_TIMEOUT),
user=hermes_user,
max_tokens=WARROOM_MAX_TOKENS,
temperature=0.35,
)
meta = dict(meta or {})
if meta.get("provider_type") != "hermes":
raise RuntimeError("Antwoord kwam niet van Hermes/mimo — afgebroken.")
return reply, meta
def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str:
name = soul.get("display_name") or soul.get("agent_key")
key = soul.get("agent_key")
role = soul.get("role_title") or "agent"
soul_md = (soul.get("soul_md") or "")[:900]
resp = (soul.get("responsibilities") or "")[:400]
extra = (
"War Room: antwoord in max 4 korte zinnen (NL). Alleen @mention als écht nodig."
if room_kind == "warroom"
else "1:1 DM: bondig in het Nederlands (max 5 zinnen)."
)
return (
f"Je bent {name} (@{key}), {role} bij Foodlinkk.\n"
f"Focus: {resp or '—'}\n"
f"{soul_md}\n\n{extra}"
)
async def _agent_speak(
room: dict[str, Any],
agent_key: str,
*,
trigger: str,
hermes_user: str,
in_reply_to: str | None = None,
) -> dict[str, Any]:
soul = agent_souls.get_soul(agent_key)
if not soul:
return _insert_message(
int(room["id"]),
sender_type="system",
content=f"Agent @{agent_key} niet gevonden.",
metadata={"error": True},
)
hist = list_messages(int(room["id"]), limit=HISTORY_LIMIT)
messages: list[dict[str, str]] = [
{"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")},
]
messages.extend(_history_as_chat(hist, limit=8))
cue = trigger
if in_reply_to:
cue = f"(Reageer op @{in_reply_to}) {trigger}"
messages.append({"role": "user", "content": cue})
try:
reply, meta = await _hermes_reply(messages, hermes_user=hermes_user)
except Exception as exc:
log.exception("agent speak failed %s", agent_key)
return _insert_message(
int(room["id"]),
sender_type="system",
content=f"@{agent_key}: Hermes/mimo fout — {exc}",
metadata={"error": True, "agent_key": agent_key},
)
return _insert_message(
int(room["id"]),
sender_type="agent",
agent_key=agent_key,
content=reply.strip() or "(geen antwoord)",
metadata={
"llm": meta,
"in_reply_to": in_reply_to,
"model": "hermes-agent/mimo",
},
)
async def _orchestrate_warroom(
room: dict[str, Any],
user_text: str,
*,
hermes_user: str,
username: str | None,
) -> list[dict[str, Any]]:
"""Herman facilitates; mentioned agents reply; limited A2A rounds."""
produced: list[dict[str, Any]] = []
mentions = parse_mentions(user_text)
speakers = mentions[:]
if not speakers:
# Herman opens, then may suggest others — still let Herman speak first
speakers = ["herman"]
# Round 0: initial speakers in parallel (sneller bij meerdere @mentions)
keys = speakers[:MAX_REPLIES_PER_ROUND]
if len(keys) == 1:
produced.append(
await _agent_speak(room, keys[0], trigger=user_text, hermes_user=hermes_user)
)
elif keys:
produced.extend(
await asyncio.gather(
*[_agent_speak(room, k, trigger=user_text, hermes_user=hermes_user) for k in keys]
)
)
# A2A follow-up rounds from @mentions in agent replies
for _round in range(MAX_ROUNDS - 1):
next_speakers: list[tuple[str, str]] = [] # (agent, replied_to)
seen = {m.get("agent_key") for m in produced if m.get("sender_type") == "agent"}
for m in produced[-MAX_REPLIES_PER_ROUND:]:
if m.get("sender_type") != "agent":
continue
from_key = m.get("agent_key") or ""
for target in parse_mentions(m.get("content") or ""):
if target == from_key:
continue
if target in seen and (target, from_key) in [(a, b) for a, b in next_speakers]:
continue
next_speakers.append((target, from_key))
# unique preserve order
uniq: list[tuple[str, str]] = []
used: set[str] = set()
for t, src in next_speakers:
if t in used:
continue
used.add(t)
uniq.append((t, src))
uniq = uniq[:MAX_REPLIES_PER_ROUND]
if not uniq:
break
for target, src in uniq:
msg = await _agent_speak(
room,
target,
trigger=(
f"War Room topic van {username or 'user'}: {user_text}\n"
f"@{src} noemde je. Reageer kort en concreet."
),
hermes_user=hermes_user,
in_reply_to=src,
)
produced.append(msg)
return produced
def post_system_line(content: str, *, agent_key: str | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
"""Proactive system/agent bubble into #war-room (no LLM)."""
room = ensure_warroom()
return _insert_message(
int(room["id"]),
sender_type="system" if not agent_key else "agent",
agent_key=agent_key,
content=content[:2000],
metadata=metadata or {"proactive": True},
)
def announce_handoff(from_agent: str, to_agent: str, handoff_type: str = "partner", *, correlation_id: str | None = None) -> None:
"""Best-effort War Room line for scheduler/API handoffs."""
try:
src = (from_agent or "").strip()
dst = (to_agent or "").strip()
if not src or not dst:
return
label = (handoff_type or "partner").strip() or "partner"
text = f"Handoff: {src} → {dst} ({label})"
post_system_line(
text,
agent_key=src,
metadata={
"proactive": True,
"kind": "handoff",
"from_agent": src,
"to_agent": dst,
"handoff_type": label,
"correlation_id": correlation_id,
},
)
except Exception:
log.exception("warroom announce_handoff failed")
def _normalize_attachments(attachments: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for a in attachments or []:
if not isinstance(a, dict):
continue
url = str(a.get("url") or "").strip()
if not url.startswith("/static/uploads/warroom/"):
continue
out.append({
"url": url,
"name": str(a.get("name") or "bestand")[:180],
"mime": str(a.get("mime") or "application/octet-stream")[:120],
"size": int(a.get("size") or 0),
})
if len(out) >= 5:
break
return out
def _trigger_text(text: str, attachments: list[dict[str, Any]]) -> str:
if not attachments:
return text
names = ", ".join(a.get("name") or "bestand" for a in attachments)
if text:
return f"{text}\n\n[Bijlage(n): {names}]"
return f"[Deelde bestand(en): {names}]"
async def post_user_message(
room_id: int,
content: str,
*,
username: str | None = None,
role: str | None = None,
sender_avatar_url: str | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
text = (content or "").strip()
files = _normalize_attachments(attachments)
if not text and not files:
raise ValueError("Leeg bericht")
room = get_room(room_id)
if not room:
raise ValueError("Room niet gevonden")
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom")
meta: dict[str, Any] = {"hermes_user": hermes_user}
if files:
meta["attachments"] = files
user_msg = _insert_message(
room_id,
sender_type="user",
content=text or ("📎 " + ", ".join(a["name"] for a in files)),
username=username,
sender_avatar_url=sender_avatar_url,
metadata=meta,
)
trigger = _trigger_text(text, files)
agent_msgs: list[dict[str, Any]] = []
kind = room.get("kind")
try:
if kind == "dm":
key = room.get("agent_key")
if not key:
raise ValueError("DM zonder agent")
agent_msgs.append(
await _agent_speak(room, str(key), trigger=trigger, hermes_user=hermes_user)
)
else:
agent_msgs = await _orchestrate_warroom(
room, trigger, hermes_user=hermes_user, username=username
)
except Exception as exc:
log.exception("warroom post failed")
agent_msgs.append(
_insert_message(
room_id,
sender_type="system",
content=f"Hermes/mimo fout: {exc}",
metadata={"error": True},
)
)
return {
"ok": True,
"room": room,
"user_message": user_msg,
"messages": [user_msg, *agent_msgs],
"hermes_user": hermes_user,
}
async def _agent_speak_stream(
room: dict[str, Any],
agent_key: str,
*,
trigger: str,
hermes_user: str,
in_reply_to: str | None = None,
):
"""Yield ('start'|'token'|'done'|'error', payload)."""
soul = agent_souls.get_soul(agent_key)
if not soul:
msg = _insert_message(
int(room["id"]),
sender_type="system",
content=f"Agent @{agent_key} niet gevonden.",
metadata={"error": True},
)
yield ("error", msg)
return
hist = list_messages(int(room["id"]), limit=HISTORY_LIMIT)
messages: list[dict[str, str]] = [
{"role": "system", "content": _soul_system(soul, room_kind=room.get("kind") or "warroom")},
]
messages.extend(_history_as_chat(hist, limit=8))
cue = trigger
if in_reply_to:
cue = f"(Reageer op @{in_reply_to}) {trigger}"
messages.append({"role": "user", "content": cue})
yield ("start", {"agent_key": agent_key, "display_name": soul.get("display_name") or agent_key})
chunks: list[str] = []
try:
async for delta in llm_router.stream_chat_messages(
messages,
timeout=WARROOM_TIMEOUT,
user=hermes_user,
max_tokens=WARROOM_MAX_TOKENS,
temperature=0.35,
):
chunks.append(delta)
yield ("token", {"agent_key": agent_key, "delta": delta})
reply = "".join(chunks).strip() or "(geen antwoord)"
msg = _insert_message(
int(room["id"]),
sender_type="agent",
agent_key=agent_key,
content=reply,
metadata={
"llm": {"provider_type": "hermes", "hermes_user": hermes_user, "streamed": True},
"in_reply_to": in_reply_to,
"model": "hermes-agent/mimo",
},
)
yield ("done", msg)
except Exception as exc:
log.exception("agent stream failed %s", agent_key)
msg = _insert_message(
int(room["id"]),
sender_type="system",
content=f"@{agent_key}: Hermes/mimo fout — {exc}",
metadata={"error": True, "agent_key": agent_key},
)
yield ("error", msg)
async def post_user_message_stream(
room_id: int,
content: str,
*,
username: str | None = None,
role: str | None = None,
sender_avatar_url: str | None = None,
attachments: list[dict[str, Any]] | None = None,
):
"""Async generator of SSE-ready event dicts for live typing."""
text = (content or "").strip()
files = _normalize_attachments(attachments)
if not text and not files:
raise ValueError("Leeg bericht")
room = get_room(room_id)
if not room:
raise ValueError("Room niet gevonden")
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom")
meta: dict[str, Any] = {"hermes_user": hermes_user}
if files:
meta["attachments"] = files
user_msg = _insert_message(
room_id,
sender_type="user",
content=text or ("📎 " + ", ".join(a["name"] for a in files)),
username=username,
sender_avatar_url=sender_avatar_url,
metadata=meta,
)
yield {"event": "user", "message": user_msg}
trigger = _trigger_text(text, files)
kind = room.get("kind")
speakers: list[str] = []
if kind == "dm":
key = room.get("agent_key")
if key:
speakers = [str(key)]
else:
speakers = parse_mentions(trigger) or ["herman"]
speakers = speakers[:MAX_REPLIES_PER_ROUND]
for key in speakers:
async for kind_ev, payload in _agent_speak_stream(
room, key, trigger=trigger, hermes_user=hermes_user
):
if kind_ev == "start":
yield {"event": "typing_start", **payload}
elif kind_ev == "token":
yield {"event": "typing", **payload}
elif kind_ev == "done":
yield {"event": "agent", "message": payload}
elif kind_ev == "error":
yield {"event": "system", "message": payload}
# light A2A: one follow-up round max from last agent reply mentions
# (non-streamed for simplicity / speed)
yield {"event": "done", "ok": True}
@@ -63,6 +63,11 @@ def create_handoff(
"handoff_type": handoff_type,
}
_log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
try:
from app.services import agent_chat
agent_chat.announce_handoff(src, dst, handoff_type, correlation_id=cid)
except Exception:
pass
return handoff
+48 -16
View File
@@ -120,18 +120,35 @@ def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dic
async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]:
from app.services import llm_router
async def _chat_via_llm_router(
message: str,
channel: str,
*,
username: str | None = None,
role: str | None = None,
) -> dict[str, Any]:
from app.services import hermes_memory, llm_router
system = (
"Je bent Herman, de AI-assistent van Foodlinkk B2B (halal vlees, export, CRM). "
"Antwoord in het Nederlands, bondig, actiegericht. Geef concrete vervolgstappen waar nuttig."
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": message},
]
reply, meta = await llm_router.chat_messages(messages, timeout=120.0)
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel=channel)
from app.config import settings as _settings
display = "Aïssa" if str(hermes_user) == str(_settings.HERMES_CEO_CHAT_ID) else (username or "gebruiker")
system = hermes_memory.continuity_system(hermes_user, display_name=display)
messages: list[dict[str, str]] = [{"role": "system", "content": system}]
feed_items = await hermes_memory.fetch_recent_feed(hermes_user)
hist = hermes_memory.feed_to_messages(feed_items)
if hist:
messages.append({
"role": "system",
"content": (
f"Recente Telegram-geschiedenis voor chat_id={hermes_user} "
f"({len(hist)} berichten, oud→nieuw). Behandel dit als jouw geheugencontext."
),
})
messages.extend(hist)
messages.append({"role": "user", "content": message})
reply, meta = await llm_router.chat_messages(messages, timeout=180.0, user=hermes_user)
provider = meta.get("provider_label") or "LLM"
model = meta.get("model") or ""
await _log_event(
@@ -139,7 +156,13 @@ async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]:
"llm_chat",
f"Herman via {provider}",
message[:2000],
{"provider": meta, "channel": channel},
{
"provider": meta,
"channel": channel,
"hermes_user": hermes_user,
"memory_messages": len(hist),
"username": username,
},
channel=channel,
)
return {
@@ -147,9 +170,16 @@ async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]:
"agent_label": f"Herman · {provider}",
"reply": reply,
"delegated_agents": ["herman"],
"routing_reason": f"Cloud LLM · {provider}" + (f" · {model}" if model else ""),
"agent_steps": _agent_steps([], f"Via {provider}"),
"routing_reason": (
f"Cloud LLM · {provider}"
+ (f" · {model}" if model else "")
+ f" · memory:{hermes_user}"
+ (f" · feed:{len(hist)}" if hist else "")
),
"agent_steps": _agent_steps([], f"Via {provider} + Hermes memory"),
"llm_provider": meta,
"hermes_user": hermes_user,
"memory_messages": len(hist),
}
@@ -158,6 +188,8 @@ async def chat(
channel: str = "cockpit",
session_id: str | None = None,
confirm_action_id: str | None = None,
username: str | None = None,
role: str | None = None,
) -> dict[str, Any]:
use_voice_router = (
channel in voice_export_actions.VOICE_CHANNELS
@@ -382,7 +414,7 @@ async def chat(
# Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
if backend in ("hermes", "router", "openrouter", "llm"):
try:
return await _chat_via_llm_router(message, channel)
return await _chat_via_llm_router(message, channel, username=username, role=role)
except Exception as exc:
hint = (
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
@@ -427,7 +459,7 @@ async def chat(
except Exception as exc:
if backend == "auto":
try:
return await _chat_via_llm_router(message, channel)
return await _chat_via_llm_router(message, channel, username=username, role=role)
except Exception as llm_exc:
return {
"agent": "herman",
+83
View File
@@ -0,0 +1,83 @@
"""Koppel cockpit Herman-chat aan Hermes/Telegram memory."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.config import settings
log = logging.getLogger("cockpit.hermes_memory")
_USERNAME_MAP = {
"aissa": "ceo",
"aïssa": "ceo",
"aissa zarioh": "ceo",
"mo": "cto",
"mohamed": "cto",
}
def resolve_hermes_user(
*,
username: str | None = None,
role: str | None = None,
channel: str | None = None,
) -> str:
"""OpenAI-compatible `user` field = Telegram chat_id for Hermes memory."""
uname = (username or "").strip().lower()
r = (role or "").strip().lower()
mapped = _USERNAME_MAP.get(uname)
if mapped == "ceo" or r in ("ceo", "admin") or uname in ("aissa", "aïssa"):
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
if mapped == "cto" or r == "cto" or uname in ("mo", "mohamed"):
return str(settings.HERMES_CTO_CHAT_ID or "789036463")
return str(settings.HERMES_CEO_CHAT_ID or "8859782446")
async def fetch_recent_feed(chat_id: str | int, limit: int | None = None) -> list[dict[str, Any]]:
limit = limit or int(getattr(settings, "HERMES_MEMORY_LIMIT", 24) or 24)
base = (settings.HERMES_FEED_URL or "").rstrip("/")
if not base:
return []
try:
async with httpx.AsyncClient(timeout=12.0) as client:
r = await client.get(f"{base}/feed", params={"chat_id": int(chat_id), "limit": limit})
if r.status_code != 200:
return []
items = (r.json() or {}).get("items") or []
return list(reversed(items))
except Exception as exc:
log.warning("hermes feed fetch failed: %s", exc)
return []
def feed_to_messages(items: list[dict[str, Any]], *, max_chars: int = 12000) -> list[dict[str, str]]:
out: list[dict[str, str]] = []
total = 0
for it in items:
text = (it.get("content_text") or "").strip()
if not text:
continue
role_raw = (it.get("role") or "").lower()
role = "assistant" if role_raw in ("assistant", "hermes", "herman") else "user"
if role == "assistant" and (it.get("direction") or "") == "in":
role = "user"
entry = {"role": role, "content": text[:2000]}
total += len(entry["content"])
if total > max_chars:
break
out.append(entry)
return out
def continuity_system(chat_id: str, display_name: str | None = None) -> str:
name = display_name or ("Aïssa" if str(chat_id) == str(settings.HERMES_CEO_CHAT_ID) else "de gebruiker")
return (
"Je bent Herman / Hermes, dezelfde AI Co-CEO als in Telegram. "
f"Deze cockpit-chat is gekoppeld aan Telegram-sessie chat_id={chat_id} ({name}). "
"Gebruik je bestaande Hermes-memory en recente Telegram-geschiedenis. "
"Doe alsof dit dezelfde doorlopende conversatie is — geen nieuw persoon, geen amnesie. "
"Antwoord in het Nederlands, bondig en actiegericht."
)
+100 -2
View File
@@ -255,6 +255,9 @@ async def chat_messages(
provider_id: int | None = None,
model: str | None = None,
timeout: float = 120.0,
user: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
) -> tuple[str, dict[str, Any]]:
"""Returns (reply_text, meta dict with provider info)."""
prov = resolve_provider(provider_id)
@@ -309,9 +312,14 @@ async def chat_messages(
payload: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": float(extra.get("temperature", 0.4)),
"max_tokens": int(extra.get("max_tokens", 2048)),
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.4)),
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 2048)),
}
# Hermes: `user` = Telegram chat_id → koppelt API-chat aan bestaande memory/sessie
if user:
payload["user"] = str(user)
elif ptype == "hermes":
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
@@ -332,9 +340,99 @@ async def chat_messages(
"provider_type": ptype,
"provider_label": prov.get("label"),
"model": use_model,
"hermes_user": payload.get("user"),
}
async def stream_chat_messages(
messages: list[dict[str, str]],
*,
provider_id: int | None = None,
model: str | None = None,
timeout: float = 90.0,
user: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
):
"""Yield text deltas from Hermes/OpenAI-compatible streaming chat."""
prov = resolve_provider(provider_id)
ptype = (prov.get("provider_type") or "ollama").lower()
use_model = model or prov.get("model") or settings.OLLAMA_MODEL
if ptype != "hermes":
# non-stream fallback: one chunk
reply, _meta = await chat_messages(
messages,
provider_id=provider_id,
model=model,
timeout=timeout,
user=user,
max_tokens=max_tokens,
temperature=temperature,
)
if reply:
yield reply
return
api_key = (prov.get("api_key") or "").strip()
if not api_key:
raise RuntimeError("Geen Hermes API key")
base = (prov.get("api_base_url") or "").strip().rstrip("/")
if not base:
raise RuntimeError("Geen Hermes API URL")
extra = prov.get("extra_config") or {}
if isinstance(extra, str):
try:
extra = json.loads(extra)
except Exception:
extra = {}
url = f"{base}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
payload: dict[str, Any] = {
"model": use_model,
"messages": messages,
"temperature": float(temperature if temperature is not None else extra.get("temperature", 0.35)),
"max_tokens": int(max_tokens if max_tokens is not None else extra.get("max_tokens", 512)),
"stream": True,
}
if user:
payload["user"] = str(user)
else:
payload["user"] = str(getattr(settings, "HERMES_CEO_CHAT_ID", None) or "8859782446")
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, headers=headers, json=payload) as resp:
if resp.status_code >= 400:
body = (await resp.aread()).decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"Hermes stream fout ({resp.status_code}): {body}")
async for line in resp.aiter_lines():
if not line:
continue
if line.startswith(":"):
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data or data == "[DONE]":
if data == "[DONE]":
break
continue
try:
obj = json.loads(data)
except Exception:
continue
choices = obj.get("choices") or []
if not choices:
continue
delta = (choices[0].get("delta") or {}).get("content")
if delta:
yield delta
async def generate(
prompt: str,
system: str | None = None,