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_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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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.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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user