Ship Herman chat, Hermes KPI explorer, and intelligence analytics.

Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
This commit is contained in:
Aissa
2026-08-01 17:50:45 +00:00
parent 62239f0a4e
commit 5e43b6b246
28 changed files with 2686 additions and 1149 deletions
+6
View File
@@ -16,3 +16,9 @@ cockpit/static/uploads/
.cursor/ .cursor/
tmp/ tmp/
cockpit/tmp-upload/ cockpit/tmp-upload/
# Local TLS private keys (self-signed cockpit HTTPS)
cockpit/caddy/certs/*.key
cockpit/caddy/certs/key.pem
cockpit/caddy/certs/*.srl
cockpit/caddy/certs/*.csr
+1
View File
@@ -23,6 +23,7 @@ class Settings:
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions") WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base") WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl") WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798") HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma") CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000")) CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
+126 -1
View File
@@ -2369,18 +2369,128 @@ class HermesSearchBody(BaseModel):
limit: int = Field(default=15, ge=1, le=30) limit: int = Field(default=15, ge=1, le=30)
def _hermes_feed_base() -> str:
return (getattr(settings, "HERMES_FEED_URL", None) or "http://10.4.7.10:8755").rstrip("/")
@admin_router.get("/hermes/stats") @admin_router.get("/hermes/stats")
async def hermes_stats_proxy() -> dict[str, Any]: async def hermes_stats_proxy() -> dict[str, Any]:
import httpx import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{_hermes_feed_base()}/stats")
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats") r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@admin_router.get("/hermes/kpis")
async def hermes_kpis_proxy() -> dict[str, Any]:
"""Technical KPIs from Hermes Agent + Herman cockpit event counts."""
import httpx
from app.db import fetch_all
payload: dict[str, Any] = {
"online": False,
"synced_at": None,
"source": "hermes-agent",
"overview": {},
"buckets": [],
"tools_top": [],
"sources": [],
"herman_cockpit": [],
}
try:
async with httpx.AsyncClient(timeout=12.0) as client:
r = await client.get(f"{_hermes_feed_base()}/kpis")
if r.status_code == 200:
payload.update(r.json())
except Exception as exc:
payload["error"] = str(exc)[:160]
try:
rows = fetch_all(
"""
SELECT event_type, COUNT(*)::int AS count
FROM agent_events
GROUP BY event_type
ORDER BY COUNT(*) DESC
LIMIT 12
"""
)
payload["herman_cockpit"] = [
{"event_type": r.get("event_type") or "unknown", "count": int(r.get("count") or 0)}
for r in (rows or [])
]
except Exception:
payload["herman_cockpit"] = []
return payload
@admin_router.get("/hermes/bucket/{key}")
async def hermes_bucket_detail_proxy(key: str, limit: int = 60, tool: str | None = None) -> dict[str, Any]:
"""Bucket detail: recent tool events + touched files for explorer popup."""
import httpx
params: dict[str, Any] = {"limit": limit}
if tool:
params["tool"] = tool
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/bucket/{key}", params=params)
if r.status_code == 404:
return {"error": "unknown bucket", "key": key, "events": [], "files": []}
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/explorer")
async def hermes_explorer_proxy(path: str | None = None) -> dict[str, Any]:
"""Browse Hermes Agent filesystem (read-only, restricted roots)."""
import httpx
params = {}
if path:
params["path"] = path
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{_hermes_feed_base()}/explorer", params=params)
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/explorer/file")
async def hermes_explorer_file_proxy(path: str) -> dict[str, Any]:
"""Preview a file from Hermes Agent filesystem."""
import httpx
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/explorer/file", params={"path": path})
if r.status_code in (403, 404, 400):
try:
err = (r.json() or {}).get("error") or r.text
except Exception:
err = r.text
return {"error": err, "path": path, "content": None}
r.raise_for_status()
return r.json()
@admin_router.get("/hermes/conversations") @admin_router.get("/hermes/conversations")
async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]: async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]:
"""Live conversations from Hermes Agent (not June Postgres archive)."""
import httpx import httpx
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(f"{_hermes_feed_base()}/conversations", params={"limit": limit})
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit}) r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit})
r.raise_for_status() r.raise_for_status()
@@ -2393,10 +2503,18 @@ async def hermes_feed_proxy(
limit: int = 80, limit: int = 80,
offset: int = 0, offset: int = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Live Telegram feed from Hermes Agent state.db."""
import httpx import httpx
params = {"limit": limit, "offset": offset} params: dict[str, Any] = {"limit": limit, "offset": offset}
if chat_id is not None: if chat_id is not None:
params["chat_id"] = chat_id params["chat_id"] = chat_id
try:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{_hermes_feed_base()}/feed", params=params)
if r.status_code == 200:
return r.json()
except Exception:
pass
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params) r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params)
r.raise_for_status() r.raise_for_status()
@@ -2469,6 +2587,13 @@ async def hermes_pa_slot_screenshot(label: str):
@admin_router.get("/hermes/users") @admin_router.get("/hermes/users")
async def hermes_users_proxy() -> dict[str, Any]: async def hermes_users_proxy() -> dict[str, Any]:
import httpx import httpx
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(f"{_hermes_feed_base()}/users")
if r.status_code == 200 and (r.json().get("users") is not None):
return r.json()
except Exception:
pass
users: list[dict[str, Any]] = [] users: list[dict[str, Any]] = []
try: try:
async with httpx.AsyncClient(timeout=8.0) as client: async with httpx.AsyncClient(timeout=8.0) as client:
+34 -4
View File
@@ -1,17 +1,28 @@
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.services.analytics_data import collect_analytics from app.services.analytics_data import add_finding, collect_analytics, list_hermes_findings
router = APIRouter(prefix="/analytics", tags=["analytics"]) router = APIRouter(prefix="/analytics", tags=["analytics"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
class FindingIn(BaseModel):
kind: str = Field(default="insight", description="win|opportunity|attention|insight")
title: str
body: str = ""
score: float | None = None
source: str = "hermes"
href: str | None = None
meta: dict[str, Any] | None = None
@router.get("") @router.get("")
async def analytics_page(request: Request): async def analytics_page(request: Request):
initial = collect_analytics({}) initial = collect_analytics({})
@@ -30,5 +41,24 @@ async def analytics_api(
days: int = Query(90, ge=7, le=365), days: int = Query(90, ge=7, le=365),
): ):
filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days} filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days}
filters = {k: v for k, v in filters.items() if v is not None and v != ""}
return JSONResponse(collect_analytics(filters)) return JSONResponse(collect_analytics(filters))
@router.get("/api/findings")
async def analytics_findings(limit: int = Query(20, ge=1, le=100)):
return {"items": list_hermes_findings(limit)}
@router.post("/api/findings")
async def analytics_findings_create(payload: FindingIn = Body(...)):
"""Hermes (of admin) kan bevindingen pushen naar het intelligence dashboard."""
row = add_finding(
kind=payload.kind,
title=payload.title,
body=payload.body,
score=payload.score,
source=payload.source,
href=payload.href,
meta=payload.meta,
)
return {"ok": True, "finding": row}
+5
View File
@@ -225,3 +225,8 @@ async def cto_dashboard_legacy(request: Request):
@router.get("/marketing-redirect") @router.get("/marketing-redirect")
async def marketing_redirect(): async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302) return RedirectResponse(url="/marketing", status_code=302)
@router.get("/trust")
async def trust_page(request: Request):
return templates.TemplateResponse("trust.html", {"request": request, "page_title": "Certificaat"})
+5 -2
View File
@@ -10,12 +10,15 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings") @router.get("/settings")
async def settings_page(request: Request, tab: str = "email"): async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social", "ai") allowed = ("email", "general", "permissions", "social")
active = tab if tab in allowed else "email"
if tab == "ai":
active = "email"
return templates.TemplateResponse( return templates.TemplateResponse(
"settings.html", "settings.html",
{ {
"request": request, "request": request,
"page_title": "Settings", "page_title": "Settings",
"active_tab": tab if tab in allowed else "email", "active_tab": active,
}, },
) )
+382 -27
View File
@@ -1,10 +1,10 @@
"""Comprehensive analytics data from all DB tables with optional filters.""" """Intelligence analytics: wins, kansen, pulse score + Hermes findings hooks."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from app.db import fetch_all, fetch_one from app.db import execute, fetch_all, fetch_one
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int: def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
@@ -26,6 +26,343 @@ def _iso_rows(rows: list) -> list:
return rows return rows
def ensure_findings_table() -> None:
try:
execute(
"""
CREATE TABLE IF NOT EXISTS analytics_findings (
id SERIAL PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'insight',
title TEXT NOT NULL,
body TEXT,
score DOUBLE PRECISION,
source TEXT DEFAULT 'hermes',
href TEXT,
meta JSONB DEFAULT '{}'::jsonb,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
"""
)
except Exception:
pass
def list_hermes_findings(limit: int = 20) -> list[dict[str, Any]]:
ensure_findings_table()
try:
rows = fetch_all(
"""
SELECT id, kind, title, body, score, source, href, meta, created_at
FROM analytics_findings
WHERE active = TRUE
ORDER BY created_at DESC
LIMIT %s
""",
(limit,),
)
return _iso_rows(rows or [])
except Exception:
return []
def add_finding(
*,
kind: str,
title: str,
body: str = "",
score: float | None = None,
source: str = "hermes",
href: str | None = None,
meta: dict | None = None,
) -> dict[str, Any]:
ensure_findings_table()
import json
row = fetch_one(
"""
INSERT INTO analytics_findings (kind, title, body, score, source, href, meta)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
RETURNING id, kind, title, body, score, source, href, meta, created_at
""",
(
kind or "insight",
title,
body or "",
score,
source or "hermes",
href,
json.dumps(meta or {}),
),
)
return _iso_rows([row])[0] if row else {}
def _revenue_kpis() -> dict[str, Any]:
out = {
"active_projects": 0,
"margin_month": 0.0,
"margin_year": 0.0,
"vision_text": "",
"horizon_text": "",
"tagline": "",
}
try:
row = fetch_one(
"""
SELECT COUNT(*)::int AS c,
COALESCE(SUM(margin_month),0) AS mm,
COALESCE(SUM(margin_year),0) AS my
FROM revenue_projects
WHERE COALESCE(status,'') NOT IN ('lost','cancelled','done')
"""
)
if row:
out["active_projects"] = int(row.get("c") or 0)
out["margin_month"] = float(row.get("mm") or 0)
out["margin_year"] = float(row.get("my") or 0)
except Exception:
pass
try:
g = fetch_one(
"""
SELECT vision_text, horizon_text, tagline, mid_text
FROM revenue_cockpit_goals
WHERE COALESCE(is_active, TRUE) = TRUE
ORDER BY updated_at DESC NULLS LAST
LIMIT 1
"""
)
if g:
out["vision_text"] = g.get("vision_text") or ""
out["horizon_text"] = g.get("horizon_text") or ""
out["tagline"] = g.get("tagline") or ""
out["mid_text"] = g.get("mid_text") or ""
except Exception:
pass
return out
def _build_pulse(data: dict[str, Any]) -> dict[str, Any]:
k = data.get("kpis") or {}
partners = {r.get("status"): int(r.get("cnt") or 0) for r in (data.get("partnership_breakdown") or [])}
active_p = int(partners.get("active") or k.get("crm_partnerships") or 0)
none_p = int(partners.get("none") or 0)
proposal_p = int(partners.get("proposal") or 0)
stores = int(k.get("supermarkets") or 0)
cover_pct = round((active_p / stores) * 100, 2) if stores else 0.0
gap = max(stores - active_p - proposal_p, 0)
wins: list[dict[str, Any]] = []
opportunities: list[dict[str, Any]] = []
attention: list[dict[str, Any]] = []
if stores >= 1000:
wins.append({
"id": "win-coverage",
"title": "Retail footprint is massief",
"body": f"{stores:,} supermarkten in de database — sterke basis voor targeting.".replace(",", "."),
"score": 92,
"metric": f"{stores} stores",
"href": "/retail",
})
if active_p >= 10:
wins.append({
"id": "win-crm",
"title": "Actieve partnerships draaien",
"body": f"{active_p} winkels met actieve CRM-status. Voorstellen: {proposal_p}.",
"score": 78,
"metric": f"{active_p} actief",
"href": "/retail",
})
if int(k.get("rss_items") or 0) >= 200:
wins.append({
"id": "win-rss",
"title": "Markt-signalen stromen binnen",
"body": f"{int(k['rss_items'])} RSS-items gevoed — concurrentie & trends blijven zichtbaar.",
"score": 74,
"metric": f"{int(k['rss_items'])} items",
"href": "/marketing",
})
if int(k.get("promo_campaigns") or 0) >= 10:
wins.append({
"id": "win-promo",
"title": "Promo-intelligence actief",
"body": f"{int(k['promo_campaigns'])} actieve folders/campagnes gemonitord.",
"score": 70,
"metric": f"{int(k['promo_campaigns'])} campagnes",
"href": "/marketing",
})
if float(k.get("margin_month") or 0) > 0:
wins.append({
"id": "win-margin",
"title": "Revenue engine levert marge",
"body": f"Cockpit-marge ≈ €{float(k['margin_month']):,.0f}/maand over {int(k.get('active_projects') or 0)} deals.".replace(",", "."),
"score": 88,
"metric": f"€{float(k['margin_month']):,.0f}".replace(",", "."),
"href": "/revenue-cockpit",
})
if int(k.get("wholesalers") or 0) >= 100:
wins.append({
"id": "win-wholesale",
"title": "Groothandel-netwerk in kaart",
"body": f"{int(k['wholesalers'])} groothandels beschikbaar voor distributie-routes.",
"score": 68,
"metric": f"{int(k['wholesalers'])} GH",
"href": "/retail",
})
if int(k.get("agent_events") or 0) >= 500:
wins.append({
"id": "win-agents",
"title": "Agent-mesh is productief",
"body": f"{int(k['agent_events'])} agent-events gelogd — Herman & crew blijven draaien.",
"score": 66,
"metric": f"{int(k['agent_events'])} events",
"href": "/agents",
})
if gap >= 100:
opportunities.append({
"id": "opp-white-space",
"title": "Enorm witruimte in retail CRM",
"body": f"{gap:,} winkels zonder partnership. Focus eerst op top-halal scores & grote ketens.".replace(",", "."),
"score": min(98, 40 + int(gap / 100)),
"metric": f"{gap} open",
"href": "/retail",
"tag": "distributie",
})
if cover_pct < 5:
opportunities.append({
"id": "opp-cover",
"title": "Partnership-dekking is nog dun",
"body": f"Slechts {cover_pct}% van de winkels is ‘active’. Doelbeeld Cucina vraagt schaal.",
"score": 90,
"metric": f"{cover_pct}% dekking",
"href": "/retail",
"tag": "groei",
})
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
opportunities.append({
"id": "opp-clients",
"title": "Klanten in intake → nog niet actief",
"body": f"{int(k['clients_total'])} klanten in CRM, 0 gemarkeerd als active. Conversie = snelle win.",
"score": 82,
"metric": "0 active",
"href": "/clients",
"tag": "crm",
})
if float(k.get("pipeline_eur") or 0) <= 0 and int(k.get("deals_total") or 0) > 0:
opportunities.append({
"id": "opp-pipeline",
"title": "Deals zonder pipeline-waarde",
"body": "Er staan deals, maar €0 open pipeline. Waardes & stages aanscherpen geeft sturing.",
"score": 76,
"metric": "€0 pipeline",
"href": "/deals",
"tag": "sales",
})
top_opp = data.get("top_opportunities") or []
if top_opp:
best = top_opp[0]
opportunities.append({
"id": "opp-halal-top",
"title": f"Halal-kans: {best.get('chain') or '?'} · {best.get('city') or '?'}",
"body": f"Score {round(float(best.get('halal_opportunity_score') or 0), 1)}/100 — sterkste retail-signaal nu.",
"score": round(float(best.get("halal_opportunity_score") or 0)),
"metric": f"{round(float(best.get('halal_opportunity_score') or 0), 1)}",
"href": "/retail",
"tag": "halal",
})
# cluster cities
cities: dict[str, int] = {}
for o in top_opp:
cities[o.get("city") or "?"] = cities.get(o.get("city") or "?", 0) + 1
top_city = sorted(cities.items(), key=lambda x: -x[1])[0]
if top_city[1] >= 2:
opportunities.append({
"id": "opp-city-cluster",
"title": f"Cluster-kans in {top_city[0]}",
"body": f"{top_city[1]} top-scores in dezelfde stad — bundel outreach / bezorgroute.",
"score": 72,
"metric": top_city[0],
"href": "/retail",
"tag": "geo",
})
if int(k.get("contacts_wholesaler") or 0) == 0 and int(k.get("wholesalers") or 0) > 0:
opportunities.append({
"id": "opp-gh-contacts",
"title": "Groothandel-contacten ontbreken",
"body": "550+ groothandels, 0 contactpersonen. Relaties = hefboom voor listingen.",
"score": 69,
"metric": "0 GH-contacten",
"href": "/retail",
"tag": "netwerk",
})
if int(k.get("pending_approvals") or 0) > 0:
attention.append({
"id": "att-approvals",
"title": "Goedkeuringen wachten",
"body": f"{int(k['pending_approvals'])} agent-items op needs_approval.",
"score": 55,
"metric": str(int(k["pending_approvals"])),
"href": "/agents",
})
if int(k.get("rss_bookmarks") or 0) == 0 and int(k.get("rss_items") or 0) > 100:
attention.append({
"id": "att-bookmarks",
"title": "Geen RSS-bookmarks",
"body": "Veel signalen, weinig vastgepind. Markeer wat telt voor de weekbriefing.",
"score": 48,
"metric": "0 bookmarks",
"href": "/marketing",
})
# Pulse score 0-100
score = 35
score += min(18, active_p) # up to +18
score += 12 if stores >= 2000 else (6 if stores >= 500 else 0)
score += 8 if int(k.get("rss_items") or 0) >= 500 else 0
score += 8 if float(k.get("margin_month") or 0) > 100000 else (4 if float(k.get("margin_month") or 0) > 0 else 0)
score += 6 if int(k.get("promo_campaigns") or 0) >= 20 else 0
score += 5 if int(k.get("nas_docs") or 0) >= 40 else 0
if cover_pct < 1:
score -= 12
elif cover_pct < 3:
score -= 6
if int(k.get("pending_approvals") or 0) >= 5:
score -= 5
if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0:
score -= 4
score = max(0, min(100, score))
if score >= 75:
label, tone = "Sterk momentum", "good"
elif score >= 55:
label, tone = "Stabiel · kansen open", "mid"
else:
label, tone = "Opbouw-fase · focus nodig", "low"
wins.sort(key=lambda x: -int(x.get("score") or 0))
opportunities.sort(key=lambda x: -int(x.get("score") or 0))
return {
"score": score,
"label": label,
"tone": tone,
"cover_pct": cover_pct,
"white_space": gap,
"wins": wins[:8],
"opportunities": opportunities[:8],
"attention": attention[:6],
"headline": "Wat goed gaat vs waar de kansen liggen",
"sub": "Live uit CRM, retail, revenue & agent-mesh — Hermes vult diepere bevindingen aan.",
}
def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]: def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]:
f = filters or {} f = filters or {}
chain = f.get("chain") or None chain = f.get("chain") or None
@@ -39,12 +376,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"filters": f, "filters": f,
} }
rev = _revenue_kpis()
data["kpis"] = { data["kpis"] = {
"clients_total": _safe_count("clients"), "clients_total": _safe_count("clients"),
"clients_active": _safe_count("clients", "stage = 'active'"), "clients_active": _safe_count("clients", "stage = 'active'"),
"deals_total": _safe_count("deals"), "deals_total": _safe_count("deals"),
"pipeline_eur": float( "pipeline_eur": float(
(fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0) (fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0)
or 0
), ),
"supermarkets": _safe_count("supermarkets"), "supermarkets": _safe_count("supermarkets"),
"wholesalers": _safe_count("wholesalers"), "wholesalers": _safe_count("wholesalers"),
@@ -57,6 +396,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
"contacts_supermarket": _safe_count("supermarket_contacts"), "contacts_supermarket": _safe_count("supermarket_contacts"),
"contacts_wholesaler": _safe_count("wholesaler_contacts"), "contacts_wholesaler": _safe_count("wholesaler_contacts"),
"nas_docs": _safe_count("document_analytics"), "nas_docs": _safe_count("document_analytics"),
"active_projects": rev.get("active_projects") or 0,
"margin_month": rev.get("margin_month") or 0,
"margin_year": rev.get("margin_year") or 0,
}
data["goals"] = {
"vision_text": rev.get("vision_text") or "",
"horizon_text": rev.get("horizon_text") or "",
"tagline": rev.get("tagline") or "",
"mid_text": rev.get("mid_text") or "",
} }
try: try:
@@ -76,8 +424,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try: try:
data["events_by_agent"] = fetch_all( data["events_by_agent"] = fetch_all(
"""SELECT agent_name, COUNT(*) AS cnt FROM agent_events """SELECT agent_name, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days' WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""",
(days,),
) )
except Exception: except Exception:
data["events_by_agent"] = [] data["events_by_agent"] = []
@@ -100,11 +449,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["supermarkets_by_chain"] = [] data["supermarkets_by_chain"] = []
try: try:
data["supermarkets_by_province"] = fetch_all( if store_where:
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12" data["supermarkets_by_province"] = fetch_all(
if store_where f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12",
else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12" tuple(store_params),
) )
else:
data["supermarkets_by_province"] = fetch_all(
"SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
)
except Exception: except Exception:
data["supermarkets_by_province"] = [] data["supermarkets_by_province"] = []
@@ -133,8 +486,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
try: try:
data["events_timeline"] = fetch_all( data["events_timeline"] = fetch_all(
"""SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events """SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events
WHERE created_at >= NOW() - INTERVAL '%s days' WHERE created_at >= NOW() - make_interval(days => %s)
GROUP BY DATE(created_at) ORDER BY day ASC""" % days GROUP BY DATE(created_at) ORDER BY day ASC""",
(days,),
) )
except Exception: except Exception:
data["events_timeline"] = [] data["events_timeline"] = []
@@ -150,7 +504,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
data["top_opportunities"] = fetch_all( data["top_opportunities"] = fetch_all(
"""SELECT s.chain, s.city, ros.halal_opportunity_score """SELECT s.chain, s.city, ros.halal_opportunity_score
FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id
ORDER BY ros.halal_opportunity_score DESC LIMIT 10""" ORDER BY ros.halal_opportunity_score DESC LIMIT 12"""
) )
except Exception: except Exception:
data["top_opportunities"] = [] data["top_opportunities"] = []
@@ -191,25 +545,23 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
except Exception: except Exception:
data["recent_deals"] = [] data["recent_deals"] = []
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'" try:
if agent: if agent:
agent_where += " AND agent_name = %s"
try:
data["recent_events"] = fetch_all( data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events """SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25""", WHERE created_at >= NOW() - make_interval(days => %s) AND agent_name = %s
(agent,), ORDER BY created_at DESC LIMIT 25""",
(days, agent),
) )
except Exception: else:
data["recent_events"] = []
else:
try:
data["recent_events"] = fetch_all( data["recent_events"] = fetch_all(
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events """SELECT agent_name, event_type, title, status, created_at FROM agent_events
{agent_where} ORDER BY created_at DESC LIMIT 25""" WHERE created_at >= NOW() - make_interval(days => %s)
ORDER BY created_at DESC LIMIT 25""",
(days,),
) )
except Exception: except Exception:
data["recent_events"] = [] data["recent_events"] = []
try: try:
data["filter_meta"] = { data["filter_meta"] = {
@@ -230,4 +582,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
): ):
if isinstance(data.get(key), list): if isinstance(data.get(key), list):
data[key] = _iso_rows(data[key]) data[key] = _iso_rows(data[key])
data["pulse"] = _build_pulse(data)
data["hermes_findings"] = list_hermes_findings(20)
return data return data
+11 -4
View File
@@ -379,16 +379,23 @@ async def chat(
} }
backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower() backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower()
if backend in ("router", "openrouter", "llm"): # Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator.
if backend in ("hermes", "router", "openrouter", "llm"):
try: try:
return await _chat_via_llm_router(message, channel) return await _chat_via_llm_router(message, channel)
except Exception as exc: except Exception as exc:
hint = (
"Controleer HERMES_API_KEY / Hermes Agent (:8642)."
if backend == "hermes"
else "Vul OpenRouter key in via Instellingen → AI / LLM."
)
return { return {
"agent": "herman", "agent": "herman",
"agent_label": "Herman", "agent_label": "Herman",
"reply": f"LLM provider niet beschikbaar: {exc}. Vul OpenRouter key in via Instellingen → AI / LLM.", "reply": f"LLM provider niet beschikbaar: {exc}. {hint}",
} }
# orchestrator | auto — try Herman orchestrator first
try: try:
async with httpx.AsyncClient(timeout=620.0) as client: async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post( r = await client.post(
@@ -427,8 +434,8 @@ async def chat(
"agent_label": "Herman", "agent_label": "Herman",
"reply": ( "reply": (
f"Orchestrator offline ({exc}). " f"Orchestrator offline ({exc}). "
f"Cloud LLM ook niet beschikbaar ({llm_exc}). " f"Cloud/Hermes LLM ook niet beschikbaar ({llm_exc}). "
"Voeg OpenRouter API key toe: Instellingen → AI / LLM." "Check Hermes (:8642) of Instellingen → AI / LLM."
), ),
} }
return { return {
+10
View File
@@ -0,0 +1,10 @@
{
auto_https off
admin off
}
:8643 {
tls /certs/cert.pem /certs/key.pem
encode gzip
reverse_proxy cockpit:8600
}
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
c9ibkVARig==
-----END CERTIFICATE-----
+27
View File
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEkDCCAnigAwIBAgIUZvhhkY0TiDOHWzrtjaX9/wc07+owDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMjgxMTAzMTI0NzQxWjAwMRIwEAYDVQQD
DAkxMC40LjcuMTgxGjAYBgNVBAoMEUZvb2RsaW5rayBDb2NrcGl0MIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzHQzBJNpldfV+1voe6Ca22p4zL0TQ4BJ
MV5rBlYqjVfgfNv4vo5l97yJ4WQL9cQkmLxUREkAj3Mesqa8BDgPkbpPg2VILqqP
0FpoZTE7mvYRur0fuTfAIeuDGpvelFbFCxjw4AdBZBLIo3h+hipsWmYzaPdximeL
45TSehgbv2rNpTi0BprGaNVQro9shzj+PRreRWcTXPs359sL5FPxbg+v6w/la8/f
stgyvWpv9SCotfQAyCKrsUm9eQqHs0nxzsT8ReouWNYeF9+bI4ENqvlHGCDxCXf3
By+YY5eIiW0puIbtYgPa8rCYVPdunp3HsrwQ59RJqLvLeL59AXpSCwIDAQABo4Gg
MIGdMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATA5BgNVHREEMjAw
hwQKBAcShwR/AAABgglsb2NhbGhvc3SCF2NvY2twaXQuZm9vZGxpbmtrLmxvY2Fs
MB0GA1UdDgQWBBTkq3lZdHcVWo6gNo+NiuRmHCWDiDAfBgNVHSMEGDAWgBT6s+9d
KpDnca/oePjCHz3V2NbYqTANBgkqhkiG9w0BAQsFAAOCAgEAdFESQuLpHXT21XqN
L0zQxGMvvffLC5fO5hN3qWrAdVJul2nImkIdLa0t36//QDr6qEkBn/sbyXxvK2R2
kZbd+zANWoHURA1nSBe5WU4X2jxnpWZsV5IcpcskGWqSMftJSU+e9OAbVxLfubFC
9pC1JCs3VtnHv5o3m4Ypi8ljousgiuwD7NgXaOGAHfnXFHwmjTNNhtNA6do/5IGV
KY8zPzyHIuufkwGAXWZeOs7W2AzUAKZsV7ZkE+C4SWNYuLcDj2k4SW46I1NyDe6x
p3QcbNq/9c4G7Ud1S/vxfaQ/jnBEVRDasjVJ8197yCSzjM9PHwMLfRStxqrUGhS/
BcTeCaDwZIbScjcbF1LBeU5G4gc9CoyWUFKGYbJ+Dk+j+GOi0+2i5xss4daQn8xV
BzeHDn6W4YMeVFOETozEWqotUi8ZI+N3ObSM5H141qHc78WWyG8WY/FgV2lvYuag
3MawO8YYM95/VN0GIUxQXdBPrZrFlyL+8Vx1V7vI0sXTrdaVeB7B3lTgYwV64bVg
8Pac8zgd3vwYI5u71K8JIB4rCujY4SW2YF712/tKG8jsNEf2D14fFRoGtX1yg41u
k0LZkLzo/ldaKFiVYRTY6qwKgN7jBxO4/89Fm9W4w3dTDE2mOX7laX6oDTZWGh64
HJOEFqjE6LCTsSnqN2da2GGm5AY=
-----END CERTIFICATE-----
+16
View File
@@ -0,0 +1,16 @@
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = 10.4.7.18
O = Foodlinkk Cockpit
[v3_req]
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
IP.1 = 10.4.7.18
IP.2 = 127.0.0.1
DNS.1 = localhost
DNS.2 = cockpit.foodlinkk.local
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
c9ibkVARig==
-----END CERTIFICATE-----
+326 -43
View File
@@ -1,50 +1,333 @@
.analytics-shell { /* Foodlinkk Intelligence Analytics — lush neo dashboard */
display: grid; .ax-shell {
grid-template-columns: 240px 1fr; --ax-bg: #070b12;
gap: 1rem; --ax-panel: rgba(12, 18, 28, 0.88);
align-items: start; --ax-line: rgba(125, 211, 252, 0.16);
--ax-cyan: #38bdf8;
--ax-lime: #b8ff3c;
--ax-amber: #fbbf24;
--ax-rose: #fb7185;
--ax-text: #e8eef7;
--ax-muted: #8fa3b8;
--ax-font: "Outfit", "Segoe UI", sans-serif;
--ax-mono: "JetBrains Mono", ui-monospace, monospace;
font-family: var(--ax-font);
color: var(--ax-text);
margin: -0.25rem -0.5rem 0;
padding: 0 0 2.5rem;
position: relative;
} }
.analytics-filters label { .ax-shell * { box-sizing: border-box; }
display: block; .ax-mono { font-family: var(--ax-mono); font-variant-numeric: tabular-nums; }
margin-bottom: 0.75rem; .ax-muted { color: var(--ax-muted); font-size: 0.82rem; }
font-size: 0.85rem; .ax-shell code { font-family: var(--ax-mono); font-size: 0.72rem; color: #7dd3fc; }
color: #94a3b8;
} .ax-hero {
.analytics-filters select, position: relative;
.analytics-filters input { overflow: hidden;
width: 100%; border-radius: 22px;
margin-top: 0.25rem; border: 1px solid var(--ax-line);
} background:
.analytics-main { min-width: 0; } radial-gradient(1200px 420px at 12% -10%, rgba(56,189,248,0.22), transparent 55%),
.analytics-kpis { radial-gradient(900px 380px at 90% 0%, rgba(184,255,60,0.12), transparent 50%),
display: grid; linear-gradient(160deg, #0b1422 0%, #0a101a 55%, #0d1520 100%);
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); padding: 1.4rem 1.5rem 1.2rem;
gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
animation: ax-fade-up .55s ease both;
} }
.analytics-kpis .kpi-card { .ax-hero-glow {
padding: 0.75rem 1rem; position: absolute; inset: auto -20% -40% 40%; height: 70%;
border-radius: 10px; background: radial-gradient(circle, rgba(56,189,248,0.18), transparent 65%);
background: rgba(15, 23, 42, 0.8); pointer-events: none; filter: blur(8px);
border: 1px solid rgba(148, 163, 184, 0.15);
} }
.analytics-kpis .kpi-card strong { .ax-hero-grid {
display: block; position: relative;
font-size: 1.35rem;
color: #e2e8f0;
}
.analytics-kpis .kpi-card span {
font-size: 0.75rem;
color: #64748b;
}
.analytics-charts {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: 1.4fr auto 1fr;
gap: 1rem; gap: 1.25rem;
margin-bottom: 1.5rem; align-items: center;
} }
.chart-panel canvas { max-height: 220px; } .ax-eyebrow {
.chart-panel h4 { margin: 0 0 0.75rem; font-size: 0.9rem; color: #cbd5e1; } margin: 0 0 0.35rem;
@media (max-width: 900px) { font-size: 0.68rem;
.analytics-shell { grid-template-columns: 1fr; } letter-spacing: 0.18em;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.ax-hero h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 2.75rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.05;
}
.ax-live {
display: inline-flex; align-items: center;
margin-left: 0.55rem; vertical-align: middle;
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em;
padding: 0.22rem 0.55rem; border-radius: 999px;
background: rgba(184,255,60,0.12); border: 1px solid rgba(184,255,60,0.35);
color: #d9ff8b; animation: ax-pulse 2.4s ease-in-out infinite;
}
.ax-lead { margin: 0.65rem 0 0; max-width: 36rem; color: #b6c7d8; font-size: 1rem; line-height: 1.5; }
.ax-goal { margin: 0.55rem 0 0; color: #7dd3fc; font-size: 0.82rem; opacity: 0.9; }
.ax-pulse-orb { text-align: center; position: relative; width: 150px; justify-self: center; }
.ax-ring { width: 150px; height: 150px; transform: rotate(-90deg); }
.ax-ring-track { fill: none; stroke: rgba(148,163,184,0.15); stroke-width: 8; }
.ax-ring-value {
fill: none; stroke: var(--ax-cyan); stroke-width: 8; stroke-linecap: round;
transition: stroke-dasharray .8s ease;
filter: drop-shadow(0 0 8px rgba(56,189,248,0.45));
}
.tone-good .ax-ring-value { stroke: var(--ax-lime); filter: drop-shadow(0 0 8px rgba(184,255,60,0.4)); }
.tone-mid .ax-ring-value { stroke: var(--ax-amber); }
.tone-low .ax-ring-value { stroke: var(--ax-rose); }
.ax-pulse-core {
position: absolute; inset: 0; display: grid; place-content: center; text-align: center;
transform: translateY(-6px);
}
.ax-pulse-core strong { font-size: 2.1rem; font-weight: 700; line-height: 1; }
.ax-pulse-core span { font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ax-muted); }
.ax-pulse-label { margin: 0.15rem 0 0; font-size: 0.75rem; color: #cbd5e1; }
.ax-hero-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; justify-content: flex-end; align-items: center; }
.ax-btn {
appearance: none; border: 1px solid rgba(148,163,184,0.28);
background: rgba(8,12,20,0.55); color: #e2e8f0;
border-radius: 999px; padding: 0.45rem 0.85rem; font: inherit; font-size: 0.78rem;
cursor: pointer; text-decoration: none; transition: border-color .15s, background .15s, transform .12s;
}
.ax-btn:hover { border-color: #38bdf8; background: rgba(14,165,233,0.12); transform: translateY(-1px); }
.ax-btn-primary {
border-color: rgba(184,255,60,0.45);
background: linear-gradient(135deg, rgba(184,255,60,0.18), rgba(56,189,248,0.12));
color: #f8ffe8;
}
.ax-updated { width: 100%; text-align: right; color: #64748b; font-size: 0.7rem; }
.ax-filters {
margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid rgba(148,163,184,0.12);
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.65rem; align-items: end;
}
.ax-filters label { font-size: 0.72rem; color: #94a3b8; display: grid; gap: 0.25rem; }
.ax-filters select, .ax-filters input {
width: 100%; border-radius: 8px; border: 1px solid rgba(148,163,184,0.25);
background: #0a1018; color: #e2e8f0; padding: 0.4rem 0.5rem; font: inherit;
}
.ax-signals {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.65rem;
margin-bottom: 1rem;
animation: ax-fade-up .6s ease both .08s;
}
.ax-signal {
position: relative; overflow: hidden;
background: var(--ax-panel);
border: 1px solid var(--ax-line);
border-radius: 16px;
padding: 0.85rem 0.9rem 0.75rem;
min-height: 96px;
}
.ax-signal::after {
content: ""; position: absolute; inset: auto 0 0 0; height: 2px;
background: linear-gradient(90deg, transparent, var(--ax-cyan), transparent);
opacity: 0.55;
}
.ax-signal.good::after { background: linear-gradient(90deg, transparent, var(--ax-lime), transparent); }
.ax-signal.warn::after { background: linear-gradient(90deg, transparent, var(--ax-amber), transparent); }
.ax-signal-label { display: block; font-size: 0.68rem; color: #94a3b8; letter-spacing: 0.04em; text-transform: uppercase; }
.ax-signal strong { display: block; margin-top: 0.35rem; font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; }
.ax-signal em { display: block; margin-top: 0.25rem; font-style: normal; font-size: 0.72rem; color: #64748b; }
.ax-split {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-bottom: 1rem;
animation: ax-fade-up .65s ease both .12s;
}
.ax-col {
background: var(--ax-panel);
border: 1px solid var(--ax-line);
border-radius: 18px;
padding: 1rem;
min-height: 320px;
}
.ax-col-win { box-shadow: inset 3px 0 0 rgba(184,255,60,0.55); }
.ax-col-opp { box-shadow: inset 3px 0 0 rgba(251,191,36,0.55); }
.ax-col-head {
display: flex; justify-content: space-between; align-items: baseline; gap: 0.75rem;
margin-bottom: 0.85rem;
}
.ax-col-head h2, .ax-hermes h2, .ax-radar h2 {
margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em;
}
.ax-col-head span, .ax-pill {
font-size: 0.72rem; color: #94a3b8;
padding: 0.2rem 0.55rem; border-radius: 999px;
border: 1px solid rgba(148,163,184,0.2);
}
.ax-insight {
display: block; text-decoration: none; color: inherit;
border: 1px solid rgba(148,163,184,0.12);
background: rgba(6, 10, 16, 0.55);
border-radius: 14px;
padding: 0.8rem 0.85rem;
margin-bottom: 0.55rem;
transition: transform .14s, border-color .14s, background .14s;
}
.ax-insight:hover {
transform: translateY(-2px);
border-color: rgba(56,189,248,0.35);
background: rgba(14,165,233,0.08);
}
.ax-insight-top { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.ax-insight-top strong { font-size: 0.95rem; line-height: 1.3; }
.ax-score {
font-size: 0.85rem; color: #d9ff8b; min-width: 2rem; text-align: right;
}
.ax-insight.opp .ax-score { color: #fcd34d; }
.ax-insight p { margin: 0.4rem 0 0.55rem; font-size: 0.82rem; color: #a7b7c8; line-height: 1.45; }
.ax-insight-foot { display: grid; gap: 0.35rem; }
.ax-insight-foot > span:first-child { font-size: 0.72rem; color: #7dd3fc; }
.ax-bar, .ax-mini-bar {
display: block; height: 4px; border-radius: 999px; background: rgba(148,163,184,0.15); overflow: hidden;
}
.ax-bar i, .ax-mini-bar i {
display: block; height: 100%; border-radius: inherit;
background: linear-gradient(90deg, #84cc16, #b8ff3c);
}
.ax-bar.opp i { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.ax-tag {
display: inline-block; font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.12rem 0.4rem; border-radius: 999px; margin-right: 0.25rem;
background: rgba(251,191,36,0.12); color: #fcd34d; border: 1px solid rgba(251,191,36,0.25);
}
.ax-empty { color: #64748b; font-size: 0.85rem; padding: 1rem 0; }
.ax-band { margin-bottom: 1rem; animation: ax-fade-up .7s ease both .16s; }
.ax-att-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; }
.ax-att-chip {
flex: 1 1 220px; text-decoration: none; color: inherit;
border: 1px solid rgba(251,113,133,0.28);
background: rgba(251,113,133,0.08);
border-radius: 12px; padding: 0.7rem 0.8rem;
}
.ax-att-chip strong { display: block; font-size: 0.85rem; margin-bottom: 0.2rem; }
.ax-att-chip span { font-size: 0.75rem; color: #cbd5e1; }
.ax-attention h3 { margin: 0 0 0.35rem; font-size: 0.85rem; color: #fda4af; letter-spacing: 0.04em; text-transform: uppercase; }
.ax-hermes {
border: 1px solid var(--ax-line);
border-radius: 18px;
background:
radial-gradient(700px 220px at 0% 0%, rgba(56,189,248,0.12), transparent 55%),
var(--ax-panel);
padding: 1rem 1.1rem 1.1rem;
}
.ax-findings-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.65rem; margin-top: 0.75rem;
}
.ax-finding {
border-radius: 14px; border: 1px solid rgba(148,163,184,0.16);
background: rgba(5,9,14,0.65); padding: 0.85rem;
}
.ax-finding.kind-win { border-color: rgba(184,255,60,0.3); }
.ax-finding.kind-opportunity { border-color: rgba(251,191,36,0.35); }
.ax-finding-top { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; }
.ax-finding-top time { font-size: 0.68rem; color: #64748b; }
.ax-finding h4 { margin: 0; font-size: 0.95rem; }
.ax-finding p { margin: 0.4rem 0 0.55rem; font-size: 0.8rem; color: #a7b7c8; line-height: 1.45; }
.ax-finding-foot { display: flex; justify-content: space-between; font-size: 0.7rem; color: #64748b; }
.ax-findings-empty { margin-top: 0.75rem; }
.ax-findings-placeholder {
border: 1px dashed rgba(56,189,248,0.35);
border-radius: 14px; padding: 1.1rem 1.2rem;
background: rgba(14,165,233,0.05);
}
.ax-findings-placeholder strong { display: block; margin-bottom: 0.3rem; }
.ax-findings-placeholder p { margin: 0; color: #94a3b8; font-size: 0.85rem; line-height: 1.5; }
.ax-radar {
border: 1px solid var(--ax-line);
border-radius: 18px;
background: var(--ax-panel);
padding: 1rem 1.1rem 1.15rem;
margin-bottom: 1rem;
animation: ax-fade-up .75s ease both .2s;
}
.ax-charts {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
}
.ax-chart-card {
grid-column: span 2;
border: 1px solid rgba(148,163,184,0.12);
border-radius: 14px;
background: rgba(5,9,14,0.55);
padding: 0.75rem 0.8rem 0.6rem;
}
.ax-chart-card.wide { grid-column: span 6; }
.ax-chart-card h3 { margin: 0 0 0.45rem; font-size: 0.82rem; color: #cbd5e1; font-weight: 600; }
.ax-chart-wrap { position: relative; height: 210px; }
.ax-chart-card.wide .ax-chart-wrap { height: 180px; }
.ax-lists {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.85rem;
animation: ax-fade-up .8s ease both .24s;
}
.ax-list-card {
border: 1px solid var(--ax-line);
border-radius: 16px;
background: var(--ax-panel);
padding: 0.9rem 1rem;
}
.ax-list-card h3 { margin: 0 0 0.75rem; font-size: 0.92rem; }
.ax-list-card ul { list-style: none; margin: 0; padding: 0; }
.ax-list-card li {
display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center;
padding: 0.55rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
}
.ax-rank {
width: 1.5rem; height: 1.5rem; border-radius: 8px;
display: grid; place-items: center; font-size: 0.72rem;
background: rgba(56,189,248,0.12); color: #7dd3fc;
}
.ax-list-card li strong { display: block; font-size: 0.82rem; }
.ax-list-card li em { font-style: normal; color: #fbbf24; font-size: 0.85rem; }
.ax-events li { grid-template-columns: 1fr auto; }
.ax-events li span { display: block; font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; }
.ax-events time { font-size: 0.68rem; color: #64748b; }
.ax-dl { margin: 0; display: grid; gap: 0.55rem; }
.ax-dl > div {
display: flex; justify-content: space-between; gap: 0.75rem;
padding: 0.45rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
}
.ax-dl dt { color: #94a3b8; font-size: 0.8rem; }
.ax-dl dd { margin: 0; font-size: 0.9rem; color: #e2e8f0; }
@keyframes ax-fade-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes ax-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(184,255,60,0.0); }
50% { box-shadow: 0 0 0 6px rgba(184,255,60,0.08); }
}
@media (max-width: 1100px) {
.ax-hero-grid { grid-template-columns: 1fr; }
.ax-hero-actions { justify-content: flex-start; }
.ax-signals { grid-template-columns: repeat(3, 1fr); }
.ax-split, .ax-lists { grid-template-columns: 1fr; }
.ax-charts { grid-template-columns: 1fr 1fr; }
.ax-chart-card, .ax-chart-card.wide { grid-column: span 2; }
}
@media (max-width: 700px) {
.ax-signals { grid-template-columns: 1fr 1fr; }
.ax-charts { grid-template-columns: 1fr; }
.ax-chart-card, .ax-chart-card.wide { grid-column: span 1; }
} }
+188 -32
View File
@@ -1,34 +1,72 @@
/* Herman browser assistant — floating widget */ /* Herman / Hermes chat — always-on floating dock */
.ha-fab { .ha-dock {
position: fixed; /* wrapper only; FAB/panel are fixed to viewport */
right: 1.25rem; }
bottom: 1.25rem; .ha-dock-hidden {
z-index: 1500; display: none !important;
width: 56px; }
height: 56px;
border-radius: 50%; .ha-fab {
border: 2px solid rgba(167, 139, 250, 0.55); position: fixed !important;
background: linear-gradient(145deg, #1e293b, #0f1419); right: max(1.25rem, env(safe-area-inset-right, 0px));
color: #f8fafc; bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));
font-size: 1.5rem; z-index: 10050 !important;
cursor: pointer; height: 52px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); width: auto;
transition: transform 0.15s, box-shadow 0.15s; min-width: 52px;
padding: 0 1.15rem 0 0.9rem;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.65);
background: linear-gradient(145deg, #0ea5e9 0%, #0284c7 55%, #0369a1 100%);
color: #f8fafc;
font-size: 1.15rem;
cursor: pointer;
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 0.45rem;
box-shadow:
0 0 0 4px rgba(14, 165, 233, 0.18),
0 10px 28px rgba(0, 0, 0, 0.5);
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
pointer-events: auto;
}
.ha-fab-icon { line-height: 1; font-size: 1.2rem; }
.ha-fab-label {
font-size: 0.88rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: none;
}
.ha-fab:hover {
transform: translateY(-2px) scale(1.03);
box-shadow:
0 0 0 5px rgba(14, 165, 233, 0.28),
0 14px 34px rgba(0, 0, 0, 0.55);
}
.ha-fab.is-open {
background: linear-gradient(145deg, #4c1d95, #1e1b4b);
border-color: rgba(167, 139, 250, 0.7);
box-shadow:
0 0 0 4px rgba(167, 139, 250, 0.2),
0 10px 28px rgba(0, 0, 0, 0.5);
}
.ha-fab.is-busy {
border-color: #38bdf8;
animation: ha-pulse 1.2s infinite;
} }
.ha-fab:hover { transform: scale(1.06); box-shadow: 0 10px 32px rgba(167, 139, 250, 0.25); }
.ha-fab.is-open { background: linear-gradient(145deg, #4c1d95, #1e1b4b); }
.ha-panel { .ha-panel {
position: fixed; position: fixed !important;
right: 1rem; right: max(1rem, env(safe-area-inset-right, 0px));
bottom: 5rem; bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
z-index: 1499; z-index: 10049 !important;
width: min(400px, calc(100vw - 2rem)); width: min(420px, calc(100vw - 2rem));
max-height: min(560px, calc(100vh - 7rem)); max-height: min(620px, calc(100vh - 8rem));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(167, 139, 250, 0.35); border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(10, 14, 20, 0.97); background: rgba(10, 14, 20, 0.97);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
overflow: hidden; overflow: hidden;
@@ -41,11 +79,33 @@
padding: 0.65rem 0.85rem; padding: 0.65rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(167, 139, 250, 0.08); background: rgba(167, 139, 250, 0.08);
gap: 0.5rem;
} }
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; } .ha-head-title { min-width: 0; }
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; } .ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; display: flex; align-items: center; gap: 0.4rem; }
.ha-badge {
font-size: 0.62rem;
font-weight: 600;
color: #ddd6fe;
background: rgba(124, 58, 237, 0.35);
border: 1px solid rgba(167, 139, 250, 0.45);
border-radius: 999px;
padding: 0.12rem 0.45rem;
letter-spacing: 0.02em;
}
.ha-conn {
margin: 0.15rem 0 0;
font-size: 0.62rem;
color: #64748b;
}
.ha-conn.is-ok { color: #34d399; }
.ha-conn.is-err { color: #f87171; }
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; flex-shrink: 0; }
.ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; } .ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; }
.ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; } .ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; }
.ha-text-btn { font-size: 0.68rem !important; color: #94a3b8 !important; padding: 0.2rem 0.35rem !important; }
.ha-text-btn:hover { color: #e2e8f0 !important; }
.ha-text-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ha-confirm { .ha-confirm {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
@@ -63,8 +123,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
min-height: 200px; min-height: 220px;
max-height: 320px; max-height: 360px;
} }
.ha-bubble { .ha-bubble {
max-width: 92%; max-width: 92%;
@@ -103,6 +163,7 @@
padding: 0.55rem 0.65rem; padding: 0.55rem 0.65rem;
border-top: 1px solid rgba(148, 163, 184, 0.12); border-top: 1px solid rgba(148, 163, 184, 0.12);
align-items: center; align-items: center;
margin: 0;
} }
.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; } .ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
.ha-mic { .ha-mic {
@@ -122,7 +183,20 @@
50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.28); } 50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.28); }
} }
.ha-status { font-size: 0.65rem; color: #64748b; padding: 0 0.65rem 0.35rem; } .ha-status { font-size: 0.72rem; color: #cbd5e1; padding: 0.25rem 0.65rem 0.4rem; line-height: 1.35; }
.ha-status.is-error { color: #fecaca; background: rgba(127, 29, 29, 0.35); border-radius: 6px; margin: 0 0.5rem 0.35rem; padding: 0.4rem 0.55rem; }
.ha-secure-hint {
margin: 0.25rem 0.55rem 0.45rem;
padding: 0.55rem 0.65rem;
border-radius: 8px;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.45);
color: #e2e8f0;
font-size: 0.78rem;
line-height: 1.4;
}
.ha-secure-hint strong { color: #f8fafc; display: block; margin-bottom: 0.25rem; }
.ha-secure-hint a { display: inline-block; margin-top: 0.4rem; }
.ha-results-modal { z-index: 1600; } .ha-results-modal { z-index: 1600; }
.ha-results-card { width: min(640px, 96vw); max-height: 80vh; } .ha-results-card { width: min(640px, 96vw); max-height: 80vh; }
@@ -143,6 +217,88 @@
.voice-results-table a { color: #38bdf8; } .voice-results-table a { color: #38bdf8; }
@media (max-width: 480px) { @media (max-width: 480px) {
.ha-panel { right: 0.5rem; left: 0.5rem; width: auto; bottom: 4.5rem; } .ha-fab {
.ha-fab { right: 1rem; bottom: 1rem; } right: 1rem;
bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px)); /* above mobile bottom nav */
}
.ha-panel {
right: 0.5rem;
left: 0.5rem;
width: auto;
bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px));
}
}
.ha-toggles {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 0.9rem;
padding: 0.15rem 0.65rem 0.35rem;
font-size: 0.68rem;
color: #94a3b8;
}
.ha-toggles label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
user-select: none;
}
.ha-toggles input {
accent-color: #38bdf8;
margin: 0;
}
.ha-mic.is-listening {
box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.35);
}
/* Toasts boven de Herman FAB, niet erachter */
.toast-container {
bottom: 5.75rem !important;
right: 1rem !important;
left: auto !important;
z-index: 10120 !important;
max-width: min(360px, calc(100vw - 2rem));
}
.toast {
color: #f1f5f9 !important;
background: #0f172a !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
}
.toast-error { border-color: #f87171 !important; color: #fecaca !important; }
.ha-https-prompt {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0 0.55rem 0.4rem;
padding: 0.45rem 0.55rem;
border-radius: 8px;
background: rgba(14, 165, 233, 0.12);
border: 1px solid rgba(56, 189, 248, 0.4);
color: #e2e8f0;
font-size: 0.75rem;
}
.ha-https-prompt .btn { flex-shrink: 0; }
.ha-https-prompt .ha-text-btn { margin-left: auto; color: #94a3b8; }
.ha-fab.has-custom-pos {
right: auto !important;
bottom: auto !important;
}
.ha-fab.is-dragging {
cursor: grabbing !important;
transition: none !important;
transform: none !important;
user-select: none;
touch-action: none;
}
.ha-fab {
touch-action: none;
user-select: none;
}
.ha-panel.has-custom-pos {
right: auto !important;
bottom: auto !important;
} }
+159
View File
@@ -216,3 +216,162 @@
.dot-in { background: var(--hm-cyan); } .dot-in { background: var(--hm-cyan); }
.dot-out { background: var(--hm-purple); } .dot-out { background: var(--hm-purple); }
.dot-edge { background: var(--hm-orange); } .dot-edge { background: var(--hm-orange); }
.hm-section-title {
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #94a3b8;
margin: 1.25rem 0 0.75rem;
}
.hm-bucket-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
}
.hm-bucket-card {
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(56, 189, 248, 0.2);
border-radius: 12px;
padding: 0.85rem 0.9rem;
}
.hm-bucket-label { font-size: 0.72rem; color: #94a3b8; margin-bottom: 0.35rem; }
.hm-bucket-count { font-size: 1.6rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
.hm-bucket-parts { font-size: 0.68rem; color: #64748b; margin-top: 0.4rem; line-height: 1.35; }
.hm-tool-list { list-style: none; margin: 0.75rem 0 0; padding: 0; }
.hm-tool-list li {
display: flex; justify-content: space-between; gap: 0.75rem;
padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.1);
font-size: 0.78rem; color: #cbd5e1;
}
.hm-tool-list li strong { color: #e2e8f0; font-variant-numeric: tabular-nums; }
.hm-tool-list-tall { max-height: 280px; overflow: auto; }
.status-badge.on { color: #b8ff3c; }
.status-badge.off { color: #f87171; }
/* Clickable buckets + explorer modal */
.hm-section-hint { font-size: 0.7rem; font-weight: 500; color: #64748b; text-transform: none; letter-spacing: 0; margin-left: 0.5rem; }
.hm-bucket-click {
display: block; width: 100%; text-align: left; cursor: pointer;
transition: border-color .15s, transform .12s, background .15s;
font: inherit; color: inherit;
}
.hm-bucket-click:hover {
border-color: rgba(0, 229, 255, 0.55);
background: rgba(14, 165, 233, 0.08);
transform: translateY(-1px);
}
.hm-bucket-open {
margin-top: 0.55rem; font-size: 0.68rem; color: #38bdf8; opacity: 0.75;
}
.hm-bucket-click:hover .hm-bucket-open { opacity: 1; }
.hm-modal-backdrop {
position: fixed; inset: 0; z-index: 80;
background: rgba(2, 6, 14, 0.72);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
padding: 1rem;
}
.hm-modal {
width: min(1100px, 96vw);
height: min(820px, 92vh);
background: #0b1220;
border: 1px solid rgba(56, 189, 248, 0.28);
border-radius: 14px;
box-shadow: 0 24px 80px rgba(0,0,0,0.55);
display: flex; flex-direction: column;
overflow: hidden;
}
.hm-modal-head {
display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start;
padding: 1rem 1.1rem 0.75rem;
border-bottom: 1px solid rgba(148,163,184,0.12);
}
.hm-modal-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
.hm-modal-sub { margin: 0.35rem 0 0; font-size: 0.72rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.hm-modal-close {
background: transparent; border: 0; color: #94a3b8; font-size: 1.6rem; line-height: 1; cursor: pointer; padding: 0 0.25rem;
}
.hm-modal-close:hover { color: #fff; }
.hm-chip {
border: 1px solid rgba(148,163,184,0.25);
background: rgba(15,23,42,0.7);
color: #cbd5e1; border-radius: 999px;
padding: 0.15rem 0.55rem; font-size: 0.65rem; cursor: pointer;
}
.hm-chip.active, .hm-chip:hover { border-color: #38bdf8; color: #e0f2fe; background: rgba(14,165,233,0.12); }
.hm-modal-tabs {
display: flex; gap: 0.25rem; padding: 0.55rem 1rem 0;
border-bottom: 1px solid rgba(148,163,184,0.1);
}
.hm-modal-tabs button {
background: transparent; border: 0; color: #94a3b8; padding: 0.45rem 0.75rem;
font-size: 0.78rem; cursor: pointer; border-bottom: 2px solid transparent;
}
.hm-modal-tabs button.active { color: #e0f2fe; border-bottom-color: #38bdf8; }
.hm-modal-body { flex: 1; min-height: 0; }
.hm-modal-split { display: grid; grid-template-columns: minmax(260px, 38%) 1fr; }
.hm-modal-side {
border-right: 1px solid rgba(148,163,184,0.12);
overflow: auto; background: rgba(8, 12, 20, 0.9);
position: relative;
}
.hm-modal-preview { overflow: auto; padding: 1rem 1.1rem; }
.hm-side-loading {
position: sticky; top: 0; z-index: 2;
padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #38bdf8;
background: rgba(14,165,233,0.08);
}
.hm-event-list, .hm-file-list { list-style: none; margin: 0; padding: 0.35rem; }
.hm-event-item, .hm-file-item {
width: 100%; text-align: left; cursor: pointer;
background: transparent; border: 1px solid transparent; border-radius: 8px;
padding: 0.55rem 0.6rem; color: inherit; font: inherit; margin-bottom: 0.25rem;
}
.hm-event-item:hover, .hm-file-item:hover,
.hm-event-item.active, .hm-file-item.active {
background: rgba(14,165,233,0.1);
border-color: rgba(56,189,248,0.25);
}
.hm-event-top { display: flex; justify-content: space-between; gap: 0.5rem; }
.hm-event-top strong { font-size: 0.8rem; color: #f1f5f9; }
.hm-event-top time, .hm-file-meta time { font-size: 0.65rem; color: #64748b; white-space: nowrap; }
.hm-event-tool { font-size: 0.65rem; color: #38bdf8; margin-top: 0.15rem; }
.hm-event-preview, .hm-file-path {
font-size: 0.68rem; color: #94a3b8; margin-top: 0.25rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hm-file-name { display: flex; gap: 0.4rem; align-items: center; }
.hm-file-name strong { font-size: 0.8rem; color: #e2e8f0; }
.hm-file-meta { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 0.25rem; font-size: 0.65rem; color: #64748b; }
.hm-explorer-bar {
display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap;
padding: 0.55rem 0.65rem; border-bottom: 1px solid rgba(148,163,184,0.1);
position: sticky; top: 0; background: #0b1220; z-index: 1;
}
.hm-explorer-path {
font-size: 0.65rem; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
}
.hm-preview-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; }
.hm-preview-head h3 { margin: 0; font-size: 1rem; color: #f8fafc; }
.hm-preview-head code { display: block; margin-top: 0.25rem; font-size: 0.68rem; color: #64748b; word-break: break-all; }
.hm-preview-meta { font-size: 0.68rem; color: #94a3b8; display: flex; flex-direction: column; align-items: flex-end; gap: 0.15rem; }
.hm-preview-code {
margin: 0; padding: 0.85rem; border-radius: 10px;
background: #020617; border: 1px solid rgba(148,163,184,0.15);
color: #e2e8f0; font-size: 0.72rem; line-height: 1.45;
white-space: pre-wrap; word-break: break-word; max-height: none;
}
.hm-preview-note { font-size: 0.68rem; color: #f59e0b; margin-top: 0.5rem; }
.hm-preview-paths { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; }
.hm-empty, .hm-empty-pane { padding: 1.25rem; color: #64748b; font-size: 0.8rem; }
@media (max-width: 820px) {
.hm-modal { height: 94vh; }
.hm-modal-split { grid-template-columns: 1fr; grid-template-rows: 42% 58%; }
.hm-modal-side { border-right: 0; border-bottom: 1px solid rgba(148,163,184,0.12); }
}
+202 -172
View File
@@ -1,199 +1,229 @@
window.AnalyticsCharts = (function () { /**
var charts = {}; * Foodlinkk Intelligence Analytics hub
var colors = { */
gold: 'rgba(252, 211, 77, 0.85)', cyan: 'rgba(56, 189, 248, 0.85)',
green: 'rgba(74, 222, 128, 0.85)', red: 'rgba(251, 113, 133, 0.85)',
purple: 'rgba(168, 85, 247, 0.85)', orange: 'rgba(255, 159, 67, 0.85)',
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.12)', text: '#c8d4e0',
};
var palette = [colors.gold, colors.cyan, colors.green, colors.purple, colors.orange, colors.red, colors.gray];
function destroyAll() {
Object.keys(charts).forEach(function (k) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
});
}
function barChart(id, labels, values, label, horizontal) {
var canvas = document.getElementById(id);
if (!canvas || typeof Chart === 'undefined') return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(canvas, {
type: 'bar',
data: {
labels: labels,
datasets: [{ label: label, data: values, backgroundColor: palette.slice(0, labels.length), borderRadius: 6 }],
},
options: {
indexAxis: horizontal ? 'y' : 'x',
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: colors.text, maxRotation: 45 }, grid: { color: colors.grid } },
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
},
},
});
}
function doughnut(id, labels, values) {
var canvas = document.getElementById(id);
if (!canvas || typeof Chart === 'undefined') return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(canvas, {
type: 'doughnut',
data: { labels: labels, datasets: [{ data: values, backgroundColor: palette, borderWidth: 0 }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: colors.text, boxWidth: 12 } } } },
});
}
function lineChart(id, labels, values) {
var canvas = document.getElementById(id);
if (!canvas || typeof Chart === 'undefined') return;
if (charts[id]) charts[id].destroy();
charts[id] = new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: [{ data: values, borderColor: colors.cyan, backgroundColor: 'rgba(56,189,248,0.15)', fill: true, tension: 0.3 }],
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: colors.text }, grid: { color: colors.grid } },
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
},
},
});
}
function renderKpis(container, kpis) {
if (!container || !kpis) return;
var items = [
['Klanten totaal', kpis.clients_total], ['Actieve klanten', kpis.clients_active],
['Pipeline €', '€' + Math.round(kpis.pipeline_eur || 0).toLocaleString('nl-NL')],
['Supermarkten', kpis.supermarkets], ['Groothandels', kpis.wholesalers],
['CRM actief', kpis.crm_partnerships], ['RSS items', kpis.rss_items],
['Bookmarks', kpis.rss_bookmarks], ['Promo's', kpis.promo_campaigns],
['Agent events', kpis.agent_events], ['Goedkeuringen', kpis.pending_approvals],
['Contacten SM', kpis.contacts_supermarket], ['NAS docs', kpis.nas_docs],
];
container.innerHTML = items.map(function (it) {
return '<div class="kpi-card"><span>' + it[0] + '</span><strong>' + it[1] + '</strong></div>';
}).join('');
}
function renderTables(data) {
var dealsEl = document.getElementById('analytics-deals-table');
var eventsEl = document.getElementById('analytics-events-table');
if (dealsEl) {
dealsEl.innerHTML = (data.recent_deals || []).map(function (d) {
return '<tr><td>' + (d.title || '—') + '</td><td>' + (d.stage || '') + '</td><td>€' + Math.round(Number(d.value || 0)).toLocaleString('nl-NL') + '</td></tr>';
}).join('') || '<tr><td colspan="3">Geen deals</td></tr>';
}
if (eventsEl) {
eventsEl.innerHTML = (data.recent_events || []).map(function (e) {
var t = (e.created_at || '').substring(0, 16).replace('T', ' ');
return '<tr><td>' + (e.agent_name || '') + '</td><td>' + (e.title || e.event_type || '') + '</td><td>' + t + '</td></tr>';
}).join('') || '<tr><td colspan="3">Geen events</td></tr>';
}
}
function render(data, mode) {
if (!data) return;
mode = mode || window.__vizMode || 'neo-bars';
window.__analyticsLastData = data;
renderKpis(document.getElementById('analytics-kpis'), data.kpis);
var useRing = mode === 'neo-rings';
var bar = function (id, labels, values, label, horizontal) {
if (useRing) doughnut(id, labels, values);
else barChart(id, labels, values, label, horizontal);
};
bar('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }),
(data.deals_by_stage || []).map(function (r) { return Number(r.total || r.cnt || 0); }), 'EUR');
bar('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }),
(data.clients_by_stage || []).map(function (r) { return Number(r.cnt || 0); }), 'Klanten');
bar('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }),
(data.supermarkets_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
if (useRing) {
doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }));
} else barChart('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }),
(data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); }));
bar('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }),
(data.events_by_agent || []).map(function (r) { return Number(r.cnt || 0); }), 'Events', true);
if (mode === 'neo-table') {
lineChart('chart-timeline', [], []);
} else {
lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }),
(data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); }));
}
doughnut('chart-rss', (data.rss_by_category || []).map(function (r) { return r.category || 'other'; }),
(data.rss_by_category || []).map(function (r) { return Number(r.cnt || 0); }));
bar('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }),
(data.wholesalers_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'GH');
doughnut('chart-sentiment', (data.sentiment_distribution || []).map(function (r) { return r.sentiment_label || 'neutral'; }),
(data.sentiment_distribution || []).map(function (r) { return Number(r.cnt || 0); }));
bar('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }),
(data.top_words || []).slice(0, 10).map(function (r) { return Number(r.total || 0); }), 'Count', true);
bar('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }),
(data.top_opportunities || []).map(function (r) { return Math.round(Number(r.halal_opportunity_score || 0)); }), 'Score', true);
bar('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }),
(data.promo_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Promo');
doughnut('chart-milestones', (data.milestones_by_status || []).map(function (r) { return r.status; }),
(data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); }));
renderTables(data);
}
document.addEventListener('foodlinkk:vizmode', function (ev) {
if (window.__analyticsLastData) render(window.__analyticsLastData, ev.detail.mode);
});
return { render: render, destroyAll: destroyAll };
})();
function analyticsHub() { function analyticsHub() {
const charts = {};
function destroyCharts() {
Object.keys(charts).forEach((k) => {
try { charts[k].destroy(); } catch (e) {}
delete charts[k];
});
}
function euro(n) {
const v = Math.round(Number(n || 0));
return '€' + v.toLocaleString('nl-NL');
}
return { return {
loading: false, loading: false,
showFilters: false,
updatedAt: '—', updatedAt: '—',
liveLabel: 'Live', liveLabel: 'Live',
f: { chain: '', province: '', stage: '', agent: '', days: 90 }, f: { chain: '', province: '', stage: '', agent: '', days: 90 },
meta: {}, meta: {},
data: {},
kpis: {},
pulse: { wins: [], opportunities: [], attention: [], score: 0 },
findings: [],
goals: {},
signals: [],
_pollStop: null, _pollStop: null,
init() {
var initial = window.ANALYTICS_INITIAL || {}; fmt(n) {
this.meta = initial.filter_meta || {}; return Number(n || 0).toLocaleString('nl-NL');
AnalyticsCharts.render(initial);
this.updatedAt = (initial.generated_at || '').substring(0, 19).replace('T', ' ');
this._pollStop = CockpitLive.startPolling(function () { return this.refresh(false); }.bind(this), 30000);
}, },
formatTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch (e) {
return String(iso).slice(0, 16).replace('T', ' ');
}
},
shortGoal(t) {
const s = String(t || '').replace(/\s+/g, ' ').trim();
return s.length > 90 ? s.slice(0, 87) + '…' : s;
},
ringDash(score) {
const s = Math.max(0, Math.min(100, Number(score || 0)));
const c = 2 * Math.PI * 52;
return ((s / 100) * c).toFixed(1);
},
applyData(payload) {
this.data = payload || {};
this.kpis = this.data.kpis || {};
this.pulse = this.data.pulse || { wins: [], opportunities: [], attention: [], score: 0 };
this.findings = this.data.hermes_findings || [];
this.goals = this.data.goals || {};
this.meta = this.data.filter_meta || this.meta;
this.updatedAt = (this.data.generated_at || '').substring(0, 19).replace('T', ' ') || '—';
this.signals = [
{ key: 'margin', label: 'Marge / mnd', value: euro(this.kpis.margin_month), hint: (this.kpis.active_projects || 0) + ' deals', tone: 'good' },
{ key: 'partners', label: 'CRM actief', value: this.fmt(this.kpis.crm_partnerships), hint: (this.pulse.cover_pct || 0) + '% dekking', tone: 'good' },
{ key: 'gap', label: 'White space', value: this.fmt(this.pulse.white_space), hint: 'winkels zonder partnership', tone: 'warn' },
{ key: 'stores', label: 'Supermarkten', value: this.fmt(this.kpis.supermarkets), hint: this.fmt(this.kpis.wholesalers) + ' groothandels' },
{ key: 'rss', label: 'Markt signalen', value: this.fmt(this.kpis.rss_items), hint: this.fmt(this.kpis.promo_campaigns) + ' promo’s' },
{ key: 'agents', label: 'Agent events', value: this.fmt(this.kpis.agent_events), hint: this.fmt(this.kpis.pending_approvals) + ' approvals', tone: this.kpis.pending_approvals ? 'warn' : '' },
];
this.$nextTick(() => this.renderCharts());
},
init() {
this.applyData(window.ANALYTICS_INITIAL || {});
if (window.CockpitLive && CockpitLive.startPolling) {
this._pollStop = CockpitLive.startPolling(() => this.refresh(false), 45000);
} else {
this._pollStop = setInterval(() => this.refresh(false), 45000);
}
},
params() { params() {
var p = new URLSearchParams(); const p = new URLSearchParams();
Object.entries(this.f).forEach(function (e) { Object.entries(this.f).forEach(([k, v]) => {
if (e[1] !== null && e[1] !== '' && e[1] !== undefined) p.set(e[0], e[1]); if (v !== null && v !== '' && v !== undefined) p.set(k, v);
}); });
return p.toString(); return p.toString();
}, },
async refresh(toast) { async refresh(toast) {
this.loading = true; this.loading = true;
try { try {
var data = await fetch('/analytics/api/data?' + this.params()).then(function (r) { return r.json(); }); const data = await fetch('/analytics/api/data?' + this.params()).then((r) => r.json());
this.meta = data.filter_meta || this.meta; this.applyData(data);
AnalyticsCharts.render(data); if (toast !== false && window.Cockpit) Cockpit.toast('Intelligence bijgewerkt', 'success');
this.updatedAt = (data.generated_at || '').substring(0, 19).replace('T', ' ');
if (toast !== false) Cockpit.toast('Analytics bijgewerkt', 'success');
} catch (e) { } catch (e) {
if (toast !== false) Cockpit.toast(e.message, 'error'); if (toast !== false && window.Cockpit) Cockpit.toast(e.message || 'Laden mislukt', 'error');
} finally { } finally {
this.loading = false; this.loading = false;
} }
}, },
resetFilters() { resetFilters() {
this.f = { chain: '', province: '', stage: '', agent: '', days: 90 }; this.f = { chain: '', province: '', stage: '', agent: '', days: 90 };
this.refresh(); this.refresh();
}, },
renderCharts() {
if (typeof Chart === 'undefined') return;
destroyCharts();
const grid = 'rgba(148,163,184,0.12)';
const tick = '#94a3b8';
const d = this.data;
const mk = (id, cfg) => {
const el = document.getElementById(id);
if (!el) return;
charts[id] = new Chart(el, cfg);
};
mk('ax-chart-timeline', {
type: 'line',
data: {
labels: (d.events_timeline || []).map((r) => String(r.day || '').substring(5)),
datasets: [{
data: (d.events_timeline || []).map((r) => Number(r.cnt || 0)),
borderColor: '#38bdf8',
backgroundColor: 'rgba(56,189,248,0.15)',
fill: true,
tension: 0.35,
pointRadius: 0,
borderWidth: 2,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick, maxTicksLimit: 10 }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { color: grid } },
},
},
});
mk('ax-chart-partners', {
type: 'doughnut',
data: {
labels: (d.partnership_breakdown || []).map((r) => r.status),
datasets: [{
data: (d.partnership_breakdown || []).map((r) => Number(r.cnt || 0)),
backgroundColor: ['#64748b', '#b8ff3c', '#fbbf24', '#38bdf8', '#fb7185'],
borderWidth: 0,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { color: tick, boxWidth: 10, font: { size: 10 } } } },
},
});
mk('ax-chart-opps', {
type: 'bar',
data: {
labels: (d.top_opportunities || []).slice(0, 8).map((r) => (r.chain || '') + ' ' + (r.city || '')),
datasets: [{
data: (d.top_opportunities || []).slice(0, 8).map((r) => Math.round(Number(r.halal_opportunity_score || 0))),
backgroundColor: 'rgba(251,191,36,0.55)',
borderColor: '#fbbf24',
borderWidth: 1,
}],
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
},
},
});
mk('ax-chart-chains', {
type: 'bar',
data: {
labels: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => r.chain),
datasets: [{
data: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
backgroundColor: 'rgba(56,189,248,0.45)',
borderColor: '#38bdf8',
borderWidth: 1,
}],
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
},
},
});
mk('ax-chart-prov', {
type: 'bar',
data: {
labels: (d.supermarkets_by_province || []).slice(0, 8).map((r) => r.province),
datasets: [{
data: (d.supermarkets_by_province || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
backgroundColor: 'rgba(184,255,60,0.4)',
borderColor: '#b8ff3c',
borderWidth: 1,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: tick, maxRotation: 45 }, grid: { display: false } },
y: { ticks: { color: tick }, grid: { color: grid } },
},
},
});
},
}; };
} }
+350 -9
View File
@@ -21,6 +21,87 @@ window.HermanAssistant = (function () {
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9); return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
} }
var STORAGE_OPEN = 'herman_assistant_open';
var STORAGE_CHAT = 'herman_assistant_chat_v1';
var STORAGE_PREFS = 'herman_assistant_prefs_v1';
var STORAGE_FAB = 'herman_assistant_fab_pos_v1';
var MAX_STORED_TURNS = 40;
var _cachedVoice = null;
function loadPrefs() {
try {
var raw = localStorage.getItem(STORAGE_PREFS);
if (!raw) return {};
var data = JSON.parse(raw);
return data && typeof data === 'object' ? data : {};
} catch (e) {
return {};
}
}
function pickMaartenVoice() {
if (!window.speechSynthesis) return null;
if (_cachedVoice) return _cachedVoice;
var voices = window.speechSynthesis.getVoices() || [];
if (!voices.length) return null;
var prefer = [
/maarten/i,
/microsoft.*nederlands/i,
/google.*nederlands/i,
/nl[-_]NL/i,
/dutch/i,
];
for (var p = 0; p < prefer.length; p++) {
for (var i = 0; i < voices.length; i++) {
var v = voices[i];
var hay = ((v.name || '') + ' ' + (v.lang || '') + ' ' + (v.voiceURI || ''));
if (prefer[p].test(hay)) {
_cachedVoice = v;
return v;
}
}
}
for (var j = 0; j < voices.length; j++) {
if (/^nl/i.test(voices[j].lang || '')) {
_cachedVoice = voices[j];
return voices[j];
}
}
return null;
}
if (typeof window !== 'undefined' && window.speechSynthesis) {
window.speechSynthesis.addEventListener('voiceschanged', function () {
_cachedVoice = null;
pickMaartenVoice();
});
}
function loadStoredChat(sessionPrefix) {
try {
var raw = localStorage.getItem(STORAGE_CHAT);
if (!raw) return null;
var data = JSON.parse(raw);
if (!data || !Array.isArray(data.turns)) return null;
if (sessionPrefix && data.sessionPrefix && data.sessionPrefix !== sessionPrefix) return null;
return data;
} catch (e) {
return null;
}
}
function saveStoredChat(state) {
try {
var turns = (state.turns || []).slice(-MAX_STORED_TURNS);
localStorage.setItem(STORAGE_CHAT, JSON.stringify({
sessionPrefix: state.sessionPrefix,
sessionId: state.sessionId,
turns: turns,
savedAt: Date.now(),
}));
} catch (e) { /* empty */ }
}
function baseState(opts) { function baseState(opts) {
opts = opts || {}; opts = opts || {};
return { return {
@@ -30,12 +111,20 @@ window.HermanAssistant = (function () {
hidden: false, hidden: false,
status: 'idle', status: 'idle',
statusDetail: '', statusDetail: '',
connOk: null,
connLabel: 'Herman',
livePreview: '', livePreview: '',
liveInterim: false, liveInterim: false,
recording: false, recording: false,
busy: false, busy: false,
speakReply: true, speakReply: false,
handsFreeMode: false, handsFreeMode: false,
httpsAudioPrompt: false,
fabPos: null,
fabDragging: false,
_fabMoved: false,
_fabPointerId: null,
_fabOrigin: null,
useBrowserPreview: !!SpeechRecognition, useBrowserPreview: !!SpeechRecognition,
turns: [], turns: [],
manualText: '', manualText: '',
@@ -65,7 +154,209 @@ window.HermanAssistant = (function () {
toggleOpen: function () { toggleOpen: function () {
this.open = !this.open; this.open = !this.open;
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ } try { localStorage.setItem(STORAGE_OPEN, this.open ? '1' : '0'); } catch (e) { /* empty */ }
if (this.open) this.scrollLog();
},
loadFabPos: function () {
try {
var raw = localStorage.getItem(STORAGE_FAB);
if (!raw) return;
var p = JSON.parse(raw);
if (p && typeof p.x === 'number' && typeof p.y === 'number') this.fabPos = p;
} catch (e) { /* empty */ }
},
saveFabPos: function () {
try {
if (!this.fabPos) localStorage.removeItem(STORAGE_FAB);
else localStorage.setItem(STORAGE_FAB, JSON.stringify(this.fabPos));
} catch (e) { /* empty */ }
},
resetFabPos: function () {
this.fabPos = null;
this.saveFabPos();
},
fabStyle: function () {
if (!this.fabPos) return {};
return {
left: this.fabPos.x + 'px',
top: this.fabPos.y + 'px',
right: 'auto',
bottom: 'auto',
transform: this.fabDragging ? 'none' : undefined,
};
},
panelStyle: function () {
if (!this.fabPos) return {};
var vw = window.innerWidth || 800;
var vh = window.innerHeight || 600;
var panelW = Math.min(420, vw - 24);
var panelH = Math.min(620, vh - 96);
var fabX = this.fabPos.x;
var fabY = this.fabPos.y;
var fabW = 130;
var fabH = 52;
var left = Math.min(Math.max(8, fabX + fabW / 2 - panelW / 2), vw - panelW - 8);
var top = fabY - panelH - 12;
if (top < 8) top = fabY + fabH + 12;
if (top + panelH > vh - 8) top = Math.max(8, vh - panelH - 8);
return {
left: left + 'px',
top: top + 'px',
right: 'auto',
bottom: 'auto',
};
},
_clampFab: function (x, y) {
var vw = window.innerWidth || 800;
var vh = window.innerHeight || 600;
var w = 56;
var h = 52;
return {
x: Math.min(Math.max(8, x), Math.max(8, vw - w - 8)),
y: Math.min(Math.max(8, y), Math.max(8, vh - h - 8)),
};
},
onFabPointerDown: function (e) {
if (e.button != null && e.button !== 0) return;
var point = e.touches && e.touches[0] ? e.touches[0] : e;
var el = e.currentTarget;
var rect = el.getBoundingClientRect();
this._fabMoved = false;
this.fabDragging = true;
this._fabOrigin = {
px: point.clientX,
py: point.clientY,
left: this.fabPos ? this.fabPos.x : rect.left,
top: this.fabPos ? this.fabPos.y : rect.top,
};
if (el.setPointerCapture && e.pointerId != null) {
try { el.setPointerCapture(e.pointerId); } catch (err) { /* empty */ }
}
},
onFabPointerMove: function (e) {
if (!this.fabDragging || !this._fabOrigin) return;
var point = e.touches && e.touches[0] ? e.touches[0] : e;
var dx = point.clientX - this._fabOrigin.px;
var dy = point.clientY - this._fabOrigin.py;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) this._fabMoved = true;
if (!this._fabMoved) return;
e.preventDefault();
this.fabPos = this._clampFab(this._fabOrigin.left + dx, this._fabOrigin.top + dy);
},
onFabPointerUp: function (e) {
if (!this.fabDragging) return;
var moved = this._fabMoved;
this.fabDragging = false;
this._fabOrigin = null;
if (moved) {
this.saveFabPos();
e.preventDefault();
e.stopPropagation();
return;
}
this.toggleOpen();
},
onFabClick: function (e) {
// click handled in pointerup; prevent double-toggle
e.preventDefault();
e.stopPropagation();
},
onFabDblClick: function (e) {
e.preventDefault();
this.resetFabPos();
Cockpit.toast('Herman-knop teruggezet', 'info');
},
persistChat: function () {
saveStoredChat(this);
},
persistPrefs: function () {
try {
localStorage.setItem(STORAGE_PREFS, JSON.stringify({
speakReply: !!this.speakReply,
handsFreeMode: !!this.handsFreeMode,
}));
} catch (e) { /* empty */ }
if (!this.speakReply && window.speechSynthesis) {
try { window.speechSynthesis.cancel(); } catch (e2) { /* empty */ }
}
},
applyPrefs: function () {
var prefs = loadPrefs();
if (typeof prefs.speakReply === 'boolean') this.speakReply = prefs.speakReply;
if (typeof prefs.handsFreeMode === 'boolean') this.handsFreeMode = prefs.handsFreeMode;
},
onHandsFreeChange: function () {
this.persistPrefs();
if (this.handsFreeMode && !this.micSecureOk()) {
this.offerHttpsAudio();
return;
}
if (this.handsFreeMode && !this.recording && !this.busy) {
this.statusDetail = 'Hands-free — luisteren…';
this.startListen();
} else if (!this.handsFreeMode && this.recording) {
this.stopListen();
this.statusDetail = '';
}
},
offerHttpsAudio: function () {
this.httpsAudioPrompt = true;
this.status = 'idle';
this.statusDetail = '';
if (this.handsFreeMode) {
this.handsFreeMode = false;
this.persistPrefs();
}
},
goHttpsAudio: function () {
var url = this.httpsChatUrl();
this.httpsAudioPrompt = false;
window.open(url, '_blank', 'noopener');
},
micSecureOk: function () {
return !!(window.isSecureContext || location.hostname === 'localhost' || location.hostname === '127.0.0.1');
},
httpsChatUrl: function () {
var host = location.hostname || '10.4.7.18';
var path = location.pathname || '/';
var q = location.search || '';
return 'https://' + host + ':8643' + path + q;
},
clearChat: function () {
this.turns = [];
this.manualText = '';
this.pendingAction = null;
this.sessionId = newSessionId(this.sessionPrefix);
this.statusDetail = '';
this.status = 'idle';
this.persistChat();
},
markConn: function (ok, label) {
this.connOk = ok;
this.connLabel = label || (ok ? 'Verbonden met Herman' : 'Herman niet bereikbaar');
}, },
scrollLog: function () { scrollLog: function () {
@@ -78,6 +369,7 @@ window.HermanAssistant = (function () {
addTurn: function (role, text, meta) { addTurn: function (role, text, meta) {
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() }); this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
this.persistChat();
this.scrollLog(); this.scrollLog();
}, },
@@ -86,8 +378,10 @@ window.HermanAssistant = (function () {
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null); this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null; if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
if (h.reply) { if (h.reply) {
var label = (h.agent_label || 'Herman').replace(/\s*[·•].*$/, '').trim() || 'Herman';
if (/hermes/i.test(label)) label = 'Herman';
this.addTurn('agent', h.reply, { this.addTurn('agent', h.reply, {
agent_label: h.agent_label, agent_label: label,
delegated: h.delegated_agents, delegated: h.delegated_agents,
routing_reason: h.routing_reason, routing_reason: h.routing_reason,
}); });
@@ -111,6 +405,8 @@ window.HermanAssistant = (function () {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800)); var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL'; u.lang = 'nl-NL';
var voice = pickMaartenVoice();
if (voice) u.voice = voice;
u.onend = function () { self.maybeResumeListening(300); }; u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u); window.speechSynthesis.speak(u);
} else { } else {
@@ -208,7 +504,12 @@ window.HermanAssistant = (function () {
async startListen() { async startListen() {
if (this.recording || this.busy) return; if (this.recording || this.busy) return;
if (!this.micSecureOk()) {
this.offerHttpsAudio();
return;
}
var self = this; var self = this;
this.httpsAudioPrompt = false;
this.livePreview = ''; this.livePreview = '';
this.status = 'listening'; this.status = 'listening';
this.statusDetail = 'Luisteren…'; this.statusDetail = 'Luisteren…';
@@ -240,8 +541,12 @@ window.HermanAssistant = (function () {
} }
} catch (e) { } catch (e) {
this.status = 'error'; this.status = 'error';
this.statusDetail = e.message; var msg = e.message || String(e);
Cockpit.toast('Microfoon: ' + e.message, 'error'); if (!this.micSecureOk()) {
msg = 'Mic alleen via HTTPS — gebruik de knop hierboven.';
}
this.statusDetail = msg;
Cockpit.toast('Microfoon: ' + msg, 'error');
} }
}, },
@@ -291,6 +596,14 @@ window.HermanAssistant = (function () {
this.status = 'idle'; this.status = 'idle';
this.statusDetail = ''; this.statusDetail = '';
} catch (e) { } catch (e) {
var preview = (this.livePreview || '').trim();
if (preview) {
this.manualText = preview;
this.busy = false;
this.statusDetail = 'Browser-spraak gebruikt (Whisper offline)';
Cockpit.toast('Whisper offline — tekst via browser-spraak', 'info');
return this.sendText();
}
this.status = 'error'; this.status = 'error';
this.statusDetail = e.message; this.statusDetail = e.message;
Cockpit.toast(e.message, 'error'); Cockpit.toast(e.message, 'error');
@@ -301,19 +614,26 @@ window.HermanAssistant = (function () {
async sendText(confirmActionId) { async sendText(confirmActionId) {
var text = (this.manualText || '').trim(); var text = (this.manualText || '').trim();
if (!text) return; if (!text) return;
this.addTurn('user', text, { source: 'text' });
this.manualText = '';
this.busy = true; this.busy = true;
this.status = 'processing'; this.status = 'processing';
this.statusDetail = 'Herman denkt…';
try { try {
var body = { message: text, channel: this.channel, session_id: this.sessionId }; var body = { message: text, channel: this.channel, session_id: this.sessionId };
if (confirmActionId) body.confirm_action_id = confirmActionId; if (confirmActionId) body.confirm_action_id = confirmActionId;
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) }); var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
this.addTurn('user', text, { source: 'text' });
this.manualText = '';
this.handleHermanResponse(r); this.handleHermanResponse(r);
this.status = 'idle'; var reply = (r && r.reply) || '';
var failed = /niet bereikbaar|niet beschikbaar|offline/i.test(reply);
this.markConn(!failed, failed ? 'Herman niet bereikbaar' : 'Verbonden met Herman');
this.status = failed ? 'error' : 'idle';
this.statusDetail = failed ? reply.slice(0, 120) : '';
} catch (e) { } catch (e) {
Cockpit.toast(e.message, 'error'); Cockpit.toast(e.message, 'error');
this.status = 'error'; this.status = 'error';
this.statusDetail = e.message;
this.markConn(false, 'Herman offline');
} }
this.busy = false; this.busy = false;
}, },
@@ -331,10 +651,21 @@ window.HermanAssistant = (function () {
s.initWidget = function () { s.initWidget = function () {
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true; if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true; if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
this.applyPrefs();
this.loadFabPos();
pickMaartenVoice();
try { try {
var raw = localStorage.getItem('herman_assistant_open'); var raw = localStorage.getItem(STORAGE_OPEN);
if (raw === '1') this.open = true; if (raw === '1') this.open = true;
} catch (e) { /* empty */ } } catch (e) { /* empty */ }
var stored = loadStoredChat(this.sessionPrefix);
if (stored) {
if (stored.sessionId) this.sessionId = stored.sessionId;
if (stored.turns && stored.turns.length) this.turns = stored.turns;
}
this.connOk = null;
this.connLabel = 'Herman';
if (this.open) this.scrollLog();
}; };
return s; return s;
}, },
@@ -345,6 +676,8 @@ window.HermanAssistant = (function () {
s.lastImage = ''; s.lastImage = '';
s.initChat = function () { s.initChat = function () {
this.open = true; this.open = true;
this.applyPrefs();
pickMaartenVoice();
}; };
s.generateImage = async function () { s.generateImage = async function () {
if (!this.imagePrompt.trim()) return; if (!this.imagePrompt.trim()) return;
@@ -371,3 +704,11 @@ window.HermanAssistant = (function () {
function hermanBrowserWidget() { function hermanBrowserWidget() {
return HermanAssistant.browserWidget(); return HermanAssistant.browserWidget();
} }
document.addEventListener('alpine:init', function () {
if (window.Alpine && typeof Alpine.data === 'function') {
Alpine.data('hermanBrowserWidget', function () {
return HermanAssistant.browserWidget();
});
}
});
+183 -296
View File
@@ -1,346 +1,233 @@
/** /**
* Hermes Neo Command Center * Hermes technical KPI dashboard + bucket explorer popup
*/ */
function hermesControl() { function hermesKpiDashboard() {
const PA_SITES = ['Airbnb', 'Booking.com', 'DuckDuckGo', 'HolidayCheck']; const SOURCE_LABELS = {
telegram: 'Telegram',
/** Canonical team — never duplicate role as name */ api_server: 'Cockpit API',
const ROSTER = [ tui: 'TUI',
{ chat_id: 8859782446, role: 'CEO', name: 'Aïssa' }, cron: 'Cron',
{ chat_id: 789036463, role: 'CTO', name: 'Mo' }, subagent: 'Subagent',
]; };
return { return {
tab: 'feed', loading: false,
live: true, kpis: { online: false, overview: {}, buckets: [], tools_top: [], sources: [], herman_cockpit: [] },
stats: { conversations: 0, messages: 0, edges: 0, embeddings: 0, inbound: 0, outbound: 0 }, chartTools: null,
conversations: [], chartSources: null,
messages: [], _timer: null,
users: [], modal: {
agentEvents: [], open: false,
selectedChatId: null, loading: false,
graphChatFilter: '', previewLoading: false,
searchQuery: '', key: null,
searchResults: [], title: '',
searchRan: false, tab: 'activity',
graphNetwork: null, toolFilter: null,
pollTimer: null, detail: { events: [], files: [], parts: {}, count: 0 },
paPollTimer: null, explorer: { path: '', parent: null, entries: [] },
paPolling: false, selectedEvent: null,
paLive: { status: 'idle', query: '', slots: [] }, preview: null,
paScreenshotTs: Date.now(),
hermesOnline: false,
hermesStatus: {},
actionBusy: false,
actionMsg: '',
chartDirection: null,
chartAgents: null,
get teamRoster() {
const byId = {};
(this.users || []).forEach(u => { byId[u.chat_id] = u; });
(this.conversations || []).forEach(c => {
if (!byId[c.chat_id]) byId[c.chat_id] = c;
else {
byId[c.chat_id].message_count = c.message_count || byId[c.chat_id].message_count;
}
});
return ROSTER.map(r => ({
...r,
online: !!(byId[r.chat_id]?.online),
pa_mode: !!(byId[r.chat_id]?.pa_mode),
message_count: byId[r.chat_id]?.message_count || 0,
}));
}, },
get paLiveUser() { get ov() { return this.kpis.overview || {}; },
const cid = this.paLive.chat_id;
if (!cid) return ''; fmt(n) {
const m = ROSTER.find(r => r.chat_id === cid); const v = Number(n || 0);
return m ? `${m.role} — ${m.name}` : this.paLive.user_name || ''; return v.toLocaleString('nl-NL');
}, },
get paSlots() { fmtBytes(n) {
const slots = this.paLive.slots || []; if (n == null || n === '') return '';
if (slots.length >= 4) return slots; const v = Number(n);
const byLabel = {}; if (v < 1024) return v + ' B';
slots.forEach(s => { byLabel[s.label] = s; }); if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB';
return PA_SITES.map(label => byLabel[label] || { return (v / (1024 * 1024)).toFixed(1) + ' MB';
label, status: 'idle', has_screenshot: false,
});
},
rosterPerson(chatId) {
return ROSTER.find(r => r.chat_id === Number(chatId)) || null;
},
msgLabel(msg) {
const p = this.rosterPerson(msg.chat_id);
if (p) return `${p.role} (${p.name})`;
if (msg.user_role && msg.user_name) return `${msg.user_role} (${msg.user_name})`;
return msg.user_role || msg.chat_id || '?';
},
async init() {
const hash = (window.location.hash || '').replace('#', '');
if (['feed', 'pa', 'graph', 'search', 'control'].includes(hash)) this.tab = hash;
await this.refreshStats();
await this.refreshConversations();
await this.refreshUsers();
await this.refreshFeed();
await this.refreshPaLive();
setTimeout(() => this.initCharts(), 200);
this.pollTimer = setInterval(() => {
if (this.tab === 'feed' && this.live) this.refreshFeed(true);
}, 5000);
this.paPollTimer = setInterval(() => {
if (this.tab === 'pa' || this.paLive.status === 'running') this.refreshPaLive(true);
}, 2000);
window.addEventListener('hashchange', () => {
const h = (window.location.hash || '').replace('#', '');
if (['feed', 'pa', 'graph', 'search', 'control'].includes(h)) this.tab = h;
});
},
initCharts() {
if (typeof Chart === 'undefined') return;
const neoColors = ['#00e5ff', '#ff2d95', '#b8ff3c', '#ff9f43', '#a855f7', '#ffd700'];
Chart.defaults.color = '#64748b';
Chart.defaults.borderColor = 'rgba(0,229,255,0.08)';
const dirEl = document.getElementById('hm-chart-direction');
if (dirEl && !this.chartDirection) {
this.chartDirection = new Chart(dirEl, {
type: 'doughnut',
data: {
labels: ['Inbound', 'Outbound', 'Vectors'],
datasets: [{
data: [1, 1, 1],
backgroundColor: ['#00e5ff', '#a855f7', '#b8ff3c'],
borderWidth: 0,
hoverOffset: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '62%',
plugins: {
legend: { position: 'right', labels: { boxWidth: 12, padding: 10, color: '#94a3b8' } },
},
},
});
}
const agEl = document.getElementById('hm-chart-agents');
if (agEl && !this.chartAgents) {
this.chartAgents = new Chart(agEl, {
type: 'bar',
data: {
labels: ['herman', 'browser', 'marketing'],
datasets: [{
label: 'events',
data: [0, 0, 0],
backgroundColor: 'rgba(0, 229, 255, 0.55)',
borderColor: '#00e5ff',
borderWidth: 1,
borderRadius: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { stepSize: 1 } },
x: { grid: { display: false } },
},
plugins: { legend: { display: false } },
},
});
}
this.updateCharts();
},
updateCharts() {
const s = this.stats;
if (this.chartDirection) {
this.chartDirection.data.datasets[0].data = [
Math.max(s.inbound || 0, 0),
Math.max(s.outbound || 0, 0),
Math.max(s.embeddings || 0, 0),
];
this.chartDirection.update('none');
}
if (this.chartAgents) {
const counts = {};
(this.agentEvents || []).forEach(ev => {
const n = (ev.agent_name || 'other').toLowerCase();
counts[n] = (counts[n] || 0) + 1;
});
const labels = Object.keys(counts).slice(0, 8);
if (!labels.length) labels.push('herman');
this.chartAgents.data.labels = labels;
this.chartAgents.data.datasets[0].data = labels.map(l => counts[l] || 0);
this.chartAgents.update('none');
}
},
setTab(t) {
this.tab = t;
history.replaceState(null, '', t === 'feed' ? '/hermes' : '/hermes#' + t);
if (t === 'graph') setTimeout(() => this.loadGraph(), 100);
if (t === 'control') { this.refreshStats(); this.refreshUsers(); }
if (t === 'pa') { this.paPolling = true; this.refreshPaLive(); }
}, },
formatTime(iso) { formatTime(iso) {
if (!iso) return ''; if (!iso) return '';
try { try {
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }); return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
} catch { return iso; } } catch (e) { return String(iso).slice(0, 19); }
}, },
paStatusLabel() { partsLine(parts) {
const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' }; if (!parts) return '';
return m[this.paLive.status] || this.paLive.status || 'Idle'; return Object.keys(parts)
.filter((k) => parts[k] > 0)
.map((k) => parts[k] + ' ' + k.replace(/_/g, ' '))
.join(' · ');
}, },
slotStatusLabel(s) { sourceLabel(s) { return SOURCE_LABELS[s] || s || '—'; },
const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' };
return m[s] || s || 'Idle'; async init() {
await this.refresh();
this._timer = setInterval(() => this.refresh(true), 15000);
}, },
slotScreenshotUrl(label) { async refresh(quiet) {
if (!label) return ''; if (!quiet) this.loading = true;
return `/api/admin/hermes/pa/live/${encodeURIComponent(label)}/screenshot.jpg?t=${this.paScreenshotTs}`;
},
async selectChat(chatId) {
this.selectedChatId = chatId;
await this.refreshFeed();
},
async refreshStats() {
try { try {
const data = await Cockpit.api('/hermes/stats'); const data = await Cockpit.api('/hermes/kpis');
this.stats = data.stats || this.stats; this.kpis = data || this.kpis;
this.agentEvents = data.agent_events || []; this.$nextTick(() => this.renderCharts());
this.updateCharts(); } catch (e) {
} catch (e) { console.warn('stats', e); } if (!quiet && window.Cockpit) Cockpit.toast(e.message || 'KPI laden mislukt', 'error');
this.kpis.online = false;
}
this.loading = false;
}, },
async refreshUsers() { async openBucket(b) {
try { this.modal.open = true;
const data = await Cockpit.api('/hermes/users'); this.modal.key = b.key;
this.users = data.users || []; this.modal.title = b.label;
} catch (e) { console.warn('users', e); } this.modal.tab = b.key === 'files' ? 'touched' : 'activity';
this.modal.toolFilter = null;
this.modal.selectedEvent = null;
this.modal.preview = null;
this.modal.detail = { events: [], files: [], parts: b.parts || {}, count: b.count || 0 };
await this.loadBucketDetail();
if (b.key === 'files') {
// preload roots quietly
this.loadExplorer('').catch(() => {});
}
}, },
async refreshConversations() { closeModal() {
try { this.modal.open = false;
const data = await Cockpit.api('/hermes/conversations'); this.modal.preview = null;
this.conversations = data.items || []; this.modal.selectedEvent = null;
} catch (e) { console.warn('conversations', e); }
}, },
async refreshFeed(quiet) { async setToolFilter(tool) {
try { this.modal.toolFilter = tool;
let path = '/hermes/feed?limit=80'; await this.loadBucketDetail();
if (this.selectedChatId) path += '&chat_id=' + this.selectedChatId;
const data = await Cockpit.api(path);
this.messages = data.items || [];
if (!quiet) await this.refreshStats();
} catch (e) { console.warn('feed', e); }
}, },
async refreshPaLive(quiet) { async loadBucketDetail() {
if (!this.modal.key) return;
this.modal.loading = true;
try { try {
const data = await Cockpit.api('/hermes/pa/live'); const q = new URLSearchParams({ limit: '80' });
this.paLive = data; if (this.modal.toolFilter) q.set('tool', this.modal.toolFilter);
this.paScreenshotTs = Date.now(); const data = await Cockpit.api('/hermes/bucket/' + encodeURIComponent(this.modal.key) + '?' + q.toString());
if (data.hermes) { this.modal.detail = data || this.modal.detail;
this.hermesOnline = data.hermes.online !== false; if (!(this.modal.detail.events || []).length) {
this.hermesStatus = data.hermes; this.modal.selectedEvent = null;
} }
} catch (e) { } catch (e) {
if (!quiet) console.warn('pa live', e); if (window.Cockpit) Cockpit.toast(e.message || 'Detail laden mislukt', 'error');
}
this.modal.loading = false;
},
selectEvent(ev) {
this.modal.selectedEvent = ev;
this.modal.preview = null;
if (ev.paths && ev.paths.length === 1 && this.modal.key === 'files') {
this.openFile(ev.paths[0]);
} }
}, },
async loadGraph() { openExplorerTab() {
this.modal.tab = 'explorer';
if (!(this.modal.explorer.entries || []).length) {
this.loadExplorer(this.modal.explorer.path || '');
}
},
async loadExplorer(path) {
this.modal.loading = true;
try { try {
let data; const q = path ? ('?path=' + encodeURIComponent(path)) : '';
if (this.graphChatFilter) { const data = await Cockpit.api('/hermes/explorer' + q);
data = await Cockpit.api('/hermes/graph/' + this.graphChatFilter + '?limit=80'); this.modal.explorer = data || { path: '', parent: null, entries: [] };
} else {
data = await Cockpit.api('/hermes/graph?limit=100');
}
this.renderGraph(data);
} catch (e) { } catch (e) {
Cockpit.toast('Graph laden mislukt: ' + e.message, 'error'); if (window.Cockpit) Cockpit.toast(e.message || 'Verkenner laden mislukt', 'error');
} }
this.modal.loading = false;
}, },
renderGraph(data) { explorerUp() {
const el = document.getElementById('hermes-graph'); if (this.modal.explorer.parent) this.loadExplorer(this.modal.explorer.parent);
if (!el || typeof vis === 'undefined') return; else this.loadExplorer('');
const nodes = new vis.DataSet((data.nodes || []).map(n => ({
id: n.id,
label: this.truncate(n.label || n.id, 40),
color: n.direction === 'in'
? { background: '#164e63', border: '#00e5ff' }
: { background: '#4c1d95', border: '#a855f7' },
font: { color: '#e8edf5', size: 11 },
})));
const edges = new vis.DataSet((data.edges || []).map(e => ({
id: 'e' + e.id, from: e.from, to: e.to,
label: e.type || '', arrows: 'to',
color: { color: '#ff9f43' },
})).filter(e => e.to));
if (this.graphNetwork) this.graphNetwork.destroy();
this.graphNetwork = new vis.Network(el, { nodes, edges }, {
physics: { stabilization: true },
interaction: { hover: true },
});
}, },
truncate(s, n) { async openFile(path) {
s = String(s || ''); if (!path) return;
return s.length > n ? s.slice(0, n) + '…' : s; this.modal.previewLoading = true;
}, this.modal.selectedEvent = null;
async runSearch() {
if (!this.searchQuery || this.searchQuery.length < 2) {
Cockpit.toast('Minimaal 2 tekens', 'warn');
return;
}
this.searchRan = true;
try { try {
const data = await Cockpit.api('/hermes/search', { const data = await Cockpit.api('/hermes/explorer/file?path=' + encodeURIComponent(path));
method: 'POST', this.modal.preview = data || { path, error: 'Geen data', name: path.split('/').pop() };
headers: { 'Content-Type': 'application/json' }, if (this.modal.key === 'files' && this.modal.tab === 'activity') {
body: JSON.stringify({ query: this.searchQuery, chat_id: this.selectedChatId || null, limit: 15 }), // keep tab; preview still shows
}
} catch (e) {
this.modal.preview = { path, name: path.split('/').pop(), error: e.message || 'Laden mislukt', content: null };
}
this.modal.previewLoading = false;
},
renderCharts() {
if (typeof Chart === 'undefined') return;
const tools = (this.kpis.tools_top || []).slice(0, 8);
const sources = this.kpis.sources || [];
const toolsCtx = document.getElementById('hm-chart-tools');
if (toolsCtx) {
if (this.chartTools) this.chartTools.destroy();
this.chartTools = new Chart(toolsCtx, {
type: 'bar',
data: {
labels: tools.map((t) => t.name),
datasets: [{
label: 'Calls',
data: tools.map((t) => t.count),
backgroundColor: 'rgba(14, 165, 233, 0.55)',
borderColor: 'rgba(56, 189, 248, 0.9)',
borderWidth: 1,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } },
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
},
},
}); });
this.searchResults = data.results || [];
} catch (e) {
Cockpit.toast('Zoeken mislukt: ' + e.message, 'error');
} }
},
async runAction(action) { const srcCtx = document.getElementById('hm-chart-sources');
this.actionBusy = true; if (srcCtx) {
this.actionMsg = ''; if (this.chartSources) this.chartSources.destroy();
try { this.chartSources = new Chart(srcCtx, {
const data = await Cockpit.api('/hermes/actions/' + action, { method: 'POST' }); type: 'doughnut',
this.actionMsg = data.output || '✅ Actie uitgevoerd'; data: {
Cockpit.toast(this.actionMsg.slice(0, 80), 'success'); labels: sources.map((s) => this.sourceLabel(s.source)),
await this.refreshStats(); datasets: [{
} catch (e) { data: sources.map((s) => s.tools || 0),
this.actionMsg = '❌ ' + e.message; backgroundColor: ['#0ea5e9', '#a855f7', '#f59e0b', '#22c55e', '#f43f5e', '#64748b'],
Cockpit.toast(this.actionMsg, 'error'); }],
} finally { },
this.actionBusy = false; options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 10, font: { size: 10 } } },
},
},
});
} }
}, },
}; };
} }
function hermesControl() { return hermesKpiDashboard(); }
+13 -1
View File
@@ -33,7 +33,7 @@ window.VoiceLive = (function () {
busy: false, busy: false,
conversationMode: true, conversationMode: true,
handsFreeMode: false, handsFreeMode: false,
speakReply: true, speakReply: false,
useBrowserPreview: !!SpeechRecognition, useBrowserPreview: !!SpeechRecognition,
pipeline: [], pipeline: [],
feed: [], feed: [],
@@ -162,6 +162,18 @@ window.VoiceLive = (function () {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800)); var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
u.lang = 'nl-NL'; u.lang = 'nl-NL';
var voices = window.speechSynthesis.getVoices() || [];
var voice = null;
for (var vi = 0; vi < voices.length; vi++) {
var hay = ((voices[vi].name || '') + ' ' + (voices[vi].lang || ''));
if (/maarten/i.test(hay)) { voice = voices[vi]; break; }
}
if (!voice) {
for (var vj = 0; vj < voices.length; vj++) {
if (/^nl/i.test(voices[vj].lang || '')) { voice = voices[vj]; break; }
}
}
if (voice) u.voice = voice;
u.onend = function () { self.maybeResumeListening(300); }; u.onend = function () { self.maybeResumeListening(300); };
window.speechSynthesis.speak(u); window.speechSynthesis.speak(u);
} else { } else {
+237 -62
View File
@@ -1,75 +1,250 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Intelligence · Analytics{% endblock %}
{% block extra_head %} {% block extra_head %}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/analytics.css?v=1" /> <link rel="stylesheet" href="/static/css/analytics.css?v=20" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="analytics-shell" x-data="analyticsHub()" x-init="init()"> <div class="ax-shell" x-data="analyticsHub()" x-init="init()">
<header class="page-header"> <!-- Hero -->
<div> <section class="ax-hero">
<h1>Analytics Command Center <span class="live-badge" x-text="liveLabel">Live</span></h1> <div class="ax-hero-glow" aria-hidden="true"></div>
<p class="subtitle">Volledige database analyse · filter · charts · realtime</p> <div class="ax-hero-grid">
<small class="muted" x-text="'Laatst: '+updatedAt"></small> <div class="ax-hero-copy">
</div> <p class="ax-eyebrow">Foodlinkk · Intelligence</p>
<div class="retail-actions"> <h1>Speelveld<span class="ax-live" x-text="liveLabel">Live</span></h1>
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">↻ Ververs</button> <p class="ax-lead" x-text="pulse.sub || 'Wat goed gaat en waar de kansen liggen.'"></p>
<a class="btn btn-sm" href="/">Herman →</a> <p class="ax-goal" x-show="goals.tagline || goals.vision_text">
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a> <span x-text="goals.tagline || ''"></span>
</div> <span x-show="goals.vision_text" x-text="' · ' + shortGoal(goals.vision_text)"></span>
</header> </p>
</div>
<aside class="analytics-filters panel"> <div class="ax-pulse-orb" :class="'tone-' + (pulse.tone || 'mid')">
<h3>Filters</h3> <svg viewBox="0 0 120 120" class="ax-ring">
<label>Keten<select x-model="f.chain" @change="refresh()"><option value="">Alle</option> <circle cx="60" cy="60" r="52" class="ax-ring-track"></circle>
<template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template> <circle cx="60" cy="60" r="52" class="ax-ring-value"
</select></label> :style="'stroke-dasharray:' + ringDash(pulse.score) + ' 999'"></circle>
<label>Provincie<select x-model="f.province" @change="refresh()"><option value="">Alle</option> </svg>
<template x-for="p in meta.provinces||[]" :key="p.province"><option :value="p.province" x-text="p.province"></option></template> <div class="ax-pulse-core">
</select></label> <strong class="ax-mono" x-text="pulse.score ?? '—'"></strong>
<label>Deal stage<select x-model="f.stage" @change="refresh()"><option value="">Alle</option> <span>pulse</span>
<template x-for="s in meta.deal_stages||[]" :key="s.stage"><option :value="s.stage" x-text="s.stage"></option></template> </div>
</select></label> <p class="ax-pulse-label" x-text="pulse.label || ''"></p>
<label>Agent<select x-model="f.agent" @change="refresh()"><option value="">Alle</option> </div>
<template x-for="a in meta.agents||[]" :key="a.agent_name"><option :value="a.agent_name" x-text="a.agent_name"></option></template> <div class="ax-hero-actions">
</select></label> <button type="button" class="ax-btn ax-btn-primary" @click="refresh()" :disabled="loading">↻ Ververs</button>
<label>Periode (dagen)<input type="number" min="7" max="365" x-model.number="f.days" @change="refresh()"></label> <button type="button" class="ax-btn" @click="showFilters = !showFilters" x-text="showFilters ? 'Filters verbergen' : 'Filters'"></button>
<button class="btn btn-sm btn-block" @click="resetFilters()">Reset</button> <a class="ax-btn" href="/retail">Retail 360</a>
</aside> <a class="ax-btn" href="/revenue-cockpit">Revenue</a>
<small class="ax-updated" x-text="'Sync ' + updatedAt"></small>
<div class="analytics-main"> </div>
<div class="kpi-row analytics-kpis" id="analytics-kpis"></div>
<div class="analytics-charts">
<div class="panel chart-panel"><h4>Pipeline per stage</h4><canvas id="chart-deals"></canvas></div>
<div class="panel chart-panel"><h4>Klanten per stage</h4><canvas id="chart-clients"></canvas></div>
<div class="panel chart-panel"><h4>Supermarkten per keten</h4><canvas id="chart-chains"></canvas></div>
<div class="panel chart-panel"><h4>Provincie verdeling</h4><canvas id="chart-provinces"></canvas></div>
<div class="panel chart-panel"><h4>CRM partnerships</h4><canvas id="chart-partnerships"></canvas></div>
<div class="panel chart-panel"><h4>Agent activiteit</h4><canvas id="chart-agents"></canvas></div>
<div class="panel chart-panel"><h4>Events timeline</h4><canvas id="chart-timeline"></canvas></div>
<div class="panel chart-panel"><h4>RSS per categorie</h4><canvas id="chart-rss"></canvas></div>
<div class="panel chart-panel"><h4>Groothandels provincie</h4><canvas id="chart-wholesale"></canvas></div>
<div class="panel chart-panel"><h4>NAS sentiment</h4><canvas id="chart-sentiment"></canvas></div>
<div class="panel chart-panel"><h4>Top woorden NAS</h4><canvas id="chart-words"></canvas></div>
<div class="panel chart-panel"><h4>Halal kansen top 10</h4><canvas id="chart-opportunities"></canvas></div>
<div class="panel chart-panel"><h4>Actieve promo folders</h4><canvas id="chart-promo"></canvas></div>
<div class="panel chart-panel"><h4>Milestones status</h4><canvas id="chart-milestones"></canvas></div>
</div> </div>
<div class="grid-2"> <div class="ax-filters" x-show="showFilters" x-cloak>
<section class="panel"><h2>Recente deals</h2> <label>Keten
<table class="data-table"><thead><tr><th>Titel</th><th>Stage</th><th>Waarde</th></tr></thead> <select x-model="f.chain" @change="refresh(false)"><option value="">Alle</option>
<tbody id="analytics-deals-table"></tbody></table> <template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
</section> </select>
<section class="panel"><h2>Agent events</h2> </label>
<table class="data-table"><thead><tr><th>Agent</th><th>Event</th><th>Tijd</th></tr></thead> <label>Provincie
<tbody id="analytics-events-table"></tbody></table> <select x-model="f.province" @change="refresh(false)"><option value="">Alle</option>
</section> <template x-for="p in meta.provinces||[]" :key="p.province"><option :value="p.province" x-text="p.province"></option></template>
</select>
</label>
<label>Deal stage
<select x-model="f.stage" @change="refresh(false)"><option value="">Alle</option>
<template x-for="s in meta.deal_stages||[]" :key="s.stage"><option :value="s.stage" x-text="s.stage"></option></template>
</select>
</label>
<label>Periode
<input type="number" min="7" max="365" x-model.number="f.days" @change="refresh(false)">
</label>
<button type="button" class="ax-btn" @click="resetFilters()">Reset</button>
</div> </div>
</div> </section>
<!-- Signal strip -->
<section class="ax-signals">
<template x-for="s in signals" :key="s.key">
<article class="ax-signal" :class="s.tone || ''">
<span class="ax-signal-label" x-text="s.label"></span>
<strong class="ax-mono" x-text="s.value"></strong>
<em x-text="s.hint"></em>
</article>
</template>
</section>
<!-- Wins vs Opportunities -->
<section class="ax-split">
<div class="ax-col ax-col-win">
<header class="ax-col-head">
<h2>Wat goed gaat</h2>
<span x-text="(pulse.wins || []).length + ' signalen'"></span>
</header>
<template x-for="w in (pulse.wins || [])" :key="w.id">
<a class="ax-insight win" :href="w.href || '#'">
<div class="ax-insight-top">
<strong x-text="w.title"></strong>
<span class="ax-score ax-mono" x-text="w.score"></span>
</div>
<p x-text="w.body"></p>
<div class="ax-insight-foot">
<span x-text="w.metric"></span>
<span class="ax-bar"><i :style="'width:' + Math.min(100, w.score) + '%'"></i></span>
</div>
</a>
</template>
<p class="ax-empty" x-show="!(pulse.wins || []).length">Nog geen wins berekend.</p>
</div>
<div class="ax-col ax-col-opp">
<header class="ax-col-head">
<h2>Kansen</h2>
<span x-text="(pulse.opportunities || []).length + ' open'"></span>
</header>
<template x-for="o in (pulse.opportunities || [])" :key="o.id">
<a class="ax-insight opp" :href="o.href || '#'">
<div class="ax-insight-top">
<strong x-text="o.title"></strong>
<span class="ax-score ax-mono" x-text="o.score"></span>
</div>
<p x-text="o.body"></p>
<div class="ax-insight-foot">
<span><span class="ax-tag" x-show="o.tag" x-text="o.tag"></span> <span x-text="o.metric"></span></span>
<span class="ax-bar opp"><i :style="'width:' + Math.min(100, o.score) + '%'"></i></span>
</div>
</a>
</template>
<p class="ax-empty" x-show="!(pulse.opportunities || []).length">Geen kansen gevonden.</p>
</div>
</section>
<!-- Attention + Hermes findings -->
<section class="ax-band">
<div class="ax-attention" x-show="(pulse.attention || []).length">
<h3>Aandacht</h3>
<div class="ax-att-row">
<template x-for="a in (pulse.attention || [])" :key="a.id">
<a class="ax-att-chip" :href="a.href || '#'">
<strong x-text="a.title"></strong>
<span x-text="a.body"></span>
</a>
</template>
</div>
</div>
<div class="ax-hermes">
<header class="ax-col-head">
<div>
<h2>Hermes bevindingen</h2>
<p class="ax-muted">Diepere analyse die Hermes hier later aanvult via <code>/analytics/api/findings</code></p>
</div>
<span class="ax-pill" x-text="(findings || []).length ? ((findings || []).length + ' findings') : 'wacht op Hermes'"></span>
</header>
<div class="ax-findings-grid" x-show="(findings || []).length">
<template x-for="f in findings" :key="f.id">
<article class="ax-finding" :class="'kind-' + (f.kind || 'insight')">
<div class="ax-finding-top">
<span class="ax-tag" x-text="f.kind || 'insight'"></span>
<time x-text="formatTime(f.created_at)"></time>
</div>
<h4 x-text="f.title"></h4>
<p x-text="f.body"></p>
<div class="ax-finding-foot">
<span class="ax-mono" x-show="f.score != null" x-text="'score ' + f.score"></span>
<span x-text="f.source || 'hermes'"></span>
</div>
</article>
</template>
</div>
<div class="ax-findings-empty" x-show="!(findings || []).length">
<div class="ax-findings-placeholder">
<strong>Nog geen Hermes-findings</strong>
<p>Automatische wins & kansen staan hierboven. Zodra Hermes diepere markt- of concurrentie-inzichten heeft, landen ze hier.</p>
</div>
</div>
</div>
</section>
<!-- Market radar -->
<section class="ax-radar">
<header class="ax-col-head">
<h2>Market radar</h2>
<span>live charts</span>
</header>
<div class="ax-charts">
<article class="ax-chart-card wide">
<h3>Agent pulse</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-timeline"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Partnership mix</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-partners"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Top halal-kansen</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-opps"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Supermarkten per keten</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-chains"></canvas></div>
</article>
<article class="ax-chart-card">
<h3>Provincie footprint</h3>
<div class="ax-chart-wrap"><canvas id="ax-chart-prov"></canvas></div>
</article>
</div>
</section>
<!-- Lists -->
<section class="ax-lists">
<article class="ax-list-card">
<h3>Halal opportunity board</h3>
<ul>
<template x-for="(o, idx) in (data.top_opportunities || []).slice(0,8)" :key="idx">
<li>
<span class="ax-rank ax-mono" x-text="idx + 1"></span>
<div>
<strong x-text="(o.chain || '') + ' · ' + (o.city || '')"></strong>
<div class="ax-mini-bar"><i :style="'width:' + Math.min(100, o.halal_opportunity_score || 0) + '%'"></i></div>
</div>
<em class="ax-mono" x-text="Math.round(o.halal_opportunity_score || 0)"></em>
</li>
</template>
</ul>
</article>
<article class="ax-list-card">
<h3>Recente agent events</h3>
<ul class="ax-events">
<template x-for="e in (data.recent_events || []).slice(0,8)" :key="e.created_at + (e.title||'')">
<li>
<div>
<strong x-text="e.title || e.event_type"></strong>
<span x-text="e.agent_name"></span>
</div>
<time x-text="formatTime(e.created_at)"></time>
</li>
</template>
</ul>
</article>
<article class="ax-list-card">
<h3>Coverage snapshot</h3>
<dl class="ax-dl">
<div><dt>Partnership dekking</dt><dd class="ax-mono" x-text="(pulse.cover_pct || 0) + '%'"></dd></div>
<div><dt>White space</dt><dd class="ax-mono" x-text="fmt(pulse.white_space)"></dd></div>
<div><dt>NAS docs</dt><dd class="ax-mono" x-text="fmt(kpis.nas_docs)"></dd></div>
<div><dt>SM contacten</dt><dd class="ax-mono" x-text="fmt(kpis.contacts_supermarket)"></dd></div>
<div><dt>GH contacten</dt><dd class="ax-mono" x-text="fmt(kpis.contacts_wholesaler)"></dd></div>
<div><dt>Pending approvals</dt><dd class="ax-mono" x-text="fmt(kpis.pending_approvals)"></dd></div>
</dl>
</article>
</section>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script> <script>window.ANALYTICS_INITIAL = {{ initial_data | tojson }};</script>
<script src="/static/js/analytics.js?v=2"></script> <script src="/static/js/analytics.js?v=20"></script>
{% endblock %} {% endblock %}
+51 -19
View File
@@ -21,12 +21,13 @@
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" /> <link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
<link rel="stylesheet" href="/static/css/mobile.css?v=5" /> <link rel="stylesheet" href="/static/css/mobile.css?v=5" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" /> <link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
<script defer src="/static/js/herman-assistant.js?v=16"></script>
<script defer src="/static/js/alpine.min.js?v=2"></script> <script defer src="/static/js/alpine.min.js?v=2"></script>
<script defer src="/static/js/htmx.min.js?v=2"></script> <script defer src="/static/js/htmx.min.js?v=2"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=11" /> <link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=11" />
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" /> <link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=1" /> <link rel="stylesheet" href="/static/css/herman-assistant.css?v=16" />
</head> </head>
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }"> <body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
<div class="app-shell"> <div class="app-shell">
@@ -55,7 +56,6 @@
<a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a> <a href="/deals" class="nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}"><span class="sidebar-nav-icon">🤝</span><span class="sidebar-nav-label" data-i18n="nav.deals">Deals</span></a>
<a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a> <a href="/products" class="nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}"><span class="sidebar-nav-icon">📦</span><span class="sidebar-nav-label" data-i18n="nav.products">Producten</span></a>
<a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a> <a href="/suppliers" class="nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}"><span class="sidebar-nav-icon">🏭</span><span class="sidebar-nav-label" data-i18n="nav.suppliers">Leveranciers</span></a>
<a href="/herman" class="nav-pill nav-pill-chat {% if request.url.path.startswith('/herman') %}active{% endif %}"><span class="sidebar-nav-icon">💬</span><span class="sidebar-nav-label" data-i18n="nav.chat">Herman</span></a>
<a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a> <a href="/reports" class="nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}"><span class="sidebar-nav-icon">📊</span><span class="sidebar-nav-label" data-i18n="nav.reports">Rapporten</span></a>
</div> </div>
@@ -111,14 +111,32 @@
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button> <button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
</div> </div>
<!-- Herman browser assistant (elke pagina) --> <!-- Herman chat — altijd vast onderin zichtbaar -->
<div x-data="hermanBrowserWidget()" x-init="initWidget()" x-show="!hidden" x-cloak> <div id="ha-dock" class="ha-dock" x-data="hermanBrowserWidget()" x-init="initWidget()" :class="{ 'ha-dock-hidden': hidden }">
<button type="button" class="ha-fab" :class="{ 'is-open': open }" @click="toggleOpen()" title="Praat met Herman" aria-label="Herman assistant">💬</button> <button type="button" class="ha-fab"
<div class="ha-panel" x-show="open" x-transition @click.outside="open = false"> :class="{ 'is-open': open, 'is-busy': busy, 'is-dragging': fabDragging, 'has-custom-pos': !!fabPos }"
:style="fabStyle()"
@pointerdown.prevent="onFabPointerDown($event)"
@pointermove="onFabPointerMove($event)"
@pointerup="onFabPointerUp($event)"
@pointercancel="onFabPointerUp($event)"
@click="onFabClick($event)"
@dblclick="onFabDblClick($event)"
title="Slepen om te verplaatsen · klik om te openen · dubbelklik = reset"
aria-label="Open Herman chat">
<span class="ha-fab-icon" aria-hidden="true">💬</span>
<span class="ha-fab-label" x-text="open ? 'Sluiten' : 'Herman'"></span>
</button>
<div class="ha-panel" :class="{ 'has-custom-pos': !!fabPos }" :style="panelStyle()" x-show="open" x-cloak x-transition.opacity.duration.150ms @keydown.escape.window="open = false">
<header class="ha-head"> <header class="ha-head">
<h2>Herman <span style="font-size:0.65rem;color:#a78bfa">browser</span></h2> <div class="ha-head-title">
<h2>Herman</h2>
<p class="ha-conn" :class="{ 'is-ok': connOk, 'is-err': connOk === false }" x-text="connLabel"></p>
</div>
<div class="ha-head-actions"> <div class="ha-head-actions">
<a href="/voice">Voice live</a> <a x-show="!micSecureOk()" class="ha-text-btn" :href="httpsChatUrl()" target="_blank" rel="noopener" title="Audio/microfoon in HTTPS-tab">Audio</a>
<button type="button" class="ha-text-btn" @click="clearChat()" title="Nieuwe chat" :disabled="busy || (!turns.length && !manualText)">Wissen</button>
<a href="/herman">Volledig</a>
<button type="button" @click="open = false" aria-label="Sluiten">×</button> <button type="button" @click="open = false" aria-label="Sluiten">×</button>
</div> </div>
</header> </header>
@@ -132,23 +150,39 @@
<div class="ha-log" x-ref="chatLog"> <div class="ha-log" x-ref="chatLog">
<template x-for="(t, i) in turns" :key="i"> <template x-for="(t, i) in turns" :key="i">
<div class="ha-bubble" :class="t.role === 'user' ? 'ha-bubble-user' : 'ha-bubble-agent'"> <div class="ha-bubble" :class="t.role === 'user' ? 'ha-bubble-user' : 'ha-bubble-agent'">
<div class="ha-bubble-meta" x-text="t.at + (t.role === 'user' ? ' · jij' : ' · Herman')"></div> <div class="ha-bubble-meta" x-text="t.at + (t.role === 'user' ? ' · jij' : (' · ' + ((t.meta && t.meta.agent_label) || 'Herman')))"></div>
<p x-text="t.text"></p> <p x-text="t.text"></p>
</div> </div>
</template> </template>
<p x-show="!turns.length" class="page-tip" style="font-size:0.75rem;margin:0">Typ of spreek — bv. “zoek distributeurs in Dubai”</p> <p x-show="!turns.length" class="page-tip" style="font-size:0.75rem;margin:0">Chat met Herman op de achtergrond — bv. “zoek distributeurs in Dubai”</p>
</div> </div>
<div class="ha-preview" :class="{ 'is-interim': liveInterim }" x-show="livePreview || recording" x-text="livePreview || 'Luisteren…'"></div> <div class="ha-preview" :class="{ 'is-interim': liveInterim }" x-show="livePreview || recording" x-text="livePreview || 'Luisteren…'"></div>
<p class="ha-status" x-show="statusDetail" x-text="statusDetail"></p> <div class="ha-toggles">
<div class="ha-input-row"> <label title="Alleen voorlezen als jij dit aanzet">
<input type="checkbox" x-model="speakReply" @change="persistPrefs()" />
<span>Voorlezen (Maarten)</span>
</label>
<label title="Na antwoord meteen weer luisteren">
<input type="checkbox" x-model="handsFreeMode" @change="onHandsFreeChange()" />
<span>Hands-free praten</span>
</label>
</div>
<p class="ha-status" :class="{ 'is-error': status === 'error' }" x-show="statusDetail || busy" x-text="statusDetail || (busy ? 'Herman denkt…' : '')"></p>
<div class="ha-https-prompt" x-show="httpsAudioPrompt" x-cloak>
<span>Microfoon werkt via HTTPS</span>
<button type="button" class="btn btn-sm btn-pulse" @click="goHttpsAudio()">Doorgaan naar HTTPS →</button>
<button type="button" class="ha-text-btn" @click="httpsAudioPrompt = false" title="Sluiten">×</button>
</div>
<form class="ha-input-row" @submit.prevent="sendText()">
<button type="button" class="ha-mic" :class="micClass()" <button type="button" class="ha-mic" :class="micClass()"
@mousedown.prevent="onMicDown()" @mouseup.prevent="onMicUp()" @mousedown.prevent="onMicDown()" @mouseup.prevent="onMicUp()"
@touchstart.prevent="onMicDown()" @touchend.prevent="onMicUp()" @touchstart.prevent="onMicDown()" @touchend.prevent="onMicUp()"
@click.prevent="onMicClick()" title="Microfoon">🎙️</button> @click.prevent="onMicClick()" title="Microfoon">🎙️</button>
<input class="form-input" type="text" placeholder="Vraag aan Herman…" x-model="manualText" <input class="form-input" type="text" name="herman_message" autocomplete="off"
placeholder="Vraag aan Herman…" x-model="manualText"
@keydown.enter.prevent="sendText()" :disabled="busy" /> @keydown.enter.prevent="sendText()" :disabled="busy" />
<button type="button" class="btn btn-sm btn-pulse" @click="sendText()" :disabled="busy || !manualText.trim()">→</button> <button type="submit" class="btn btn-sm btn-pulse" :disabled="busy || !manualText.trim()">→</button>
</div> </form>
</div> </div>
<div class="ei-modal-root ha-results-modal" x-show="resultsOpen" @click.self="closeResultsModal()"> <div class="ei-modal-root ha-results-modal" x-show="resultsOpen" @click.self="closeResultsModal()">
<div class="ei-modal-card ha-results-card" @click.stop> <div class="ei-modal-card ha-results-card" @click.stop>
@@ -189,8 +223,6 @@
</div> </div>
</div> </div>
<script defer src="/static/js/herman-assistant.js?v=2"></script>
<script src="/static/js/i18n.js?v=5"></script> <script src="/static/js/i18n.js?v=5"></script>
<script src="/static/js/cockpit.js?v=2"></script> <script src="/static/js/cockpit.js?v=2"></script>
<script src="/static/js/viz-engine.js?v=3"></script> <script src="/static/js/viz-engine.js?v=3"></script>
@@ -232,7 +264,7 @@
_offset: 0, _offset: 0,
async initApp() { async initApp() {
this.brand = window.FoodlinkkBrand.detect(location.pathname); this.brand = window.FoodlinkkBrand.detect(location.pathname);
if (!localStorage.getItem('flk-cache-v3')) { if (!localStorage.getItem('flk-cache-v4')) {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations(); const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister())); await Promise.all(regs.map((r) => r.unregister()));
@@ -241,7 +273,7 @@
const keys = await caches.keys(); const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k))); await Promise.all(keys.map((k) => caches.delete(k)));
} }
localStorage.setItem('flk-cache-v3', '1'); localStorage.setItem('flk-cache-v4', '1');
} }
if (window.I18n) await window.I18n.init(); if (window.I18n) await window.I18n.init();
this.locale = window.I18n ? window.I18n.locale() : 'nl'; this.locale = window.I18n ? window.I18n.locale() : 'nl';
+3 -3
View File
@@ -64,8 +64,8 @@
</div> </div>
<div class="voice-toggles" style="margin-bottom:0.75rem"> <div class="voice-toggles" style="margin-bottom:0.75rem">
<label><input type="checkbox" x-model="speakReply" /> Antwoord voorlezen</label> <label><input type="checkbox" x-model="speakReply" @change="persistPrefs()" /> Voorlezen (Maarten)</label>
<label><input type="checkbox" x-model="handsFreeMode" /> Hands-free na antwoord</label> <label><input type="checkbox" x-model="handsFreeMode" @change="onHandsFreeChange()" /> Hands-free praten</label>
</div> </div>
<div class="voice-manual-row"> <div class="voice-manual-row">
@@ -107,6 +107,6 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/herman-assistant.js?v=1"></script> <script src="/static/js/herman-assistant.js?v=7"></script>
<style>.voice-mic-inline{width:44px;height:44px;border-radius:50%;font-size:1rem;flex-shrink:0}</style> <style>.voice-mic-inline{width:44px;height:44px;border-radius:50%;font-size:1rem;flex-shrink:0}</style>
{% endblock %} {% endblock %}
+230 -206
View File
@@ -1,244 +1,268 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Hermes · Telegram Command Center{% endblock %} {% block title %}Hermes · KPI's{% endblock %}
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="/static/css/hermes.css" /> <link rel="stylesheet" href="/static/css/hermes.css?v=11" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div x-data="hermesControl()" x-init="init()" class="hermes-shell hm-neo"> <div x-data="hermesKpiDashboard()" x-init="init()" class="hermes-shell hm-neo">
<header class="hm-neo-header page-header"> <header class="hm-neo-header page-header">
<h1>Hermes — Telegram Command Center</h1> <div>
<p class="hm-sub">Live feed · 4 PA browsers · second brain · volledige controle</p> <h1>Hermes · Technische KPI's</h1>
<p class="hm-sub">Live processen van Hermes Agent + Herman — klik een blok voor detail ·
<a href="http://10.4.7.10:9119/" target="_blank" rel="noopener">Hermes UI</a>
<span x-show="kpis.synced_at" class="text-muted" x-text="' · sync ' + formatTime(kpis.synced_at)"></span>
</p>
</div>
<div class="retail-actions">
<span class="status-badge" :class="kpis.online ? 'on' : 'off'" x-text="kpis.online ? 'Online' : 'Offline'"></span>
<button type="button" class="btn btn-sm" @click="refresh()" :disabled="loading">↻ Vernieuwen</button>
</div>
</header> </header>
<!-- Neo KPI row --> <!-- Zone A -->
<div class="hm-neo-kpi-row"> <div class="hm-neo-kpi-row">
<div class="hm-neo-kpi" :class="{ highlight: tab === 'feed' }"> <div class="hm-neo-kpi" :class="{ highlight: kpis.online }">
<div class="hm-neo-kpi-top"> <div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:72%"><div class="hm-neo-ring-inner">💬</div></div> <div class="hm-neo-ring" :style="'--ring-color:' + (kpis.online ? '#b8ff3c' : '#f87171') + ';--ring-pct:80%'"><div class="hm-neo-ring-inner" x-text="kpis.online ? '●' : '○'"></div></div>
<div><div class="hm-neo-kpi-label">Conversaties</div><div class="hm-neo-kpi-sub">Telegram chats</div></div> <div><div class="hm-neo-kpi-label">Hermes</div><div class="hm-neo-kpi-sub">Agent status</div></div>
</div> </div>
<div class="hm-neo-kpi-value" x-text="stats.conversations">0</div> <div class="hm-neo-kpi-value" style="font-size:1.25rem" x-text="kpis.online ? 'Online' : 'Offline'"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div> </div>
<div class="hm-neo-kpi"> <div class="hm-neo-kpi">
<div class="hm-neo-kpi-top"> <div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#b8ff3c;--ring-pct:85%"><div class="hm-neo-ring-inner">📨</div></div> <div class="hm-neo-ring" style="--ring-color:#00e5ff;--ring-pct:70%"><div class="hm-neo-ring-inner">✈️</div></div>
<div><div class="hm-neo-kpi-label">Berichten</div><div class="hm-neo-kpi-sub">in + out</div></div> <div><div class="hm-neo-kpi-label">Telegram</div><div class="hm-neo-kpi-sub">actieve chats</div></div>
</div> </div>
<div class="hm-neo-kpi-value" x-text="stats.messages">0</div> <div class="hm-neo-kpi-value" x-text="ov.telegram_sessions_active || 0"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div> </div>
<div class="hm-neo-kpi"> <div class="hm-neo-kpi">
<div class="hm-neo-kpi-top"> <div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:60%"><div class="hm-neo-ring-inner">🕸</div></div> <div class="hm-neo-ring" style="--ring-color:#ff9f43;--ring-pct:75%"><div class="hm-neo-ring-inner">🔧</div></div>
<div><div class="hm-neo-kpi-label">Relaties</div><div class="hm-neo-kpi-sub">graph edges</div></div> <div><div class="hm-neo-kpi-label">Tool-calls</div><div class="hm-neo-kpi-sub">totaal werk</div></div>
</div> </div>
<div class="hm-neo-kpi-value" x-text="stats.edges">0</div> <div class="hm-neo-kpi-value" x-text="fmt(ov.tool_calls_total)"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div> </div>
<div class="hm-neo-kpi"> <div class="hm-neo-kpi">
<div class="hm-neo-kpi-top"> <div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#a855f7;--ring-pct:55%"><div class="hm-neo-ring-inner">🧠</div></div> <div class="hm-neo-ring" style="--ring-color:#a855f7;--ring-pct:65%"><div class="hm-neo-ring-inner">💬</div></div>
<div><div class="hm-neo-kpi-label">Vectors</div><div class="hm-neo-kpi-sub">pgvector</div></div> <div><div class="hm-neo-kpi-label">Tekst in / uit</div><div class="hm-neo-kpi-sub">user · agent</div></div>
</div> </div>
<div class="hm-neo-kpi-value" x-text="stats.embeddings">0</div> <div class="hm-neo-kpi-value" style="font-size:1.15rem" x-text="(ov.text_in||0) + ' / ' + (ov.text_out||0)"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div> </div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#38bdf8;--ring-pct:55%"><div class="hm-neo-ring-inner">🧩</div></div>
<div><div class="hm-neo-kpi-label">Skills</div><div class="hm-neo-kpi-sub">geïnstalleerd</div></div>
</div>
<div class="hm-neo-kpi-value" x-text="ov.skills_installed || 0"></div>
</div>
<div class="hm-neo-kpi">
<div class="hm-neo-kpi-top">
<div class="hm-neo-ring" style="--ring-color:#94a3b8;--ring-pct:50%"><div class="hm-neo-ring-inner">⏱</div></div>
<div><div class="hm-neo-kpi-label">Sync</div><div class="hm-neo-kpi-sub">laatste snapshot</div></div>
</div>
<div class="hm-neo-kpi-value" style="font-size:0.95rem" x-text="formatTime(kpis.synced_at) || '—'"></div>
</div> </div>
</div> </div>
<!-- Neo charts --> <!-- Zone B -->
<div class="hm-neo-charts"> <h2 class="hm-section-title">Wat doet hij? <span class="hm-section-hint">klik een blok</span></h2>
<div class="hm-bucket-grid">
<template x-for="b in (kpis.buckets || [])" :key="b.key">
<button type="button" class="hm-bucket-card hm-bucket-click" @click="openBucket(b)">
<div class="hm-bucket-label" x-text="b.label"></div>
<div class="hm-bucket-count" x-text="fmt(b.count)"></div>
<div class="hm-bucket-parts" x-text="partsLine(b.parts)"></div>
<div class="hm-bucket-open">Open →</div>
</button>
</template>
</div>
<!-- Zone C -->
<div class="hm-neo-charts" style="margin-top:1.25rem">
<div class="hm-chart-panel"> <div class="hm-chart-panel">
<div class="hm-chart-head"> <div class="hm-chart-head"><h3>Top tools</h3><small>meest gebruikt</small></div>
<h3><span class="hm-live-dot"></span> Berichten per richting</h3> <div class="hm-chart-wrap"><canvas id="hm-chart-tools"></canvas></div>
<small>klik voor detail</small> <ul class="hm-tool-list">
</div> <template x-for="t in (kpis.tools_top || []).slice(0,8)" :key="t.name">
<div class="hm-chart-wrap"><canvas id="hm-chart-direction"></canvas></div> <li><span x-text="t.name"></span><strong x-text="fmt(t.count)"></strong></li>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head">
<h3><span class="hm-live-dot"></span> Agent activiteit</h3>
<small>laatste events</small>
</div>
<div class="hm-chart-wrap"><canvas id="hm-chart-agents"></canvas></div>
</div>
</div>
<div class="hermes-tabs">
<button type="button" class="hermes-tab" :class="{ active: tab === 'feed' }" @click="setTab('feed')">📨 Live Feed</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'pa' }" @click="setTab('pa')">🌐 PA Live (4×)</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'graph' }" @click="setTab('graph')">🕸 Graph</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'search' }" @click="setTab('search')">🔍 Memory</button>
<button type="button" class="hermes-tab" :class="{ active: tab === 'control' }" @click="setTab('control')">⚙️ Control</button>
</div>
<!-- LIVE FEED -->
<div x-show="tab === 'feed'" class="hermes-grid">
<aside class="panel hermes-sidebar">
<div class="hm-team">
<h3>Team — Foodlinkk leadership</h3>
<template x-for="m in teamRoster" :key="m.chat_id">
<div class="hm-team-card" :class="{ online: m.online || m.pa_mode }">
<span class="hm-status-dot" :class="m.online ? 'on' : 'off'"></span>
<span class="hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
<div class="hm-person">
<strong x-text="m.name"></strong>
<small>
<span x-text="m.chat_id"></span>
<span x-show="m.pa_mode" class="tag tag-pa">PA</span>
<span x-show="m.message_count" x-text="' · ' + m.message_count + ' msgs'"></span>
</small>
</div>
</div>
</template> </template>
</div>
<h3 style="font-size:0.7rem;letter-spacing:0.12em;text-transform:uppercase;color:#64748b;margin:1rem 0 0.5rem">Filter chat</h3>
<button class="btn btn-sm" @click="selectChat(null)" :class="{ 'btn-primary': !selectedChatId }">Alle chats</button>
<template x-for="m in teamRoster" :key="'c'+m.chat_id">
<button class="hermes-conv-btn" @click="selectChat(m.chat_id)" :class="{ active: selectedChatId == m.chat_id }">
<span class="conv-role hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
<span x-text="m.name"></span>
</button>
</template>
</aside>
<section class="panel hermes-feed">
<div class="panel-header">
<h3>Telegram live feed</h3>
<span class="text-muted" x-text="live ? '🟢 Live (5s)' : '⏸'"></span>
<button class="btn btn-sm" @click="refreshFeed()">↻ Refresh</button>
</div>
<div class="hermes-messages">
<template x-for="msg in messages" :key="msg.id">
<div class="hermes-msg" :class="msg.direction === 'in' ? 'msg-in' : 'msg-out'">
<div class="hermes-msg-meta">
<span x-text="msg.direction === 'in' ? '← IN' : '→ OUT'"></span>
<span x-text="msgLabel(msg)"></span>
<span x-text="formatTime(msg.created_at)"></span>
<span class="badge" x-show="msg.has_embedding">vector</span>
<span class="badge" x-show="msg.agent_name" x-text="msg.agent_name"></span>
</div>
<div class="hermes-msg-body" x-text="msg.content_text || '(' + (msg.content_type || 'empty') + ')'"></div>
</div>
</template>
<p x-show="!messages.length" class="text-muted">Nog geen berichten — stuur iets via @klaploper_bot</p>
</div>
</section>
</div>
<!-- PA LIVE -->
<div x-show="tab === 'pa'" class="panel">
<div class="hermes-pa-header">
<div>
<strong>Personal PA — 4 site browsers</strong>
<p class="text-muted" style="margin:0.25rem 0 0" x-text="paLive.query ? 'Zoekt: ' + paLive.query : 'Wacht op @PA via Telegram'"></p>
</div>
<span class="status-badge" :class="paLive.status || 'idle'" x-text="paStatusLabel()"></span>
<span class="text-muted" x-show="paLiveUser" x-text="paLiveUser"></span>
<button class="btn btn-sm btn-primary" @click="refreshPaLive()">↻ Refresh</button>
</div>
<div class="hermes-pa-grid">
<template x-for="slot in paSlots" :key="slot.label">
<div class="hermes-pa-slot" :class="'status-' + (slot.status || 'idle')">
<div class="hermes-pa-slot-head">
<strong x-text="slot.label"></strong>
<span class="status-badge" :class="slot.status || 'idle'" x-text="slotStatusLabel(slot.status)"></span>
</div>
<div class="hermes-pa-slot-body">
<img x-show="slot.has_screenshot" :src="slotScreenshotUrl(slot.label)" :alt="slot.label" />
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && slot.status === 'waiting'">⏳ Wacht…</div>
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && slot.status === 'loading'">🔄 Bezig…</div>
<div class="hermes-pa-placeholder" x-show="!slot.has_screenshot && (!slot.status || slot.status === 'idle')">— idle —</div>
<div class="hermes-pa-placeholder" x-show="slot.status === 'failed'" x-text="slot.error || 'Mislukt'"></div>
</div>
<div class="hermes-pa-slot-foot" x-text="slot.title || slot.url || slot.label"></div>
</div>
</template>
</div>
</div>
<!-- GRAPH -->
<div x-show="tab === 'graph'" class="panel">
<div class="panel-header">
<h3>Second brain — message graph</h3>
<button class="btn btn-sm btn-primary" @click="loadGraph()">↻ Herlaad</button>
<select class="form-input hermes-select" x-model="graphChatFilter" @change="loadGraph()">
<option value="">Alle chats</option>
<template x-for="m in teamRoster" :key="'g'+m.chat_id">
<option :value="m.chat_id" x-text="m.role + ' — ' + m.name"></option>
</template>
</select>
</div>
<div id="hermes-graph" class="hermes-graph-canvas"></div>
</div>
<!-- SEARCH -->
<div x-show="tab === 'search'" class="panel">
<h3>Vector + full-text memory search</h3>
<div class="hermes-search-bar">
<input class="form-input" type="search" placeholder="Zoek in Telegram-geheugen…" x-model="searchQuery" @keydown.enter="runSearch()" />
<button class="btn btn-primary" @click="runSearch()">Zoeken</button>
</div>
<div class="hermes-search-results">
<template x-for="r in searchResults" :key="r.id">
<div class="hermes-search-hit">
<div class="hermes-msg-meta">
<span x-text="msgLabel(r)"></span>
<span x-text="formatTime(r.created_at)"></span>
<span class="badge" x-text="'score ' + (r.score || 0).toFixed(2)"></span>
</div>
<p x-text="r.content_text"></p>
</div>
</template>
</div>
</div>
<!-- CONTROL -->
<div x-show="tab === 'control'" class="hermes-control-grid">
<div class="panel">
<h3>Hermes status</h3>
<ul class="hermes-info-list">
<li>Bot: <strong>@klaploper_bot</strong></li>
<li>CEO: <strong>Aïssa</strong> · CTO: <strong>Mo</strong></li>
<li>Control API: <span x-text="hermesOnline ? '🟢 Online' : '🔴 Offline'"></span></li>
<li>Inbound: <span x-text="stats.inbound || 0"></span> · Outbound: <span x-text="stats.outbound || 0"></span></li>
</ul> </ul>
<div class="hermes-actions"> </div>
<button class="btn btn-primary" @click="runAction('evening-briefing')" :disabled="actionBusy">🌙 Avondbriefing → CEO</button> <div class="hm-chart-panel">
<button class="btn" @click="runAction('morning-briefing')" :disabled="actionBusy">☀️ Ochtendbriefing</button> <div class="hm-chart-head"><h3>Bronnen</h3><small>via welk kanaal</small></div>
<button class="btn" @click="runAction('resume-jobs')" :disabled="actionBusy">🔧 Herstel jobs</button> <div class="hm-chart-wrap"><canvas id="hm-chart-sources"></canvas></div>
<ul class="hm-tool-list">
<template x-for="s in (kpis.sources || [])" :key="s.source">
<li>
<span x-text="sourceLabel(s.source)"></span>
<strong x-text="fmt(s.tools) + ' tools · ' + (s.sessions||0) + ' sessies'"></strong>
</li>
</template>
</ul>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h3>Herman (cockpit)</h3><small>agent_events</small></div>
<ul class="hm-tool-list hm-tool-list-tall">
<template x-for="e in (kpis.herman_cockpit || [])" :key="e.event_type">
<li><span x-text="e.event_type"></span><strong x-text="fmt(e.count)"></strong></li>
</template>
<li x-show="!(kpis.herman_cockpit || []).length" class="text-muted">Geen events</li>
</ul>
<p class="page-tip" style="margin-top:0.75rem;font-size:0.72rem">
Delegaties: <strong x-text="ov.async_delegations || 0"></strong> ·
Delivery: <strong x-text="ov.delivery_obligations || 0"></strong>
</p>
</div>
</div>
<!-- Explorer / detail modal -->
<div class="hm-modal-backdrop" x-show="modal.open" x-cloak @keydown.escape.window="closeModal()" @click.self="closeModal()">
<div class="hm-modal" role="dialog" aria-modal="true" @click.stop>
<header class="hm-modal-head">
<div>
<h2 x-text="modal.title"></h2>
<p class="hm-modal-sub">
<span x-text="fmt(modal.detail.count || 0) + ' calls'"></span>
<template x-for="(n, name) in (modal.detail.parts || {})" :key="name">
<button type="button" class="hm-chip" :class="{ active: modal.toolFilter === name }"
@click="setToolFilter(name)" x-text="n + ' ' + name.replace(/_/g,' ')"></button>
</template>
<button type="button" class="hm-chip" :class="{ active: !modal.toolFilter }" @click="setToolFilter(null)">alles</button>
</p>
</div>
<button type="button" class="hm-modal-close" @click="closeModal()" aria-label="Sluiten">×</button>
</header>
<div class="hm-modal-tabs" x-show="modal.key === 'files'">
<button type="button" :class="{ active: modal.tab === 'activity' }" @click="modal.tab = 'activity'">Activiteit</button>
<button type="button" :class="{ active: modal.tab === 'touched' }" @click="modal.tab = 'touched'">Aangeraakte files</button>
<button type="button" :class="{ active: modal.tab === 'explorer' }" @click="openExplorerTab()">Verkenner</button>
</div>
<div class="hm-modal-body" :class="{ 'hm-modal-split': true }">
<!-- Left pane -->
<aside class="hm-modal-side">
<div class="hm-side-loading" x-show="modal.loading">Laden…</div>
<!-- Activity list -->
<template x-if="modal.tab === 'activity' || modal.key !== 'files'">
<ul class="hm-event-list">
<template x-for="ev in (modal.detail.events || [])" :key="ev.id">
<li>
<button type="button" class="hm-event-item" :class="{ active: modal.selectedEvent && modal.selectedEvent.id === ev.id }"
@click="selectEvent(ev)">
<div class="hm-event-top">
<strong x-text="ev.title"></strong>
<time x-text="formatTime(ev.created_at)"></time>
</div>
<div class="hm-event-tool" x-text="ev.tool"></div>
<div class="hm-event-preview" x-text="ev.preview"></div>
</button>
</li>
</template>
<li x-show="!(modal.detail.events || []).length && !modal.loading" class="text-muted hm-empty">Geen events</li>
</ul>
</template>
<!-- Touched files -->
<template x-if="modal.key === 'files' && modal.tab === 'touched'">
<ul class="hm-file-list">
<template x-for="f in (modal.detail.files || [])" :key="f.path">
<li>
<button type="button" class="hm-file-item" :class="{ active: modal.preview && modal.preview.path === f.path }"
@click="openFile(f.path)">
<div class="hm-file-name">
<span x-text="f.exists ? '📄' : '◌'"></span>
<strong x-text="f.name"></strong>
</div>
<div class="hm-file-path" x-text="f.path"></div>
<div class="hm-file-meta">
<span x-text="Object.keys(f.ops || {}).map(k => (f.ops[k] + ' ' + k.replace(/_/g,' '))).join(' · ')"></span>
<time x-text="formatTime(f.last_at)"></time>
</div>
</button>
</li>
</template>
<li x-show="!(modal.detail.files || []).length && !modal.loading" class="text-muted hm-empty">Geen bestanden gevonden</li>
</ul>
</template>
<!-- Explorer -->
<template x-if="modal.key === 'files' && modal.tab === 'explorer'">
<div class="hm-explorer">
<div class="hm-explorer-bar">
<button type="button" class="btn btn-sm" @click="explorerUp()" :disabled="!modal.explorer.parent && !modal.explorer.path">↑ Omhoog</button>
<button type="button" class="btn btn-sm" @click="loadExplorer('')">Roots</button>
<code class="hm-explorer-path" x-text="modal.explorer.path || '(roots)'"></code>
</div>
<ul class="hm-file-list">
<template x-for="e in (modal.explorer.entries || [])" :key="e.path">
<li>
<button type="button" class="hm-file-item" @click="e.type === 'dir' ? loadExplorer(e.path) : openFile(e.path)">
<div class="hm-file-name">
<span x-text="e.type === 'dir' ? '📁' : '📄'"></span>
<strong x-text="e.name"></strong>
</div>
<div class="hm-file-meta">
<span x-show="e.size != null" x-text="fmtBytes(e.size)"></span>
<time x-text="formatTime(e.mtime)"></time>
</div>
</button>
</li>
</template>
</ul>
</div>
</template>
</aside>
<!-- Right pane preview -->
<section class="hm-modal-preview">
<div x-show="modal.previewLoading" class="hm-side-loading">Bestand laden…</div>
<template x-if="modal.preview && !modal.previewLoading">
<div>
<div class="hm-preview-head">
<div>
<h3 x-text="modal.preview.name || 'Preview'"></h3>
<code x-text="modal.preview.path || ''"></code>
</div>
<div class="hm-preview-meta" x-show="modal.preview.size != null">
<span x-text="fmtBytes(modal.preview.size)"></span>
<span x-text="formatTime(modal.preview.mtime)"></span>
</div>
</div>
<pre class="hm-preview-code" x-text="modal.preview.content || modal.preview.preview || modal.preview.error || '—'"></pre>
<p class="hm-preview-note" x-show="modal.preview.truncated">Preview afgekapt (bestand te groot).</p>
</div>
</template>
<template x-if="!modal.preview && modal.selectedEvent && !modal.previewLoading">
<div>
<div class="hm-preview-head">
<div>
<h3 x-text="modal.selectedEvent.title"></h3>
<p class="hm-modal-sub" x-text="modal.selectedEvent.tool + ' · ' + formatTime(modal.selectedEvent.created_at)"></p>
</div>
</div>
<pre class="hm-preview-code" x-text="modal.selectedEvent.preview || '—'"></pre>
<div class="hm-preview-paths" x-show="(modal.selectedEvent.paths || []).length">
<template x-for="p in (modal.selectedEvent.paths || [])" :key="p">
<button type="button" class="hm-chip" @click="openFile(p); if(modal.key==='files') modal.tab='touched'" x-text="p"></button>
</template>
</div>
</div>
</template>
<div x-show="!modal.preview && !modal.selectedEvent && !modal.previewLoading" class="hm-empty-pane">
Selecteer een event of bestand links om details te zien.
</div>
</section>
</div> </div>
<p x-show="actionMsg" class="text-muted" style="margin-top:0.5rem" x-text="actionMsg"></p>
</div>
<div class="panel">
<h3>Team</h3>
<template x-for="m in teamRoster" :key="'t'+m.chat_id">
<div class="hm-team-card" :class="{ online: m.online }">
<span class="hm-role-badge" :class="m.role === 'CEO' ? 'hm-role-ceo' : 'hm-role-cto'" x-text="m.role"></span>
<div class="hm-person"><strong x-text="m.name"></strong><small x-text="m.chat_id"></small></div>
</div>
</template>
</div>
<div class="panel hermes-events">
<h3>Agent events</h3>
<template x-for="ev in agentEvents" :key="ev.id">
<div class="hermes-event">
<strong x-text="ev.title"></strong>
<span class="badge" x-text="ev.agent_name"></span>
<small x-text="formatTime(ev.created_at)"></small>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/hermes-ui.js"></script> <script src="/static/js/hermes-ui.js?v=11"></script>
{% endblock %} {% endblock %}
-265
View File
@@ -11,7 +11,6 @@
<nav class="settings-tabs"> <nav class="settings-tabs">
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a> <a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
<a href="/settings?tab=social" class="settings-tab {% if active_tab == 'social' %}active{% endif %}">Social API's</a> <a href="/settings?tab=social" class="settings-tab {% if active_tab == 'social' %}active{% endif %}">Social API's</a>
<a href="/settings?tab=ai" class="settings-tab {% if active_tab == 'ai' %}active{% endif %}">AI / LLM</a>
<a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a> <a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a>
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a> <a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
</nav> </nav>
@@ -114,130 +113,6 @@
</div> </div>
</div> </div>
</div> </div>
{% elif active_tab == 'ai' %}
<section class="panel settings-panel" x-data="llmSettingsPage()" x-init="load()">
<div class="settings-panel-head">
<div>
<h2>AI / LLM providers</h2>
<p class="page-tip">Cloud LLM naast lokale Ollama. Standaard provider = Herman, briefing, documenten.</p>
</div>
<button type="button" class="btn btn-primary" @click="openForm()">+ Nieuwe provider</button>
</div>
<div class="llm-hero-card">
<div class="llm-hero-body">
<div class="llm-hero-badge">Aanbevolen</div>
<h3>OpenRouter · DeepSeek V4 Flash</h3>
<p class="llm-hero-lead">
Eén API key voor Herman, briefing en document-chat.
Model: <code>deepseek/deepseek-v4-flash</code>
</p>
<ol class="llm-hero-steps">
<li>Key op <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a></li>
<li><strong>Configureren</strong> → API key plakken → Opslaan → Test</li>
<li><strong>Maak standaard</strong> op de provider-kaart hieronder</li>
</ol>
</div>
<button type="button" class="btn btn-primary" @click="quickAddOpenRouter()">Configureren</button>
</div>
<div class="settings-active-banner" x-show="defaultProvider" x-cloak>
<span class="badge badge-positive">Standaard</span>
<span x-text="defaultProvider ? defaultProvider.label + ' · ' + defaultProvider.model : ''"></span>
</div>
<h3 class="llm-section-title">Snel toevoegen</h3>
<p class="llm-section-sub">Klik een preset — daarna API key invullen in het formulier.</p>
<div class="llm-preset-grid">
<template x-for="pr in presets" :key="pr.id">
<button type="button" class="llm-preset-card" @click="quickAdd(pr)">
<span class="llm-preset-name" x-text="pr.label"></span>
<span class="llm-preset-hint" x-text="pr.hint"></span>
<span class="llm-preset-models" x-text="(pr.models||[]).slice(0,2).join(' · ')"></span>
</button>
</template>
</div>
<h3 class="llm-section-title">Geconfigureerde providers</h3>
<div class="email-account-list llm-provider-list">
<template x-for="p in providers" :key="p.id">
<article class="email-account-card" :class="{ 'is-active': p.is_default }">
<div class="email-account-card-head">
<div>
<strong x-text="p.label"></strong>
<span class="muted" x-text="p.preset_label + ' · ' + p.model"></span>
</div>
<span class="badge" :class="p.is_default ? 'badge-positive' : (p.is_active ? 'badge-neutral' : 'badge-muted')"
x-text="p.is_default ? 'Standaard' : (p.is_active ? 'Actief' : 'Inactief')"></span>
</div>
<div class="email-account-meta">
<span x-text="p.api_base_url || 'Ollama lokaal'"></span>
<span x-text="p.api_key_set ? '🔒 API key opgeslagen' : (p.needs_key ? '⚠️ geen API key' : 'Geen key nodig')"></span>
<span x-show="p.last_test_status" x-text="'Test: ' + p.last_test_status"></span>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary btn-sm" @click="edit(p)">Bewerken</button>
<button type="button" class="btn btn-primary btn-sm" @click="activate(p.id)" x-show="!p.is_default">Maak standaard</button>
<button type="button" class="btn btn-secondary btn-sm" @click="testProvider(p.id)">Test</button>
<button type="button" class="btn btn-reject btn-sm" @click="remove(p.id)" x-show="p.provider_type !== 'ollama' || providers.length > 1">Verwijder</button>
</div>
</article>
</template>
<p class="empty-state" x-show="!providers.length && !loading">Nog geen LLM provider — voeg DeepSeek, Gemini of Groq toe.</p>
</div>
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
<div class="modal-card settings-modal">
<h3 x-text="form.id ? 'Provider bewerken' : 'Nieuwe LLM provider'"></h3>
<label class="form-label">Label
<input class="form-input" x-model="form.label" placeholder="bv. DeepSeek productie" />
</label>
<label class="form-label">Type
<select class="form-input" x-model="form.provider_type" @change="applyPreset()">
<template x-for="pr in presets" :key="'fp'+pr.id">
<option :value="pr.id" x-text="pr.label"></option>
</template>
</select>
</label>
<label class="form-label">Model
<input class="form-input" x-model="form.model" list="llm-model-list" placeholder="deepseek-chat" />
<datalist id="llm-model-list">
<template x-for="m in currentPresetModels()" :key="m">
<option :value="m"></option>
</template>
</datalist>
</label>
<label class="form-label" x-show="formNeedsKey()">API base URL
<input class="form-input" x-model="form.api_base_url" placeholder="https://api.deepseek.com/v1" />
</label>
<label class="form-label" x-show="formNeedsKey()">API key
<input class="form-input" type="password" x-model="form.api_key"
:placeholder="form.api_key_set ? '•••••••• (laat leeg om te behouden)' : 'sk-...'" />
</label>
<p class="page-tip" x-show="presetHint()" x-text="presetHint()"></p>
<p class="page-tip" x-show="presetSignup()" >
<a :href="presetSignup()" target="_blank" rel="noopener">→ Gratis API key aanmaken</a>
</p>
<label class="form-check">
<input type="checkbox" x-model="form.is_default" /> Instellen als standaard provider
</label>
<div class="btn-group">
<button type="button" class="btn btn-primary" @click="save()" :disabled="saving">Opslaan</button>
<button type="button" class="btn btn-secondary" @click="testForm()" :disabled="saving">Test</button>
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleren</button>
</div>
</div>
</div>
</section>
{% elif active_tab == 'permissions' %} {% elif active_tab == 'permissions' %}
<section class="panel settings-panel hm-neo" x-data="permissionsPage()" x-init="loadPerms()"> <section class="panel settings-panel hm-neo" x-data="permissionsPage()" x-init="loadPerms()">
<div class="settings-panel-head"> <div class="settings-panel-head">
@@ -553,145 +428,5 @@ function defaultForm() {
}; };
} }
</script> </script>
{% elif active_tab == 'ai' %}
<script>
function llmDefaultForm() {
return {
id: null, label: '', provider_type: 'deepseek', api_base_url: '', api_key: '',
model: 'deepseek-chat', is_active: true, is_default: false, api_key_set: false,
};
}
function llmSettingsPage() {
return {
providers: [],
presets: [],
defaultProvider: null,
loading: true,
formOpen: false,
saving: false,
form: llmDefaultForm(),
async load() {
this.loading = true;
try {
const r = await Cockpit.api('/api/settings/llm');
this.providers = r.providers || [];
this.presets = r.presets || [];
this.defaultProvider = r.default || this.providers.find(p => p.is_default) || null;
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.loading = false;
},
openForm() {
this.form = llmDefaultForm();
this.applyPreset();
this.formOpen = true;
},
quickAdd(pr) {
this.form = llmDefaultForm();
this.form.provider_type = pr.id;
this.form.label = pr.label;
this.applyPreset();
this.formOpen = true;
},
edit(p) {
this.form = Object.assign(llmDefaultForm(), p, { api_key: '' });
this.formOpen = true;
},
applyPreset() {
const pr = this.presets.find(x => x.id === this.form.provider_type) || {};
if (pr.api_base_url) this.form.api_base_url = pr.api_base_url;
if ((pr.models || []).length && !this.form.id) this.form.model = pr.models[0];
if (!this.form.label) this.form.label = pr.label || this.form.provider_type;
},
currentPresetModels() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return (pr && pr.models) ? pr.models : [];
},
formNeedsKey() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return pr ? !!pr.needs_key : true;
},
presetHint() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return pr ? (pr.hint || '') : '';
},
presetSignup() {
const pr = this.presets.find(x => x.id === this.form.provider_type);
return pr ? (pr.signup_url || '') : '';
},
bodyFromForm() {
const b = {
label: this.form.label,
provider_type: this.form.provider_type,
api_base_url: this.form.api_base_url,
model: this.form.model,
is_active: true,
is_default: this.form.is_default,
extra_config: {},
};
if (this.form.api_key) b.api_key = this.form.api_key;
return b;
},
async save() {
this.saving = true;
try {
const body = this.bodyFromForm();
if (this.form.id) {
await Cockpit.api('/api/settings/llm/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
} else {
await Cockpit.api('/api/settings/llm', { method: 'POST', body: JSON.stringify(body) });
}
Cockpit.toast('LLM provider opgeslagen', 'success');
this.formOpen = false;
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.saving = false;
},
async testForm() {
if (!this.form.id) {
Cockpit.toast('Sla eerst op, daarna testen', 'info');
return;
}
await this.testProvider(this.form.id);
},
async testProvider(id) {
try {
const r = await Cockpit.api('/api/settings/llm/' + id + '/test', { method: 'POST' });
Cockpit.toast(r.message || 'OK', 'success');
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async activate(id) {
try {
await Cockpit.api('/api/settings/llm/' + id + '/activate', { method: 'POST' });
Cockpit.toast('Standaard LLM bijgewerkt', 'success');
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async remove(id) {
if (!confirm('Deze LLM provider verwijderen?')) return;
try {
await Cockpit.api('/api/settings/llm/' + id, { method: 'DELETE' });
await this.load();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
};
}
</script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Certificaat · Cockpit{% endblock %}
{% block content %}
<div class="card" style="max-width:640px;margin:2rem auto;padding:1.25rem 1.4rem">
<h1 style="margin-top:0;font-size:1.25rem">Eenmalig: vertrouw Cockpit</h1>
<p class="page-tip">Daarna geen “onveilige website”-melding meer, en de microfoon werkt direct.</p>
<ol style="line-height:1.55;padding-left:1.2rem;color:#e2e8f0">
<li>Download het CA-certificaat:
<a class="btn btn-sm btn-pulse" href="http://10.4.7.18:8600/cockpit-local-ca.crt">Download CA</a>
</li>
<li>Windows: dubbelklik → <strong>Certificaat installeren</strong> →
<em>Lokale computer</em> → plaats in <strong>Vertrouwde basiscertificeringsautoriteiten</strong> → Voltooien.</li>
<li>Of PowerShell als Administrator:
<code style="display:block;margin:.4rem 0;padding:.5rem;background:#0b1220;border-radius:6px;font-size:.75rem">certutil -addstore -f Root \$env:USERPROFILE\\Downloads\\foodlinkk-cockpit-ca.crt</code>
</li>
<li>Chrome/Edge volledig afsluiten en opnieuw openen, daarna
<a href="https://10.4.7.18:8643/">https://10.4.7.18:8643/</a>
</li>
</ol>
</div>
{% endblock %}
+2 -2
View File
@@ -63,7 +63,7 @@
</label> </label>
<label> <label>
<input type="checkbox" x-model="speakReply" /> <input type="checkbox" x-model="speakReply" />
<span>Antwoord voorlezen (browser TTS)</span> <span>Voorlezen (Maarten)</span>
</label> </label>
</div> </div>
@@ -164,5 +164,5 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/voice-live.js?v=3"></script> <script src="/static/js/voice-live.js?v=4"></script>
{% endblock %} {% endblock %}
+36 -1
View File
@@ -47,6 +47,18 @@ services:
- "foodlinkk_db:10.4.7.18" - "foodlinkk_db:10.4.7.18"
restart: unless-stopped restart: unless-stopped
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: foodlinkk_whisper
environment:
WHISPER__MODEL: Systran/faster-whisper-base
WHISPER__INFERENCE_DEVICE: cpu
ports:
- "8877:8000"
volumes:
- whisper_models:/root/.cache/huggingface
restart: unless-stopped
cockpit: cockpit:
build: ./cockpit build: ./cockpit
container_name: foodlinkk_cockpit container_name: foodlinkk_cockpit
@@ -73,21 +85,41 @@ services:
HERMES_API_BASE: ${HERMES_API_BASE:-http://10.4.7.10:8642/v1} HERMES_API_BASE: ${HERMES_API_BASE:-http://10.4.7.10:8642/v1}
HERMES_API_KEY: ${HERMES_API_KEY:-} HERMES_API_KEY: ${HERMES_API_KEY:-}
HERMES_API_MODEL: ${HERMES_API_MODEL:-hermes-agent} HERMES_API_MODEL: ${HERMES_API_MODEL:-hermes-agent}
HERMES_FEED_URL: ${HERMES_FEED_URL:-http://10.4.7.10:8755}
WHISPER_API_URL: ${WHISPER_API_URL:-http://whisper:8000/v1/audio/transcriptions}
WHISPER_MODEL: ${WHISPER_MODEL:-Systran/faster-whisper-base}
WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-nl}
HERMES_BUILD_URL: http://10.4.7.27:8798 HERMES_BUILD_URL: http://10.4.7.27:8798
HERMES_PREVIEW_BASE: http://10.4.7.27:8080 HERMES_PREVIEW_BASE: http://10.4.7.27:8080
NAS_CLIENTS_ROOT: /data/nas-clients NAS_CLIENTS_ROOT: /data/nas-clients
NAS_SHARE_ROOT: /data/nas-share NAS_SHARE_ROOT: /data/nas-share
ports: ports:
- "8600:8600" - "8610:8600"
volumes: volumes:
- /home/aissa/nas-clients:/data/nas-clients - /home/aissa/nas-clients:/data/nas-clients
- /mnt/synology:/data/nas-share:ro - /mnt/synology:/data/nas-share:ro
depends_on: depends_on:
- tools-api - tools-api
- whisper
extra_hosts: extra_hosts:
- "foodlinkk_db:10.4.7.18" - "foodlinkk_db:10.4.7.18"
restart: unless-stopped restart: unless-stopped
cockpit-tls:
image: caddy:2-alpine
container_name: foodlinkk_cockpit_tls
depends_on:
- cockpit
ports:
- "8643:8643"
volumes:
- ./cockpit/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./cockpit/caddy/certs:/certs:ro
- cockpit_caddy_data:/data
- cockpit_caddy_config:/config
restart: unless-stopped
browser-agent: browser-agent:
build: ./browser-agent build: ./browser-agent
container_name: foodlinkk_browser_agent container_name: foodlinkk_browser_agent
@@ -162,3 +194,6 @@ services:
volumes: volumes:
gitea_data: gitea_data:
cockpit_caddy_data:
cockpit_caddy_config:
whisper_models: