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
+31
View File
@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL
BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k
bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD
DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ
KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL
q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ
BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp
a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO
1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu
8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK
tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ
2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa
/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA
W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6
jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa
cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY
qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7
7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW
A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV
r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp
33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn
Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A
ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5
ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe
A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W
dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa
UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0
c9ibkVARig==
-----END CERTIFICATE-----
+326 -43
View File
@@ -1,50 +1,333 @@
.analytics-shell {
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; }
}
+188 -32
View File
@@ -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;
}
+159
View File
@@ -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
View File
@@ -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 } },
},
},
});
},
};
}
+350 -9
View File
@@ -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
View File
@@ -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(); }
+13 -1
View File
@@ -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 {