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:
@@ -16,3 +16,9 @@ cockpit/static/uploads/
|
||||
.cursor/
|
||||
tmp/
|
||||
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
|
||||
|
||||
@@ -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_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
|
||||
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
|
||||
HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755")
|
||||
HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
|
||||
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
|
||||
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
|
||||
|
||||
@@ -2369,18 +2369,128 @@ class HermesSearchBody(BaseModel):
|
||||
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")
|
||||
async def hermes_stats_proxy() -> dict[str, Any]:
|
||||
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:
|
||||
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
|
||||
r.raise_for_status()
|
||||
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")
|
||||
async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]:
|
||||
"""Live conversations from Hermes Agent (not June Postgres archive)."""
|
||||
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:
|
||||
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit})
|
||||
r.raise_for_status()
|
||||
@@ -2393,10 +2503,18 @@ async def hermes_feed_proxy(
|
||||
limit: int = 80,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Live Telegram feed from Hermes Agent state.db."""
|
||||
import httpx
|
||||
params = {"limit": limit, "offset": offset}
|
||||
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if chat_id is not None:
|
||||
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:
|
||||
r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params)
|
||||
r.raise_for_status()
|
||||
@@ -2469,6 +2587,13 @@ async def hermes_pa_slot_screenshot(label: str):
|
||||
@admin_router.get("/hermes/users")
|
||||
async def hermes_users_proxy() -> dict[str, Any]:
|
||||
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]] = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
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.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"])
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
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("")
|
||||
async def analytics_page(request: Request):
|
||||
initial = collect_analytics({})
|
||||
@@ -30,5 +41,24 @@ async def analytics_api(
|
||||
days: int = Query(90, ge=7, le=365),
|
||||
):
|
||||
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))
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
@@ -225,3 +225,8 @@ async def cto_dashboard_legacy(request: Request):
|
||||
@router.get("/marketing-redirect")
|
||||
async def marketing_redirect():
|
||||
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"})
|
||||
|
||||
@@ -10,12 +10,15 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
@router.get("/settings")
|
||||
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(
|
||||
"settings.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "Settings",
|
||||
"active_tab": tab if tab in allowed else "email",
|
||||
"active_tab": active,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 datetime import datetime, timezone
|
||||
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:
|
||||
@@ -26,6 +26,343 @@ def _iso_rows(rows: list) -> list:
|
||||
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]:
|
||||
f = filters or {}
|
||||
chain = f.get("chain") or None
|
||||
@@ -39,12 +376,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
"filters": f,
|
||||
}
|
||||
|
||||
rev = _revenue_kpis()
|
||||
data["kpis"] = {
|
||||
"clients_total": _safe_count("clients"),
|
||||
"clients_active": _safe_count("clients", "stage = 'active'"),
|
||||
"deals_total": _safe_count("deals"),
|
||||
"pipeline_eur": float(
|
||||
(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"),
|
||||
"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_wholesaler": _safe_count("wholesaler_contacts"),
|
||||
"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:
|
||||
@@ -76,8 +424,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
try:
|
||||
data["events_by_agent"] = fetch_all(
|
||||
"""SELECT agent_name, COUNT(*) AS cnt FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '%s days'
|
||||
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days
|
||||
WHERE created_at >= NOW() - make_interval(days => %s)
|
||||
GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""",
|
||||
(days,),
|
||||
)
|
||||
except Exception:
|
||||
data["events_by_agent"] = []
|
||||
@@ -100,11 +449,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
data["supermarkets_by_chain"] = []
|
||||
|
||||
try:
|
||||
data["supermarkets_by_province"] = fetch_all(
|
||||
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
|
||||
if store_where
|
||||
else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12"
|
||||
)
|
||||
if store_where:
|
||||
data["supermarkets_by_province"] = fetch_all(
|
||||
f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND 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:
|
||||
data["supermarkets_by_province"] = []
|
||||
|
||||
@@ -133,8 +486,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
try:
|
||||
data["events_timeline"] = fetch_all(
|
||||
"""SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events
|
||||
WHERE created_at >= NOW() - INTERVAL '%s days'
|
||||
GROUP BY DATE(created_at) ORDER BY day ASC""" % days
|
||||
WHERE created_at >= NOW() - make_interval(days => %s)
|
||||
GROUP BY DATE(created_at) ORDER BY day ASC""",
|
||||
(days,),
|
||||
)
|
||||
except Exception:
|
||||
data["events_timeline"] = []
|
||||
@@ -150,7 +504,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
data["top_opportunities"] = fetch_all(
|
||||
"""SELECT s.chain, s.city, ros.halal_opportunity_score
|
||||
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:
|
||||
data["top_opportunities"] = []
|
||||
@@ -191,25 +545,23 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any
|
||||
except Exception:
|
||||
data["recent_deals"] = []
|
||||
|
||||
agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'"
|
||||
if agent:
|
||||
agent_where += " AND agent_name = %s"
|
||||
try:
|
||||
try:
|
||||
if agent:
|
||||
data["recent_events"] = fetch_all(
|
||||
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
{agent_where} ORDER BY created_at DESC LIMIT 25""",
|
||||
(agent,),
|
||||
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
WHERE created_at >= NOW() - make_interval(days => %s) AND agent_name = %s
|
||||
ORDER BY created_at DESC LIMIT 25""",
|
||||
(days, agent),
|
||||
)
|
||||
except Exception:
|
||||
data["recent_events"] = []
|
||||
else:
|
||||
try:
|
||||
else:
|
||||
data["recent_events"] = fetch_all(
|
||||
f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
{agent_where} ORDER BY created_at DESC LIMIT 25"""
|
||||
"""SELECT agent_name, event_type, title, status, created_at FROM agent_events
|
||||
WHERE created_at >= NOW() - make_interval(days => %s)
|
||||
ORDER BY created_at DESC LIMIT 25""",
|
||||
(days,),
|
||||
)
|
||||
except Exception:
|
||||
data["recent_events"] = []
|
||||
except Exception:
|
||||
data["recent_events"] = []
|
||||
|
||||
try:
|
||||
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):
|
||||
data[key] = _iso_rows(data[key])
|
||||
|
||||
data["pulse"] = _build_pulse(data)
|
||||
data["hermes_findings"] = list_hermes_findings(20)
|
||||
return data
|
||||
|
||||
@@ -379,16 +379,23 @@ async def chat(
|
||||
}
|
||||
|
||||
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:
|
||||
return await _chat_via_llm_router(message, channel)
|
||||
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 {
|
||||
"agent": "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:
|
||||
async with httpx.AsyncClient(timeout=620.0) as client:
|
||||
r = await client.post(
|
||||
@@ -427,8 +434,8 @@ async def chat(
|
||||
"agent_label": "Herman",
|
||||
"reply": (
|
||||
f"Orchestrator offline ({exc}). "
|
||||
f"Cloud LLM ook niet beschikbaar ({llm_exc}). "
|
||||
"Voeg OpenRouter API key toe: Instellingen → AI / LLM."
|
||||
f"Cloud/Hermes LLM ook niet beschikbaar ({llm_exc}). "
|
||||
"Check Hermes (:8642) of Instellingen → AI / LLM."
|
||||
),
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
}
|
||||
|
||||
:8643 {
|
||||
tls /certs/cert.pem /certs/key.pem
|
||||
encode gzip
|
||||
reverse_proxy cockpit:8600
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
@@ -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
|
||||
@@ -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-----
|
||||
@@ -1,50 +1,333 @@
|
||||
.analytics-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
/* Foodlinkk Intelligence Analytics — lush neo dashboard */
|
||||
.ax-shell {
|
||||
--ax-bg: #070b12;
|
||||
--ax-panel: rgba(12, 18, 28, 0.88);
|
||||
--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 {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.analytics-filters select,
|
||||
.analytics-filters input {
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.analytics-main { min-width: 0; }
|
||||
.analytics-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
.ax-shell * { box-sizing: border-box; }
|
||||
.ax-mono { font-family: var(--ax-mono); font-variant-numeric: tabular-nums; }
|
||||
.ax-muted { color: var(--ax-muted); font-size: 0.82rem; }
|
||||
.ax-shell code { font-family: var(--ax-mono); font-size: 0.72rem; color: #7dd3fc; }
|
||||
|
||||
.ax-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--ax-line);
|
||||
background:
|
||||
radial-gradient(1200px 420px at 12% -10%, rgba(56,189,248,0.22), transparent 55%),
|
||||
radial-gradient(900px 380px at 90% 0%, rgba(184,255,60,0.12), transparent 50%),
|
||||
linear-gradient(160deg, #0b1422 0%, #0a101a 55%, #0d1520 100%);
|
||||
padding: 1.4rem 1.5rem 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: ax-fade-up .55s ease both;
|
||||
}
|
||||
.analytics-kpis .kpi-card {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
.ax-hero-glow {
|
||||
position: absolute; inset: auto -20% -40% 40%; height: 70%;
|
||||
background: radial-gradient(circle, rgba(56,189,248,0.18), transparent 65%);
|
||||
pointer-events: none; filter: blur(8px);
|
||||
}
|
||||
.analytics-kpis .kpi-card strong {
|
||||
display: block;
|
||||
font-size: 1.35rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.analytics-kpis .kpi-card span {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.analytics-charts {
|
||||
.ax-hero-grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
grid-template-columns: 1.4fr auto 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
.chart-panel canvas { max-height: 220px; }
|
||||
.chart-panel h4 { margin: 0 0 0.75rem; font-size: 0.9rem; color: #cbd5e1; }
|
||||
@media (max-width: 900px) {
|
||||
.analytics-shell { grid-template-columns: 1fr; }
|
||||
.ax-eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,34 +1,72 @@
|
||||
/* Herman browser assistant — floating widget */
|
||||
.ha-fab {
|
||||
position: fixed;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
z-index: 1500;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(167, 139, 250, 0.55);
|
||||
background: linear-gradient(145deg, #1e293b, #0f1419);
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
/* Herman / Hermes chat — always-on floating dock */
|
||||
.ha-dock {
|
||||
/* wrapper only; FAB/panel are fixed to viewport */
|
||||
}
|
||||
.ha-dock-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ha-fab {
|
||||
position: fixed !important;
|
||||
right: max(1.25rem, env(safe-area-inset-right, 0px));
|
||||
bottom: max(1.25rem, env(safe-area-inset-bottom, 0px));
|
||||
z-index: 10050 !important;
|
||||
height: 52px;
|
||||
width: auto;
|
||||
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 {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 5rem;
|
||||
z-index: 1499;
|
||||
width: min(400px, calc(100vw - 2rem));
|
||||
max-height: min(560px, calc(100vh - 7rem));
|
||||
position: fixed !important;
|
||||
right: max(1rem, env(safe-area-inset-right, 0px));
|
||||
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 10049 !important;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
max-height: min(620px, calc(100vh - 8rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
@@ -41,11 +79,33 @@
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; }
|
||||
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; }
|
||||
.ha-head-title { min-width: 0; }
|
||||
.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 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 {
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -63,8 +123,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 200px;
|
||||
max-height: 320px;
|
||||
min-height: 220px;
|
||||
max-height: 360px;
|
||||
}
|
||||
.ha-bubble {
|
||||
max-width: 92%;
|
||||
@@ -103,6 +163,7 @@
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
|
||||
.ha-mic {
|
||||
@@ -122,7 +183,20 @@
|
||||
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-card { width: min(640px, 96vw); max-height: 80vh; }
|
||||
@@ -143,6 +217,88 @@
|
||||
.voice-results-table a { color: #38bdf8; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ha-panel { right: 0.5rem; left: 0.5rem; width: auto; bottom: 4.5rem; }
|
||||
.ha-fab { right: 1rem; bottom: 1rem; }
|
||||
.ha-fab {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -216,3 +216,162 @@
|
||||
.dot-in { background: var(--hm-cyan); }
|
||||
.dot-out { background: var(--hm-purple); }
|
||||
.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
@@ -1,199 +1,229 @@
|
||||
window.AnalyticsCharts = (function () {
|
||||
var charts = {};
|
||||
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 };
|
||||
})();
|
||||
|
||||
/**
|
||||
* Foodlinkk Intelligence Analytics hub
|
||||
*/
|
||||
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 {
|
||||
loading: false,
|
||||
showFilters: false,
|
||||
updatedAt: '—',
|
||||
liveLabel: 'Live',
|
||||
f: { chain: '', province: '', stage: '', agent: '', days: 90 },
|
||||
meta: {},
|
||||
data: {},
|
||||
kpis: {},
|
||||
pulse: { wins: [], opportunities: [], attention: [], score: 0 },
|
||||
findings: [],
|
||||
goals: {},
|
||||
signals: [],
|
||||
_pollStop: null,
|
||||
init() {
|
||||
var initial = window.ANALYTICS_INITIAL || {};
|
||||
this.meta = initial.filter_meta || {};
|
||||
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);
|
||||
|
||||
fmt(n) {
|
||||
return Number(n || 0).toLocaleString('nl-NL');
|
||||
},
|
||||
|
||||
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() {
|
||||
var p = new URLSearchParams();
|
||||
Object.entries(this.f).forEach(function (e) {
|
||||
if (e[1] !== null && e[1] !== '' && e[1] !== undefined) p.set(e[0], e[1]);
|
||||
const p = new URLSearchParams();
|
||||
Object.entries(this.f).forEach(([k, v]) => {
|
||||
if (v !== null && v !== '' && v !== undefined) p.set(k, v);
|
||||
});
|
||||
return p.toString();
|
||||
},
|
||||
|
||||
async refresh(toast) {
|
||||
this.loading = true;
|
||||
try {
|
||||
var data = await fetch('/analytics/api/data?' + this.params()).then(function (r) { return r.json(); });
|
||||
this.meta = data.filter_meta || this.meta;
|
||||
AnalyticsCharts.render(data);
|
||||
this.updatedAt = (data.generated_at || '').substring(0, 19).replace('T', ' ');
|
||||
if (toast !== false) Cockpit.toast('Analytics bijgewerkt', 'success');
|
||||
const data = await fetch('/analytics/api/data?' + this.params()).then((r) => r.json());
|
||||
this.applyData(data);
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast('Intelligence bijgewerkt', 'success');
|
||||
} catch (e) {
|
||||
if (toast !== false) Cockpit.toast(e.message, 'error');
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast(e.message || 'Laden mislukt', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.f = { chain: '', province: '', stage: '', agent: '', days: 90 };
|
||||
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 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,87 @@ window.HermanAssistant = (function () {
|
||||
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) {
|
||||
opts = opts || {};
|
||||
return {
|
||||
@@ -30,12 +111,20 @@ window.HermanAssistant = (function () {
|
||||
hidden: false,
|
||||
status: 'idle',
|
||||
statusDetail: '',
|
||||
connOk: null,
|
||||
connLabel: 'Herman',
|
||||
livePreview: '',
|
||||
liveInterim: false,
|
||||
recording: false,
|
||||
busy: false,
|
||||
speakReply: true,
|
||||
speakReply: false,
|
||||
handsFreeMode: false,
|
||||
httpsAudioPrompt: false,
|
||||
fabPos: null,
|
||||
fabDragging: false,
|
||||
_fabMoved: false,
|
||||
_fabPointerId: null,
|
||||
_fabOrigin: null,
|
||||
useBrowserPreview: !!SpeechRecognition,
|
||||
turns: [],
|
||||
manualText: '',
|
||||
@@ -65,7 +154,209 @@ window.HermanAssistant = (function () {
|
||||
|
||||
toggleOpen: function () {
|
||||
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 () {
|
||||
@@ -78,6 +369,7 @@ window.HermanAssistant = (function () {
|
||||
|
||||
addTurn: function (role, text, meta) {
|
||||
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
|
||||
this.persistChat();
|
||||
this.scrollLog();
|
||||
},
|
||||
|
||||
@@ -86,8 +378,10 @@ window.HermanAssistant = (function () {
|
||||
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
|
||||
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
|
||||
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, {
|
||||
agent_label: h.agent_label,
|
||||
agent_label: label,
|
||||
delegated: h.delegated_agents,
|
||||
routing_reason: h.routing_reason,
|
||||
});
|
||||
@@ -111,6 +405,8 @@ window.HermanAssistant = (function () {
|
||||
window.speechSynthesis.cancel();
|
||||
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
|
||||
u.lang = 'nl-NL';
|
||||
var voice = pickMaartenVoice();
|
||||
if (voice) u.voice = voice;
|
||||
u.onend = function () { self.maybeResumeListening(300); };
|
||||
window.speechSynthesis.speak(u);
|
||||
} else {
|
||||
@@ -208,7 +504,12 @@ window.HermanAssistant = (function () {
|
||||
|
||||
async startListen() {
|
||||
if (this.recording || this.busy) return;
|
||||
if (!this.micSecureOk()) {
|
||||
this.offerHttpsAudio();
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
this.httpsAudioPrompt = false;
|
||||
this.livePreview = '';
|
||||
this.status = 'listening';
|
||||
this.statusDetail = 'Luisteren…';
|
||||
@@ -240,8 +541,12 @@ window.HermanAssistant = (function () {
|
||||
}
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
Cockpit.toast('Microfoon: ' + e.message, 'error');
|
||||
var msg = e.message || String(e);
|
||||
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.statusDetail = '';
|
||||
} 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.statusDetail = e.message;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
@@ -301,19 +614,26 @@ window.HermanAssistant = (function () {
|
||||
async sendText(confirmActionId) {
|
||||
var text = (this.manualText || '').trim();
|
||||
if (!text) return;
|
||||
this.addTurn('user', text, { source: 'text' });
|
||||
this.manualText = '';
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
this.statusDetail = 'Herman denkt…';
|
||||
try {
|
||||
var body = { message: text, channel: this.channel, session_id: this.sessionId };
|
||||
if (confirmActionId) body.confirm_action_id = confirmActionId;
|
||||
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.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) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
this.markConn(false, 'Herman offline');
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
@@ -331,10 +651,21 @@ window.HermanAssistant = (function () {
|
||||
s.initWidget = function () {
|
||||
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
|
||||
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
|
||||
this.applyPrefs();
|
||||
this.loadFabPos();
|
||||
pickMaartenVoice();
|
||||
try {
|
||||
var raw = localStorage.getItem('herman_assistant_open');
|
||||
var raw = localStorage.getItem(STORAGE_OPEN);
|
||||
if (raw === '1') this.open = true;
|
||||
} 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;
|
||||
},
|
||||
@@ -345,6 +676,8 @@ window.HermanAssistant = (function () {
|
||||
s.lastImage = '';
|
||||
s.initChat = function () {
|
||||
this.open = true;
|
||||
this.applyPrefs();
|
||||
pickMaartenVoice();
|
||||
};
|
||||
s.generateImage = async function () {
|
||||
if (!this.imagePrompt.trim()) return;
|
||||
@@ -371,3 +704,11 @@ window.HermanAssistant = (function () {
|
||||
function hermanBrowserWidget() {
|
||||
return HermanAssistant.browserWidget();
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && typeof Alpine.data === 'function') {
|
||||
Alpine.data('hermanBrowserWidget', function () {
|
||||
return HermanAssistant.browserWidget();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+183
-296
@@ -1,346 +1,233 @@
|
||||
/**
|
||||
* Hermes Neo Command Center
|
||||
* Hermes technical KPI dashboard + bucket explorer popup
|
||||
*/
|
||||
function hermesControl() {
|
||||
const PA_SITES = ['Airbnb', 'Booking.com', 'DuckDuckGo', 'HolidayCheck'];
|
||||
|
||||
/** Canonical team — never duplicate role as name */
|
||||
const ROSTER = [
|
||||
{ chat_id: 8859782446, role: 'CEO', name: 'Aïssa' },
|
||||
{ chat_id: 789036463, role: 'CTO', name: 'Mo' },
|
||||
];
|
||||
function hermesKpiDashboard() {
|
||||
const SOURCE_LABELS = {
|
||||
telegram: 'Telegram',
|
||||
api_server: 'Cockpit API',
|
||||
tui: 'TUI',
|
||||
cron: 'Cron',
|
||||
subagent: 'Subagent',
|
||||
};
|
||||
|
||||
return {
|
||||
tab: 'feed',
|
||||
live: true,
|
||||
stats: { conversations: 0, messages: 0, edges: 0, embeddings: 0, inbound: 0, outbound: 0 },
|
||||
conversations: [],
|
||||
messages: [],
|
||||
users: [],
|
||||
agentEvents: [],
|
||||
selectedChatId: null,
|
||||
graphChatFilter: '',
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
searchRan: false,
|
||||
graphNetwork: null,
|
||||
pollTimer: null,
|
||||
paPollTimer: null,
|
||||
paPolling: false,
|
||||
paLive: { status: 'idle', query: '', slots: [] },
|
||||
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,
|
||||
}));
|
||||
loading: false,
|
||||
kpis: { online: false, overview: {}, buckets: [], tools_top: [], sources: [], herman_cockpit: [] },
|
||||
chartTools: null,
|
||||
chartSources: null,
|
||||
_timer: null,
|
||||
modal: {
|
||||
open: false,
|
||||
loading: false,
|
||||
previewLoading: false,
|
||||
key: null,
|
||||
title: '',
|
||||
tab: 'activity',
|
||||
toolFilter: null,
|
||||
detail: { events: [], files: [], parts: {}, count: 0 },
|
||||
explorer: { path: '', parent: null, entries: [] },
|
||||
selectedEvent: null,
|
||||
preview: null,
|
||||
},
|
||||
|
||||
get paLiveUser() {
|
||||
const cid = this.paLive.chat_id;
|
||||
if (!cid) return '';
|
||||
const m = ROSTER.find(r => r.chat_id === cid);
|
||||
return m ? `${m.role} — ${m.name}` : this.paLive.user_name || '';
|
||||
get ov() { return this.kpis.overview || {}; },
|
||||
|
||||
fmt(n) {
|
||||
const v = Number(n || 0);
|
||||
return v.toLocaleString('nl-NL');
|
||||
},
|
||||
|
||||
get paSlots() {
|
||||
const slots = this.paLive.slots || [];
|
||||
if (slots.length >= 4) return slots;
|
||||
const byLabel = {};
|
||||
slots.forEach(s => { byLabel[s.label] = s; });
|
||||
return PA_SITES.map(label => byLabel[label] || {
|
||||
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(); }
|
||||
fmtBytes(n) {
|
||||
if (n == null || n === '') return '';
|
||||
const v = Number(n);
|
||||
if (v < 1024) return v + ' B';
|
||||
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB';
|
||||
return (v / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch { return iso; }
|
||||
} catch (e) { return String(iso).slice(0, 19); }
|
||||
},
|
||||
|
||||
paStatusLabel() {
|
||||
const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' };
|
||||
return m[this.paLive.status] || this.paLive.status || 'Idle';
|
||||
partsLine(parts) {
|
||||
if (!parts) return '';
|
||||
return Object.keys(parts)
|
||||
.filter((k) => parts[k] > 0)
|
||||
.map((k) => parts[k] + ' ' + k.replace(/_/g, ' '))
|
||||
.join(' · ');
|
||||
},
|
||||
|
||||
slotStatusLabel(s) {
|
||||
const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' };
|
||||
return m[s] || s || 'Idle';
|
||||
sourceLabel(s) { return SOURCE_LABELS[s] || s || '—'; },
|
||||
|
||||
async init() {
|
||||
await this.refresh();
|
||||
this._timer = setInterval(() => this.refresh(true), 15000);
|
||||
},
|
||||
|
||||
slotScreenshotUrl(label) {
|
||||
if (!label) return '';
|
||||
return `/api/admin/hermes/pa/live/${encodeURIComponent(label)}/screenshot.jpg?t=${this.paScreenshotTs}`;
|
||||
},
|
||||
|
||||
async selectChat(chatId) {
|
||||
this.selectedChatId = chatId;
|
||||
await this.refreshFeed();
|
||||
},
|
||||
|
||||
async refreshStats() {
|
||||
async refresh(quiet) {
|
||||
if (!quiet) this.loading = true;
|
||||
try {
|
||||
const data = await Cockpit.api('/hermes/stats');
|
||||
this.stats = data.stats || this.stats;
|
||||
this.agentEvents = data.agent_events || [];
|
||||
this.updateCharts();
|
||||
} catch (e) { console.warn('stats', e); }
|
||||
const data = await Cockpit.api('/hermes/kpis');
|
||||
this.kpis = data || this.kpis;
|
||||
this.$nextTick(() => this.renderCharts());
|
||||
} catch (e) {
|
||||
if (!quiet && window.Cockpit) Cockpit.toast(e.message || 'KPI laden mislukt', 'error');
|
||||
this.kpis.online = false;
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async refreshUsers() {
|
||||
try {
|
||||
const data = await Cockpit.api('/hermes/users');
|
||||
this.users = data.users || [];
|
||||
} catch (e) { console.warn('users', e); }
|
||||
async openBucket(b) {
|
||||
this.modal.open = true;
|
||||
this.modal.key = b.key;
|
||||
this.modal.title = b.label;
|
||||
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() {
|
||||
try {
|
||||
const data = await Cockpit.api('/hermes/conversations');
|
||||
this.conversations = data.items || [];
|
||||
} catch (e) { console.warn('conversations', e); }
|
||||
closeModal() {
|
||||
this.modal.open = false;
|
||||
this.modal.preview = null;
|
||||
this.modal.selectedEvent = null;
|
||||
},
|
||||
|
||||
async refreshFeed(quiet) {
|
||||
try {
|
||||
let path = '/hermes/feed?limit=80';
|
||||
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 setToolFilter(tool) {
|
||||
this.modal.toolFilter = tool;
|
||||
await this.loadBucketDetail();
|
||||
},
|
||||
|
||||
async refreshPaLive(quiet) {
|
||||
async loadBucketDetail() {
|
||||
if (!this.modal.key) return;
|
||||
this.modal.loading = true;
|
||||
try {
|
||||
const data = await Cockpit.api('/hermes/pa/live');
|
||||
this.paLive = data;
|
||||
this.paScreenshotTs = Date.now();
|
||||
if (data.hermes) {
|
||||
this.hermesOnline = data.hermes.online !== false;
|
||||
this.hermesStatus = data.hermes;
|
||||
const q = new URLSearchParams({ limit: '80' });
|
||||
if (this.modal.toolFilter) q.set('tool', this.modal.toolFilter);
|
||||
const data = await Cockpit.api('/hermes/bucket/' + encodeURIComponent(this.modal.key) + '?' + q.toString());
|
||||
this.modal.detail = data || this.modal.detail;
|
||||
if (!(this.modal.detail.events || []).length) {
|
||||
this.modal.selectedEvent = null;
|
||||
}
|
||||
} 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 {
|
||||
let data;
|
||||
if (this.graphChatFilter) {
|
||||
data = await Cockpit.api('/hermes/graph/' + this.graphChatFilter + '?limit=80');
|
||||
} else {
|
||||
data = await Cockpit.api('/hermes/graph?limit=100');
|
||||
}
|
||||
this.renderGraph(data);
|
||||
const q = path ? ('?path=' + encodeURIComponent(path)) : '';
|
||||
const data = await Cockpit.api('/hermes/explorer' + q);
|
||||
this.modal.explorer = data || { path: '', parent: null, entries: [] };
|
||||
} 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) {
|
||||
const el = document.getElementById('hermes-graph');
|
||||
if (!el || typeof vis === 'undefined') return;
|
||||
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 },
|
||||
});
|
||||
explorerUp() {
|
||||
if (this.modal.explorer.parent) this.loadExplorer(this.modal.explorer.parent);
|
||||
else this.loadExplorer('');
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
s = String(s || '');
|
||||
return s.length > n ? s.slice(0, n) + '…' : s;
|
||||
},
|
||||
|
||||
async runSearch() {
|
||||
if (!this.searchQuery || this.searchQuery.length < 2) {
|
||||
Cockpit.toast('Minimaal 2 tekens', 'warn');
|
||||
return;
|
||||
}
|
||||
this.searchRan = true;
|
||||
async openFile(path) {
|
||||
if (!path) return;
|
||||
this.modal.previewLoading = true;
|
||||
this.modal.selectedEvent = null;
|
||||
try {
|
||||
const data = await Cockpit.api('/hermes/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: this.searchQuery, chat_id: this.selectedChatId || null, limit: 15 }),
|
||||
const data = await Cockpit.api('/hermes/explorer/file?path=' + encodeURIComponent(path));
|
||||
this.modal.preview = data || { path, error: 'Geen data', name: path.split('/').pop() };
|
||||
if (this.modal.key === 'files' && this.modal.tab === 'activity') {
|
||||
// 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) {
|
||||
this.actionBusy = true;
|
||||
this.actionMsg = '';
|
||||
try {
|
||||
const data = await Cockpit.api('/hermes/actions/' + action, { method: 'POST' });
|
||||
this.actionMsg = data.output || '✅ Actie uitgevoerd';
|
||||
Cockpit.toast(this.actionMsg.slice(0, 80), 'success');
|
||||
await this.refreshStats();
|
||||
} catch (e) {
|
||||
this.actionMsg = '❌ ' + e.message;
|
||||
Cockpit.toast(this.actionMsg, 'error');
|
||||
} finally {
|
||||
this.actionBusy = false;
|
||||
const srcCtx = document.getElementById('hm-chart-sources');
|
||||
if (srcCtx) {
|
||||
if (this.chartSources) this.chartSources.destroy();
|
||||
this.chartSources = new Chart(srcCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sources.map((s) => this.sourceLabel(s.source)),
|
||||
datasets: [{
|
||||
data: sources.map((s) => s.tools || 0),
|
||||
backgroundColor: ['#0ea5e9', '#a855f7', '#f59e0b', '#22c55e', '#f43f5e', '#64748b'],
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 10, font: { size: 10 } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hermesControl() { return hermesKpiDashboard(); }
|
||||
|
||||
@@ -33,7 +33,7 @@ window.VoiceLive = (function () {
|
||||
busy: false,
|
||||
conversationMode: true,
|
||||
handsFreeMode: false,
|
||||
speakReply: true,
|
||||
speakReply: false,
|
||||
useBrowserPreview: !!SpeechRecognition,
|
||||
pipeline: [],
|
||||
feed: [],
|
||||
@@ -162,6 +162,18 @@ window.VoiceLive = (function () {
|
||||
window.speechSynthesis.cancel();
|
||||
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
|
||||
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); };
|
||||
window.speechSynthesis.speak(u);
|
||||
} else {
|
||||
|
||||
@@ -1,75 +1,250 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Intelligence · Analytics{% endblock %}
|
||||
{% 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>
|
||||
<link rel="stylesheet" href="/static/css/analytics.css?v=1" />
|
||||
<link rel="stylesheet" href="/static/css/analytics.css?v=20" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="analytics-shell" x-data="analyticsHub()" x-init="init()">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Analytics Command Center <span class="live-badge" x-text="liveLabel">Live</span></h1>
|
||||
<p class="subtitle">Volledige database analyse · filter · charts · realtime</p>
|
||||
<small class="muted" x-text="'Laatst: '+updatedAt"></small>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="refresh()" :disabled="loading">↻ Ververs</button>
|
||||
<a class="btn btn-sm" href="/">Herman →</a>
|
||||
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="analytics-filters panel">
|
||||
<h3>Filters</h3>
|
||||
<label>Keten<select x-model="f.chain" @change="refresh()"><option value="">Alle</option>
|
||||
<template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
|
||||
</select></label>
|
||||
<label>Provincie<select x-model="f.province" @change="refresh()"><option value="">Alle</option>
|
||||
<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()"><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>Agent<select x-model="f.agent" @change="refresh()"><option value="">Alle</option>
|
||||
<template x-for="a in meta.agents||[]" :key="a.agent_name"><option :value="a.agent_name" x-text="a.agent_name"></option></template>
|
||||
</select></label>
|
||||
<label>Periode (dagen)<input type="number" min="7" max="365" x-model.number="f.days" @change="refresh()"></label>
|
||||
<button class="btn btn-sm btn-block" @click="resetFilters()">Reset</button>
|
||||
</aside>
|
||||
|
||||
<div class="analytics-main">
|
||||
<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 class="ax-shell" x-data="analyticsHub()" x-init="init()">
|
||||
<!-- Hero -->
|
||||
<section class="ax-hero">
|
||||
<div class="ax-hero-glow" aria-hidden="true"></div>
|
||||
<div class="ax-hero-grid">
|
||||
<div class="ax-hero-copy">
|
||||
<p class="ax-eyebrow">Foodlinkk · Intelligence</p>
|
||||
<h1>Speelveld<span class="ax-live" x-text="liveLabel">Live</span></h1>
|
||||
<p class="ax-lead" x-text="pulse.sub || 'Wat goed gaat en waar de kansen liggen.'"></p>
|
||||
<p class="ax-goal" x-show="goals.tagline || goals.vision_text">
|
||||
<span x-text="goals.tagline || ''"></span>
|
||||
<span x-show="goals.vision_text" x-text="' · ' + shortGoal(goals.vision_text)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ax-pulse-orb" :class="'tone-' + (pulse.tone || 'mid')">
|
||||
<svg viewBox="0 0 120 120" class="ax-ring">
|
||||
<circle cx="60" cy="60" r="52" class="ax-ring-track"></circle>
|
||||
<circle cx="60" cy="60" r="52" class="ax-ring-value"
|
||||
:style="'stroke-dasharray:' + ringDash(pulse.score) + ' 999'"></circle>
|
||||
</svg>
|
||||
<div class="ax-pulse-core">
|
||||
<strong class="ax-mono" x-text="pulse.score ?? '—'"></strong>
|
||||
<span>pulse</span>
|
||||
</div>
|
||||
<p class="ax-pulse-label" x-text="pulse.label || ''"></p>
|
||||
</div>
|
||||
<div class="ax-hero-actions">
|
||||
<button type="button" class="ax-btn ax-btn-primary" @click="refresh()" :disabled="loading">↻ Ververs</button>
|
||||
<button type="button" class="ax-btn" @click="showFilters = !showFilters" x-text="showFilters ? 'Filters verbergen' : 'Filters'"></button>
|
||||
<a class="ax-btn" href="/retail">Retail 360</a>
|
||||
<a class="ax-btn" href="/revenue-cockpit">Revenue</a>
|
||||
<small class="ax-updated" x-text="'Sync ' + updatedAt"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<section class="panel"><h2>Recente deals</h2>
|
||||
<table class="data-table"><thead><tr><th>Titel</th><th>Stage</th><th>Waarde</th></tr></thead>
|
||||
<tbody id="analytics-deals-table"></tbody></table>
|
||||
</section>
|
||||
<section class="panel"><h2>Agent events</h2>
|
||||
<table class="data-table"><thead><tr><th>Agent</th><th>Event</th><th>Tijd</th></tr></thead>
|
||||
<tbody id="analytics-events-table"></tbody></table>
|
||||
</section>
|
||||
<div class="ax-filters" x-show="showFilters" x-cloak>
|
||||
<label>Keten
|
||||
<select x-model="f.chain" @change="refresh(false)"><option value="">Alle</option>
|
||||
<template x-for="c in meta.chains||[]" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
<label>Provincie
|
||||
<select x-model="f.province" @change="refresh(false)"><option value="">Alle</option>
|
||||
<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>
|
||||
</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>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<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 %}
|
||||
|
||||
+51
-19
@@ -21,12 +21,13 @@
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
|
||||
<script defer src="/static/js/herman-assistant.js?v=16"></script>
|
||||
<script defer src="/static/js/alpine.min.js?v=2"></script>
|
||||
<script defer src="/static/js/htmx.min.js?v=2"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=11" />
|
||||
<link rel="stylesheet" href="/static/css/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>
|
||||
<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">
|
||||
@@ -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="/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="/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>
|
||||
</div>
|
||||
|
||||
@@ -111,14 +111,32 @@
|
||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
||||
</div>
|
||||
|
||||
<!-- Herman browser assistant (elke pagina) -->
|
||||
<div x-data="hermanBrowserWidget()" x-init="initWidget()" x-show="!hidden" x-cloak>
|
||||
<button type="button" class="ha-fab" :class="{ 'is-open': open }" @click="toggleOpen()" title="Praat met Herman" aria-label="Herman assistant">💬</button>
|
||||
<div class="ha-panel" x-show="open" x-transition @click.outside="open = false">
|
||||
<!-- Herman chat — altijd vast onderin zichtbaar -->
|
||||
<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, '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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
@@ -132,23 +150,39 @@
|
||||
<div class="ha-log" x-ref="chatLog">
|
||||
<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-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>
|
||||
</div>
|
||||
</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 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-input-row">
|
||||
<div class="ha-toggles">
|
||||
<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()"
|
||||
@mousedown.prevent="onMicDown()" @mouseup.prevent="onMicUp()"
|
||||
@touchstart.prevent="onMicDown()" @touchend.prevent="onMicUp()"
|
||||
@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" />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="sendText()" :disabled="busy || !manualText.trim()">→</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-pulse" :disabled="busy || !manualText.trim()">→</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="ei-modal-root ha-results-modal" x-show="resultsOpen" @click.self="closeResultsModal()">
|
||||
<div class="ei-modal-card ha-results-card" @click.stop>
|
||||
@@ -189,8 +223,6 @@
|
||||
</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/cockpit.js?v=2"></script>
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
@@ -232,7 +264,7 @@
|
||||
_offset: 0,
|
||||
async initApp() {
|
||||
this.brand = window.FoodlinkkBrand.detect(location.pathname);
|
||||
if (!localStorage.getItem('flk-cache-v3')) {
|
||||
if (!localStorage.getItem('flk-cache-v4')) {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
@@ -241,7 +273,7 @@
|
||||
const keys = await caches.keys();
|
||||
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();
|
||||
this.locale = window.I18n ? window.I18n.locale() : 'nl';
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
</div>
|
||||
|
||||
<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="handsFreeMode" /> Hands-free na antwoord</label>
|
||||
<label><input type="checkbox" x-model="speakReply" @change="persistPrefs()" /> Voorlezen (Maarten)</label>
|
||||
<label><input type="checkbox" x-model="handsFreeMode" @change="onHandsFreeChange()" /> Hands-free praten</label>
|
||||
</div>
|
||||
|
||||
<div class="voice-manual-row">
|
||||
@@ -107,6 +107,6 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
+230
-206
@@ -1,244 +1,268 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Hermes · Telegram Command Center{% endblock %}
|
||||
|
||||
{% block title %}Hermes · KPI's{% endblock %}
|
||||
{% 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://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<h1>Hermes — Telegram Command Center</h1>
|
||||
<p class="hm-sub">Live feed · 4 PA browsers · second brain · volledige controle</p>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<!-- Neo KPI row -->
|
||||
<!-- Zone A -->
|
||||
<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-ring" style="--ring-color:#00e5ff;--ring-pct:72%"><div class="hm-neo-ring-inner">💬</div></div>
|
||||
<div><div class="hm-neo-kpi-label">Conversaties</div><div class="hm-neo-kpi-sub">Telegram chats</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">Hermes</div><div class="hm-neo-kpi-sub">Agent status</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.conversations">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="hm-neo-kpi-value" style="font-size:1.25rem" x-text="kpis.online ? 'Online' : 'Offline'"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<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><div class="hm-neo-kpi-label">Berichten</div><div class="hm-neo-kpi-sub">in + out</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">Telegram</div><div class="hm-neo-kpi-sub">actieve chats</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.messages">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="hm-neo-kpi-value" x-text="ov.telegram_sessions_active || 0"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<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><div class="hm-neo-kpi-label">Relaties</div><div class="hm-neo-kpi-sub">graph edges</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">Tool-calls</div><div class="hm-neo-kpi-sub">totaal werk</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.edges">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="hm-neo-kpi-value" x-text="fmt(ov.tool_calls_total)"></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi">
|
||||
<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><div class="hm-neo-kpi-label">Vectors</div><div class="hm-neo-kpi-sub">pgvector</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">Tekst in / uit</div><div class="hm-neo-kpi-sub">user · agent</div></div>
|
||||
</div>
|
||||
<div class="hm-neo-kpi-value" x-text="stats.embeddings">0</div>
|
||||
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="hm-neo-kpi-value" style="font-size:1.15rem" x-text="(ov.text_in||0) + ' / ' + (ov.text_out||0)"></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>
|
||||
|
||||
<!-- Neo charts -->
|
||||
<div class="hm-neo-charts">
|
||||
<!-- Zone B -->
|
||||
<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-head">
|
||||
<h3><span class="hm-live-dot"></span> Berichten per richting</h3>
|
||||
<small>klik voor detail</small>
|
||||
</div>
|
||||
<div class="hm-chart-wrap"><canvas id="hm-chart-direction"></canvas></div>
|
||||
</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>
|
||||
<div class="hm-chart-head"><h3>Top tools</h3><small>meest gebruikt</small></div>
|
||||
<div class="hm-chart-wrap"><canvas id="hm-chart-tools"></canvas></div>
|
||||
<ul class="hm-tool-list">
|
||||
<template x-for="t in (kpis.tools_top || []).slice(0,8)" :key="t.name">
|
||||
<li><span x-text="t.name"></span><strong x-text="fmt(t.count)"></strong></li>
|
||||
</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>
|
||||
<div class="hermes-actions">
|
||||
<button class="btn btn-primary" @click="runAction('evening-briefing')" :disabled="actionBusy">🌙 Avondbriefing → CEO</button>
|
||||
<button class="btn" @click="runAction('morning-briefing')" :disabled="actionBusy">☀️ Ochtendbriefing</button>
|
||||
<button class="btn" @click="runAction('resume-jobs')" :disabled="actionBusy">🔧 Herstel jobs</button>
|
||||
</div>
|
||||
<div class="hm-chart-panel">
|
||||
<div class="hm-chart-head"><h3>Bronnen</h3><small>via welk kanaal</small></div>
|
||||
<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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/hermes-ui.js"></script>
|
||||
<script src="/static/js/hermes-ui.js?v=11"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<nav class="settings-tabs">
|
||||
<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=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=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
|
||||
</nav>
|
||||
@@ -114,130 +113,6 @@
|
||||
</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' %}
|
||||
<section class="panel settings-panel hm-neo" x-data="permissionsPage()" x-init="loadPerms()">
|
||||
<div class="settings-panel-head">
|
||||
@@ -553,145 +428,5 @@ function defaultForm() {
|
||||
};
|
||||
}
|
||||
</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 %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -63,7 +63,7 @@
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" x-model="speakReply" />
|
||||
<span>Antwoord voorlezen (browser TTS)</span>
|
||||
<span>Voorlezen (Maarten)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -164,5 +164,5 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/voice-live.js?v=3"></script>
|
||||
<script src="/static/js/voice-live.js?v=4"></script>
|
||||
{% endblock %}
|
||||
|
||||
+36
-1
@@ -47,6 +47,18 @@ services:
|
||||
- "foodlinkk_db:10.4.7.18"
|
||||
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:
|
||||
build: ./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_KEY: ${HERMES_API_KEY:-}
|
||||
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_PREVIEW_BASE: http://10.4.7.27:8080
|
||||
NAS_CLIENTS_ROOT: /data/nas-clients
|
||||
NAS_SHARE_ROOT: /data/nas-share
|
||||
ports:
|
||||
- "8600:8600"
|
||||
- "8610:8600"
|
||||
volumes:
|
||||
- /home/aissa/nas-clients:/data/nas-clients
|
||||
- /mnt/synology:/data/nas-share:ro
|
||||
depends_on:
|
||||
- tools-api
|
||||
- whisper
|
||||
extra_hosts:
|
||||
- "foodlinkk_db:10.4.7.18"
|
||||
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:
|
||||
build: ./browser-agent
|
||||
container_name: foodlinkk_browser_agent
|
||||
@@ -162,3 +194,6 @@ services:
|
||||
|
||||
volumes:
|
||||
gitea_data:
|
||||
cockpit_caddy_data:
|
||||
cockpit_caddy_config:
|
||||
whisper_models:
|
||||
|
||||
Reference in New Issue
Block a user