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

Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
This commit is contained in:
Aissa
2026-08-01 17:50:45 +00:00
parent 62239f0a4e
commit 5e43b6b246
28 changed files with 2686 additions and 1149 deletions
+237 -62
View File
@@ -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
View File
@@ -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';
+3 -3
View File
@@ -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
View File
@@ -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 %}
-265
View File
@@ -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 %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Certificaat · Cockpit{% endblock %}
{% block content %}
<div class="card" style="max-width:640px;margin:2rem auto;padding:1.25rem 1.4rem">
<h1 style="margin-top:0;font-size:1.25rem">Eenmalig: vertrouw Cockpit</h1>
<p class="page-tip">Daarna geen “onveilige website”-melding meer, en de microfoon werkt direct.</p>
<ol style="line-height:1.55;padding-left:1.2rem;color:#e2e8f0">
<li>Download het CA-certificaat:
<a class="btn btn-sm btn-pulse" href="http://10.4.7.18:8600/cockpit-local-ca.crt">Download CA</a>
</li>
<li>Windows: dubbelklik → <strong>Certificaat installeren</strong> →
<em>Lokale computer</em> → plaats in <strong>Vertrouwde basiscertificeringsautoriteiten</strong> → Voltooien.</li>
<li>Of PowerShell als Administrator:
<code style="display:block;margin:.4rem 0;padding:.5rem;background:#0b1220;border-radius:6px;font-size:.75rem">certutil -addstore -f Root \$env:USERPROFILE\\Downloads\\foodlinkk-cockpit-ca.crt</code>
</li>
<li>Chrome/Edge volledig afsluiten en opnieuw openen, daarna
<a href="https://10.4.7.18:8643/">https://10.4.7.18:8643/</a>
</li>
</ol>
</div>
{% endblock %}
+2 -2
View File
@@ -63,7 +63,7 @@
</label>
<label>
<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 %}