From 26fe76afdd53a986a5dda243715e5a7df8cbbc74 Mon Sep 17 00:00:00 2001
From: sysops
Date: Tue, 23 Jun 2026 10:04:23 +0000
Subject: [PATCH] =?UTF-8?q?SysOps:=20voice-agy-webbuilder-backup=20?=
=?UTF-8?q?=E2=80=94=202026-06-23=2010:04=20UTC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
028_docling_results.sql | 21 +
029_document_workspace.sql | 10 +
030_document_links_360.sql | 30 +
admin_api.py | 2265 ++++++++++++++++
auto_ingest.py | 130 +
browser-agent/app/main.py | 10 +-
client_360.py | 147 +
cockpit/app/config.py | 4 +
cockpit/app/main.py | 22 +-
cockpit/app/routes/admin_api.py | 1191 +++++++-
cockpit/app/routes/agents.py | 2 +-
cockpit/app/routes/agents_api.py | 389 ++-
cockpit/app/routes/dashboard.py | 60 +-
cockpit/app/routes/documents.py | 3 +-
cockpit/app/routes/export_intel.py | 209 ++
cockpit/app/routes/herman.py | 6 +-
cockpit/app/routes/revenue_cockpit.py | 230 ++
cockpit/app/routes/settings.py | 2 +-
cockpit/app/routes/settings_api.py | 115 +
cockpit/app/services/admin_api.py | 2277 ++++++++++++++++
cockpit/app/services/agent_approvals.py | 45 +
cockpit/app/services/agent_events_log.py | 48 +
cockpit/app/services/agent_integration.py | 140 +
cockpit/app/services/agent_names.py | 22 +
cockpit/app/services/agent_terminal.py | 456 ++++
cockpit/app/services/auto_ingest.py | 130 +
cockpit/app/services/briefing.py | 30 +-
cockpit/app/services/client_360.py | 147 +
cockpit/app/services/docker-compose.yml | 152 ++
cockpit/app/services/documents-v2.js | 1271 +++++++++
cockpit/app/services/excel_import.py | 145 +
cockpit/app/services/herman.py | 151 +-
cockpit/app/services/llm_router.py | 277 ++
cockpit/app/services/monitor.py | 591 +++-
cockpit/app/services/ollama.py | 4 +-
cockpit/app/services/requirements.txt | 11 +
cockpit/app/services/revenue_cockpit.py | 424 +++
cockpit/app/services/voice_export_actions.py | 594 ++++
cockpit/app/services/webbuilder_agent.py | 267 ++
cockpit/base.html | 242 ++
cockpit/cto-dashboard.html | 195 ++
cockpit/dashboard.html | 541 ++++
cockpit/dashboard.py | 216 ++
cockpit/doc-tabs.js | 66 +
cockpit/documents-v2.css | 893 ++++++
cockpit/documents-v2.js | 1607 +++++++++++
cockpit/documents.html | 674 +++++
cockpit/en.json | 128 +
cockpit/manifest.json | 26 +
cockpit/nl.json | 128 +
cockpit/requirements.txt | 1 +
cockpit/revenue-cockpit.css | 501 ++++
cockpit/revenue-cockpit.js | 422 +++
cockpit/revenue_cockpit.html | 269 ++
cockpit/static/css/agents-mesh.css | 524 +++-
cockpit/static/css/app-sidebar-neo.css | 177 ++
cockpit/static/css/clients-crm.css | 227 +-
cockpit/static/css/documents-v2.css | 877 ++++++
cockpit/static/css/documents-workbench.css | 51 +
cockpit/static/css/export-intel-tabs.css | 56 +
cockpit/static/css/export-intel.css | 368 +++
cockpit/static/css/herman-assistant.css | 148 +
cockpit/static/css/herman-dashboard.css | 94 +
cockpit/static/css/mobile.css | 10 -
cockpit/static/css/parse-intel.css | 442 +++
cockpit/static/css/revenue-cockpit.css | 493 ++++
cockpit/static/css/revenue-cockpit.js | 422 +++
cockpit/static/css/revenue_cockpit.html | 262 ++
cockpit/static/css/topnav-neo.css | 13 +
cockpit/static/css/vertical-tabs.css | 303 ++-
cockpit/static/css/voice-live.css | 179 ++
cockpit/static/i18n/en.json | 13 +-
cockpit/static/i18n/manifest.json | 26 +
cockpit/static/i18n/nl.json | 13 +-
cockpit/static/js/agent-avatars.js | 1 +
cockpit/static/js/agents-mesh.js | 839 +++++-
cockpit/static/js/agents-terminals.js | 244 ++
cockpit/static/js/alpine.min.js | 5 +
cockpit/static/js/briefing-charts.js | 43 +-
cockpit/static/js/dashboard-layout.js | 7 +-
cockpit/static/js/doc-tabs.js | 66 +
cockpit/static/js/documents-v2.js | 1499 +++++++++++
cockpit/static/js/export-intel-map.js | 280 ++
cockpit/static/js/export-intel.js | 596 ++++
cockpit/static/js/herman-assistant.js | 373 +++
cockpit/static/js/htmx.min.js | 1 +
cockpit/static/js/i18n.js | 11 +-
cockpit/static/js/parse-intel.js | 143 +
cockpit/static/js/revenue-cockpit.js | 422 +++
cockpit/static/js/voice-live.js | 464 ++++
cockpit/static/manifest.json | 4 +-
cockpit/static/sw.js | 58 +-
cockpit/templates/agents.html | 224 +-
cockpit/templates/base.html | 255 +-
cockpit/templates/browser.html | 486 +++-
cockpit/templates/clients.html | 521 +++-
cockpit/templates/cto-dashboard.html | 195 ++
cockpit/templates/dashboard.html | 217 +-
cockpit/templates/documents.html | 1004 ++++---
cockpit/templates/export-intel.css | 177 ++
cockpit/templates/export_intel.html | 764 ++++++
cockpit/templates/herman_chat.html | 197 +-
cockpit/templates/marketing.html | 19 +-
cockpit/templates/revenue_cockpit.html | 262 ++
cockpit/templates/settings.html | 244 ++
cockpit/templates/voice.html | 217 +-
cockpit/tmp-upload/admin_api.py | 2392 +++++++++++++++++
cockpit/tmp-upload/doc-tabs.js | 54 +
cockpit/tmp-upload/documents-v2.css | 868 ++++++
cockpit/tmp-upload/documents-v2.js | 1596 +++++++++++
cockpit/tmp-upload/documents.html | 666 +++++
cockpit/vertical-tabs.css | 165 ++
deploy-all.sh | 12 +-
deploy-ceo-cto.sh | 43 +
deploy-docling-labels.sh | 61 +
deploy-revenue-cockpit.sh | 97 +
doc-ingest/app/docling_routes.py | 212 ++
doc-ingest/app/docling_service.py | 323 +++
doc-ingest/app/excel_routes.py | 43 +
doc-ingest/app/excel_service.py | 101 +
docker-compose.yml | 4 +
docling_routes.py | 212 ++
docs/AGENTS.md | 1 +
documents-v2.css | 818 ++++++
documents-v2.js | 1179 ++++++++
documents-workbench.css | 51 +
documents.html | 604 +++++
documents.py | 96 +
email-agent/app/__init__.py | 1 +
herman.py | 251 ++
migrations/026_agent_integration.sql | 88 +
migrations/027_data_labels.sql | 14 +
migrations/028_docling_results.sql | 21 +
migrations/029_document_workspace.sql | 10 +
migrations/030_document_links_360.sql | 30 +
migrations/031_llm_providers.sql | 25 +
migrations/032_document_versions.sql | 14 +
migrations/033_revenue_cockpit.sql | 89 +
migrations/034_revenue_goals_mid.sql | 1 +
migrations/035_webbuilder_agent.sql | 59 +
migrations/036_export_intel.sql | 302 +++
migrations/037_export_intel_phase_b.sql | 80 +
migrations/038_export_intel_world.sql | 135 +
migrations/039_export_intel_crm_favorites.sql | 12 +
scripts/agent_scheduler.py | 98 +
scripts/fix-nas-mount.sh | 34 +
scripts/hermes-webbuilder-api.py | 217 ++
scripts/hermes_telegram_main.py | 237 ++
scripts/install-hermes-webbuilder-api.sh | 50 +
scripts/marketing_publish_due.py | 37 +
scripts/patch-orchestrator-webbuilder.sh | 56 +
scripts/push-gitea-now.sh | 19 +
.../cockpit/static/css/documents-v2.css | 821 ++++++
.../cockpit/static/js/documents-v2.js | 1212 +++++++++
.../cockpit/templates/documents.html | 619 +++++
tools-api/app/agent_names.py | 22 +
tools-api/app/connectors/__init__.py | 1 +
tools-api/app/export_halal_scoring.py | 109 +
tools-api/app/export_intel.py | 842 ++++++
tools-api/app/export_intel_crm.py | 193 ++
tools-api/app/export_intel_places.py | 183 ++
tools-api/app/export_intel_sync.py | 583 ++++
tools-api/app/export_territory_seeds.py | 133 +
tools-api/app/main.py | 2 +
tools-api/app/middleware.py | 20 +-
165 files changed, 47427 insertions(+), 1264 deletions(-)
create mode 100644 028_docling_results.sql
create mode 100644 029_document_workspace.sql
create mode 100644 030_document_links_360.sql
create mode 100644 admin_api.py
create mode 100644 auto_ingest.py
create mode 100644 client_360.py
create mode 100644 cockpit/app/routes/export_intel.py
create mode 100644 cockpit/app/routes/revenue_cockpit.py
create mode 100644 cockpit/app/services/admin_api.py
create mode 100644 cockpit/app/services/agent_events_log.py
create mode 100644 cockpit/app/services/agent_integration.py
create mode 100644 cockpit/app/services/agent_names.py
create mode 100644 cockpit/app/services/agent_terminal.py
create mode 100644 cockpit/app/services/auto_ingest.py
create mode 100644 cockpit/app/services/client_360.py
create mode 100644 cockpit/app/services/docker-compose.yml
create mode 100644 cockpit/app/services/documents-v2.js
create mode 100644 cockpit/app/services/excel_import.py
create mode 100644 cockpit/app/services/llm_router.py
create mode 100644 cockpit/app/services/requirements.txt
create mode 100644 cockpit/app/services/revenue_cockpit.py
create mode 100644 cockpit/app/services/voice_export_actions.py
create mode 100644 cockpit/app/services/webbuilder_agent.py
create mode 100644 cockpit/base.html
create mode 100644 cockpit/cto-dashboard.html
create mode 100644 cockpit/dashboard.html
create mode 100644 cockpit/dashboard.py
create mode 100644 cockpit/doc-tabs.js
create mode 100644 cockpit/documents-v2.css
create mode 100644 cockpit/documents-v2.js
create mode 100644 cockpit/documents.html
create mode 100644 cockpit/en.json
create mode 100644 cockpit/manifest.json
create mode 100644 cockpit/nl.json
create mode 100644 cockpit/revenue-cockpit.css
create mode 100644 cockpit/revenue-cockpit.js
create mode 100644 cockpit/revenue_cockpit.html
create mode 100644 cockpit/static/css/app-sidebar-neo.css
create mode 100644 cockpit/static/css/documents-v2.css
create mode 100644 cockpit/static/css/documents-workbench.css
create mode 100644 cockpit/static/css/export-intel-tabs.css
create mode 100644 cockpit/static/css/export-intel.css
create mode 100644 cockpit/static/css/herman-assistant.css
create mode 100644 cockpit/static/css/parse-intel.css
create mode 100644 cockpit/static/css/revenue-cockpit.css
create mode 100644 cockpit/static/css/revenue-cockpit.js
create mode 100644 cockpit/static/css/revenue_cockpit.html
create mode 100644 cockpit/static/css/voice-live.css
create mode 100644 cockpit/static/i18n/manifest.json
create mode 100644 cockpit/static/js/agents-terminals.js
create mode 100644 cockpit/static/js/alpine.min.js
create mode 100644 cockpit/static/js/doc-tabs.js
create mode 100644 cockpit/static/js/documents-v2.js
create mode 100644 cockpit/static/js/export-intel-map.js
create mode 100644 cockpit/static/js/export-intel.js
create mode 100644 cockpit/static/js/herman-assistant.js
create mode 100644 cockpit/static/js/htmx.min.js
create mode 100644 cockpit/static/js/parse-intel.js
create mode 100644 cockpit/static/js/revenue-cockpit.js
create mode 100644 cockpit/static/js/voice-live.js
create mode 100644 cockpit/templates/cto-dashboard.html
create mode 100644 cockpit/templates/export-intel.css
create mode 100644 cockpit/templates/export_intel.html
create mode 100644 cockpit/templates/revenue_cockpit.html
create mode 100644 cockpit/tmp-upload/admin_api.py
create mode 100644 cockpit/tmp-upload/doc-tabs.js
create mode 100644 cockpit/tmp-upload/documents-v2.css
create mode 100644 cockpit/tmp-upload/documents-v2.js
create mode 100644 cockpit/tmp-upload/documents.html
create mode 100644 cockpit/vertical-tabs.css
create mode 100755 deploy-ceo-cto.sh
create mode 100755 deploy-docling-labels.sh
create mode 100755 deploy-revenue-cockpit.sh
create mode 100644 doc-ingest/app/docling_routes.py
create mode 100644 doc-ingest/app/docling_service.py
create mode 100644 doc-ingest/app/excel_routes.py
create mode 100644 doc-ingest/app/excel_service.py
create mode 100644 docling_routes.py
create mode 100644 documents-v2.css
create mode 100644 documents-v2.js
create mode 100644 documents-workbench.css
create mode 100644 documents.html
create mode 100644 documents.py
create mode 100644 herman.py
create mode 100644 migrations/026_agent_integration.sql
create mode 100644 migrations/027_data_labels.sql
create mode 100644 migrations/028_docling_results.sql
create mode 100644 migrations/029_document_workspace.sql
create mode 100644 migrations/030_document_links_360.sql
create mode 100644 migrations/031_llm_providers.sql
create mode 100644 migrations/032_document_versions.sql
create mode 100644 migrations/033_revenue_cockpit.sql
create mode 100644 migrations/034_revenue_goals_mid.sql
create mode 100644 migrations/035_webbuilder_agent.sql
create mode 100644 migrations/036_export_intel.sql
create mode 100644 migrations/037_export_intel_phase_b.sql
create mode 100644 migrations/038_export_intel_world.sql
create mode 100644 migrations/039_export_intel_crm_favorites.sql
create mode 100755 scripts/agent_scheduler.py
create mode 100755 scripts/fix-nas-mount.sh
create mode 100644 scripts/hermes-webbuilder-api.py
create mode 100755 scripts/hermes_telegram_main.py
create mode 100755 scripts/install-hermes-webbuilder-api.sh
create mode 100755 scripts/marketing_publish_due.py
create mode 100755 scripts/patch-orchestrator-webbuilder.sh
create mode 100755 scripts/push-gitea-now.sh
create mode 100644 tmp/foodlinkk-deploy/cockpit/static/css/documents-v2.css
create mode 100644 tmp/foodlinkk-deploy/cockpit/static/js/documents-v2.js
create mode 100644 tmp/foodlinkk-deploy/cockpit/templates/documents.html
create mode 100644 tools-api/app/agent_names.py
create mode 100644 tools-api/app/export_halal_scoring.py
create mode 100644 tools-api/app/export_intel.py
create mode 100644 tools-api/app/export_intel_crm.py
create mode 100644 tools-api/app/export_intel_places.py
create mode 100644 tools-api/app/export_intel_sync.py
create mode 100644 tools-api/app/export_territory_seeds.py
diff --git a/028_docling_results.sql b/028_docling_results.sql
new file mode 100644
index 0000000..1c2e432
--- /dev/null
+++ b/028_docling_results.sql
@@ -0,0 +1,21 @@
+-- Docling conversion results for NAS documents
+
+CREATE TABLE IF NOT EXISTS docling_results (
+ id SERIAL PRIMARY KEY,
+ storage_path TEXT NOT NULL,
+ file_sig VARCHAR(64) NOT NULL DEFAULT '',
+ status VARCHAR(32) NOT NULL DEFAULT 'pending',
+ options JSONB NOT NULL DEFAULT '{}'::jsonb,
+ exports JSONB NOT NULL DEFAULT '{}'::jsonb,
+ tables_data JSONB NOT NULL DEFAULT '[]'::jsonb,
+ pictures JSONB NOT NULL DEFAULT '[]'::jsonb,
+ page_count INT DEFAULT 0,
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+ error_text TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ completed_at TIMESTAMPTZ,
+ UNIQUE (storage_path, file_sig)
+);
+
+CREATE INDEX IF NOT EXISTS idx_docling_results_path ON docling_results (storage_path);
+CREATE INDEX IF NOT EXISTS idx_docling_results_status ON docling_results (status, created_at DESC);
diff --git a/029_document_workspace.sql b/029_document_workspace.sql
new file mode 100644
index 0000000..0878f18
--- /dev/null
+++ b/029_document_workspace.sql
@@ -0,0 +1,10 @@
+-- Editable workspace drafts for Docling / document editor
+
+CREATE TABLE IF NOT EXISTS document_workspace (
+ storage_path TEXT PRIMARY KEY,
+ source_format VARCHAR(32) DEFAULT 'markdown',
+ content TEXT NOT NULL DEFAULT '',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_document_workspace_updated ON document_workspace (updated_at DESC);
diff --git a/030_document_links_360.sql b/030_document_links_360.sql
new file mode 100644
index 0000000..d3b9da6
--- /dev/null
+++ b/030_document_links_360.sql
@@ -0,0 +1,30 @@
+-- Document ↔ klant/project koppelingen + auto-ingest log
+
+CREATE TABLE IF NOT EXISTS document_links (
+ id SERIAL PRIMARY KEY,
+ storage_path TEXT NOT NULL,
+ is_folder BOOLEAN NOT NULL DEFAULT FALSE,
+ client_id INT REFERENCES clients(id) ON DELETE CASCADE,
+ project_id INT REFERENCES cockpit_projects(id) ON DELETE SET NULL,
+ link_type VARCHAR(32) NOT NULL DEFAULT 'nas_share',
+ notes TEXT DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_by VARCHAR(64) DEFAULT 'cockpit'
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_document_links_unique
+ ON document_links (storage_path, COALESCE(client_id, 0), COALESCE(project_id, 0));
+
+CREATE INDEX IF NOT EXISTS idx_document_links_client ON document_links (client_id);
+CREATE INDEX IF NOT EXISTS idx_document_links_project ON document_links (project_id);
+CREATE INDEX IF NOT EXISTS idx_document_links_path ON document_links (storage_path);
+
+CREATE TABLE IF NOT EXISTS ingest_automation_log (
+ id SERIAL PRIMARY KEY,
+ source VARCHAR(64) NOT NULL,
+ status VARCHAR(32) NOT NULL DEFAULT 'ok',
+ details JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_ingest_automation_log_at ON ingest_automation_log (created_at DESC);
diff --git a/admin_api.py b/admin_api.py
new file mode 100644
index 0000000..092b6cf
--- /dev/null
+++ b/admin_api.py
@@ -0,0 +1,2265 @@
+from __future__ import annotations
+
+from app.routes.reco_proxy import register_recommendation_routes
+
+import json
+import os
+from datetime import datetime
+from typing import Any, Optional
+
+import httpx
+from fastapi import APIRouter, File, HTTPException, UploadFile
+from pydantic import BaseModel, Field
+
+from app.config import settings
+from app.db import execute, fetch_all, fetch_one
+from app.services import herman as herman_service
+from app.services import ollama
+from app.services.auto_ingest import last_auto_sync, run_full_auto_sync
+from app.services.client_360 import get_client_360
+from app.services import nas_folders
+from app.services.monitor import add_site, build_parse_intelligence, get_parse_page, list_parse_results, remove_site, trigger_crawl
+from app.services.agent_events_log import log_agent_event
+
+admin_router = APIRouter(prefix="/api/admin", tags=["admin-api"])
+register_recommendation_routes(admin_router)
+ai_router = APIRouter(prefix="/api/ai", tags=["ai"])
+herman_api = APIRouter(prefix="/api/herman", tags=["herman-api"])
+voice_api = APIRouter(prefix="/api/voice", tags=["voice-api"])
+
+
+def _serialize(row: dict | None) -> dict | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ elif isinstance(v, (datetime,)):
+ out[k] = v.isoformat()
+ return out
+
+
+def _serialize_rows(rows: list) -> list:
+ return [_serialize(r) for r in rows]
+
+
+# --- Pydantic models ---
+
+class ClientBody(BaseModel):
+ name: str
+ contact: Optional[str] = None
+ email: Optional[str] = None
+ stage: str = "intake"
+ sector: Optional[str] = None
+ mrr_estimate: Optional[float] = None
+ notes: Optional[str] = None
+
+
+class DealBody(BaseModel):
+ client_id: Optional[int] = None
+ title: str
+ value: float = 0
+ stage: str = "lead"
+ agent_owner: str = "herman"
+ next_action: Optional[str] = None
+ deadline: Optional[str] = None
+
+
+class ProductBody(BaseModel):
+ client_id: Optional[int] = None
+ name: str
+ status: str = "concept"
+ margin_pct: Optional[float] = None
+ moq: Optional[int] = None
+ shelf_target: Optional[str] = None
+ launch_date: Optional[str] = None
+
+
+class SupplierBody(BaseModel):
+ name: str
+ country: Optional[str] = None
+ category: Optional[str] = None
+ moq: Optional[int] = None
+ lead_time_days: Optional[int] = None
+ rating: Optional[float] = None
+ contact: Optional[str] = None
+
+
+class MentionBody(BaseModel):
+ platform: str
+ text: str
+
+
+class AccountBody(BaseModel):
+ platform: str
+ username: str
+ is_active: bool = True
+
+
+class ScheduledPostBody(BaseModel):
+ account_id: int
+ content: str
+ scheduled_time: str
+ status: str = "pending"
+
+
+class PostStatusBody(BaseModel):
+ status: str
+
+
+class AgentRuleBody(BaseModel):
+ name: str
+ condition_type: str
+ threshold: float
+ action: str = "notify"
+ is_active: bool = True
+
+
+class SiteBody(BaseModel):
+ url: str
+ name: str
+
+
+class MarginBody(BaseModel):
+ cost: float
+ sell: float
+
+
+class AIGenerateBody(BaseModel):
+ platform: str = "Instagram"
+ topic: str
+ tone: str = "vriendelijk en professioneel"
+
+
+class AIPostDraftBody(BaseModel):
+ account_id: Optional[int] = None
+ platform: str = "Instagram"
+ topic: str
+ tone: str = "vriendelijk en professioneel"
+
+
+class HermanChatBody(BaseModel):
+ message: str
+ channel: str = "cockpit"
+
+
+class ImageGenerateBody(BaseModel):
+ prompt: str = Field(..., min_length=3, max_length=2000)
+ width: int = Field(default=1024, ge=256, le=1024)
+ height: int = Field(default=1024, ge=256, le=1024)
+ steps: int = Field(default=28, ge=5, le=40)
+ quality: str = Field(default="hd")
+
+
+class ImageStartBody(BaseModel):
+ prompt: str = Field(..., min_length=3, max_length=2000)
+ negative_prompt: str = Field(default="blurry, low quality, watermark, text, ugly, deformed", max_length=2000)
+ quality: str = Field(default="hd")
+
+
+class CrawlBody(BaseModel):
+ site_id: Optional[int] = None
+
+
+class DelegateBody(BaseModel):
+ agent: str
+ task: str
+
+
+# --- Clients CRUD ---
+
+@admin_router.get("/clients")
+def list_clients():
+ return {"items": _serialize_rows(fetch_all(
+ """SELECT c.*,
+ (SELECT COUNT(*) FROM client_supermarket_links l WHERE l.client_id = c.id)
+ + (SELECT COUNT(*) FROM supermarkets s WHERE s.client_id = c.id
+ AND NOT EXISTS (
+ SELECT 1 FROM client_supermarket_links l2
+ WHERE l2.supermarket_id = s.id AND l2.client_id = c.id
+ )) AS store_count
+ FROM clients c
+ ORDER BY c.updated_at DESC NULLS LAST, c.created_at DESC LIMIT 500"""
+ ))}
+
+
+@admin_router.post("/clients")
+def create_client(body: ClientBody):
+ row = fetch_one(
+ """INSERT INTO clients (name, contact, email, stage, sector, mrr_estimate, notes, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""",
+ (body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes),
+ )
+ cid = row["id"]
+ try:
+ nas_folders.ensure_client_folder(cid, body.name)
+ except Exception:
+ pass
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (cid,)))}
+
+
+@admin_router.put("/clients/{client_id}")
+def update_client(client_id: int, body: ClientBody):
+ execute(
+ """UPDATE clients SET name=%s, contact=%s, email=%s, stage=%s, sector=%s,
+ mrr_estimate=%s, notes=%s, updated_at=NOW() WHERE id=%s""",
+ (body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes, client_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (client_id,)))}
+
+
+@admin_router.delete("/clients/{client_id}")
+def delete_client(client_id: int):
+ execute("DELETE FROM clients WHERE id=%s", (client_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/clients/stats")
+def clients_stats():
+ stats: dict[str, Any] = {}
+ try:
+ rows = fetch_all("SELECT stage, COUNT(*) AS n FROM clients GROUP BY stage")
+ stats["by_stage"] = {r["stage"]: int(r["n"]) for r in rows}
+ stats["total"] = sum(stats["by_stage"].values())
+ stats["active"] = stats["by_stage"].get("active", 0)
+ row = fetch_one("SELECT COALESCE(SUM(mrr_estimate),0) AS s FROM clients WHERE stage NOT IN ('churned')")
+ stats["mrr_total"] = float(row["s"] or 0) if row else 0
+ row = fetch_one(
+ """SELECT COALESCE(SUM(d.value),0) AS s FROM deals d
+ JOIN clients c ON c.id = d.client_id
+ WHERE d.stage NOT IN ('won','lost')"""
+ )
+ stats["pipeline_value"] = float(row["s"] or 0) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM client_supermarket_links")
+ stats["store_links"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM deals")
+ stats["deals"] = int(row["n"] or 0) if row else 0
+ except Exception:
+ stats = {"total": 0, "active": 0, "mrr_total": 0, "pipeline_value": 0, "store_links": 0, "deals": 0, "by_stage": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/clients/{client_id}/detail")
+def client_detail(client_id: int):
+ client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
+ if not client:
+ raise HTTPException(404, "Client not found")
+ deals = fetch_all(
+ """SELECT id, title, value, stage, next_action, deadline, updated_at
+ FROM deals WHERE client_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 20""",
+ (client_id,),
+ )
+ stores = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
+ s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
+ l.relationship_type, l.notes AS link_notes, l.deal_id, l.created_at AS linked_at
+ FROM client_supermarket_links l
+ JOIN supermarkets s ON s.id = l.supermarket_id
+ WHERE l.client_id = %s
+ ORDER BY s.chain, s.city, s.name
+ """,
+ (client_id,),
+ )
+ direct = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
+ s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
+ 'direct' AS relationship_type, NULL AS link_notes, s.deal_id, s.last_updated AS linked_at
+ FROM supermarkets s
+ WHERE s.client_id = %s
+ AND NOT EXISTS (
+ SELECT 1 FROM client_supermarket_links l
+ WHERE l.supermarket_id = s.id AND l.client_id = s.client_id
+ )
+ ORDER BY s.chain, s.city, s.name
+ """,
+ (client_id,),
+ )
+ merged: dict[int, dict] = {}
+ for row in list(stores) + list(direct):
+ merged[int(row["id"])] = row
+ return {
+ "ok": True,
+ "client": _serialize(client),
+ "deals": _serialize_rows(deals),
+ "stores": _serialize_rows(list(merged.values())),
+ }
+
+
+@admin_router.get("/clients/{client_id}/360")
+def client_360_view(client_id: int) -> dict[str, Any]:
+ data = get_client_360(client_id)
+ if not data.get("ok"):
+ raise HTTPException(404, data.get("error", "not found"))
+ return data
+
+
+@admin_router.get("/documents/links")
+def document_links_list(
+ client_id: int | None = None,
+ project_id: int | None = None,
+) -> dict[str, Any]:
+ clauses, params = [], []
+ if client_id:
+ clauses.append("dl.client_id = %s")
+ params.append(client_id)
+ if project_id:
+ clauses.append("dl.project_id = %s")
+ params.append(project_id)
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+ rows = fetch_all(
+ f"""
+ SELECT dl.*, c.name AS client_name, p.name AS project_name
+ FROM document_links dl
+ LEFT JOIN clients c ON c.id = dl.client_id
+ LEFT JOIN cockpit_projects p ON p.id = dl.project_id
+ {where}
+ ORDER BY dl.created_at DESC
+ LIMIT 500
+ """,
+ tuple(params),
+ )
+ return {"ok": True, "items": _serialize_rows(rows)}
+
+
+@admin_router.post("/documents/links")
+def document_links_create(body: DocumentLinkBody) -> dict[str, Any]:
+ if not body.client_id and not body.project_id:
+ raise HTTPException(400, "client_id of project_id vereist")
+ row = fetch_one(
+ """
+ INSERT INTO document_links (storage_path, is_folder, client_id, project_id, notes)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (body.storage_path.strip(), body.is_folder, body.client_id, body.project_id, body.notes or ""),
+ )
+ if body.project_id and row:
+ try:
+ from app.services import projects as projects_svc
+ projects_svc.add_asset(
+ body.project_id,
+ "nas_file",
+ body.storage_path.split("/")[-1],
+ file_path=body.storage_path.strip(),
+ source_agent="documents",
+ )
+ except Exception:
+ pass
+ return {"ok": True, "item": _serialize(row)}
+
+
+@admin_router.delete("/documents/links/{link_id}")
+def document_links_delete(link_id: int) -> dict[str, Any]:
+ execute("DELETE FROM document_links WHERE id = %s", (link_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/brain/auto-sync")
+async def brain_auto_sync(force: bool = False) -> dict[str, Any]:
+ return await run_full_auto_sync(force_scan=force)
+
+
+@admin_router.get("/brain/auto-sync/status")
+def brain_auto_sync_status() -> dict[str, Any]:
+ last = last_auto_sync()
+ return {"ok": True, "last": last}
+
+
+# --- Deals CRUD ---
+
+@admin_router.get("/deals")
+def list_deals():
+ return {"items": _serialize_rows(fetch_all(
+ """SELECT d.*, c.name AS client_name FROM deals d
+ LEFT JOIN clients c ON c.id = d.client_id
+ ORDER BY d.updated_at DESC NULLS LAST LIMIT 500"""
+ ))}
+
+
+@admin_router.post("/deals")
+def create_deal(body: DealBody):
+ row = fetch_one(
+ """INSERT INTO deals (client_id, title, value, stage, agent_owner, next_action, deadline, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""",
+ (body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None),
+ )
+ did = row["id"]
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (did,)))}
+
+
+@admin_router.put("/deals/{deal_id}")
+def update_deal(deal_id: int, body: DealBody):
+ execute(
+ """UPDATE deals SET client_id=%s, title=%s, value=%s, stage=%s, agent_owner=%s,
+ next_action=%s, deadline=%s, updated_at=NOW() WHERE id=%s""",
+ (body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None, deal_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (deal_id,)))}
+
+
+@admin_router.delete("/deals/{deal_id}")
+def delete_deal(deal_id: int):
+ execute("DELETE FROM deals WHERE id=%s", (deal_id,))
+ return {"ok": True}
+
+
+# --- Products CRUD ---
+
+@admin_router.get("/products/stats")
+def products_stats():
+ stats: dict[str, Any] = {}
+ try:
+ row = fetch_one("SELECT COUNT(*) AS n FROM products")
+ stats["total"] = int(row["n"] or 0) if row else 0
+ rows = fetch_all("SELECT status, COUNT(*) AS n FROM products GROUP BY status")
+ stats["by_status"] = {r["status"]: int(r["n"]) for r in rows}
+ stats["active"] = stats["by_status"].get("active", 0)
+ row = fetch_one("SELECT AVG(margin_pct) AS a FROM products WHERE margin_pct IS NOT NULL")
+ stats["avg_margin"] = round(float(row["a"] or 0), 1) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM products WHERE client_id IS NOT NULL")
+ stats["with_client"] = int(row["n"] or 0) if row else 0
+ except Exception:
+ stats = {"total": 0, "active": 0, "avg_margin": 0, "with_client": 0, "by_status": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/products/{product_id}/detail")
+def product_detail(product_id: int):
+ product = fetch_one(
+ """SELECT p.*, c.name AS client_name, c.email AS client_email, c.stage AS client_stage
+ FROM products p LEFT JOIN clients c ON c.id = p.client_id WHERE p.id = %s""",
+ (product_id,),
+ )
+ if not product:
+ raise HTTPException(404, "Product not found")
+ return {"ok": True, "product": _serialize(product)}
+
+
+@admin_router.get("/products")
+def list_products():
+ return {"items": _serialize_rows(fetch_all(
+ """SELECT p.*, c.name AS client_name FROM products p
+ LEFT JOIN clients c ON c.id = p.client_id ORDER BY p.created_at DESC LIMIT 500"""
+ ))}
+
+
+@admin_router.post("/products")
+def create_product(body: ProductBody):
+ row = fetch_one(
+ """INSERT INTO products (client_id, name, status, margin_pct, moq, shelf_target, launch_date)
+ VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
+ (body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None),
+ )
+ pid = row["id"]
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (pid,)))}
+
+
+@admin_router.put("/products/{product_id}")
+def update_product(product_id: int, body: ProductBody):
+ execute(
+ """UPDATE products SET client_id=%s, name=%s, status=%s, margin_pct=%s, moq=%s,
+ shelf_target=%s, launch_date=%s WHERE id=%s""",
+ (body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None, product_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (product_id,)))}
+
+
+@admin_router.delete("/products/{product_id}")
+def delete_product(product_id: int):
+ execute("DELETE FROM products WHERE id=%s", (product_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/products/margin-calc")
+def margin_calc(body: MarginBody):
+ if body.sell <= 0:
+ raise HTTPException(400, "sell must be > 0")
+ margin = ((body.sell - body.cost) / body.sell) * 100
+ return {"cost": body.cost, "sell": body.sell, "margin_pct": round(margin, 2), "profit": round(body.sell - body.cost, 2)}
+
+
+# --- Suppliers CRUD ---
+
+@admin_router.get("/suppliers/stats")
+def suppliers_stats():
+ stats: dict[str, Any] = {}
+ try:
+ row = fetch_one("SELECT COUNT(*) AS n FROM suppliers")
+ stats["total"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT AVG(rating) AS a FROM suppliers WHERE rating IS NOT NULL")
+ stats["avg_rating"] = round(float(row["a"] or 0), 1) if row else 0
+ row = fetch_one("SELECT COUNT(DISTINCT country) AS n FROM suppliers WHERE country IS NOT NULL AND country <> ''")
+ stats["countries"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT AVG(lead_time_days) AS a FROM suppliers WHERE lead_time_days IS NOT NULL")
+ stats["avg_lead"] = round(float(row["a"] or 0), 0) if row else 0
+ rows = fetch_all(
+ "SELECT COALESCE(category, 'Overig') AS category, COUNT(*) AS n FROM suppliers GROUP BY category ORDER BY n DESC LIMIT 8"
+ )
+ stats["by_category"] = {r["category"]: int(r["n"]) for r in rows}
+ except Exception:
+ stats = {"total": 0, "avg_rating": 0, "countries": 0, "avg_lead": 0, "by_category": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/suppliers/{supplier_id}/detail")
+def supplier_detail(supplier_id: int):
+ supplier = fetch_one("SELECT * FROM suppliers WHERE id = %s", (supplier_id,))
+ if not supplier:
+ raise HTTPException(404, "Supplier not found")
+ return {"ok": True, "supplier": _serialize(supplier)}
+
+
+@admin_router.get("/suppliers")
+def list_suppliers():
+ return {"items": _serialize_rows(fetch_all("SELECT * FROM suppliers ORDER BY name LIMIT 500"))}
+
+
+@admin_router.post("/suppliers")
+def create_supplier(body: SupplierBody):
+ row = fetch_one(
+ """INSERT INTO suppliers (name, country, category, moq, lead_time_days, rating, contact)
+ VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
+ (body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact),
+ )
+ sid = row["id"]
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (sid,)))}
+
+
+@admin_router.put("/suppliers/{supplier_id}")
+def update_supplier(supplier_id: int, body: SupplierBody):
+ execute(
+ """UPDATE suppliers SET name=%s, country=%s, category=%s, moq=%s,
+ lead_time_days=%s, rating=%s, contact=%s WHERE id=%s""",
+ (body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact, supplier_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (supplier_id,)))}
+
+
+@admin_router.delete("/suppliers/{supplier_id}")
+def delete_supplier(supplier_id: int):
+ execute("DELETE FROM suppliers WHERE id=%s", (supplier_id,))
+ return {"ok": True}
+
+
+# --- Marketing ---
+
+@admin_router.get("/mentions")
+def list_mentions(platform: str = "all"):
+ if platform == "all":
+ rows = fetch_all("SELECT * FROM social_mentions ORDER BY created_at DESC LIMIT 200")
+ else:
+ rows = fetch_all(
+ "SELECT * FROM social_mentions WHERE platform=%s ORDER BY created_at DESC LIMIT 200",
+ (platform,),
+ )
+ evaluate_agent_rules()
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.post("/mentions")
+def create_mention(body: MentionBody):
+ text = body.text.strip()
+ if not text:
+ raise HTTPException(400, "text required")
+ score = sentiment_score(text)
+ row = fetch_one(
+ "INSERT INTO social_mentions (platform, text, sentiment_score) VALUES (%s,%s,%s) RETURNING id",
+ (body.platform, text, score),
+ )
+ evaluate_agent_rules(row["id"])
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_mentions WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.delete("/mentions/{mention_id}")
+def delete_mention(mention_id: int):
+ execute("DELETE FROM social_mentions WHERE id=%s", (mention_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/accounts")
+def list_accounts():
+ return {"items": _serialize_rows(fetch_all("SELECT * FROM social_accounts ORDER BY platform, username"))}
+
+
+@admin_router.post("/accounts")
+def create_account(body: AccountBody):
+ row = fetch_one(
+ "INSERT INTO social_accounts (platform, username, is_active) VALUES (%s,%s,%s) RETURNING id",
+ (body.platform, body.username.strip(), body.is_active),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_accounts WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.delete("/accounts/{account_id}")
+def delete_account(account_id: int):
+ execute("DELETE FROM social_accounts WHERE id=%s", (account_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/scheduled-posts")
+def list_scheduled_posts():
+ rows = fetch_all(
+ """SELECT sp.*, sa.platform, sa.username FROM scheduled_posts sp
+ JOIN social_accounts sa ON sp.account_id = sa.id
+ ORDER BY sp.scheduled_time ASC"""
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.post("/scheduled-posts")
+def create_scheduled_post(body: ScheduledPostBody):
+ row = fetch_one(
+ "INSERT INTO scheduled_posts (account_id, content, scheduled_time, status) VALUES (%s,%s,%s,%s) RETURNING id",
+ (body.account_id, body.content.strip(), body.scheduled_time, body.status),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM scheduled_posts WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.patch("/scheduled-posts/{post_id}/status")
+def update_scheduled_status(post_id: int, body: PostStatusBody):
+ allowed = {"pending", "approved", "posted", "rejected", "published", "cancelled"}
+ if body.status not in allowed:
+ raise HTTPException(400, "invalid status")
+ if body.status in ("posted", "published"):
+ execute(
+ "UPDATE scheduled_posts SET status=%s, posted_at=NOW() WHERE id=%s",
+ (body.status, post_id),
+ )
+ else:
+ execute("UPDATE scheduled_posts SET status=%s WHERE id=%s", (body.status, post_id))
+ return {"ok": True}
+
+
+@admin_router.put("/scheduled-posts/{post_id}")
+def update_scheduled_post(post_id: int, body: ScheduledPostBody):
+ execute(
+ "UPDATE scheduled_posts SET account_id=%s, content=%s, scheduled_time=%s, status=%s WHERE id=%s",
+ (body.account_id, body.content, body.scheduled_time, body.status, post_id),
+ )
+ return {"ok": True}
+
+
+@admin_router.delete("/scheduled-posts/{post_id}")
+def delete_scheduled_post(post_id: int):
+ execute("DELETE FROM scheduled_posts WHERE id=%s", (post_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/scheduled-posts/publish-due")
+def publish_due_scheduled_posts():
+ """Mark due scheduled posts as published (marketing cron)."""
+ rows = fetch_all(
+ """
+ SELECT id, content FROM scheduled_posts
+ WHERE status = 'scheduled' AND scheduled_time <= NOW()
+ ORDER BY scheduled_time ASC
+ LIMIT 50
+ """
+ )
+ published = []
+ for row in rows:
+ pid = int(row["id"])
+ execute(
+ "UPDATE scheduled_posts SET status = 'published', posted_at = NOW() WHERE id = %s",
+ (pid,),
+ )
+ try:
+ execute(
+ """
+ INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ """,
+ (
+ "marketing",
+ "marketing",
+ "post_published",
+ f"Scheduled post #{pid} gepubliceerd",
+ (row.get("content") or "")[:500],
+ "completed",
+ "cron",
+ json.dumps({"post_id": pid}),
+ ),
+ )
+ except Exception:
+ pass
+ published.append(pid)
+ return {"ok": True, "published": published, "count": len(published)}
+
+
+@admin_router.get("/agent-rules")
+def list_agent_rules():
+ evaluate_agent_rules()
+ return {"items": _serialize_rows(fetch_all("SELECT * FROM agent_rules ORDER BY id"))}
+
+
+@admin_router.post("/agent-rules")
+def create_agent_rule(body: AgentRuleBody):
+ row = fetch_one(
+ """INSERT INTO agent_rules (name, condition_type, threshold, action, is_active)
+ VALUES (%s,%s,%s,%s,%s) RETURNING id""",
+ (body.name, body.condition_type, body.threshold, body.action, body.is_active),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM agent_rules WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.patch("/agent-rules/{rule_id}/toggle")
+def toggle_agent_rule(rule_id: int):
+ execute("UPDATE agent_rules SET is_active = NOT is_active WHERE id=%s", (rule_id,))
+ return {"ok": True}
+
+
+@admin_router.delete("/agent-rules/{rule_id}")
+def delete_agent_rule(rule_id: int):
+ execute("DELETE FROM agent_rules WHERE id=%s", (rule_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/agent-logs")
+def list_agent_logs(limit: int = 100):
+ limit = max(1, min(limit, 500))
+ rows = fetch_all(
+ """SELECT al.*, ar.name AS rule_name FROM agent_logs al
+ LEFT JOIN agent_rules ar ON al.rule_id = ar.id
+ ORDER BY al.created_at DESC LIMIT %s""",
+ (limit,),
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.get("/analytics/social")
+def social_analytics():
+ account_stats = fetch_all(
+ """SELECT sa.platform, sa.username, SUM(sa2.impressions) AS impressions,
+ SUM(sa2.engagements) AS engagements, SUM(sa2.reach) AS reach
+ FROM social_analytics sa2
+ JOIN social_accounts sa ON sa2.account_id = sa.id
+ WHERE sa2.date > CURRENT_DATE - interval '7 days'
+ GROUP BY sa.platform, sa.username ORDER BY impressions DESC"""
+ )
+ daily = fetch_all(
+ """SELECT date, SUM(impressions) AS impressions, SUM(engagements) AS engagements
+ FROM social_analytics WHERE date > CURRENT_DATE - interval '7 days'
+ GROUP BY date ORDER BY date"""
+ )
+ mention_stats = fetch_one(
+ "SELECT COUNT(*) AS cnt, COALESCE(AVG(sentiment_score),0) AS avg_sentiment FROM social_mentions"
+ )
+ return {
+ "account_stats": _serialize_rows(account_stats),
+ "daily_stats": _serialize_rows(daily),
+ "mentions": _serialize(mention_stats),
+ }
+
+
+# --- Monitor ---
+
+@admin_router.get("/monitor/sites")
+def list_monitor_sites():
+ rows = fetch_all(
+ """SELECT id, url, name, last_hash, last_crawled, is_active FROM monitored_sites
+ ORDER BY last_crawled DESC NULLS LAST"""
+ )
+ active = fetch_one("SELECT COUNT(*) AS c FROM monitored_sites WHERE is_active = TRUE")
+ changes_24h = fetch_one(
+ "SELECT COUNT(*) AS c FROM page_changes WHERE changed_at > NOW() - interval '24 hours'"
+ )
+ return {
+ "items": _serialize_rows(rows),
+ "stats": {
+ "active_sites": int(active["c"]) if active else 0,
+ "changes_24h": int(changes_24h["c"]) if changes_24h else 0,
+ },
+ }
+
+
+@admin_router.post("/monitor/sites")
+def create_monitor_site(body: SiteBody):
+ try:
+ item = add_site(body.url, body.name)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ for k, v in list(item.items()):
+ if hasattr(v, "isoformat"):
+ item[k] = v.isoformat()
+ try:
+ log_agent_event(
+ "browser",
+ "monitor_site_added",
+ f"Site toegevoegd: {(body.name or body.url)[:80]}",
+ body.url,
+ channel="monitor",
+ metadata={"url": body.url, "name": body.name, "site_id": item.get("id")},
+ )
+ except Exception:
+ pass
+ return {"ok": True, "item": item}
+
+
+@admin_router.delete("/monitor/sites/{site_id}")
+def delete_monitor_site(site_id: int, hard: bool = False):
+ remove_site(site_id, soft=not hard)
+ return {"ok": True}
+
+
+@admin_router.patch("/monitor/sites/{site_id}/toggle")
+def toggle_monitor_site(site_id: int):
+ execute("UPDATE monitored_sites SET is_active = NOT is_active WHERE id=%s", (site_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/monitor/trigger-crawl")
+def monitor_trigger_crawl(body: CrawlBody = CrawlBody()):
+ result = trigger_crawl(body.site_id)
+ summary_bits = []
+ for r in result.get("results") or []:
+ if r.get("status") == "ERROR":
+ summary_bits.append(f"{r.get('name') or r.get('url')}: fout")
+ else:
+ summary_bits.append(
+ f"{r.get('title') or r.get('name')}: {r.get('word_count', 0)} woorden"
+ )
+ try:
+ log_agent_event(
+ "browser",
+ "monitor_crawl",
+ f"Crawl klaar — {result.get('sites', 0)} site(s), {result.get('changed', 0)} wijziging(en)",
+ "; ".join(summary_bits)[:800],
+ channel="monitor",
+ metadata={"site_id": body.site_id, **{k: v for k, v in result.items() if k != "results"}},
+ )
+ except Exception:
+ pass
+ return result
+
+
+@admin_router.get("/monitor/parse-results")
+def monitor_parse_results(site_id: int | None = None, limit: int = 20):
+ items = list_parse_results(site_id=site_id, limit=limit)
+ return {"items": items, "count": len(items)}
+
+
+@admin_router.get("/monitor/intelligence")
+def monitor_intelligence(site_id: int | None = None, q: str | None = None, limit: int = 100):
+ data = build_parse_intelligence(site_id=site_id, query=q, limit=min(limit, 100))
+ return data
+
+
+@admin_router.get("/monitor/parse-results/{page_id}")
+def monitor_parse_page_detail(page_id: int):
+ page = get_parse_page(page_id)
+ if not page:
+ raise HTTPException(404, "Parse result not found")
+ return {"page": page}
+
+
+@admin_router.get("/monitor/page-changes")
+def monitor_page_changes(limit: int = 50):
+ rows = fetch_all(
+ """SELECT pc.*, ms.url, ms.name FROM page_changes pc
+ JOIN monitored_sites ms ON pc.site_id = ms.id
+ ORDER BY pc.changed_at DESC LIMIT %s""",
+ (max(1, min(limit, 200)),),
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.get("/monitor/crawl-logs")
+def monitor_crawl_logs(limit: int = 50):
+ rows = fetch_all(
+ """SELECT cl.*, ms.url, ms.name FROM crawl_logs cl
+ LEFT JOIN monitored_sites ms ON cl.site_id = ms.id
+ ORDER BY cl.logged_at DESC LIMIT %s""",
+ (max(1, min(limit, 200)),),
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+# --- Agent events (approvals) ---
+
+@admin_router.patch("/agent-events/{event_id}/status")
+def patch_agent_event_status(event_id: int, body: PostStatusBody):
+ if body.status not in ("approved", "rejected", "needs_approval", "completed"):
+ raise HTTPException(400, "invalid status")
+ execute("UPDATE agent_events SET status=%s WHERE id=%s", (body.status, event_id))
+ return {"ok": True}
+
+
+@admin_router.post("/delegate")
+async def delegate_task(body: DelegateBody):
+ msg = f"[{body.agent}] {body.task}"
+ result = await herman_service.chat(msg)
+ return {"ok": True, "result": result}
+
+
+# --- AI ---
+
+async def _generate_social(prompt: str) -> str:
+ try:
+ return await ollama.generate(prompt)
+ except Exception as exc:
+ raise HTTPException(502, f"Ollama error: {exc}") from exc
+
+
+@ai_router.post("/generate-content")
+async def ai_generate_content(body: AIGenerateBody):
+ topic = body.topic.strip()
+ if not topic:
+ raise HTTPException(400, "topic required")
+ prompt = (
+ f"Schrijf een korte social media post voor {body.platform} over: {topic}. "
+ f"Toon: {body.tone}. Maximaal 280 tekens. Alleen de posttekst, geen uitleg."
+ )
+ content = await _generate_social(prompt)
+ return {"ok": True, "content": content}
+
+
+
+
+@ai_router.post("/generate-image")
+async def ai_generate_image(body: ImageGenerateBody):
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=620.0) as client:
+ r = await client.post(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate",
+ json=body.model_dump(),
+ )
+ r.raise_for_status()
+ data = r.json()
+ fn = data.get("filename", "")
+ sub = data.get("subfolder", "")
+ typ = data.get("type", "output")
+ data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}"
+ return data
+ except httpx.HTTPStatusError as exc:
+ detail = exc.response.text[:300] if exc.response else str(exc)
+ raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@ai_router.get("/generated-image")
+async def ai_generated_image(
+ filename: str,
+ subfolder: str = "",
+ type: str = "output",
+):
+ import httpx
+ from fastapi.responses import Response
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/images/view",
+ params={"filename": filename, "subfolder": subfolder, "type": type},
+ )
+ r.raise_for_status()
+ media = r.headers.get("content-type", "image/png")
+ return Response(content=r.content, media_type=media)
+ except Exception as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+
+
+@ai_router.post("/generate-image/start")
+async def ai_generate_image_start(body: ImageStartBody):
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.post(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate/start",
+ json=body.model_dump(),
+ )
+ r.raise_for_status()
+ return r.json()
+ except httpx.HTTPStatusError as exc:
+ detail = exc.response.text[:300] if exc.response else str(exc)
+ raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@ai_router.get("/generate-image/progress/{prompt_id}")
+async def ai_generate_image_progress(prompt_id: str):
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/images/progress/{prompt_id}")
+ r.raise_for_status()
+ data = r.json()
+ result = data.get("result") or {}
+ if data.get("status") == "done" and result.get("filename"):
+ fn = result["filename"]
+ sub = result.get("subfolder", "")
+ typ = result.get("type", "output")
+ data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}"
+ return data
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=exc.response.text[:300]) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+@ai_router.post("/generate-post-draft")
+async def ai_generate_post_draft(body: AIPostDraftBody):
+ platform = body.platform
+ if body.account_id:
+ acc = fetch_one("SELECT platform, username FROM social_accounts WHERE id=%s", (body.account_id,))
+ if acc:
+ platform = f"{acc['platform']} (@{acc['username']})"
+ prompt = (
+ f"Schrijf een geplande social post voor {platform} over: {body.topic.strip()}. "
+ f"Toon: {body.tone}. Geef titel + body."
+ )
+ content = await _generate_social(prompt)
+ return {"ok": True, "content": content, "platform": platform}
+
+
+@herman_api.post("/chat")
+async def herman_chat_api(body: HermanChatBody):
+ if not body.message.strip():
+ raise HTTPException(400, "message required")
+ result = await herman_service.chat(body.message.strip(), channel=(body.channel or "cockpit").strip())
+ return {"ok": True, **result}
+
+
+@voice_api.post("/transcribe")
+async def voice_transcribe(file: UploadFile = File(...)):
+ url = "http://10.4.7.19:8877/v1/audio/transcriptions"
+ data = await file.read()
+ if not data:
+ raise HTTPException(400, "empty file")
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ resp = await client.post(
+ url,
+ files={"file": (file.filename or "audio.webm", data, file.content_type or "audio/webm")},
+ data={"model": "Systran/faster-whisper-base", "language": "nl"},
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return {"text": data.get("text", ""), "raw": data}
+ except httpx.HTTPError as exc:
+ raise HTTPException(502, f"transcribe proxy failed: {exc}") from exc
+
+# --- Document word analytics & sentiment ---
+
+@admin_router.get("/documents/summary")
+def documents_summary():
+ try:
+ row = fetch_one(
+ """
+ SELECT COUNT(*) AS documents,
+ COALESCE(SUM(word_count), 0) AS total_words,
+ COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
+ FROM document_analytics
+ """
+ )
+ unique = fetch_one(
+ "SELECT COUNT(DISTINCT lemma) AS unique_words FROM document_word_counts WHERE NOT is_stopword"
+ )
+ sentiment = fetch_all(
+ """
+ SELECT sentiment_label, COUNT(*) AS cnt
+ FROM document_analytics
+ GROUP BY sentiment_label
+ """
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {
+ "ok": True,
+ "summary": {
+ "documents": int(row["documents"] or 0) if row else 0,
+ "total_words": int(row["total_words"] or 0) if row else 0,
+ "avg_sentiment": round(float(row["avg_sentiment"] or 0), 4) if row else 0,
+ "unique_words": int(unique["unique_words"] or 0) if unique else 0,
+ "sentiment": {r["sentiment_label"]: int(r["cnt"]) for r in sentiment},
+ },
+ }
+
+
+@admin_router.get("/documents/words")
+def documents_words(
+ q: str | None = None,
+ limit: int = 40,
+ stopwords: bool = False,
+):
+ limit = max(1, min(limit, 200))
+ clauses = []
+ params: list = []
+ if not stopwords:
+ clauses.append("NOT is_stopword")
+ if q and q.strip():
+ clauses.append("lemma ILIKE %s")
+ params.append(f"%{q.strip()}%")
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(limit)
+ try:
+ rows = fetch_all(
+ f"""
+ SELECT lemma, MAX(token) AS token, SUM(count) AS total_count,
+ COUNT(DISTINCT storage_path) AS document_count
+ FROM document_word_counts
+ {where}
+ GROUP BY lemma
+ ORDER BY total_count DESC
+ LIMIT %s
+ """,
+ tuple(params),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "items": [_serialize(r) for r in rows]}
+
+
+@admin_router.get("/documents/list")
+def documents_list(limit: int = 50, label: str | None = None):
+ limit = max(1, min(limit, 200))
+ clauses: list[str] = []
+ params: list[Any] = []
+ if label and label.strip():
+ clauses.append("%s = ANY(user_labels)")
+ params.append(label.strip().lower())
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(limit)
+ try:
+ rows = fetch_all(
+ f"""
+ SELECT filename, storage_path, doc_type, language, word_count, unique_lemmas,
+ sentiment_label, sentiment_compound, sentiment_positive,
+ sentiment_negative, sentiment_neutral, sentiment_subjectivity,
+ extraction_method, analyzed_at,
+ COALESCE(user_labels, '{{}}') AS user_labels, label_notes, labeled_at
+ FROM document_analytics
+ {where}
+ ORDER BY analyzed_at DESC
+ LIMIT %s
+ """,
+ tuple(params),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "items": [_serialize(r) for r in rows]}
+
+
+class DocumentLabelsBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ labels: list[str] = Field(default_factory=list)
+ notes: str | None = None
+
+
+class PhotoLabelsBody(BaseModel):
+ labels: list[str] = Field(default_factory=list)
+ notes: str | None = None
+
+
+def _normalize_labels(raw: list[str]) -> list[str]:
+ seen: set[str] = set()
+ out: list[str] = []
+ for item in raw:
+ tag = (item or "").strip().lower()
+ if not tag or tag in seen:
+ continue
+ seen.add(tag)
+ out.append(tag[:64])
+ return out
+
+
+@admin_router.patch("/documents/labels")
+def documents_patch_labels(body: DocumentLabelsBody) -> dict[str, Any]:
+ labels = _normalize_labels(body.labels)
+ notes = (body.notes or "").strip() or None
+ try:
+ row = fetch_one(
+ """
+ UPDATE document_analytics
+ SET user_labels = %s, label_notes = %s, labeled_at = NOW()
+ WHERE storage_path = %s
+ RETURNING filename, storage_path, user_labels, label_notes, labeled_at
+ """,
+ (labels, notes, body.storage_path),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ execute(
+ """
+ INSERT INTO document_analytics (storage_path, filename, user_labels, label_notes, labeled_at)
+ VALUES (%s, %s, %s, %s, NOW())
+ ON CONFLICT (storage_path) DO UPDATE
+ SET user_labels = EXCLUDED.user_labels,
+ label_notes = EXCLUDED.label_notes,
+ labeled_at = NOW()
+ """,
+ (body.storage_path, body.storage_path.split("/")[-1], labels, notes),
+ )
+ row = fetch_one(
+ """
+ SELECT filename, storage_path, user_labels, label_notes, labeled_at
+ FROM document_analytics WHERE storage_path = %s
+ """,
+ (body.storage_path,),
+ )
+ return {"ok": True, "document": _serialize(row)}
+
+
+@admin_router.patch("/photos/{photo_id}/labels")
+def photos_patch_labels(photo_id: int, body: PhotoLabelsBody) -> dict[str, Any]:
+ labels = _normalize_labels(body.labels)
+ notes = (body.notes or "").strip() or None
+ try:
+ row = fetch_one(
+ """
+ UPDATE photo_imports
+ SET user_labels = %s, label_notes = %s, labeled_at = NOW()
+ WHERE id = %s
+ RETURNING id, filename, storage_path, user_labels, label_notes, labeled_at
+ """,
+ (labels, notes, photo_id),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ return {"ok": True, "photo": _serialize(row)}
+
+
+@admin_router.get("/labels/vocabulary")
+def labels_vocabulary(limit: int = 80) -> dict[str, Any]:
+ limit = max(1, min(limit, 200))
+ try:
+ rows = fetch_all(
+ """
+ SELECT label, COUNT(*) AS usage_count FROM (
+ SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM document_analytics
+ UNION ALL
+ SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM photo_imports
+ ) t
+ WHERE label IS NOT NULL AND label <> ''
+ GROUP BY label
+ ORDER BY usage_count DESC, label ASC
+ LIMIT %s
+ """,
+ (limit,),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "labels": [_serialize(r) for r in rows]}
+
+
+PHOTO_LABEL_TAXONOMY = [
+ {"id": "productfoto", "label": "Productfoto", "emoji": "📦", "color": "#00e5ff"},
+ {"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
+ {"id": "verpakking", "label": "Verpakking", "emoji": "📋", "color": "#b8ff3c"},
+ {"id": "halal", "label": "Halal certificaat", "emoji": "✅", "color": "#4ade80"},
+ {"id": "recept", "label": "Recept / technisch", "emoji": "🧪", "color": "#a78bfa"},
+ {"id": "factuur", "label": "Factuur / bon", "emoji": "🧾", "color": "#fb7185"},
+ {"id": "etiket", "label": "Etiket", "emoji": "🏷️", "color": "#f97316"},
+ {"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
+ {"id": "schap", "label": "Schap / retail", "emoji": "🛒", "color": "#22d3ee"},
+ {"id": "menu", "label": "Menu / horeca", "emoji": "🍽️", "color": "#fbbf24"},
+ {"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
+]
+
+DOCUMENT_LABEL_TAXONOMY = [
+ {"id": "contract", "label": "Contract", "emoji": "📄", "color": "#00e5ff"},
+ {"id": "presentatie", "label": "Presentatie", "emoji": "📊", "color": "#a78bfa"},
+ {"id": "rapport", "label": "Rapport", "emoji": "📈", "color": "#4ade80"},
+ {"id": "halal", "label": "Halal / certificaat", "emoji": "✅", "color": "#22c55e"},
+ {"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
+ {"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
+ {"id": "juridisch", "label": "Juridisch", "emoji": "⚖️", "color": "#fb7185"},
+ {"id": "financieel", "label": "Financieel", "emoji": "🧾", "color": "#f97316"},
+ {"id": "product", "label": "Product info", "emoji": "📦", "color": "#b8ff3c"},
+ {"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
+]
+
+
+@admin_router.get("/labels/taxonomy")
+def labels_taxonomy() -> dict[str, Any]:
+ return {
+ "ok": True,
+ "photo": PHOTO_LABEL_TAXONOMY,
+ "document": DOCUMENT_LABEL_TAXONOMY,
+ }
+
+
+class DoclingConvertBody(BaseModel):
+ path: str = Field(..., min_length=1)
+ ocr: bool = True
+ tables: bool = True
+ page_images: bool = False
+ picture_images: bool = True
+ force_full_page_ocr: bool = False
+ formats: list[str] = Field(default_factory=lambda: ["markdown", "html", "json", "text", "doctags", "yaml"])
+
+
+class DoclingBatchBody(BaseModel):
+ paths: list[str] = Field(default_factory=list)
+ limit: int = Field(default=10, ge=1, le=50)
+ ext: str | None = None
+ ocr: bool = True
+ tables: bool = True
+ page_images: bool = False
+ picture_images: bool = True
+ force_full_page_ocr: bool = False
+ formats: list[str] = Field(default_factory=lambda: ["markdown", "json"])
+
+
+@admin_router.get("/docling/capabilities")
+async def docling_capabilities_proxy() -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/capabilities")
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ return {"ok": False, "installed": False, "error": str(exc)}
+
+
+@admin_router.post("/docling/convert")
+async def docling_convert_proxy(body: DoclingConvertBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=600.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/convert", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/docling/batch")
+async def docling_batch_proxy(body: DoclingBatchBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=900.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/batch", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/docling/history")
+async def docling_history_proxy(limit: int = 30) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/history", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ return {"ok": False, "items": [], "error": str(exc)}
+
+
+@admin_router.get("/docling/result")
+async def docling_result_proxy(path: str, file_sig: str | None = None) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ params: dict[str, str] = {"path": path}
+ if file_sig:
+ params["file_sig"] = file_sig
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/result", params=params)
+ if r.status_code == 404:
+ return {"ok": False, "result": None}
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+class WorkspaceBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ content: str = ""
+ source_format: str = "markdown"
+
+
+class NasWriteBody(BaseModel):
+ path: str = Field(..., min_length=1)
+ content: str = ""
+ subdir: str = "Telegram/Exports"
+
+
+class PptxSlideBody(BaseModel):
+ title: str = ""
+ bullets: list[str] = Field(default_factory=list)
+
+
+class PptxCreateBody(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ subtitle: str = Field(default="Foodlinkk", max_length=200)
+ slides: list[PptxSlideBody] = Field(default_factory=list)
+ filename: str = ""
+
+
+class DocumentsChatMessage(BaseModel):
+ role: str = Field(..., pattern="^(user|assistant)$")
+ content: str = ""
+
+
+class DocumentsChatBody(BaseModel):
+ message: str = Field(..., min_length=1, max_length=8000)
+ path_prefix: str = ""
+ client_id: int | None = None
+ project_id: int | None = None
+ use_herman: bool = False
+ history: list[DocumentsChatMessage] = Field(default_factory=list)
+
+
+class DocumentLinkBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ client_id: int | None = None
+ project_id: int | None = None
+ is_folder: bool = False
+ notes: str = ""
+
+
+@admin_router.get("/docling/workspace")
+def docling_workspace_get(path: str) -> dict[str, Any]:
+ try:
+ row = fetch_one(
+ "SELECT storage_path, source_format, content, updated_at FROM document_workspace WHERE storage_path = %s",
+ (path,),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ return {"ok": True, "workspace": None}
+ return {"ok": True, "workspace": _serialize(row)}
+
+
+@admin_router.put("/docling/workspace")
+def docling_workspace_put(body: WorkspaceBody) -> dict[str, Any]:
+ try:
+ row = fetch_one(
+ """
+ INSERT INTO document_workspace (storage_path, source_format, content, updated_at)
+ VALUES (%s, %s, %s, NOW())
+ ON CONFLICT (storage_path) DO UPDATE
+ SET source_format = EXCLUDED.source_format,
+ content = EXCLUDED.content,
+ updated_at = NOW()
+ RETURNING storage_path, source_format, content, updated_at
+ """,
+ (body.storage_path, body.source_format, body.content),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "workspace": _serialize(row)}
+
+
+@admin_router.get("/docling/nas-read")
+async def docling_nas_read_proxy(path: str) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/docling/nas/read",
+ params={"path": path},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/documents/nas-file")
+async def documents_nas_file_proxy(path: str):
+ from fastapi.responses import Response
+
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/docling/nas/file",
+ params={"path": path},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:300])
+ media = r.headers.get("content-type", "application/octet-stream")
+ return Response(
+ content=r.content,
+ media_type=media,
+ headers={"Content-Disposition": f'inline; filename="{path.split("/")[-1]}"'},
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/docling/save-nas")
+async def docling_save_nas(body: NasWriteBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/nas/write", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/pptx/create")
+async def pptx_create_proxy(body: PptxCreateBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ payload = {
+ "title": body.title,
+ "subtitle": body.subtitle,
+ "filename": body.filename,
+ "slides": [{"title": s.title, "bullets": s.bullets} for s in body.slides],
+ }
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/convert/create-pptx", json=payload)
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/pptx/pdf-to-pptx")
+async def pptx_from_pdf_proxy(
+ path: str,
+ mode: str = "smart",
+ max_pages: int = 50,
+) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/convert/pdf-to-pptx",
+ params={"path": path, "mode": mode, "max_pages": max_pages},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/pptx/download")
+async def pptx_download_proxy(path: str):
+ from fastapi.responses import Response
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/convert/pdf-to-pptx/download",
+ params={"path": path},
+ )
+ if r.status_code != 200:
+ raise HTTPException(r.status_code, r.text[:300])
+ return Response(
+ content=r.content,
+ media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ headers={"Content-Disposition": f'attachment; filename="{path.split("/")[-1]}"'},
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/documents/{storage_path:path}/words")
+def document_words(storage_path: str, limit: int = 100, stopwords: bool = False):
+ limit = max(1, min(limit, 500))
+ clauses = ["storage_path = %s"]
+ params: list = [storage_path]
+ if not stopwords:
+ clauses.append("NOT is_stopword")
+ try:
+ rows = fetch_all(
+ f"""
+ SELECT lemma, token, count, pos_tag, is_stopword, language
+ FROM document_word_counts
+ WHERE {' AND '.join(clauses)}
+ ORDER BY count DESC
+ LIMIT %s
+ """,
+ tuple(params + [limit]),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "items": [_serialize(r) for r in rows]}
+
+BROWSER_AGENT_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790")
+
+
+class BrowserBrowseBody(BaseModel):
+ url: str
+ task: Optional[str] = None
+ site_id: Optional[int] = None
+ wait_seconds: float = 4.0
+
+
+@admin_router.post("/browser/browse")
+async def browser_browse(body: BrowserBrowseBody) -> dict[str, Any]:
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse", json=body.model_dump())
+ r.raise_for_status()
+ data = r.json()
+ except httpx.HTTPStatusError as exc:
+ detail = exc.response.text[:500]
+ raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ try:
+ meta = data.get("session") or data
+ steps = (meta.get("metadata") or {}).get("steps") or []
+ log_agent_event(
+ "browser",
+ "browse",
+ f"Browse: {(meta.get('title') or body.url)[:100]}",
+ body.task or body.url,
+ channel="browser",
+ metadata={
+ "url": body.url,
+ "final_url": meta.get("final_url"),
+ "session_id": meta.get("id"),
+ "steps": steps[:12],
+ },
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.get("/browser/sessions")
+async def browser_sessions(limit: int = 20) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/browser/sessions/{session_id}")
+async def browser_session_detail(session_id: int, include_screenshot: bool = True) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(
+ f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}",
+ params={"include_screenshot": include_screenshot},
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/browser/live/screenshot.jpg")
+async def browser_live_screenshot():
+ import httpx
+ from fastapi.responses import Response
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/live/screenshot.jpg")
+ if r.status_code == 404:
+ return Response(status_code=404)
+ return Response(content=r.content, media_type="image/jpeg")
+ except Exception:
+ return Response(status_code=404)
+
+
+@admin_router.get("/browser/sessions/{session_id}/screenshot.jpg")
+async def browser_session_screenshot(session_id: int):
+ import httpx
+ from fastapi.responses import Response
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/screenshot.jpg")
+ if r.status_code != 200:
+ raise HTTPException(status_code=r.status_code, detail="Screenshot not found")
+ return Response(content=r.content, media_type="image/jpeg")
+
+
+
+class BrowserInstructBody(BaseModel):
+ url: str
+ instruction: str
+ wait_seconds: float = 5.0
+
+
+@admin_router.post("/browser/instruct")
+async def browser_instruct(body: BrowserInstructBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=180.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/instruct", json=body.model_dump())
+ r.raise_for_status()
+ data = r.json()
+ try:
+ meta = data.get("session") or data
+ steps = (meta.get("metadata") or {}).get("steps") or [body.instruction[:200]]
+ log_agent_event(
+ "browser",
+ "browse_instruct",
+ f"Instructie: {body.instruction[:80]}",
+ " · ".join(steps[:8])[:500],
+ channel="browser",
+ metadata={"url": body.url, "instruction": body.instruction, "session_id": meta.get("id")},
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.get("/browser/sessions/{session_id}/ocr")
+async def browser_session_ocr(session_id: int) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/ocr")
+ r.raise_for_status()
+ return r.json()
+
+class BrowserVncBody(BaseModel):
+ url: str
+ instruction: Optional[str] = None
+ wait_seconds: float = 4.0
+
+
+class BrowserExtractFullBody(BaseModel):
+ url: str
+ instruction: Optional[str] = None
+ wait_seconds: float = 4.0
+ scroll_pages: int = 6
+ site_id: Optional[int] = None
+ also_vnc: bool = True
+
+
+class PhotoUploadBody(BaseModel):
+ image_b64: str
+ source: str = "cockpit"
+ filename: Optional[str] = None
+ storage_path: Optional[str] = None
+ session_id: Optional[int] = None
+
+
+@admin_router.post("/browser/vnc-navigate")
+async def browser_vnc_navigate(body: BrowserVncBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/navigate", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:500])
+ data = r.json()
+ try:
+ log_agent_event(
+ "browser",
+ "vnc_navigate",
+ f"VNC → {body.url[:100]}",
+ body.instruction or "navigate",
+ channel="browser-vnc",
+ metadata={"url": body.url, "instruction": body.instruction},
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.post("/browser/extract-full")
+async def browser_extract_full(body: BrowserExtractFullBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/extract-full", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:500])
+ data = r.json()
+ try:
+ meta = data.get("session") or data
+ log_agent_event(
+ "browser",
+ "extract_full",
+ f"Extract: {(meta.get('title') or body.url)[:100]}",
+ body.instruction or body.url,
+ channel="browser",
+ metadata={"url": body.url, "session_id": meta.get("id"), "scroll_pages": body.scroll_pages},
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.get("/photos")
+async def photos_list(limit: int = 30) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/photos/{photo_id}")
+async def photos_detail(photo_id: int) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}")
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/photos/{photo_id}/image.jpg")
+async def photos_image(photo_id: int):
+ import httpx
+ from fastapi.responses import Response
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/image.jpg")
+ if r.status_code != 200:
+ raise HTTPException(status_code=r.status_code)
+ return Response(content=r.content, media_type="image/jpeg")
+
+
+@admin_router.get("/photos/{photo_id}/detections")
+async def photos_detections(photo_id: int) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/detections")
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.post("/photos/analyze")
+async def photos_analyze(body: PhotoUploadBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/analyze", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:500])
+ return r.json()
+
+
+
+
+@admin_router.get("/documents/share-files")
+async def documents_share_files(
+ limit: int = 200,
+ ext: Optional[str] = None,
+) -> dict[str, Any]:
+ """Alle bestanden op NAS share — incl. pptx, pdf, etc."""
+ import httpx
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ limit = max(1, min(limit, 1000))
+ params: dict[str, Any] = {"limit": limit}
+ if ext:
+ params["ext"] = ext
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/nas/files", params=params)
+ r.raise_for_status()
+ data = r.json()
+ return {"ok": True, **data}
+ except Exception as exc:
+ return {"ok": False, "items": [], "error": str(exc)}
+
+
+@admin_router.post("/documents/trigger-scan")
+async def documents_trigger_scan(force: bool = False) -> dict[str, Any]:
+ """Start doc-ingest scan zodat nieuwe share-bestanden worden herkend."""
+ import httpx
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/ingest/scan",
+ params={"force": "true" if force else "false"},
+ )
+ r.raise_for_status()
+ return {"ok": True, **r.json()}
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@admin_router.get("/documents/search")
+async def documents_search(
+ q: str,
+ limit: int = 8,
+) -> dict[str, Any]:
+ """Semantisch zoeken in NAS-documenten (Chroma RAG)."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/search",
+ params={"q": q, "limit": max(1, min(limit, 20))},
+ )
+ r.raise_for_status()
+ return {"ok": True, **r.json()}
+ except Exception as exc:
+ return {"ok": False, "results": [], "error": str(exc)}
+
+
+@admin_router.get("/documents/nas-diagnostics")
+async def documents_nas_diagnostics() -> dict[str, Any]:
+ """NAS mount status + zichtbare vs. ontbrekende bestanden."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ out: dict[str, Any] = {"ok": True}
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ h = await client.get(f"{doc_url.rstrip('/')}/health")
+ h.raise_for_status()
+ out["health"] = h.json()
+ f = await client.get(f"{doc_url.rstrip('/')}/nas/files", params={"limit": 1000})
+ f.raise_for_status()
+ files = f.json()
+ out["visible_files"] = files.get("total", 0)
+ out["sample_paths"] = [i.get("path") for i in (files.get("items") or [])[:8]]
+ except Exception as exc:
+ out["ok"] = False
+ out["error"] = str(exc)
+ out["hint"] = (
+ "Zie je mappen (CUCINA/Foodlinkk) maar weinig bestanden? "
+ "Geef gebruiker aissa Read/Write op de Synology share + submappen in DSM."
+ )
+ return out
+
+
+@admin_router.post("/documents/chat")
+async def documents_chat(body: DocumentsChatBody) -> dict[str, Any]:
+ """Chat met NAS-data via RAG + Ollama (snel) of optioneel Herman."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ message = body.message.strip()
+ sources: list[str] = []
+ context = ""
+
+ linked_paths: list[str] = []
+ if body.client_id:
+ rows = fetch_all(
+ "SELECT storage_path, is_folder FROM document_links WHERE client_id = %s",
+ (body.client_id,),
+ )
+ linked_paths = [r["storage_path"] for r in rows if r.get("storage_path")]
+ if body.project_id:
+ rows = fetch_all(
+ "SELECT storage_path, is_folder FROM document_links WHERE project_id = %s",
+ (body.project_id,),
+ )
+ linked_paths.extend([r["storage_path"] for r in rows if r.get("storage_path")])
+
+ try:
+ async with httpx.AsyncClient(timeout=45.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/context",
+ params={"q": message, "limit": 8},
+ )
+ r.raise_for_status()
+ data = r.json()
+ context = (data.get("context") or "").strip()
+ sources = [s for s in (data.get("sources") or []) if s]
+ except Exception:
+ context = ""
+ sources = []
+
+ scope_prefix = (body.path_prefix or "").strip().lstrip("/")
+ if linked_paths or scope_prefix:
+ try:
+ async with httpx.AsyncClient(timeout=45.0) as client:
+ sr = await client.get(
+ f"{doc_url.rstrip('/')}/search",
+ params={"q": message, "limit": 12},
+ )
+ sr.raise_for_status()
+ hits = sr.json().get("results") or []
+
+ def _in_scope(path: str) -> bool:
+ if not path:
+ return False
+ if scope_prefix and (path == scope_prefix or path.startswith(scope_prefix + "/")):
+ return True
+ for lp in linked_paths:
+ if path == lp or path.startswith(lp.rstrip("/") + "/"):
+ return True
+ return False
+
+ scoped = [h for h in hits if _in_scope(h.get("path") or "")]
+ if scoped:
+ parts = [f"[{h.get('filename', 'doc')}] {(h.get('text') or '')[:1200]}" for h in scoped[:8]]
+ context = "\n\n".join(parts)
+ sources = [h.get("path") for h in scoped if h.get("path")]
+ except Exception:
+ pass
+
+ if body.use_herman:
+ try:
+ scoped_msg = message
+ if scope_prefix:
+ scoped_msg = f"[Scope: {scope_prefix}]\n{message}"
+ result = await herman_service.chat(scoped_msg, channel="documents")
+ result["rag_sources"] = result.get("rag_sources") or sources
+ result["ok"] = True
+ result["context_used"] = bool(context)
+ return result
+ except Exception as exc:
+ pass
+
+ client_note = ""
+ if body.client_id:
+ c = fetch_one("SELECT name FROM clients WHERE id = %s", (body.client_id,))
+ if c:
+ client_note = f"\nKlantcontext: {c['name']} (ID {body.client_id})"
+
+ system = (
+ "Je bent Herman, Foodlinkk document-assistent en second brain. Beantwoord in het Nederlands "
+ "op basis van de NAS-documentfragmenten hieronder. Citeer bronbestanden als [bestandsnaam]. "
+ "Als het antwoord niet in de context staat, zeg dat eerlijk."
+ f"{client_note}\n\nDOCUMENT CONTEXT:\n{context or '(geen documenten gevonden — geef algemeen antwoord)'}"
+ )
+ messages: list[dict[str, str]] = [{"role": "system", "content": system}]
+ for h in body.history[-6:]:
+ messages.append({"role": h.role, "content": h.content})
+ messages.append({"role": "user", "content": message})
+ try:
+ reply = await ollama.chat_messages(messages, timeout=120.0)
+ except Exception as exc:
+ raise HTTPException(502, f"LLM niet bereikbaar: {exc}") from exc
+
+ return {
+ "ok": True,
+ "agent": "knowledge",
+ "agent_label": "Herman · NAS RAG",
+ "reply": reply,
+ "rag_sources": sources,
+ "context_used": bool(context),
+ "delegated_agents": ["knowledge"],
+ }
+
+
+@admin_router.get("/documents/nas-images")
+async def documents_nas_images(limit: int = 40) -> dict[str, Any]:
+ import httpx
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ exts = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/nas/list", params={"limit": 500})
+ r.raise_for_status()
+ data = r.json()
+ files = data.get("files") or []
+ images = [f for f in files if (f.get("path") or "").lower().endswith(exts)]
+ return {"ok": True, "items": images[:limit], "total": len(images)}
+ except Exception as exc:
+ return {"ok": False, "items": [], "error": str(exc)}
+
+
+@admin_router.post("/documents/nas-ocr")
+async def documents_nas_ocr(
+ path: str | None = None,
+ force: bool = False,
+) -> dict[str, Any]:
+ """OCR alle NAS-afbeeldingen (jpg/png) + plaatjes uit pdf/docx via doc-ingest → browser-agent."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ params: dict[str, str] = {"force": "true" if force else "false"}
+ if path:
+ params["path"] = path
+ try:
+ async with httpx.AsyncClient(timeout=600.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/ingest/extract-images",
+ params=params,
+ )
+ r.raise_for_status()
+ data = r.json()
+ try:
+ log_agent_event(
+ "browser",
+ "nas_ocr_batch",
+ f"NAS OCR: {data.get('images_extracted', 0)} afbeeldingen",
+ path or "alle NAS-bestanden",
+ channel="documents",
+ metadata={"path": path, "force": force, **data},
+ )
+ except Exception:
+ pass
+ return {"ok": True, **data}
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@admin_router.get("/browser/vnc/screenshot.jpg")
+async def browser_vnc_screenshot():
+ import httpx
+ from fastapi.responses import Response
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/screenshot.jpg")
+ if r.status_code != 200:
+ return Response(status_code=404)
+ return Response(content=r.content, media_type="image/jpeg")
+ except Exception:
+ return Response(status_code=404)
+
+# --- Hermes / Telegram second brain proxies ---
+class HermesSearchBody(BaseModel):
+ query: str = Field(..., min_length=2)
+ chat_id: Optional[int] = None
+ limit: int = Field(default=15, ge=1, le=30)
+
+
+@admin_router.get("/hermes/stats")
+async def hermes_stats_proxy() -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/conversations")
+async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/feed")
+async def hermes_feed_proxy(
+ chat_id: Optional[int] = None,
+ limit: int = 80,
+ offset: int = 0,
+) -> dict[str, Any]:
+ import httpx
+ params = {"limit": limit, "offset": offset}
+ if chat_id is not None:
+ params["chat_id"] = chat_id
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params)
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/graph")
+async def hermes_graph_global(limit: int = 100) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/graph/{chat_id}")
+async def hermes_graph_chat(chat_id: int, limit: int = 80) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph/{chat_id}", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.post("/hermes/search")
+async def hermes_search_proxy(body: HermesSearchBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/brain/search",
+ json=body.model_dump(),
+ )
+ r.raise_for_status()
+ return r.json()
+
+# --- Hermes PA live + users + control (added by deploy) ---
+HERMES_CONTROL_URL = os.getenv("HERMES_CONTROL_URL", "http://10.4.7.19:8799").rstrip("/")
+
+
+@admin_router.get("/hermes/pa/live")
+async def hermes_pa_live_proxy() -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live")
+ r.raise_for_status()
+ live = r.json()
+ try:
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ ctrl = await client.get(f"{HERMES_CONTROL_URL}/status")
+ if ctrl.status_code == 200:
+ live["hermes"] = ctrl.json()
+ except Exception:
+ live["hermes"] = {"online": False}
+ return live
+
+
+@admin_router.get("/hermes/pa/live/{label}/screenshot.jpg")
+async def hermes_pa_slot_screenshot(label: str):
+ import httpx
+ from fastapi.responses import Response
+ async with httpx.AsyncClient(timeout=20.0) as client:
+ r = await client.get(
+ f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live/{label}/screenshot.jpg"
+ )
+ if r.status_code != 200:
+ raise HTTPException(status_code=r.status_code, detail="No screenshot")
+ return Response(content=r.content, media_type="image/jpeg")
+
+
+@admin_router.get("/hermes/users")
+async def hermes_users_proxy() -> dict[str, Any]:
+ import httpx
+ users: list[dict[str, Any]] = []
+ try:
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ r = await client.get(f"{HERMES_CONTROL_URL}/users")
+ if r.status_code == 200:
+ return r.json()
+ except Exception:
+ pass
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ conv = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": 20})
+ items = conv.json().get("items") or [] if conv.status_code == 200 else []
+ default = [
+ {"chat_id": 8859782446, "name": "Aïssa", "role": "CEO", "allowed": True, "pa_mode": False, "online": True},
+ {"chat_id": 789036463, "name": "Mo", "role": "CTO", "allowed": True, "pa_mode": False, "online": False},
+ ]
+ by_id = {u["chat_id"]: u for u in default}
+ for c in items:
+ cid = c.get("chat_id")
+ if cid in by_id:
+ by_id[cid]["message_count"] = c.get("message_count")
+ by_id[cid]["last_message_at"] = c.get("last_message_at")
+ by_id[cid]["online"] = True
+ else:
+ by_id[cid] = {
+ "chat_id": cid,
+ "name": c.get("user_name") or str(cid),
+ "role": c.get("user_role") or "user",
+ "allowed": True,
+ "pa_mode": False,
+ "online": True,
+ "message_count": c.get("message_count"),
+ "last_message_at": c.get("last_message_at"),
+ }
+ return {"users": list(by_id.values())}
+
+
+@admin_router.post("/hermes/actions/{action}")
+async def hermes_action_proxy(action: str) -> dict[str, Any]:
+ import httpx
+ allowed = {"evening-briefing", "morning-briefing", "resume-jobs", "test-briefing"}
+ if action not in allowed:
+ raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
+ async with httpx.AsyncClient(timeout=180.0) as client:
+ r = await client.post(f"{HERMES_CONTROL_URL}/actions/{action}")
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:300])
+ return r.json()
+
+
diff --git a/auto_ingest.py b/auto_ingest.py
new file mode 100644
index 0000000..1e95661
--- /dev/null
+++ b/auto_ingest.py
@@ -0,0 +1,130 @@
+"""Automatische NAS + second-brain synchronisatie."""
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+from typing import Any
+
+import httpx
+
+from app.db import execute, fetch_all, fetch_one
+
+log = logging.getLogger("cockpit.auto_ingest")
+
+DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
+
+
+def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
+ try:
+ execute(
+ "INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
+ (source, status, json.dumps(details)),
+ )
+ except Exception as exc:
+ log.warning("ingest log failed: %s", exc)
+
+
+def _brain_chat_id_for_client(client_id: int) -> int:
+ return -int(client_id)
+
+
+async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
+ """Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
+ rows = fetch_all(
+ """
+ SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
+ da.word_count, dl.client_id, c.name AS client_name
+ FROM document_analytics da
+ LEFT JOIN document_links dl ON (
+ da.storage_path = dl.storage_path
+ OR da.storage_path LIKE dl.storage_path || '/%'
+ ) AND dl.is_folder = TRUE
+ LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
+ LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
+ WHERE da.storage_path IS NOT NULL
+ ORDER BY da.analyzed_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (max(1, min(limit, 200)),),
+ )
+ synced = 0
+ errors = 0
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ for row in rows:
+ path = row.get("storage_path") or ""
+ if not path:
+ continue
+ client_id = row.get("client_id")
+ chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
+ text = (
+ f"NAS document: {row.get('filename') or path}\n"
+ f"Pad: {path}\nType: {row.get('doc_type')}\n"
+ f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
+ )
+ msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
+ try:
+ r = await client.post(
+ f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
+ json={
+ "chat_id": chat_id,
+ "direction": "inbound",
+ "role": "system",
+ "content_type": "document",
+ "content_text": text[:4000],
+ "agent_name": "auto-ingest",
+ "embed": True,
+ "chat_type": "client_brain" if client_id else "nas_corpus",
+ "user_name": row.get("client_name") or "NAS",
+ "content_json": {"storage_path": path, "client_id": client_id},
+ "telegram_message_id": msg_hash,
+ },
+ )
+ if r.status_code < 400:
+ synced += 1
+ else:
+ errors += 1
+ except Exception as exc:
+ log.warning("brain sync %s: %s", path, exc)
+ errors += 1
+ return {"synced": synced, "errors": errors, "candidates": len(rows)}
+
+
+async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
+ """Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
+ out: dict[str, Any] = {"ok": True, "steps": {}}
+ try:
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(
+ f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
+ params={"force": "true" if force_scan else "false"},
+ )
+ r.raise_for_status()
+ out["steps"]["nas_scan"] = r.json()
+ except Exception as exc:
+ out["steps"]["nas_scan"] = {"error": str(exc)}
+ brain = await sync_nas_to_brain()
+ out["steps"]["brain_sync"] = brain
+ status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
+ _log_run("auto_sync", status, out)
+ out["last_logged"] = True
+ return out
+
+
+def last_auto_sync() -> dict[str, Any] | None:
+ row = fetch_one(
+ "SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
+ )
+ if not row:
+ return None
+ d = dict(row)
+ if hasattr(d.get("created_at"), "isoformat"):
+ d["created_at"] = d["created_at"].isoformat()
+ if isinstance(d.get("details"), str):
+ try:
+ d["details"] = json.loads(d["details"])
+ except Exception:
+ pass
+ return d
diff --git a/browser-agent/app/main.py b/browser-agent/app/main.py
index b11cefe..f27b2fe 100644
--- a/browser-agent/app/main.py
+++ b/browser-agent/app/main.py
@@ -560,6 +560,7 @@ def photos_list(limit: int = Query(default=30, ge=1, le=100)) -> dict[str, Any]:
SELECT id, source, filename, storage_path, LEFT(ocr_text, 400) AS ocr_preview,
jsonb_array_length(COALESCE(detections, '[]'::jsonb)) AS box_count,
jsonb_array_length(COALESCE(extracted_items, '[]'::jsonb)) AS item_count,
+ COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at,
session_id, created_at
FROM photo_imports ORDER BY created_at DESC LIMIT %s
""",
@@ -589,7 +590,11 @@ def photos_image(photo_id: int) -> Response:
@app.get("/photos/{photo_id}/detections")
def photos_detections(photo_id: int) -> dict[str, Any]:
row = fetch_one(
- "SELECT id, detections, extracted_items, ocr_text FROM photo_imports WHERE id = %s",
+ """
+ SELECT id, detections, extracted_items, ocr_text,
+ COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
+ FROM photo_imports WHERE id = %s
+ """,
(photo_id,),
)
if not row:
@@ -600,6 +605,9 @@ def photos_detections(photo_id: int) -> dict[str, Any]:
"detections": row.get("detections") or [],
"extracted_items": row.get("extracted_items") or [],
"ocr_text": row.get("ocr_text") or "",
+ "user_labels": row.get("user_labels") or [],
+ "label_notes": row.get("label_notes") or "",
+ "labeled_at": row.get("labeled_at"),
}
diff --git a/client_360.py b/client_360.py
new file mode 100644
index 0000000..ab14c09
--- /dev/null
+++ b/client_360.py
@@ -0,0 +1,147 @@
+"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
+from __future__ import annotations
+
+from typing import Any
+
+from app.db import fetch_all, fetch_one
+from app.services import projects as projects_svc
+
+
+def _serialize_row(row: dict | None) -> dict | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ return out
+
+
+def _serialize_rows(rows: list) -> list[dict]:
+ return [_serialize_row(r) for r in rows if r]
+
+
+def get_client_360(client_id: int) -> dict[str, Any]:
+ client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
+ if not client:
+ return {"ok": False, "error": "Client not found"}
+
+ projs = projects_svc.list_projects(client_id=client_id, limit=50)
+ links = fetch_all(
+ """
+ SELECT dl.*, p.name AS project_name
+ FROM document_links dl
+ LEFT JOIN cockpit_projects p ON p.id = dl.project_id
+ WHERE dl.client_id = %s
+ ORDER BY dl.created_at DESC
+ """,
+ (client_id,),
+ )
+ paths = [l["storage_path"] for l in links if l.get("storage_path")]
+ docs: list[dict] = []
+ if paths:
+ placeholders = ",".join(["%s"] * len(paths))
+ docs = fetch_all(
+ f"""
+ SELECT filename, storage_path, doc_type, word_count, sentiment_label,
+ sentiment_compound, analyzed_at, user_labels
+ FROM document_analytics
+ WHERE storage_path IN ({placeholders})
+ OR storage_path LIKE ANY (
+ SELECT dl.storage_path || '/%%' FROM document_links dl
+ WHERE dl.client_id = %s AND dl.is_folder = TRUE
+ )
+ ORDER BY analyzed_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ tuple(paths + [client_id]),
+ )
+ sentiment_rows = fetch_all(
+ """
+ SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
+ FROM document_analytics da
+ WHERE EXISTS (
+ SELECT 1 FROM document_links dl
+ WHERE dl.client_id = %s
+ AND (da.storage_path = dl.storage_path
+ OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
+ )
+ GROUP BY sentiment_label
+ """,
+ (client_id,),
+ )
+ top_words = fetch_all(
+ """
+ SELECT dwc.lemma, SUM(dwc.count) AS total
+ FROM document_word_counts dwc
+ JOIN document_analytics da ON da.id = dwc.document_id
+ WHERE EXISTS (
+ SELECT 1 FROM document_links dl
+ WHERE dl.client_id = %s
+ AND (da.storage_path = dl.storage_path
+ OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
+ )
+ AND NOT dwc.is_stopword
+ GROUP BY dwc.lemma
+ ORDER BY total DESC
+ LIMIT 25
+ """,
+ (client_id,),
+ )
+ deals = fetch_all(
+ "SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
+ (client_id,),
+ )
+ stores = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
+ FROM client_supermarket_links l
+ JOIN supermarkets s ON s.id = l.supermarket_id
+ WHERE l.client_id = %s
+ LIMIT 30
+ """,
+ (client_id,),
+ )
+ trends = fetch_all(
+ """
+ SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
+ COUNT(*) AS docs,
+ AVG(da.sentiment_compound) AS avg_sentiment,
+ SUM(da.word_count) AS words
+ FROM document_analytics da
+ WHERE da.analyzed_at IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM document_links dl
+ WHERE dl.client_id = %s
+ AND (da.storage_path = dl.storage_path
+ OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
+ )
+ GROUP BY 1
+ ORDER BY 1 DESC
+ LIMIT 12
+ """,
+ (client_id,),
+ )
+ for t in trends:
+ if hasattr(t.get("month"), "isoformat"):
+ t["month"] = t["month"].isoformat()
+
+ return {
+ "ok": True,
+ "client": _serialize_row(client),
+ "projects": projs,
+ "links": _serialize_rows(links),
+ "documents": _serialize_rows(docs),
+ "sentiment_breakdown": _serialize_rows(sentiment_rows),
+ "top_words": _serialize_rows(top_words),
+ "deals": _serialize_rows(deals),
+ "stores": _serialize_rows(stores),
+ "monthly_trends": trends,
+ "stats": {
+ "linked_paths": len(links),
+ "documents": len(docs),
+ "projects": len(projs),
+ "stores": len(stores),
+ "deals": len(deals),
+ },
+ }
diff --git a/cockpit/app/config.py b/cockpit/app/config.py
index 4642a43..b5cc9ba 100644
--- a/cockpit/app/config.py
+++ b/cockpit/app/config.py
@@ -10,6 +10,10 @@ class Settings:
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b")
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090")
+ WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
+ WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
+ WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
+ HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798")
CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma")
CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000"))
MINIO_ENDPOINT: str = os.getenv("MINIO_ENDPOINT", "minio:9000")
diff --git a/cockpit/app/main.py b/cockpit/app/main.py
index ca3fc16..abbd629 100644
--- a/cockpit/app/main.py
+++ b/cockpit/app/main.py
@@ -5,6 +5,7 @@ from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
+from starlette.responses import Response
from app.db import close_pool, fetch_all, init_pool
from app.routes import (
@@ -31,7 +32,10 @@ from app.routes import (
packaging,
ops,
ops_api,
+ revenue_cockpit,
+ export_intel,
)
+from app.routes.revenue_cockpit import api as revenue_cockpit_api
from app.routes.admin_api import admin_router, ai_router, herman_api, voice_api
from app.routes.settings_api import settings_router
from app.routes.agents_api import router as agents_api_router
@@ -41,8 +45,21 @@ from app.routes.projects_api import router as projects_api_router
BASE_DIR = Path(__file__).resolve().parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+class NoCacheStaticFiles(StaticFiles):
+ """Serve static assets without browser/SW long-lived caching."""
+
+ async def get_response(self, path: str, scope) -> Response:
+ response = await super().get_response(path, scope)
+ if path.endswith((".css", ".js", ".html")) or "/css/" in path or "/js/" in path:
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ return response
+
+
app = FastAPI(title="Foodlinkk Command Center", version="2.5.0")
-app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
+app.mount("/static", NoCacheStaticFiles(directory=str(BASE_DIR / "static")), name="static")
for r in (
dashboard.router,
@@ -66,6 +83,9 @@ for r in (
hermes.router,
packaging.router,
ops.router,
+ revenue_cockpit.router,
+ export_intel.router,
+ revenue_cockpit_api,
api.router,
ops_api.router,
admin_router,
diff --git a/cockpit/app/routes/admin_api.py b/cockpit/app/routes/admin_api.py
index 7e835dc..83f1e7b 100644
--- a/cockpit/app/routes/admin_api.py
+++ b/cockpit/app/routes/admin_api.py
@@ -8,15 +8,19 @@ from datetime import datetime
from typing import Any, Optional
import httpx
-from fastapi import APIRouter, File, HTTPException, UploadFile
+from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import herman as herman_service
-from app.services.marketing import evaluate_agent_rules, sentiment_score
-from app.services.monitor import add_site, remove_site, trigger_crawl
from app.services import ollama
+from app.services import llm_router
+from app.services.auto_ingest import last_auto_sync, run_full_auto_sync
+from app.services.client_360 import get_client_360
+from app.services import nas_folders
+from app.services.monitor import add_site, build_parse_intelligence, get_parse_page, list_parse_results, remove_site, trigger_crawl
+from app.services.agent_events_log import log_agent_event
admin_router = APIRouter(prefix="/api/admin", tags=["admin-api"])
register_recommendation_routes(admin_router)
@@ -138,6 +142,9 @@ class AIPostDraftBody(BaseModel):
class HermanChatBody(BaseModel):
message: str
+ channel: str = "cockpit"
+ session_id: Optional[str] = None
+ confirm_action_id: Optional[str] = None
class ImageGenerateBody(BaseModel):
@@ -188,6 +195,10 @@ def create_client(body: ClientBody):
(body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes),
)
cid = row["id"]
+ try:
+ nas_folders.ensure_client_folder(cid, body.name)
+ except Exception:
+ pass
return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (cid,)))}
@@ -280,6 +291,86 @@ def client_detail(client_id: int):
}
+@admin_router.get("/clients/{client_id}/360")
+def client_360_view(client_id: int) -> dict[str, Any]:
+ data = get_client_360(client_id)
+ if not data.get("ok"):
+ raise HTTPException(404, data.get("error", "not found"))
+ return data
+
+
+@admin_router.get("/documents/links")
+def document_links_list(
+ client_id: int | None = None,
+ project_id: int | None = None,
+) -> dict[str, Any]:
+ clauses, params = [], []
+ if client_id:
+ clauses.append("dl.client_id = %s")
+ params.append(client_id)
+ if project_id:
+ clauses.append("dl.project_id = %s")
+ params.append(project_id)
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+ rows = fetch_all(
+ f"""
+ SELECT dl.*, c.name AS client_name, p.name AS project_name
+ FROM document_links dl
+ LEFT JOIN clients c ON c.id = dl.client_id
+ LEFT JOIN cockpit_projects p ON p.id = dl.project_id
+ {where}
+ ORDER BY dl.created_at DESC
+ LIMIT 500
+ """,
+ tuple(params),
+ )
+ return {"ok": True, "items": _serialize_rows(rows)}
+
+
+@admin_router.post("/documents/links")
+def document_links_create(body: DocumentLinkBody) -> dict[str, Any]:
+ if not body.client_id and not body.project_id:
+ raise HTTPException(400, "client_id of project_id vereist")
+ row = fetch_one(
+ """
+ INSERT INTO document_links (storage_path, is_folder, client_id, project_id, notes)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (body.storage_path.strip(), body.is_folder, body.client_id, body.project_id, body.notes or ""),
+ )
+ if body.project_id and row:
+ try:
+ from app.services import projects as projects_svc
+ projects_svc.add_asset(
+ body.project_id,
+ "nas_file",
+ body.storage_path.split("/")[-1],
+ file_path=body.storage_path.strip(),
+ source_agent="documents",
+ )
+ except Exception:
+ pass
+ return {"ok": True, "item": _serialize(row)}
+
+
+@admin_router.delete("/documents/links/{link_id}")
+def document_links_delete(link_id: int) -> dict[str, Any]:
+ execute("DELETE FROM document_links WHERE id = %s", (link_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/brain/auto-sync")
+async def brain_auto_sync(force: bool = False) -> dict[str, Any]:
+ return await run_full_auto_sync(force_scan=force)
+
+
+@admin_router.get("/brain/auto-sync/status")
+def brain_auto_sync_status() -> dict[str, Any]:
+ last = last_auto_sync()
+ return {"ok": True, "last": last}
+
+
# --- Deals CRUD ---
@admin_router.get("/deals")
@@ -560,6 +651,47 @@ def delete_scheduled_post(post_id: int):
return {"ok": True}
+@admin_router.post("/scheduled-posts/publish-due")
+def publish_due_scheduled_posts():
+ """Mark due scheduled posts as published (marketing cron)."""
+ rows = fetch_all(
+ """
+ SELECT id, content FROM scheduled_posts
+ WHERE status = 'scheduled' AND scheduled_time <= NOW()
+ ORDER BY scheduled_time ASC
+ LIMIT 50
+ """
+ )
+ published = []
+ for row in rows:
+ pid = int(row["id"])
+ execute(
+ "UPDATE scheduled_posts SET status = 'published', posted_at = NOW() WHERE id = %s",
+ (pid,),
+ )
+ try:
+ execute(
+ """
+ INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ """,
+ (
+ "marketing",
+ "marketing",
+ "post_published",
+ f"Scheduled post #{pid} gepubliceerd",
+ (row.get("content") or "")[:500],
+ "completed",
+ "cron",
+ json.dumps({"post_id": pid}),
+ ),
+ )
+ except Exception:
+ pass
+ published.append(pid)
+ return {"ok": True, "published": published, "count": len(published)}
+
+
@admin_router.get("/agent-rules")
def list_agent_rules():
evaluate_agent_rules()
@@ -655,6 +787,17 @@ def create_monitor_site(body: SiteBody):
for k, v in list(item.items()):
if hasattr(v, "isoformat"):
item[k] = v.isoformat()
+ try:
+ log_agent_event(
+ "browser",
+ "monitor_site_added",
+ f"Site toegevoegd: {(body.name or body.url)[:80]}",
+ body.url,
+ channel="monitor",
+ metadata={"url": body.url, "name": body.name, "site_id": item.get("id")},
+ )
+ except Exception:
+ pass
return {"ok": True, "item": item}
@@ -672,7 +815,47 @@ def toggle_monitor_site(site_id: int):
@admin_router.post("/monitor/trigger-crawl")
def monitor_trigger_crawl(body: CrawlBody = CrawlBody()):
- return trigger_crawl(body.site_id)
+ result = trigger_crawl(body.site_id)
+ summary_bits = []
+ for r in result.get("results") or []:
+ if r.get("status") == "ERROR":
+ summary_bits.append(f"{r.get('name') or r.get('url')}: fout")
+ else:
+ summary_bits.append(
+ f"{r.get('title') or r.get('name')}: {r.get('word_count', 0)} woorden"
+ )
+ try:
+ log_agent_event(
+ "browser",
+ "monitor_crawl",
+ f"Crawl klaar — {result.get('sites', 0)} site(s), {result.get('changed', 0)} wijziging(en)",
+ "; ".join(summary_bits)[:800],
+ channel="monitor",
+ metadata={"site_id": body.site_id, **{k: v for k, v in result.items() if k != "results"}},
+ )
+ except Exception:
+ pass
+ return result
+
+
+@admin_router.get("/monitor/parse-results")
+def monitor_parse_results(site_id: int | None = None, limit: int = 20):
+ items = list_parse_results(site_id=site_id, limit=limit)
+ return {"items": items, "count": len(items)}
+
+
+@admin_router.get("/monitor/intelligence")
+def monitor_intelligence(site_id: int | None = None, q: str | None = None, limit: int = 100):
+ data = build_parse_intelligence(site_id=site_id, query=q, limit=min(limit, 100))
+ return data
+
+
+@admin_router.get("/monitor/parse-results/{page_id}")
+def monitor_parse_page_detail(page_id: int):
+ page = get_parse_page(page_id)
+ if not page:
+ raise HTTPException(404, "Parse result not found")
+ return {"page": page}
@admin_router.get("/monitor/page-changes")
@@ -718,9 +901,9 @@ async def delegate_task(body: DelegateBody):
async def _generate_social(prompt: str) -> str:
try:
- return await ollama.generate(prompt)
+ return await llm_router.generate(prompt)
except Exception as exc:
- raise HTTPException(502, f"Ollama error: {exc}") from exc
+ raise HTTPException(502, f"LLM error: {exc}") from exc
@ai_router.post("/generate-content")
@@ -839,28 +1022,153 @@ async def ai_generate_post_draft(body: AIPostDraftBody):
async def herman_chat_api(body: HermanChatBody):
if not body.message.strip():
raise HTTPException(400, "message required")
- result = await herman_service.chat(body.message.strip())
+ result = await herman_service.chat(
+ body.message.strip(),
+ channel=(body.channel or "cockpit").strip(),
+ session_id=body.session_id,
+ confirm_action_id=body.confirm_action_id,
+ )
return {"ok": True, **result}
-@voice_api.post("/transcribe")
-async def voice_transcribe(file: UploadFile = File(...)):
- url = "http://10.4.7.19:8877/v1/audio/transcriptions"
- data = await file.read()
+def _log_voice_pipeline(title: str, body: str = "", metadata: dict | None = None, event_type: str = "voice_pipeline") -> None:
+ try:
+ execute(
+ """INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
+ (
+ "voice",
+ "voice",
+ event_type,
+ title[:255],
+ body[:4000] if body else "",
+ "completed",
+ "voice",
+ json.dumps(metadata or {}),
+ ),
+ )
+ except Exception:
+ pass
+
+
+async def _whisper_transcribe_bytes(data: bytes, filename: str, content_type: str) -> dict[str, Any]:
if not data:
raise HTTPException(400, "empty file")
+ url = settings.WHISPER_API_URL
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
url,
- files={"file": (file.filename or "audio.webm", data, file.content_type or "audio/webm")},
- data={"model": "Systran/faster-whisper-base", "language": "nl"},
+ files={"file": (filename or "audio.webm", data, content_type or "audio/webm")},
+ data={
+ "model": settings.WHISPER_MODEL,
+ "language": settings.WHISPER_LANGUAGE,
+ },
)
resp.raise_for_status()
- data = resp.json()
- return {"text": data.get("text", ""), "raw": data}
+ raw = resp.json()
+ return {"text": (raw.get("text") or "").strip(), "raw": raw}
except httpx.HTTPError as exc:
- raise HTTPException(502, f"transcribe proxy failed: {exc}") from exc
+ raise HTTPException(502, f"Whisper niet bereikbaar ({url}): {exc}") from exc
+
+
+@voice_api.post("/transcribe")
+async def voice_transcribe(file: UploadFile = File(...)):
+ data = await file.read()
+ result = await _whisper_transcribe_bytes(data, file.filename or "audio.webm", file.content_type or "audio/webm")
+ text = result["text"]
+ _log_voice_pipeline(
+ "Whisper transcript",
+ text,
+ {"filename": file.filename, "bytes": len(data)},
+ event_type="voice_stt",
+ )
+ return {"text": text, "raw": result["raw"]}
+
+
+@voice_api.post("/turn")
+async def voice_turn(
+ file: UploadFile = File(...),
+ session_id: Optional[str] = Form(None),
+ confirm_action_id: Optional[str] = Form(None),
+):
+ """Eén stem-beurt: STT → Herman (channel=voice) met pipeline-metadata."""
+ pipeline: list[dict[str, Any]] = []
+
+ def step(phase: str, label: str, detail: str = "", status: str = "done") -> None:
+ entry = {
+ "phase": phase,
+ "label": label,
+ "detail": detail,
+ "status": status,
+ "at": datetime.utcnow().isoformat() + "Z",
+ }
+ pipeline.append(entry)
+
+ data = await file.read()
+ step("capture", "Audio ontvangen", f"{len(data)} bytes · {file.filename or 'audio.webm'}")
+ step("stt", "Whisper STT gestart", settings.WHISPER_API_URL, "running")
+ try:
+ result = await _whisper_transcribe_bytes(
+ data, file.filename or "audio.webm", file.content_type or "audio/webm"
+ )
+ except HTTPException as exc:
+ pipeline[-1]["status"] = "error"
+ pipeline[-1]["detail"] = str(exc.detail)
+ raise
+ text = result["text"]
+ pipeline[-1]["status"] = "done"
+ pipeline[-1]["detail"] = text or "(leeg — probeer opnieuw)"
+ if not text:
+ return {"ok": False, "text": "", "pipeline": pipeline, "error": "Geen spraak herkend"}
+
+ _log_voice_pipeline("Jij zei", text, event_type="voice_user")
+
+ step("herman", "Herman orchestrator", f"{settings.HERMAN_ORCHESTRATOR_URL} · channel=voice", "running")
+ herman = await herman_service.chat(
+ text,
+ channel="voice",
+ session_id=session_id,
+ confirm_action_id=confirm_action_id,
+ )
+ pipeline[-1]["status"] = "done"
+ pipeline[-1]["detail"] = herman.get("agent_label") or "Herman"
+
+ delegated = [a for a in (herman.get("delegated_agents") or []) if str(a).lower() != "herman"]
+ if delegated:
+ step(
+ "delegate",
+ "Agents aangestuurd",
+ ", ".join(delegated) + ((" — " + herman.get("routing_reason", "")) if herman.get("routing_reason") else ""),
+ )
+ elif herman.get("routing_reason"):
+ step("route", "Routing", herman.get("routing_reason", ""))
+
+ for s in herman.get("agent_steps") or []:
+ step("agent_step", s.get("agent", "agent"), s.get("message", ""))
+
+ reply = herman.get("reply") or ""
+ if reply:
+ _log_voice_pipeline(
+ herman.get("agent_label") or "Herman",
+ reply[:2000],
+ {"delegated": delegated, "routing_reason": herman.get("routing_reason")},
+ event_type="voice_reply",
+ )
+ step("reply", "Antwoord klaar", reply[:280] + ("…" if len(reply) > 280 else ""))
+
+ if herman.get("needs_confirmation"):
+ step("confirm", "Bevestiging gevraagd", (herman.get("pending_action") or {}).get("entity_label", ""))
+ for ua in herman.get("ui_actions") or []:
+ if ua.get("type") == "show_export_results":
+ step("ui", "Resultaten popup", f"{ua.get('total', 0)} entiteiten")
+
+ return {
+ "ok": True,
+ "text": text,
+ "herman": herman,
+ "pipeline": pipeline,
+ }
# --- Document word analytics & sentiment ---
@@ -934,24 +1242,544 @@ def documents_words(
@admin_router.get("/documents/list")
-def documents_list(limit: int = 50):
+def documents_list(limit: int = 50, label: str | None = None):
+ limit = max(1, min(limit, 200))
+ clauses: list[str] = []
+ params: list[Any] = []
+ if label and label.strip():
+ clauses.append("%s = ANY(user_labels)")
+ params.append(label.strip().lower())
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(limit)
+ try:
+ rows = fetch_all(
+ f"""
+ SELECT filename, storage_path, doc_type, language, word_count, unique_lemmas,
+ sentiment_label, sentiment_compound, sentiment_positive,
+ sentiment_negative, sentiment_neutral, sentiment_subjectivity,
+ extraction_method, analyzed_at,
+ COALESCE(user_labels, '{{}}') AS user_labels, label_notes, labeled_at
+ FROM document_analytics
+ {where}
+ ORDER BY analyzed_at DESC
+ LIMIT %s
+ """,
+ tuple(params),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "items": [_serialize(r) for r in rows]}
+
+
+class DocumentLabelsBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ labels: list[str] = Field(default_factory=list)
+ notes: str | None = None
+
+
+class PhotoLabelsBody(BaseModel):
+ labels: list[str] = Field(default_factory=list)
+ notes: str | None = None
+
+
+def _normalize_labels(raw: list[str]) -> list[str]:
+ seen: set[str] = set()
+ out: list[str] = []
+ for item in raw:
+ tag = (item or "").strip().lower()
+ if not tag or tag in seen:
+ continue
+ seen.add(tag)
+ out.append(tag[:64])
+ return out
+
+
+@admin_router.patch("/documents/labels")
+def documents_patch_labels(body: DocumentLabelsBody) -> dict[str, Any]:
+ labels = _normalize_labels(body.labels)
+ notes = (body.notes or "").strip() or None
+ try:
+ row = fetch_one(
+ """
+ UPDATE document_analytics
+ SET user_labels = %s, label_notes = %s, labeled_at = NOW()
+ WHERE storage_path = %s
+ RETURNING filename, storage_path, user_labels, label_notes, labeled_at
+ """,
+ (labels, notes, body.storage_path),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ execute(
+ """
+ INSERT INTO document_analytics (storage_path, filename, user_labels, label_notes, labeled_at)
+ VALUES (%s, %s, %s, %s, NOW())
+ ON CONFLICT (storage_path) DO UPDATE
+ SET user_labels = EXCLUDED.user_labels,
+ label_notes = EXCLUDED.label_notes,
+ labeled_at = NOW()
+ """,
+ (body.storage_path, body.storage_path.split("/")[-1], labels, notes),
+ )
+ row = fetch_one(
+ """
+ SELECT filename, storage_path, user_labels, label_notes, labeled_at
+ FROM document_analytics WHERE storage_path = %s
+ """,
+ (body.storage_path,),
+ )
+ return {"ok": True, "document": _serialize(row)}
+
+
+@admin_router.patch("/photos/{photo_id}/labels")
+def photos_patch_labels(photo_id: int, body: PhotoLabelsBody) -> dict[str, Any]:
+ labels = _normalize_labels(body.labels)
+ notes = (body.notes or "").strip() or None
+ try:
+ row = fetch_one(
+ """
+ UPDATE photo_imports
+ SET user_labels = %s, label_notes = %s, labeled_at = NOW()
+ WHERE id = %s
+ RETURNING id, filename, storage_path, user_labels, label_notes, labeled_at
+ """,
+ (labels, notes, photo_id),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ return {"ok": True, "photo": _serialize(row)}
+
+
+@admin_router.get("/labels/vocabulary")
+def labels_vocabulary(limit: int = 80) -> dict[str, Any]:
limit = max(1, min(limit, 200))
try:
rows = fetch_all(
"""
- SELECT filename, storage_path, doc_type, language, word_count, unique_lemmas,
- sentiment_label, sentiment_compound, sentiment_positive,
- sentiment_negative, sentiment_neutral, sentiment_subjectivity,
- extraction_method, analyzed_at
- FROM document_analytics
- ORDER BY analyzed_at DESC
+ SELECT label, COUNT(*) AS usage_count FROM (
+ SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM document_analytics
+ UNION ALL
+ SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM photo_imports
+ ) t
+ WHERE label IS NOT NULL AND label <> ''
+ GROUP BY label
+ ORDER BY usage_count DESC, label ASC
LIMIT %s
""",
(limit,),
)
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
- return {"ok": True, "items": [_serialize(r) for r in rows]}
+ return {"ok": True, "labels": [_serialize(r) for r in rows]}
+
+
+PHOTO_LABEL_TAXONOMY = [
+ {"id": "productfoto", "label": "Productfoto", "emoji": "📦", "color": "#00e5ff"},
+ {"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
+ {"id": "verpakking", "label": "Verpakking", "emoji": "📋", "color": "#b8ff3c"},
+ {"id": "halal", "label": "Halal certificaat", "emoji": "✅", "color": "#4ade80"},
+ {"id": "recept", "label": "Recept / technisch", "emoji": "🧪", "color": "#a78bfa"},
+ {"id": "factuur", "label": "Factuur / bon", "emoji": "🧾", "color": "#fb7185"},
+ {"id": "etiket", "label": "Etiket", "emoji": "🏷️", "color": "#f97316"},
+ {"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
+ {"id": "schap", "label": "Schap / retail", "emoji": "🛒", "color": "#22d3ee"},
+ {"id": "menu", "label": "Menu / horeca", "emoji": "🍽️", "color": "#fbbf24"},
+ {"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
+]
+
+DOCUMENT_LABEL_TAXONOMY = [
+ {"id": "contract", "label": "Contract", "emoji": "📄", "color": "#00e5ff"},
+ {"id": "presentatie", "label": "Presentatie", "emoji": "📊", "color": "#a78bfa"},
+ {"id": "rapport", "label": "Rapport", "emoji": "📈", "color": "#4ade80"},
+ {"id": "halal", "label": "Halal / certificaat", "emoji": "✅", "color": "#22c55e"},
+ {"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
+ {"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
+ {"id": "juridisch", "label": "Juridisch", "emoji": "⚖️", "color": "#fb7185"},
+ {"id": "financieel", "label": "Financieel", "emoji": "🧾", "color": "#f97316"},
+ {"id": "product", "label": "Product info", "emoji": "📦", "color": "#b8ff3c"},
+ {"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
+]
+
+
+@admin_router.get("/labels/taxonomy")
+def labels_taxonomy() -> dict[str, Any]:
+ return {
+ "ok": True,
+ "photo": PHOTO_LABEL_TAXONOMY,
+ "document": DOCUMENT_LABEL_TAXONOMY,
+ }
+
+
+class DoclingConvertBody(BaseModel):
+ path: str = Field(..., min_length=1)
+ ocr: bool = True
+ tables: bool = True
+ page_images: bool = False
+ picture_images: bool = True
+ force_full_page_ocr: bool = False
+ formats: list[str] = Field(default_factory=lambda: ["markdown", "html", "json", "text", "doctags", "yaml"])
+
+
+class DoclingBatchBody(BaseModel):
+ paths: list[str] = Field(default_factory=list)
+ limit: int = Field(default=10, ge=1, le=50)
+ ext: str | None = None
+ ocr: bool = True
+ tables: bool = True
+ page_images: bool = False
+ picture_images: bool = True
+ force_full_page_ocr: bool = False
+ formats: list[str] = Field(default_factory=lambda: ["markdown", "json"])
+
+
+@admin_router.get("/docling/capabilities")
+async def docling_capabilities_proxy() -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/capabilities")
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ return {"ok": False, "installed": False, "error": str(exc)}
+
+
+@admin_router.post("/docling/convert")
+async def docling_convert_proxy(body: DoclingConvertBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=600.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/convert", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/docling/batch")
+async def docling_batch_proxy(body: DoclingBatchBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=900.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/batch", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/docling/history")
+async def docling_history_proxy(limit: int = 30) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/history", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ return {"ok": False, "items": [], "error": str(exc)}
+
+
+@admin_router.get("/docling/result")
+async def docling_result_proxy(path: str, file_sig: str | None = None) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ params: dict[str, str] = {"path": path}
+ if file_sig:
+ params["file_sig"] = file_sig
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/result", params=params)
+ if r.status_code == 404:
+ return {"ok": False, "result": None}
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+class WorkspaceBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ content: str = ""
+ source_format: str = "markdown"
+
+
+class NasWriteBody(BaseModel):
+ path: str = Field(..., min_length=1)
+ content: str = ""
+ subdir: str = "Telegram/Exports"
+
+
+class PptxSlideBody(BaseModel):
+ title: str = ""
+ bullets: list[str] = Field(default_factory=list)
+
+
+class PptxCreateBody(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ subtitle: str = Field(default="Foodlinkk", max_length=200)
+ slides: list[PptxSlideBody] = Field(default_factory=list)
+ filename: str = ""
+
+
+class DocumentsChatMessage(BaseModel):
+ role: str = Field(..., pattern="^(user|assistant)$")
+ content: str = ""
+
+
+class DocumentsChatBody(BaseModel):
+ message: str = Field(..., min_length=1, max_length=8000)
+ path_prefix: str = ""
+ client_id: int | None = None
+ project_id: int | None = None
+ use_herman: bool = False
+ llm_provider_id: int | None = None
+ history: list[DocumentsChatMessage] = Field(default_factory=list)
+
+
+class DocumentLinkBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ client_id: int | None = None
+ project_id: int | None = None
+ is_folder: bool = False
+ notes: str = ""
+
+
+@admin_router.get("/docling/workspace")
+def docling_workspace_get(path: str) -> dict[str, Any]:
+ try:
+ row = fetch_one(
+ "SELECT storage_path, source_format, content, updated_at FROM document_workspace WHERE storage_path = %s",
+ (path,),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ return {"ok": True, "workspace": None}
+ return {"ok": True, "workspace": _serialize(row)}
+
+
+@admin_router.put("/docling/workspace")
+def docling_workspace_put(body: WorkspaceBody) -> dict[str, Any]:
+ try:
+ row = fetch_one(
+ """
+ INSERT INTO document_workspace (storage_path, source_format, content, updated_at)
+ VALUES (%s, %s, %s, NOW())
+ ON CONFLICT (storage_path) DO UPDATE
+ SET source_format = EXCLUDED.source_format,
+ content = EXCLUDED.content,
+ updated_at = NOW()
+ RETURNING storage_path, source_format, content, updated_at
+ """,
+ (body.storage_path, body.source_format, body.content),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "workspace": _serialize(row)}
+
+
+class DocumentVersionBody(BaseModel):
+ storage_path: str
+ content: str = ""
+ source_format: str = "markdown"
+ version_label: str = ""
+ nas_mtime: Optional[str] = None
+
+
+@admin_router.get("/docling/versions")
+def docling_versions_list(path: str, limit: int = 40) -> dict[str, Any]:
+ limit = max(1, min(limit, 100))
+ try:
+ rows = fetch_all(
+ """
+ SELECT id, storage_path, source_format, version_label, nas_mtime, created_at,
+ LENGTH(content) AS content_length
+ FROM document_versions
+ WHERE storage_path = %s
+ ORDER BY created_at DESC
+ LIMIT %s
+ """,
+ (path, limit),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "versions": [_serialize(r) for r in rows]}
+
+
+@admin_router.get("/docling/versions/{version_id}")
+def docling_version_get(version_id: int) -> dict[str, Any]:
+ try:
+ row = fetch_one(
+ """
+ SELECT id, storage_path, content, source_format, version_label, nas_mtime, created_at
+ FROM document_versions WHERE id = %s
+ """,
+ (version_id,),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ raise HTTPException(404, "Versie niet gevonden")
+ return {"ok": True, "version": _serialize(row)}
+
+
+@admin_router.post("/docling/versions")
+def docling_version_create(body: DocumentVersionBody) -> dict[str, Any]:
+ nas_mtime = body.nas_mtime or None
+ try:
+ row = fetch_one(
+ """
+ INSERT INTO document_versions
+ (storage_path, content, source_format, version_label, nas_mtime)
+ VALUES (%s, %s, %s, %s, %s::timestamptz)
+ RETURNING id, storage_path, source_format, version_label, nas_mtime, created_at
+ """,
+ (
+ body.storage_path,
+ body.content,
+ body.source_format,
+ body.version_label or "bewerking",
+ nas_mtime,
+ ),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "version": _serialize(row)}
+
+
+@admin_router.get("/docling/nas-read")
+async def docling_nas_read_proxy(path: str) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/docling/nas/read",
+ params={"path": path},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/documents/nas-file")
+async def documents_nas_file_proxy(path: str, inline: bool = True):
+ from fastapi.responses import Response
+
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/docling/nas/file",
+ params={"path": path, "inline": "true" if inline else "false"},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:300])
+ media = r.headers.get("content-type", "application/octet-stream")
+ fname = path.split("/")[-1]
+ disp = "inline" if inline else "attachment"
+ return Response(
+ content=r.content,
+ media_type=media,
+ headers={"Content-Disposition": f'{disp}; filename="{fname}"'},
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/docling/save-nas")
+async def docling_save_nas(body: NasWriteBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/nas/write", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/pptx/create")
+async def pptx_create_proxy(body: PptxCreateBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ payload = {
+ "title": body.title,
+ "subtitle": body.subtitle,
+ "filename": body.filename,
+ "slides": [{"title": s.title, "bullets": s.bullets} for s in body.slides],
+ }
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/convert/create-pptx", json=payload)
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/pptx/pdf-to-pptx")
+async def pptx_from_pdf_proxy(
+ path: str,
+ mode: str = "smart",
+ max_pages: int = 50,
+) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/convert/pdf-to-pptx",
+ params={"path": path, "mode": mode, "max_pages": max_pages},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/pptx/download")
+async def pptx_download_proxy(path: str):
+ from fastapi.responses import Response
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/convert/pdf-to-pptx/download",
+ params={"path": path},
+ )
+ if r.status_code != 200:
+ raise HTTPException(r.status_code, r.text[:300])
+ return Response(
+ content=r.content,
+ media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ headers={"Content-Disposition": f'attachment; filename="{path.split("/")[-1]}"'},
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
@admin_router.get("/documents/{storage_path:path}/words")
@@ -993,12 +1821,31 @@ async def browser_browse(body: BrowserBrowseBody) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse", json=body.model_dump())
r.raise_for_status()
- return r.json()
+ data = r.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:500]
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
+ try:
+ meta = data.get("session") or data
+ steps = (meta.get("metadata") or {}).get("steps") or []
+ log_agent_event(
+ "browser",
+ "browse",
+ f"Browse: {(meta.get('title') or body.url)[:100]}",
+ body.task or body.url,
+ channel="browser",
+ metadata={
+ "url": body.url,
+ "final_url": meta.get("final_url"),
+ "session_id": meta.get("id"),
+ "steps": steps[:12],
+ },
+ )
+ except Exception:
+ pass
+ return data
@admin_router.get("/browser/sessions")
@@ -1060,7 +1907,21 @@ async def browser_instruct(body: BrowserInstructBody) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=180.0) as client:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/instruct", json=body.model_dump())
r.raise_for_status()
- return r.json()
+ data = r.json()
+ try:
+ meta = data.get("session") or data
+ steps = (meta.get("metadata") or {}).get("steps") or [body.instruction[:200]]
+ log_agent_event(
+ "browser",
+ "browse_instruct",
+ f"Instructie: {body.instruction[:80]}",
+ " · ".join(steps[:8])[:500],
+ channel="browser",
+ metadata={"url": body.url, "instruction": body.instruction, "session_id": meta.get("id")},
+ )
+ except Exception:
+ pass
+ return data
@admin_router.get("/browser/sessions/{session_id}/ocr")
@@ -1101,7 +1962,19 @@ async def browser_vnc_navigate(body: BrowserVncBody) -> dict[str, Any]:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/navigate", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(status_code=r.status_code, detail=r.text[:500])
- return r.json()
+ data = r.json()
+ try:
+ log_agent_event(
+ "browser",
+ "vnc_navigate",
+ f"VNC → {body.url[:100]}",
+ body.instruction or "navigate",
+ channel="browser-vnc",
+ metadata={"url": body.url, "instruction": body.instruction},
+ )
+ except Exception:
+ pass
+ return data
@admin_router.post("/browser/extract-full")
@@ -1111,7 +1984,20 @@ async def browser_extract_full(body: BrowserExtractFullBody) -> dict[str, Any]:
r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/extract-full", json=body.model_dump())
if r.status_code >= 400:
raise HTTPException(status_code=r.status_code, detail=r.text[:500])
- return r.json()
+ data = r.json()
+ try:
+ meta = data.get("session") or data
+ log_agent_event(
+ "browser",
+ "extract_full",
+ f"Extract: {(meta.get('title') or body.url)[:100]}",
+ body.instruction or body.url,
+ channel="browser",
+ metadata={"url": body.url, "session_id": meta.get("id"), "scroll_pages": body.scroll_pages},
+ )
+ except Exception:
+ pass
+ return data
@admin_router.get("/photos")
@@ -1172,12 +2058,12 @@ async def documents_share_files(
"""Alle bestanden op NAS share — incl. pptx, pdf, etc."""
import httpx
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
- limit = max(1, min(limit, 500))
+ limit = max(1, min(limit, 1000))
params: dict[str, Any] = {"limit": limit}
if ext:
params["ext"] = ext
try:
- async with httpx.AsyncClient(timeout=30.0) as client:
+ async with httpx.AsyncClient(timeout=120.0) as client:
r = await client.get(f"{doc_url.rstrip('/')}/nas/files", params=params)
r.raise_for_status()
data = r.json()
@@ -1186,6 +2072,29 @@ async def documents_share_files(
return {"ok": False, "items": [], "error": str(exc)}
+@admin_router.get("/documents/share-browse")
+async def documents_share_browse(
+ path: str = "",
+ limit: int = 500,
+ recursive: bool = False,
+) -> dict[str, Any]:
+ """Mappen + bestanden op share (optioneel recursief)."""
+ import httpx
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ limit = max(1, min(limit, 5000))
+ try:
+ async with httpx.AsyncClient(timeout=180.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/nas/browse",
+ params={"path": path, "limit": limit, "recursive": str(recursive).lower()},
+ )
+ r.raise_for_status()
+ data = r.json()
+ return {"ok": True, **data}
+ except Exception as exc:
+ return {"ok": False, "entries": [], "error": str(exc)}
+
+
@admin_router.post("/documents/trigger-scan")
async def documents_trigger_scan(force: bool = False) -> dict[str, Any]:
"""Start doc-ingest scan zodat nieuwe share-bestanden worden herkend."""
@@ -1203,11 +2112,197 @@ async def documents_trigger_scan(force: bool = False) -> dict[str, Any]:
raise HTTPException(status_code=502, detail=str(exc)) from exc
+@admin_router.get("/documents/search")
+async def documents_search(
+ q: str,
+ limit: int = 8,
+) -> dict[str, Any]:
+ """Semantisch zoeken in NAS-documenten (Chroma RAG)."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/search",
+ params={"q": q, "limit": max(1, min(limit, 20))},
+ )
+ r.raise_for_status()
+ return {"ok": True, **r.json()}
+ except Exception as exc:
+ return {"ok": False, "results": [], "error": str(exc)}
+
+
+@admin_router.get("/documents/nas-diagnostics")
+async def documents_nas_diagnostics() -> dict[str, Any]:
+ """NAS mount status + zichtbare vs. ontbrekende bestanden."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ out: dict[str, Any] = {"ok": True}
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ h = await client.get(f"{doc_url.rstrip('/')}/health")
+ h.raise_for_status()
+ out["health"] = h.json()
+ f = await client.get(f"{doc_url.rstrip('/')}/nas/files", params={"limit": 1000})
+ f.raise_for_status()
+ files = f.json()
+ out["visible_files"] = files.get("total", 0)
+ out["sample_paths"] = [i.get("path") for i in (files.get("items") or [])[:8]]
+ except Exception as exc:
+ out["ok"] = False
+ out["error"] = str(exc)
+ out["hint"] = (
+ "Zie je mappen (CUCINA/Foodlinkk) maar weinig bestanden? "
+ "Geef gebruiker aissa Read/Write op de Synology share + submappen in DSM."
+ )
+ return out
+
+
+@admin_router.post("/documents/chat")
+async def documents_chat(body: DocumentsChatBody) -> dict[str, Any]:
+ """Chat met NAS-data via RAG + Ollama (snel) of optioneel Herman."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ message = body.message.strip()
+ sources: list[str] = []
+ context = ""
+
+ linked_paths: list[str] = []
+ if body.client_id:
+ rows = fetch_all(
+ "SELECT storage_path, is_folder FROM document_links WHERE client_id = %s",
+ (body.client_id,),
+ )
+ linked_paths = [r["storage_path"] for r in rows if r.get("storage_path")]
+ if body.project_id:
+ rows = fetch_all(
+ "SELECT storage_path, is_folder FROM document_links WHERE project_id = %s",
+ (body.project_id,),
+ )
+ linked_paths.extend([r["storage_path"] for r in rows if r.get("storage_path")])
+
+ try:
+ async with httpx.AsyncClient(timeout=45.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/context",
+ params={"q": message, "limit": 8},
+ )
+ r.raise_for_status()
+ data = r.json()
+ context = (data.get("context") or "").strip()
+ sources = [s for s in (data.get("sources") or []) if s]
+ except Exception:
+ context = ""
+ sources = []
+
+ scope_prefix = (body.path_prefix or "").strip().lstrip("/")
+ if linked_paths or scope_prefix:
+ try:
+ async with httpx.AsyncClient(timeout=45.0) as client:
+ sr = await client.get(
+ f"{doc_url.rstrip('/')}/search",
+ params={"q": message, "limit": 12},
+ )
+ sr.raise_for_status()
+ hits = sr.json().get("results") or []
+
+ def _in_scope(path: str) -> bool:
+ if not path:
+ return False
+ if scope_prefix and (path == scope_prefix or path.startswith(scope_prefix + "/")):
+ return True
+ for lp in linked_paths:
+ if path == lp or path.startswith(lp.rstrip("/") + "/"):
+ return True
+ return False
+
+ scoped = [h for h in hits if _in_scope(h.get("path") or "")]
+ if scoped:
+ parts = [f"[{h.get('filename', 'doc')}] {(h.get('text') or '')[:1200]}" for h in scoped[:8]]
+ context = "\n\n".join(parts)
+ sources = [h.get("path") for h in scoped if h.get("path")]
+ except Exception:
+ pass
+
+ if len(context) > 6000:
+ context = context[:6000] + "\n…(context ingekort)"
+
+ # Ollama op CPU kan grote RAG-context niet aan — inkorten vóór LLM-call
+ prov_hint = llm_router.resolve_provider(body.llm_provider_id)
+ is_ollama = (prov_hint.get("provider_type") or "ollama") == "ollama"
+ if is_ollama and len(context) > 2500:
+ context = context[:2500] + "\n…(ingekort voor lokale Ollama — gebruik cloud LLM voor volledige RAG)"
+
+ if body.use_herman:
+ try:
+ scoped_msg = message
+ if scope_prefix:
+ scoped_msg = f"[Scope: {scope_prefix}]\n{message}"
+ result = await herman_service.chat(scoped_msg, channel="documents")
+ result["rag_sources"] = result.get("rag_sources") or sources
+ result["ok"] = True
+ result["context_used"] = bool(context)
+ return result
+ except Exception as exc:
+ pass
+
+ client_note = ""
+ if body.client_id:
+ c = fetch_one("SELECT name FROM clients WHERE id = %s", (body.client_id,))
+ if c:
+ client_note = f"\nKlantcontext: {c['name']} (ID {body.client_id})"
+
+ system = (
+ "Je bent Herman, Foodlinkk document-assistent en second brain. Beantwoord in het Nederlands "
+ "op basis van de NAS-documentfragmenten hieronder. Citeer bronbestanden als [bestandsnaam]. "
+ "Als het antwoord niet in de context staat, zeg dat eerlijk."
+ f"{client_note}\n\nDOCUMENT CONTEXT:\n{context or '(geen documenten gevonden — geef algemeen antwoord)'}"
+ )
+ messages: list[dict[str, str]] = [{"role": "system", "content": system}]
+ for h in body.history[-6:]:
+ messages.append({"role": h.role, "content": h.content})
+ messages.append({"role": "user", "content": message})
+
+ try:
+ reply, llm_meta = await llm_router.chat_messages(
+ messages, timeout=12.0 if is_ollama else 100.0, provider_id=body.llm_provider_id
+ )
+ except Exception as exc:
+ if context and is_ollama:
+ src_list = "\n".join(f"• {s}" for s in sources[:8]) if sources else ""
+ reply = (
+ "⚠️ Ollama is te traag voor RAG op CPU. Hier zijn de relevante NAS-fragmenten:\n\n"
+ + context[:3500]
+ + (f"\n\nBronnen:\n{src_list}" if src_list else "")
+ + "\n\n💡 Tip: voeg DeepSeek/Gemini toe via Instellingen → AI / LLM voor echte AI-antwoorden."
+ )
+ llm_meta = {
+ "provider_id": prov_hint.get("id"),
+ "provider_type": "ollama",
+ "provider_label": "RAG fallback",
+ "model": prov_hint.get("model"),
+ }
+ else:
+ raise HTTPException(502, f"LLM niet bereikbaar: {exc}") from exc
+
+ label = llm_meta.get("provider_label") or "LLM"
+ model = llm_meta.get("model") or ""
+ return {
+ "ok": True,
+ "agent": "knowledge",
+ "agent_label": f"Herman · NAS RAG · {label}",
+ "reply": reply,
+ "rag_sources": sources,
+ "context_used": bool(context),
+ "delegated_agents": ["knowledge"],
+ "llm_provider_id": llm_meta.get("provider_id"),
+ "llm_provider_type": llm_meta.get("provider_type"),
+ "llm_model": model,
+ }
+
+
@admin_router.get("/documents/nas-images")
async def documents_nas_images(limit: int = 40) -> dict[str, Any]:
import httpx
doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
- exts = (".jpg", ".jpeg", ".png", ".webp", ".gif")
+ exts = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(f"{doc_url.rstrip('/')}/nas/list", params={"limit": 500})
@@ -1220,6 +2315,40 @@ async def documents_nas_images(limit: int = 40) -> dict[str, Any]:
return {"ok": False, "items": [], "error": str(exc)}
+@admin_router.post("/documents/nas-ocr")
+async def documents_nas_ocr(
+ path: str | None = None,
+ force: bool = False,
+) -> dict[str, Any]:
+ """OCR alle NAS-afbeeldingen (jpg/png) + plaatjes uit pdf/docx via doc-ingest → browser-agent."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ params: dict[str, str] = {"force": "true" if force else "false"}
+ if path:
+ params["path"] = path
+ try:
+ async with httpx.AsyncClient(timeout=600.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/ingest/extract-images",
+ params=params,
+ )
+ r.raise_for_status()
+ data = r.json()
+ try:
+ log_agent_event(
+ "browser",
+ "nas_ocr_batch",
+ f"NAS OCR: {data.get('images_extracted', 0)} afbeeldingen",
+ path or "alle NAS-bestanden",
+ channel="documents",
+ metadata={"path": path, "force": force, **data},
+ )
+ except Exception:
+ pass
+ return {"ok": True, **data}
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
@admin_router.get("/browser/vnc/screenshot.jpg")
async def browser_vnc_screenshot():
import httpx
diff --git a/cockpit/app/routes/agents.py b/cockpit/app/routes/agents.py
index 131bffe..cba4fa9 100644
--- a/cockpit/app/routes/agents.py
+++ b/cockpit/app/routes/agents.py
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
- if active_tab not in {"souls", "mesh", "approvals"}:
+ if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
active_tab = "souls"
events: list = []
try:
diff --git a/cockpit/app/routes/agents_api.py b/cockpit/app/routes/agents_api.py
index bd2efab..e0dada0 100644
--- a/cockpit/app/routes/agents_api.py
+++ b/cockpit/app/routes/agents_api.py
@@ -1,13 +1,18 @@
"""Agents API — souls & activity."""
from __future__ import annotations
+import asyncio
+import json
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
+from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
-from app.db import fetch_all
-from app.services import agent_approvals, agent_souls
+from app.db import fetch_all, fetch_one
+from app.services import agent_approvals, agent_integration, agent_souls
+from app.services.agent_names import normalize_agent_key
+from app.services.agent_terminal import execute_terminal_command
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
@@ -35,6 +40,236 @@ class ExecuteBody(BaseModel):
result: dict[str, Any] = Field(default_factory=dict)
+class HandoffBody(BaseModel):
+ from_agent: str
+ to_agent: str
+ handoff_type: str = "partner"
+ payload: dict[str, Any] = Field(default_factory=dict)
+ correlation_id: Optional[str] = None
+
+
+class TerminalCommandBody(BaseModel):
+ command: str = Field(..., min_length=1, max_length=2000)
+
+
+def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
+ from datetime import datetime, timedelta, timezone
+
+ rows = fetch_all(
+ """
+ SELECT agent_name, title, status, created_at
+ FROM agent_events
+ WHERE created_at >= NOW() - INTERVAL '7 days'
+ ORDER BY created_at DESC
+ """
+ )
+ now = datetime.now(timezone.utc)
+ by_key: dict[str, dict[str, Any]] = {}
+ for r in rows:
+ key = normalize_agent_key(str(r.get("agent_name") or ""))
+ if not key:
+ continue
+ bucket = by_key.setdefault(
+ key,
+ {"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
+ )
+ created = r.get("created_at")
+ if bucket["last_event_at"] is None and created is not None:
+ bucket["last_event_at"] = created
+ bucket["last_event_title"] = r.get("title")
+ if created is not None:
+ if hasattr(created, "tzinfo") and created.tzinfo is None:
+ created = created.replace(tzinfo=timezone.utc)
+ if created >= now - timedelta(hours=6):
+ bucket["events_6h"] += 1
+ if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
+ bucket["errors_24h"] += 1
+ return by_key
+
+
+def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
+ if events_6h > 0 and errors_24h == 0:
+ return "healthy"
+ if events_6h > 0:
+ return "warn"
+ if event_count > 0:
+ return "idle"
+ return "offline"
+
+
+@router.get("/collaboration")
+def api_collaboration() -> dict[str, Any]:
+ items = agent_integration.list_collaboration()
+ return {"items": items, "count": len(items)}
+
+
+@router.post("/handoff")
+def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
+ try:
+ handoff = agent_integration.create_handoff(
+ body.from_agent,
+ body.to_agent,
+ handoff_type=body.handoff_type,
+ payload=body.payload,
+ correlation_id=body.correlation_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ return {"ok": True, "handoff": handoff}
+
+
+def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
+ if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
+ ev["created_at"] = ev["created_at"].isoformat()
+ meta = ev.get("metadata")
+ if isinstance(meta, str):
+ try:
+ meta = json.loads(meta)
+ except Exception:
+ meta = {}
+ ev["metadata"] = meta or {}
+ line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
+ if str(ev.get("event_type") or "").startswith("terminal_"):
+ line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
+ if ev["metadata"].get("target_agent"):
+ line_type = "handoff_out"
+ if ev["metadata"].get("source_agent"):
+ line_type = "handoff_in"
+ if str(ev.get("status") or "") in ("error", "rejected"):
+ line_type = "error"
+ return {
+ "type": line_type,
+ "id": ev.get("id"),
+ "agent": normalize_agent_key(ev.get("agent_name")),
+ "message": ev.get("title") or ev.get("event_type"),
+ "detail": (ev.get("body") or "")[:500],
+ "status": ev.get("status"),
+ "correlation_id": ev["metadata"].get("correlation_id"),
+ "at": ev.get("created_at"),
+ }
+
+
+@router.get("/souls/{agent_key}/terminal/stream")
+async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
+ key = normalize_agent_key(agent_key)
+ soul = agent_souls.get_soul(key)
+ if not soul:
+ raise HTTPException(404, "Agent not found")
+
+ async def event_gen():
+ last_id = 0
+ try:
+ hist = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
+ FROM agent_events
+ WHERE LOWER(agent_name) = %s
+ ORDER BY id DESC
+ LIMIT 30
+ """,
+ (key,),
+ )
+ for row in reversed(hist):
+ last_id = max(last_id, int(row["id"]))
+ payload = _terminal_payload(dict(row))
+ yield f"data: {json.dumps(payload, default=str)}\n\n"
+ except Exception:
+ pass
+
+ while True:
+ try:
+ if correlation_id:
+ rows = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
+ FROM agent_events
+ WHERE metadata->>'correlation_id' = %s
+ AND id > %s
+ ORDER BY id ASC
+ LIMIT 50
+ """,
+ (correlation_id, last_id),
+ )
+ else:
+ rows = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
+ FROM agent_events
+ WHERE LOWER(agent_name) = %s AND id > %s
+ ORDER BY id ASC
+ LIMIT 50
+ """,
+ (key, last_id),
+ )
+ for row in rows:
+ last_id = max(last_id, int(row["id"]))
+ payload = _terminal_payload(dict(row))
+ yield f"data: {json.dumps(payload, default=str)}\n\n"
+ except Exception as exc:
+ err = {"type": "error", "message": str(exc)}
+ yield f"data: {json.dumps(err)}\n\n"
+ await asyncio.sleep(0.4)
+
+ return StreamingResponse(
+ event_gen(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+@router.get("/live/stream")
+async def api_agents_live_stream():
+ """Single realtime stream for all agent terminals."""
+
+ async def event_gen():
+ last_id = 0
+ try:
+ recent = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
+ FROM agent_events
+ WHERE created_at >= NOW() - INTERVAL '30 minutes'
+ ORDER BY id ASC
+ LIMIT 80
+ """
+ )
+ for row in recent:
+ last_id = max(last_id, int(row["id"]))
+ payload = _terminal_payload(dict(row))
+ payload["replay"] = True
+ yield f"data: {json.dumps(payload, default=str)}\n\n"
+ except Exception:
+ pass
+ yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
+
+ while True:
+ try:
+ rows = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
+ FROM agent_events
+ WHERE id > %s
+ ORDER BY id ASC
+ LIMIT 100
+ """,
+ (last_id,),
+ )
+ for row in rows:
+ last_id = max(last_id, int(row["id"]))
+ payload = _terminal_payload(dict(row))
+ yield f"data: {json.dumps(payload, default=str)}\n\n"
+ except Exception as exc:
+ err = {"type": "error", "message": str(exc)}
+ yield f"data: {json.dumps(err)}\n\n"
+ await asyncio.sleep(0.25)
+
+ return StreamingResponse(
+ event_gen(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
@router.get("/souls")
def api_list_souls() -> dict[str, Any]:
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
@@ -57,6 +292,16 @@ def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
+@router.post("/souls/{agent_key}/terminal/command")
+async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
+ try:
+ return await execute_terminal_command(agent_key, body.command.strip())
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+
@router.put("/souls/{agent_key}")
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
try:
@@ -69,20 +314,7 @@ def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
@router.get("/mesh")
def api_agents_mesh() -> dict[str, Any]:
souls = agent_souls.list_souls()
- stats_rows = fetch_all(
- """
- SELECT LOWER(agent_name) AS agent_key,
- MAX(created_at) AS last_event_at,
- COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '6 hours') AS events_6h,
- COUNT(*) FILTER (
- WHERE created_at >= NOW() - INTERVAL '24 hours'
- AND status IN ('error', 'rejected')
- ) AS errors_24h
- FROM agent_events
- GROUP BY LOWER(agent_name)
- """
- )
- by_key = {str(r["agent_key"]): dict(r) for r in stats_rows}
+ by_key = _aggregate_agent_stats()
nodes: list[dict[str, Any]] = []
for soul in souls:
@@ -90,33 +322,112 @@ def api_agents_mesh() -> dict[str, Any]:
row = by_key.get(key, {})
events_6h = int(row.get("events_6h") or 0)
errors_24h = int(row.get("errors_24h") or 0)
- health = "offline"
- if events_6h > 0 and errors_24h == 0:
- health = "healthy"
- elif events_6h > 0:
- health = "warn"
- elif int(soul.get("event_count") or 0) > 0:
- health = "idle"
+ health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
+ is_active = health == "healthy" and events_6h > 0
node = dict(soul)
node["health"] = health
node["events_6h"] = events_6h
node["errors_24h"] = errors_24h
- if row.get("last_event_at") is not None and hasattr(row["last_event_at"], "isoformat"):
- node["last_event_at"] = row["last_event_at"].isoformat()
+ node["is_active"] = is_active
+ node["last_event_title"] = row.get("last_event_title")
+ lat = row.get("last_event_at")
+ if lat is not None and hasattr(lat, "isoformat"):
+ node["last_event_at"] = lat.isoformat()
nodes.append(node)
- edge_rows = fetch_all(
+ report_edges: list[dict[str, Any]] = []
+ for node in nodes:
+ key = str(node.get("agent_key") or "").lower()
+ if key == "herman" or not node.get("is_active"):
+ continue
+ report_edges.append(
+ {
+ "source": key,
+ "target": "herman",
+ "type": "report",
+ "active": True,
+ "weight": int(node.get("events_6h") or 1),
+ }
+ )
+
+ delegate_rows = fetch_all(
"""
- SELECT LOWER(agent_name) AS source_key, COUNT(*) AS weight
+ SELECT metadata, created_at
FROM agent_events
- WHERE created_at >= NOW() - INTERVAL '24 hours'
- AND LOWER(agent_name) <> 'herman'
- GROUP BY LOWER(agent_name)
- ORDER BY weight DESC
+ WHERE agent_name = 'herman'
+ AND created_at >= NOW() - INTERVAL '6 hours'
+ AND (
+ event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
+ OR metadata ? 'delegated'
+ )
+ ORDER BY created_at DESC
+ LIMIT 100
"""
)
- edges = [{"source": str(r["source_key"]), "target": "herman", "weight": int(r["weight"])} for r in edge_rows]
- return {"nodes": nodes, "edges": edges, "executives": [{"id": "ceo", "label": "CEO", "role": "Aissa"}, {"id": "cto", "label": "CTO", "role": "Platform"}]}
+ delegate_edges: list[dict[str, Any]] = []
+ seen_delegate: set[tuple[str, str]] = set()
+ for r in delegate_rows:
+ meta = r.get("metadata") or {}
+ if isinstance(meta, str):
+ try:
+ meta = json.loads(meta)
+ except Exception:
+ meta = {}
+ delegated = meta.get("delegated") or meta.get("delegated_agents") or []
+ if isinstance(delegated, str):
+ delegated = [delegated]
+ channel = meta.get("channel") or "herman"
+ for agent in delegated:
+ tgt = normalize_agent_key(str(agent))
+ if not tgt or tgt == "herman":
+ continue
+ pair = ("herman", tgt)
+ if pair in seen_delegate:
+ continue
+ seen_delegate.add(pair)
+ delegate_edges.append(
+ {
+ "source": "herman",
+ "target": tgt,
+ "type": "delegate",
+ "active": True,
+ "channel": channel,
+ }
+ )
+
+ peer_static = [
+ {
+ "source": str(r["from_agent"]),
+ "target": str(r["to_agent"]),
+ "type": "static",
+ "handoff_type": r.get("handoff_type"),
+ }
+ for r in agent_integration.list_collaboration()
+ ]
+ peer_live = agent_integration.peer_edges_live(hours=6)
+ peer_edges = peer_static + peer_live
+
+ herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
+ herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
+ executive_edges = []
+ if herman_active:
+ executive_edges = [
+ {"source": "herman", "target": "ceo", "type": "executive", "active": True},
+ {"source": "herman", "target": "cto", "type": "executive", "active": True},
+ ]
+
+ return {
+ "nodes": nodes,
+ "report_edges": report_edges,
+ "delegate_edges": delegate_edges,
+ "peer_edges": peer_edges,
+ "executive_edges": executive_edges,
+ "edges": report_edges,
+ "executives": [
+ {"id": "ceo", "label": "CEO", "role": "Aissa"},
+ {"id": "cto", "label": "CTO", "role": "Platform"},
+ ],
+ }
@router.get("/approvals")
@@ -141,7 +452,17 @@ def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
- return {"ok": True, "request": req, "message": "Wacht op goedkeuring voordat query uitgevoerd mag worden"}
+ auto = None
+ rid = (req or {}).get("id")
+ if rid:
+ try:
+ auto = agent_approvals.try_auto_approve_and_execute(int(rid))
+ except Exception:
+ auto = None
+ msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
+ if auto:
+ msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
+ return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
@router.post("/approvals/{request_id}/approve")
diff --git a/cockpit/app/routes/dashboard.py b/cockpit/app/routes/dashboard.py
index efa3c4b..34282bb 100644
--- a/cockpit/app/routes/dashboard.py
+++ b/cockpit/app/routes/dashboard.py
@@ -145,7 +145,7 @@ async def dashboard(request: Request):
"dashboard.html",
{
"request": request,
- "page_title": "Herman · Command Center",
+ "page_title": "CEO Dashboard",
"kpis": kpis,
"briefing": briefing,
"briefing_payload": _briefing_payload(briefing),
@@ -158,6 +158,64 @@ async def dashboard(request: Request):
)
+@router.get("/cto")
+async def cto_redirect():
+ return RedirectResponse(url="/ops", status_code=302)
+
+
+@router.get("/cto/dashboard")
+async def cto_dashboard_legacy(request: Request):
+ kpis = {
+ "pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
+ "browser_sessions_24h": 0,
+ }
+ try:
+ kpis["browser_sessions_24h"] = _safe_count(
+ "browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
+ )
+ except Exception:
+ pass
+
+ agent_feed: list = []
+ try:
+ agent_feed = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, created_at
+ FROM agent_events ORDER BY created_at DESC LIMIT 25
+ """
+ )
+ for ev in agent_feed:
+ if ev.get("created_at"):
+ ev["created_at"] = ev["created_at"].isoformat()
+ except Exception:
+ agent_feed = []
+
+ browser_sessions: list = []
+ try:
+ browser_sessions = fetch_all(
+ """
+ SELECT id, url, final_url, title, task, status, created_at
+ FROM browser_sessions ORDER BY created_at DESC LIMIT 8
+ """
+ )
+ for s in browser_sessions:
+ if s.get("created_at"):
+ s["created_at"] = s["created_at"].isoformat()
+ except Exception:
+ browser_sessions = []
+
+ return templates.TemplateResponse(
+ "cto-dashboard.html",
+ {
+ "request": request,
+ "page_title": "CTO Dashboard",
+ "kpis": kpis,
+ "agent_feed": agent_feed,
+ "browser_sessions": browser_sessions,
+ },
+ )
+
+
@router.get("/marketing-redirect")
async def marketing_redirect():
return RedirectResponse(url="/marketing", status_code=302)
diff --git a/cockpit/app/routes/documents.py b/cockpit/app/routes/documents.py
index 479fdc9..e231307 100644
--- a/cockpit/app/routes/documents.py
+++ b/cockpit/app/routes/documents.py
@@ -73,7 +73,8 @@ async def documents_page(request: Request):
SELECT filename, storage_path, doc_type, language, word_count,
unique_lemmas, sentiment_label, sentiment_compound,
sentiment_positive, sentiment_negative, sentiment_neutral,
- extraction_method, analyzed_at
+ extraction_method, analyzed_at,
+ COALESCE(user_labels, '{}') AS user_labels, label_notes, labeled_at
FROM document_analytics
ORDER BY analyzed_at DESC
LIMIT 50
diff --git a/cockpit/app/routes/export_intel.py b/cockpit/app/routes/export_intel.py
new file mode 100644
index 0000000..7ab5d70
--- /dev/null
+++ b/cockpit/app/routes/export_intel.py
@@ -0,0 +1,209 @@
+"""Export Intel Cockpit page + API proxy."""
+from __future__ import annotations
+
+import os
+from typing import Any, Optional
+
+import httpx
+from fastapi import APIRouter, Query, Request
+from fastapi.responses import RedirectResponse, StreamingResponse
+from fastapi.templating import Jinja2Templates
+from pathlib import Path
+
+router = APIRouter()
+BASE = Path(__file__).resolve().parent.parent.parent
+templates = Jinja2Templates(directory=str(BASE / "templates"))
+TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
+
+
+async def _proxy(method: str, path: str, **kwargs) -> Any:
+ url = f"{TOOLS}/export-intel{path}"
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.request(method, url, **kwargs)
+ if r.status_code >= 400:
+ return {"error": r.text, "status": r.status_code}
+ if "text/csv" in r.headers.get("content-type", ""):
+ return r
+ return r.json()
+
+
+@router.get("/foodlinkk")
+def foodlinkk_home():
+ return RedirectResponse(url="/export-intel", status_code=302)
+
+
+@router.get("/export-intel")
+def export_intel_page(request: Request):
+ return templates.TemplateResponse(
+ "export_intel.html",
+ {"request": request, "page_title": "Wereldexport"},
+ )
+
+
+@router.get("/api/export-intel/stats")
+async def api_stats(country: Optional[str] = None, region: Optional[str] = None):
+ params = {k: v for k, v in {"country": country, "region": region}.items() if v}
+ return await _proxy("GET", "/stats", params=params)
+
+
+@router.get("/api/export-intel/regions")
+async def api_regions():
+ return await _proxy("GET", "/regions")
+
+
+@router.get("/api/export-intel/territories")
+async def api_territories(region: Optional[str] = None):
+ params = {"region": region} if region else {}
+ return await _proxy("GET", "/territories", params=params)
+
+
+@router.get("/api/export-intel/entities")
+async def api_entities(
+ country: Optional[str] = None,
+ region: Optional[str] = None,
+ entity_type: Optional[str] = None,
+ entity_types: Optional[str] = None,
+ q: Optional[str] = None,
+ favorite_only: bool = False,
+ crm_linked: Optional[bool] = None,
+ limit: int = 200,
+ offset: int = 0,
+):
+ params = {k: v for k, v in {
+ "country": country, "region": region, "entity_type": entity_type,
+ "entity_types": entity_types, "q": q, "limit": limit, "offset": offset,
+ "favorite_only": favorite_only, "crm_linked": crm_linked,
+ }.items() if v is not None and v is not False}
+ return await _proxy("GET", "/entities", params=params)
+
+
+@router.get("/api/export-intel/entities/{entity_id}")
+async def api_entity(entity_id: int):
+ return await _proxy("GET", f"/entities/{entity_id}")
+
+
+@router.get("/api/export-intel/contacts")
+async def api_contacts(
+ country: Optional[str] = None,
+ entity_type: Optional[str] = None,
+ has_email: Optional[bool] = None,
+ q: Optional[str] = None,
+ limit: int = 200,
+ offset: int = 0,
+):
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
+ if country:
+ params["country"] = country
+ if entity_type:
+ params["entity_type"] = entity_type
+ if has_email is not None:
+ params["has_email"] = has_email
+ if q:
+ params["q"] = q
+ return await _proxy("GET", "/contacts", params=params)
+
+
+@router.get("/api/export-intel/contacts/export.csv")
+async def api_contacts_export(country: Optional[str] = None, entity_type: Optional[str] = None):
+ params = {k: v for k, v in {"country": country, "entity_type": entity_type}.items() if v}
+ url = f"{TOOLS}/export-intel/contacts/export.csv"
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(url, params=params)
+ return StreamingResponse(
+ iter([r.content]),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=export-intel-contacts.csv"},
+ )
+
+
+@router.get("/api/export-intel/caterers/brands")
+async def api_caterer_brands():
+ return await _proxy("GET", "/caterers/brands")
+
+
+@router.get("/api/export-intel/caterers/presence")
+async def api_caterer_presence(country: Optional[str] = None):
+ params = {"country": country} if country else {}
+ return await _proxy("GET", "/caterers/presence", params=params)
+
+
+@router.get("/api/export-intel/gov-sources")
+async def api_gov_sources(country: Optional[str] = None):
+ params = {"country": country} if country else {}
+ return await _proxy("GET", "/gov-sources", params=params)
+
+
+@router.get("/api/export-intel/map/bundle")
+async def api_map_bundle(
+ country: Optional[str] = None,
+ region: Optional[str] = None,
+ entity_type: Optional[str] = None,
+ entity_types: Optional[str] = None,
+ q: Optional[str] = None,
+ halal_min: Optional[float] = None,
+ favorite_only: bool = False,
+ crm_linked: Optional[bool] = None,
+):
+ params = {k: v for k, v in {
+ "country": country, "region": region, "entity_type": entity_type,
+ "entity_types": entity_types, "q": q, "halal_min": halal_min,
+ "favorite_only": favorite_only, "crm_linked": crm_linked,
+ }.items() if v is not None and v is not False}
+ return await _proxy("GET", "/map/bundle", params=params)
+
+
+@router.post("/api/export-intel/entities/favorites")
+async def api_set_favorites(request: Request):
+ body = await request.json()
+ return await _proxy("POST", "/entities/favorites", json=body)
+
+
+@router.get("/api/export-intel/entities/favorites")
+async def api_list_favorites(country: Optional[str] = None, region: Optional[str] = None, limit: int = 200):
+ params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
+ return await _proxy("GET", "/entities/favorites", params=params)
+
+
+@router.post("/api/export-intel/crm/push")
+async def api_crm_push(request: Request):
+ body = await request.json()
+ return await _proxy("POST", "/crm/push", json=body)
+
+
+@router.get("/api/export-intel/crm/pipeline")
+async def api_crm_pipeline(country: Optional[str] = None, region: Optional[str] = None, limit: int = 100):
+ params = {k: v for k, v in {"country": country, "region": region, "limit": limit}.items() if v is not None}
+ return await _proxy("GET", "/crm/pipeline", params=params)
+
+
+@router.get("/api/export-intel/halal/markets")
+async def api_halal_markets(
+ region: Optional[str] = None,
+ country: Optional[str] = None,
+ limit: int = 30,
+):
+ params = {k: v for k, v in {"region": region, "country": country, "limit": limit}.items() if v is not None}
+ return await _proxy("GET", "/halal/markets", params=params)
+
+
+@router.get("/api/export-intel/tenders")
+async def api_tenders(country: Optional[str] = None, status: Optional[str] = "open", limit: int = 100):
+ params = {k: v for k, v in {"country": country, "status": status, "limit": limit}.items() if v is not None}
+ return await _proxy("GET", "/tenders", params=params)
+
+
+@router.post("/api/export-intel/sync/{kind}")
+async def api_sync(kind: str, request: Request):
+ body = {}
+ if request.headers.get("content-type", "").startswith("application/json"):
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+ if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region"):
+ return {"error": "unknown sync kind"}
+ timeout = 7200.0 if kind in ("world", "region", "all") else 600.0
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ url = f"{TOOLS}/export-intel/sync/{kind}"
+ r = await client.post(url, json=body)
+ return r.json()
diff --git a/cockpit/app/routes/herman.py b/cockpit/app/routes/herman.py
index a785964..e5dec39 100644
--- a/cockpit/app/routes/herman.py
+++ b/cockpit/app/routes/herman.py
@@ -22,7 +22,7 @@ async def herman_page(request: Request):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
- WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
+ WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
@@ -38,7 +38,7 @@ async def herman_chat(request: Request, message: str = Form(...)):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
- WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
+ WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
@@ -62,7 +62,7 @@ async def herman_briefing_page(request: Request):
try:
history = _iso_rows(fetch_all(
"""SELECT id, agent_name, title, body, metadata, created_at FROM agent_events
- WHERE channel IN ('herman','dashboard') ORDER BY created_at DESC LIMIT 40"""
+ WHERE channel IN ('herman','dashboard','browser','voice') ORDER BY created_at DESC LIMIT 40"""
))
except Exception:
history = []
diff --git a/cockpit/app/routes/revenue_cockpit.py b/cockpit/app/routes/revenue_cockpit.py
new file mode 100644
index 0000000..242eca9
--- /dev/null
+++ b/cockpit/app/routes/revenue_cockpit.py
@@ -0,0 +1,230 @@
+"""Revenue Cockpit — page + API."""
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Optional
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel, Field
+
+from app.services import revenue_cockpit as svc
+
+BASE_DIR = Path(__file__).resolve().parent.parent.parent
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+router = APIRouter(tags=["revenue-cockpit"])
+api = APIRouter(prefix="/api/revenue-cockpit", tags=["revenue-cockpit-api"])
+
+
+class ProjectUpdateBody(BaseModel):
+ name: Optional[str] = None
+ category: Optional[str] = None
+ margin_month: Optional[float] = None
+ margin_year: Optional[float] = None
+ target_revenue: Optional[float] = None
+ next_steps: Optional[str] = None
+ status: Optional[str] = None
+ priority: Optional[str] = None
+ row_style: Optional[str] = None
+
+
+class GoalsUpdateBody(BaseModel):
+ vision_text: Optional[str] = None
+ horizon_text: Optional[str] = None
+ mid_text: Optional[str] = None
+ tagline: Optional[str] = None
+
+
+class ObjectiveBody(BaseModel):
+ title: str = Field(..., min_length=1)
+ description: Optional[str] = None
+ priority: str = "normal"
+ due_date: Optional[str] = None
+
+
+class ObjectiveUpdateBody(BaseModel):
+ title: Optional[str] = None
+ description: Optional[str] = None
+ status: Optional[str] = None
+ priority: Optional[str] = None
+ due_date: Optional[str] = None
+
+
+class AssignTaskBody(BaseModel):
+ agent_name: str = Field(..., min_length=1)
+ title: str = Field(..., min_length=1)
+ description: Optional[str] = None
+ objective_id: Optional[int] = None
+ priority: str = "normal"
+ delegate_herman: bool = True
+
+
+class DelegateBody(BaseModel):
+ message: str = Field(..., min_length=1)
+
+
+class ImportBody(BaseModel):
+ path: str = "Succes Sheet .xlsx"
+ sheet: Optional[str] = "Projects next steps revenue"
+ replace: bool = True
+
+
+@router.get("/revenue-cockpit", response_class=HTMLResponse)
+async def revenue_cockpit_page(request: Request):
+ return templates.TemplateResponse(
+ "revenue_cockpit.html",
+ {"request": request, "page_title": "Revenue Cockpit"},
+ )
+
+
+@api.get("/live")
+async def live_from_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
+ """Leading data source: fresh parse from NAS Excel."""
+ try:
+ parsed = await svc.fetch_excel_parse_async(path, sheet)
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+ projects = parsed.get("projects") or []
+ margin_month = sum(p.get("margin_month") or 0 for p in projects)
+ margin_year = sum(p.get("margin_year") or 0 for p in projects)
+ by_style: dict[str, int] = {}
+ by_category: dict[str, int] = {}
+ for p in projects:
+ by_style[p.get("row_style") or "white"] = by_style.get(p.get("row_style") or "white", 0) + 1
+ by_category[p.get("category") or "deal"] = by_category.get(p.get("category") or "deal", 0) + 1
+ return {
+ "ok": True,
+ "source": "excel",
+ **parsed,
+ "aggregates": {
+ "total_margin_month": margin_month,
+ "total_margin_year": margin_year,
+ "with_margin": sum(1 for p in projects if p.get("margin_month")),
+ "by_style": by_style,
+ "by_category": by_category,
+ },
+ }
+
+
+@api.get("/dashboard")
+async def dashboard():
+ return {"ok": True, "stats": svc.dashboard_stats(), "projects": svc.list_projects()}
+
+
+@api.get("/projects")
+def list_projects(status: Optional[str] = None):
+ return {"ok": True, "items": svc.list_projects(status)}
+
+
+@api.get("/projects/{project_id}")
+def get_project(project_id: int):
+ p = svc.get_project(project_id)
+ if not p:
+ raise HTTPException(404, "Project not found")
+ return {"ok": True, "project": p}
+
+
+@api.patch("/projects/{project_id}")
+def patch_project(project_id: int, body: ProjectUpdateBody):
+ data = body.model_dump(exclude_unset=True)
+ row_style = data.pop("row_style", None)
+ if row_style is not None:
+ svc.set_project_row_style(project_id, row_style)
+ p = svc.update_project(project_id, data)
+ if not p:
+ raise HTTPException(404, "Project not found")
+ svc.take_snapshot()
+ return {"ok": True, "project": p}
+
+
+@api.patch("/goals")
+def patch_goals(body: GoalsUpdateBody):
+ data = body.model_dump(exclude_unset=True)
+ g = svc.update_goals(data)
+ if not g:
+ raise HTTPException(404, "Goals not found")
+ return {"ok": True, "goals": g}
+
+
+@api.post("/projects/{project_id}/objectives")
+def add_objective(project_id: int, body: ObjectiveBody):
+ try:
+ obj = svc.create_objective(project_id, body.title, body.description, body.priority)
+ except Exception as exc:
+ raise HTTPException(400, str(exc)) from exc
+ svc.take_snapshot()
+ return {"ok": True, "objective": obj}
+
+
+@api.patch("/objectives/{objective_id}")
+def patch_objective(objective_id: int, body: ObjectiveUpdateBody):
+ data = body.model_dump(exclude_unset=True)
+ obj = svc.update_objective(objective_id, data)
+ if not obj:
+ raise HTTPException(404, "Objective not found")
+ svc.take_snapshot()
+ return {"ok": True, "objective": obj}
+
+
+@api.post("/projects/{project_id}/assign-task")
+def assign_task(project_id: int, body: AssignTaskBody):
+ try:
+ task = svc.assign_agent_task(
+ project_id,
+ body.agent_name,
+ body.title,
+ body.description,
+ body.objective_id,
+ body.priority,
+ body.delegate_herman,
+ )
+ except ValueError as exc:
+ raise HTTPException(404, str(exc)) from exc
+ svc.take_snapshot()
+ return {"ok": True, "task": task}
+
+
+@api.post("/projects/{project_id}/delegate")
+async def delegate_project(project_id: int, body: DelegateBody):
+ try:
+ result = await svc.delegate_via_herman(project_id, body.message)
+ except ValueError as exc:
+ raise HTTPException(404, str(exc)) from exc
+ return result
+
+
+@api.get("/tasks")
+def list_tasks(limit: int = 50):
+ return {"ok": True, "items": svc.list_agent_tasks(limit)}
+
+
+@api.get("/snapshots")
+def snapshots(limit: int = 90):
+ return {"ok": True, "items": svc.list_snapshots(limit)}
+
+
+@api.post("/snapshot")
+def create_snapshot():
+ snap = svc.take_snapshot()
+ return {"ok": True, "snapshot": snap}
+
+
+@api.post("/import-from-excel")
+async def import_from_excel(body: ImportBody):
+ try:
+ parsed = await svc.fetch_excel_parse_async(body.path, body.sheet)
+ result = svc.import_from_parsed(parsed, imported_by="ceo", replace=body.replace)
+ return {"ok": True, **result, "preview": {"project_count": parsed.get("project_count"), "sheet": parsed.get("sheet_name")}}
+ except Exception as exc:
+ raise HTTPException(502, f"Import failed: {exc}") from exc
+
+
+@api.get("/preview-excel")
+async def preview_excel(path: str = "Succes Sheet .xlsx", sheet: Optional[str] = "Projects next steps revenue"):
+ try:
+ parsed = await svc.fetch_excel_parse_async(path, sheet)
+ return {"ok": True, **parsed}
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
diff --git a/cockpit/app/routes/settings.py b/cockpit/app/routes/settings.py
index 3db9618..bdb02c3 100644
--- a/cockpit/app/routes/settings.py
+++ b/cockpit/app/routes/settings.py
@@ -10,7 +10,7 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
- allowed = ("email", "general", "permissions", "social")
+ allowed = ("email", "general", "permissions", "social", "ai")
return templates.TemplateResponse(
"settings.html",
{
diff --git a/cockpit/app/routes/settings_api.py b/cockpit/app/routes/settings_api.py
index 4faf92e..d859a23 100644
--- a/cockpit/app/routes/settings_api.py
+++ b/cockpit/app/routes/settings_api.py
@@ -381,3 +381,118 @@ def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]:
def grant_all_permissions() -> dict[str, Any]:
n = agent_souls.grant_all_permissions()
return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}
+
+
+# ── LLM providers ──────────────────────────────────────────────────────────
+
+from app.services import llm_router
+
+
+class LlmProviderBody(BaseModel):
+ label: str = Field(..., max_length=128)
+ provider_type: str = Field(default="deepseek", max_length=48)
+ api_base_url: Optional[str] = None
+ api_key: Optional[str] = None
+ model: str = Field(default="deepseek-chat", max_length=128)
+ is_active: bool = True
+ is_default: bool = False
+ extra_config: dict[str, Any] = Field(default_factory=dict)
+
+
+@settings_router.get("/llm/presets")
+def llm_presets() -> dict[str, Any]:
+ return {"presets": llm_router.list_presets()}
+
+
+@settings_router.get("/llm")
+def llm_list() -> dict[str, Any]:
+ items = llm_router.list_providers()
+ default = next((i for i in items if i.get("is_default")), items[0] if items else None)
+ return {"providers": items, "default": default, "presets": llm_router.list_presets()}
+
+
+@settings_router.post("/llm")
+def llm_create(body: LlmProviderBody) -> dict[str, Any]:
+ preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
+ base_url = (body.api_base_url or preset.get("api_base_url") or "").strip() or None
+ model = body.model or (preset.get("models") or ["deepseek-chat"])[0]
+ if body.is_default:
+ execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
+ row = fetch_one(
+ """INSERT INTO llm_providers (
+ label, provider_type, api_base_url, api_key, model,
+ is_active, is_default, extra_config, updated_at
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
+ RETURNING *""",
+ (
+ body.label,
+ body.provider_type,
+ base_url,
+ body.api_key or "",
+ model,
+ body.is_active,
+ body.is_default,
+ json.dumps(body.extra_config or {}),
+ ),
+ )
+ return {"provider": llm_router._mask_provider(row)}
+
+
+@settings_router.put("/llm/{provider_id}")
+def llm_update(provider_id: int, body: LlmProviderBody) -> dict[str, Any]:
+ existing = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
+ if not existing:
+ raise HTTPException(404, "Provider niet gevonden")
+ preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
+ base_url = (body.api_base_url or preset.get("api_base_url") or existing.get("api_base_url") or "").strip() or None
+ api_key = body.api_key if body.api_key else existing.get("api_key") or ""
+ if body.is_default:
+ execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
+ row = fetch_one(
+ """UPDATE llm_providers SET
+ label = %s, provider_type = %s, api_base_url = %s, api_key = %s, model = %s,
+ is_active = %s, is_default = %s, extra_config = %s::jsonb, updated_at = NOW()
+ WHERE id = %s RETURNING *""",
+ (
+ body.label,
+ body.provider_type,
+ base_url,
+ api_key,
+ body.model,
+ body.is_active,
+ body.is_default,
+ json.dumps(body.extra_config or {}),
+ provider_id,
+ ),
+ )
+ return {"provider": llm_router._mask_provider(row)}
+
+
+@settings_router.delete("/llm/{provider_id}")
+def llm_delete(provider_id: int) -> dict[str, Any]:
+ row = fetch_one("SELECT is_default FROM llm_providers WHERE id = %s", (provider_id,))
+ if not row:
+ raise HTTPException(404, "Provider niet gevonden")
+ execute("DELETE FROM llm_providers WHERE id = %s", (provider_id,))
+ if row.get("is_default"):
+ execute(
+ """UPDATE llm_providers SET is_default = TRUE, updated_at = NOW()
+ WHERE id = (SELECT id FROM llm_providers ORDER BY id LIMIT 1)"""
+ )
+ return {"ok": True}
+
+
+@settings_router.post("/llm/{provider_id}/activate")
+def llm_activate(provider_id: int) -> dict[str, Any]:
+ llm_router.set_default(provider_id)
+ row = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
+ return {"ok": True, "provider": llm_router._mask_provider(row)}
+
+
+@settings_router.post("/llm/{provider_id}/test")
+async def llm_test(provider_id: int) -> dict[str, Any]:
+ ok, msg = await llm_router.test_provider(provider_id)
+ if not ok:
+ raise HTTPException(502, msg)
+ return {"ok": True, "message": msg}
+
diff --git a/cockpit/app/services/admin_api.py b/cockpit/app/services/admin_api.py
new file mode 100644
index 0000000..694197b
--- /dev/null
+++ b/cockpit/app/services/admin_api.py
@@ -0,0 +1,2277 @@
+from __future__ import annotations
+
+from app.routes.reco_proxy import register_recommendation_routes
+
+import json
+import os
+from datetime import datetime
+from typing import Any, Optional
+
+import httpx
+from fastapi import APIRouter, File, HTTPException, UploadFile
+from pydantic import BaseModel, Field
+
+from app.config import settings
+from app.db import execute, fetch_all, fetch_one
+from app.services import herman as herman_service
+from app.services import ollama
+from app.services import llm_router
+from app.services.auto_ingest import last_auto_sync, run_full_auto_sync
+from app.services.client_360 import get_client_360
+from app.services import nas_folders
+from app.services.monitor import add_site, build_parse_intelligence, get_parse_page, list_parse_results, remove_site, trigger_crawl
+from app.services.agent_events_log import log_agent_event
+
+admin_router = APIRouter(prefix="/api/admin", tags=["admin-api"])
+register_recommendation_routes(admin_router)
+ai_router = APIRouter(prefix="/api/ai", tags=["ai"])
+herman_api = APIRouter(prefix="/api/herman", tags=["herman-api"])
+voice_api = APIRouter(prefix="/api/voice", tags=["voice-api"])
+
+
+def _serialize(row: dict | None) -> dict | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ elif isinstance(v, (datetime,)):
+ out[k] = v.isoformat()
+ return out
+
+
+def _serialize_rows(rows: list) -> list:
+ return [_serialize(r) for r in rows]
+
+
+# --- Pydantic models ---
+
+class ClientBody(BaseModel):
+ name: str
+ contact: Optional[str] = None
+ email: Optional[str] = None
+ stage: str = "intake"
+ sector: Optional[str] = None
+ mrr_estimate: Optional[float] = None
+ notes: Optional[str] = None
+
+
+class DealBody(BaseModel):
+ client_id: Optional[int] = None
+ title: str
+ value: float = 0
+ stage: str = "lead"
+ agent_owner: str = "herman"
+ next_action: Optional[str] = None
+ deadline: Optional[str] = None
+
+
+class ProductBody(BaseModel):
+ client_id: Optional[int] = None
+ name: str
+ status: str = "concept"
+ margin_pct: Optional[float] = None
+ moq: Optional[int] = None
+ shelf_target: Optional[str] = None
+ launch_date: Optional[str] = None
+
+
+class SupplierBody(BaseModel):
+ name: str
+ country: Optional[str] = None
+ category: Optional[str] = None
+ moq: Optional[int] = None
+ lead_time_days: Optional[int] = None
+ rating: Optional[float] = None
+ contact: Optional[str] = None
+
+
+class MentionBody(BaseModel):
+ platform: str
+ text: str
+
+
+class AccountBody(BaseModel):
+ platform: str
+ username: str
+ is_active: bool = True
+
+
+class ScheduledPostBody(BaseModel):
+ account_id: int
+ content: str
+ scheduled_time: str
+ status: str = "pending"
+
+
+class PostStatusBody(BaseModel):
+ status: str
+
+
+class AgentRuleBody(BaseModel):
+ name: str
+ condition_type: str
+ threshold: float
+ action: str = "notify"
+ is_active: bool = True
+
+
+class SiteBody(BaseModel):
+ url: str
+ name: str
+
+
+class MarginBody(BaseModel):
+ cost: float
+ sell: float
+
+
+class AIGenerateBody(BaseModel):
+ platform: str = "Instagram"
+ topic: str
+ tone: str = "vriendelijk en professioneel"
+
+
+class AIPostDraftBody(BaseModel):
+ account_id: Optional[int] = None
+ platform: str = "Instagram"
+ topic: str
+ tone: str = "vriendelijk en professioneel"
+
+
+class HermanChatBody(BaseModel):
+ message: str
+ channel: str = "cockpit"
+
+
+class ImageGenerateBody(BaseModel):
+ prompt: str = Field(..., min_length=3, max_length=2000)
+ width: int = Field(default=1024, ge=256, le=1024)
+ height: int = Field(default=1024, ge=256, le=1024)
+ steps: int = Field(default=28, ge=5, le=40)
+ quality: str = Field(default="hd")
+
+
+class ImageStartBody(BaseModel):
+ prompt: str = Field(..., min_length=3, max_length=2000)
+ negative_prompt: str = Field(default="blurry, low quality, watermark, text, ugly, deformed", max_length=2000)
+ quality: str = Field(default="hd")
+
+
+class CrawlBody(BaseModel):
+ site_id: Optional[int] = None
+
+
+class DelegateBody(BaseModel):
+ agent: str
+ task: str
+
+
+# --- Clients CRUD ---
+
+@admin_router.get("/clients")
+def list_clients():
+ return {"items": _serialize_rows(fetch_all(
+ """SELECT c.*,
+ (SELECT COUNT(*) FROM client_supermarket_links l WHERE l.client_id = c.id)
+ + (SELECT COUNT(*) FROM supermarkets s WHERE s.client_id = c.id
+ AND NOT EXISTS (
+ SELECT 1 FROM client_supermarket_links l2
+ WHERE l2.supermarket_id = s.id AND l2.client_id = c.id
+ )) AS store_count
+ FROM clients c
+ ORDER BY c.updated_at DESC NULLS LAST, c.created_at DESC LIMIT 500"""
+ ))}
+
+
+@admin_router.post("/clients")
+def create_client(body: ClientBody):
+ row = fetch_one(
+ """INSERT INTO clients (name, contact, email, stage, sector, mrr_estimate, notes, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""",
+ (body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes),
+ )
+ cid = row["id"]
+ try:
+ nas_folders.ensure_client_folder(cid, body.name)
+ except Exception:
+ pass
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (cid,)))}
+
+
+@admin_router.put("/clients/{client_id}")
+def update_client(client_id: int, body: ClientBody):
+ execute(
+ """UPDATE clients SET name=%s, contact=%s, email=%s, stage=%s, sector=%s,
+ mrr_estimate=%s, notes=%s, updated_at=NOW() WHERE id=%s""",
+ (body.name, body.contact, body.email, body.stage, body.sector, body.mrr_estimate, body.notes, client_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM clients WHERE id=%s", (client_id,)))}
+
+
+@admin_router.delete("/clients/{client_id}")
+def delete_client(client_id: int):
+ execute("DELETE FROM clients WHERE id=%s", (client_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/clients/stats")
+def clients_stats():
+ stats: dict[str, Any] = {}
+ try:
+ rows = fetch_all("SELECT stage, COUNT(*) AS n FROM clients GROUP BY stage")
+ stats["by_stage"] = {r["stage"]: int(r["n"]) for r in rows}
+ stats["total"] = sum(stats["by_stage"].values())
+ stats["active"] = stats["by_stage"].get("active", 0)
+ row = fetch_one("SELECT COALESCE(SUM(mrr_estimate),0) AS s FROM clients WHERE stage NOT IN ('churned')")
+ stats["mrr_total"] = float(row["s"] or 0) if row else 0
+ row = fetch_one(
+ """SELECT COALESCE(SUM(d.value),0) AS s FROM deals d
+ JOIN clients c ON c.id = d.client_id
+ WHERE d.stage NOT IN ('won','lost')"""
+ )
+ stats["pipeline_value"] = float(row["s"] or 0) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM client_supermarket_links")
+ stats["store_links"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM deals")
+ stats["deals"] = int(row["n"] or 0) if row else 0
+ except Exception:
+ stats = {"total": 0, "active": 0, "mrr_total": 0, "pipeline_value": 0, "store_links": 0, "deals": 0, "by_stage": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/clients/{client_id}/detail")
+def client_detail(client_id: int):
+ client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
+ if not client:
+ raise HTTPException(404, "Client not found")
+ deals = fetch_all(
+ """SELECT id, title, value, stage, next_action, deadline, updated_at
+ FROM deals WHERE client_id = %s ORDER BY updated_at DESC NULLS LAST LIMIT 20""",
+ (client_id,),
+ )
+ stores = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
+ s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
+ l.relationship_type, l.notes AS link_notes, l.deal_id, l.created_at AS linked_at
+ FROM client_supermarket_links l
+ JOIN supermarkets s ON s.id = l.supermarket_id
+ WHERE l.client_id = %s
+ ORDER BY s.chain, s.city, s.name
+ """,
+ (client_id,),
+ )
+ direct = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode, s.phone, s.email,
+ s.partnership_status, s.halal_certified, s.has_halal_section, s.manager_name,
+ 'direct' AS relationship_type, NULL AS link_notes, s.deal_id, s.last_updated AS linked_at
+ FROM supermarkets s
+ WHERE s.client_id = %s
+ AND NOT EXISTS (
+ SELECT 1 FROM client_supermarket_links l
+ WHERE l.supermarket_id = s.id AND l.client_id = s.client_id
+ )
+ ORDER BY s.chain, s.city, s.name
+ """,
+ (client_id,),
+ )
+ merged: dict[int, dict] = {}
+ for row in list(stores) + list(direct):
+ merged[int(row["id"])] = row
+ return {
+ "ok": True,
+ "client": _serialize(client),
+ "deals": _serialize_rows(deals),
+ "stores": _serialize_rows(list(merged.values())),
+ }
+
+
+@admin_router.get("/clients/{client_id}/360")
+def client_360_view(client_id: int) -> dict[str, Any]:
+ data = get_client_360(client_id)
+ if not data.get("ok"):
+ raise HTTPException(404, data.get("error", "not found"))
+ return data
+
+
+@admin_router.get("/documents/links")
+def document_links_list(
+ client_id: int | None = None,
+ project_id: int | None = None,
+) -> dict[str, Any]:
+ clauses, params = [], []
+ if client_id:
+ clauses.append("dl.client_id = %s")
+ params.append(client_id)
+ if project_id:
+ clauses.append("dl.project_id = %s")
+ params.append(project_id)
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+ rows = fetch_all(
+ f"""
+ SELECT dl.*, c.name AS client_name, p.name AS project_name
+ FROM document_links dl
+ LEFT JOIN clients c ON c.id = dl.client_id
+ LEFT JOIN cockpit_projects p ON p.id = dl.project_id
+ {where}
+ ORDER BY dl.created_at DESC
+ LIMIT 500
+ """,
+ tuple(params),
+ )
+ return {"ok": True, "items": _serialize_rows(rows)}
+
+
+@admin_router.post("/documents/links")
+def document_links_create(body: DocumentLinkBody) -> dict[str, Any]:
+ if not body.client_id and not body.project_id:
+ raise HTTPException(400, "client_id of project_id vereist")
+ row = fetch_one(
+ """
+ INSERT INTO document_links (storage_path, is_folder, client_id, project_id, notes)
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (body.storage_path.strip(), body.is_folder, body.client_id, body.project_id, body.notes or ""),
+ )
+ if body.project_id and row:
+ try:
+ from app.services import projects as projects_svc
+ projects_svc.add_asset(
+ body.project_id,
+ "nas_file",
+ body.storage_path.split("/")[-1],
+ file_path=body.storage_path.strip(),
+ source_agent="documents",
+ )
+ except Exception:
+ pass
+ return {"ok": True, "item": _serialize(row)}
+
+
+@admin_router.delete("/documents/links/{link_id}")
+def document_links_delete(link_id: int) -> dict[str, Any]:
+ execute("DELETE FROM document_links WHERE id = %s", (link_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/brain/auto-sync")
+async def brain_auto_sync(force: bool = False) -> dict[str, Any]:
+ return await run_full_auto_sync(force_scan=force)
+
+
+@admin_router.get("/brain/auto-sync/status")
+def brain_auto_sync_status() -> dict[str, Any]:
+ last = last_auto_sync()
+ return {"ok": True, "last": last}
+
+
+# --- Deals CRUD ---
+
+@admin_router.get("/deals")
+def list_deals():
+ return {"items": _serialize_rows(fetch_all(
+ """SELECT d.*, c.name AS client_name FROM deals d
+ LEFT JOIN clients c ON c.id = d.client_id
+ ORDER BY d.updated_at DESC NULLS LAST LIMIT 500"""
+ ))}
+
+
+@admin_router.post("/deals")
+def create_deal(body: DealBody):
+ row = fetch_one(
+ """INSERT INTO deals (client_id, title, value, stage, agent_owner, next_action, deadline, updated_at)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,NOW()) RETURNING id""",
+ (body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None),
+ )
+ did = row["id"]
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (did,)))}
+
+
+@admin_router.put("/deals/{deal_id}")
+def update_deal(deal_id: int, body: DealBody):
+ execute(
+ """UPDATE deals SET client_id=%s, title=%s, value=%s, stage=%s, agent_owner=%s,
+ next_action=%s, deadline=%s, updated_at=NOW() WHERE id=%s""",
+ (body.client_id, body.title, body.value, body.stage, body.agent_owner, body.next_action, body.deadline or None, deal_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM deals WHERE id=%s", (deal_id,)))}
+
+
+@admin_router.delete("/deals/{deal_id}")
+def delete_deal(deal_id: int):
+ execute("DELETE FROM deals WHERE id=%s", (deal_id,))
+ return {"ok": True}
+
+
+# --- Products CRUD ---
+
+@admin_router.get("/products/stats")
+def products_stats():
+ stats: dict[str, Any] = {}
+ try:
+ row = fetch_one("SELECT COUNT(*) AS n FROM products")
+ stats["total"] = int(row["n"] or 0) if row else 0
+ rows = fetch_all("SELECT status, COUNT(*) AS n FROM products GROUP BY status")
+ stats["by_status"] = {r["status"]: int(r["n"]) for r in rows}
+ stats["active"] = stats["by_status"].get("active", 0)
+ row = fetch_one("SELECT AVG(margin_pct) AS a FROM products WHERE margin_pct IS NOT NULL")
+ stats["avg_margin"] = round(float(row["a"] or 0), 1) if row else 0
+ row = fetch_one("SELECT COUNT(*) AS n FROM products WHERE client_id IS NOT NULL")
+ stats["with_client"] = int(row["n"] or 0) if row else 0
+ except Exception:
+ stats = {"total": 0, "active": 0, "avg_margin": 0, "with_client": 0, "by_status": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/products/{product_id}/detail")
+def product_detail(product_id: int):
+ product = fetch_one(
+ """SELECT p.*, c.name AS client_name, c.email AS client_email, c.stage AS client_stage
+ FROM products p LEFT JOIN clients c ON c.id = p.client_id WHERE p.id = %s""",
+ (product_id,),
+ )
+ if not product:
+ raise HTTPException(404, "Product not found")
+ return {"ok": True, "product": _serialize(product)}
+
+
+@admin_router.get("/products")
+def list_products():
+ return {"items": _serialize_rows(fetch_all(
+ """SELECT p.*, c.name AS client_name FROM products p
+ LEFT JOIN clients c ON c.id = p.client_id ORDER BY p.created_at DESC LIMIT 500"""
+ ))}
+
+
+@admin_router.post("/products")
+def create_product(body: ProductBody):
+ row = fetch_one(
+ """INSERT INTO products (client_id, name, status, margin_pct, moq, shelf_target, launch_date)
+ VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
+ (body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None),
+ )
+ pid = row["id"]
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (pid,)))}
+
+
+@admin_router.put("/products/{product_id}")
+def update_product(product_id: int, body: ProductBody):
+ execute(
+ """UPDATE products SET client_id=%s, name=%s, status=%s, margin_pct=%s, moq=%s,
+ shelf_target=%s, launch_date=%s WHERE id=%s""",
+ (body.client_id, body.name, body.status, body.margin_pct, body.moq, body.shelf_target, body.launch_date or None, product_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM products WHERE id=%s", (product_id,)))}
+
+
+@admin_router.delete("/products/{product_id}")
+def delete_product(product_id: int):
+ execute("DELETE FROM products WHERE id=%s", (product_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/products/margin-calc")
+def margin_calc(body: MarginBody):
+ if body.sell <= 0:
+ raise HTTPException(400, "sell must be > 0")
+ margin = ((body.sell - body.cost) / body.sell) * 100
+ return {"cost": body.cost, "sell": body.sell, "margin_pct": round(margin, 2), "profit": round(body.sell - body.cost, 2)}
+
+
+# --- Suppliers CRUD ---
+
+@admin_router.get("/suppliers/stats")
+def suppliers_stats():
+ stats: dict[str, Any] = {}
+ try:
+ row = fetch_one("SELECT COUNT(*) AS n FROM suppliers")
+ stats["total"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT AVG(rating) AS a FROM suppliers WHERE rating IS NOT NULL")
+ stats["avg_rating"] = round(float(row["a"] or 0), 1) if row else 0
+ row = fetch_one("SELECT COUNT(DISTINCT country) AS n FROM suppliers WHERE country IS NOT NULL AND country <> ''")
+ stats["countries"] = int(row["n"] or 0) if row else 0
+ row = fetch_one("SELECT AVG(lead_time_days) AS a FROM suppliers WHERE lead_time_days IS NOT NULL")
+ stats["avg_lead"] = round(float(row["a"] or 0), 0) if row else 0
+ rows = fetch_all(
+ "SELECT COALESCE(category, 'Overig') AS category, COUNT(*) AS n FROM suppliers GROUP BY category ORDER BY n DESC LIMIT 8"
+ )
+ stats["by_category"] = {r["category"]: int(r["n"]) for r in rows}
+ except Exception:
+ stats = {"total": 0, "avg_rating": 0, "countries": 0, "avg_lead": 0, "by_category": {}}
+ return {"ok": True, "stats": stats}
+
+
+@admin_router.get("/suppliers/{supplier_id}/detail")
+def supplier_detail(supplier_id: int):
+ supplier = fetch_one("SELECT * FROM suppliers WHERE id = %s", (supplier_id,))
+ if not supplier:
+ raise HTTPException(404, "Supplier not found")
+ return {"ok": True, "supplier": _serialize(supplier)}
+
+
+@admin_router.get("/suppliers")
+def list_suppliers():
+ return {"items": _serialize_rows(fetch_all("SELECT * FROM suppliers ORDER BY name LIMIT 500"))}
+
+
+@admin_router.post("/suppliers")
+def create_supplier(body: SupplierBody):
+ row = fetch_one(
+ """INSERT INTO suppliers (name, country, category, moq, lead_time_days, rating, contact)
+ VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
+ (body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact),
+ )
+ sid = row["id"]
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (sid,)))}
+
+
+@admin_router.put("/suppliers/{supplier_id}")
+def update_supplier(supplier_id: int, body: SupplierBody):
+ execute(
+ """UPDATE suppliers SET name=%s, country=%s, category=%s, moq=%s,
+ lead_time_days=%s, rating=%s, contact=%s WHERE id=%s""",
+ (body.name, body.country, body.category, body.moq, body.lead_time_days, body.rating, body.contact, supplier_id),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM suppliers WHERE id=%s", (supplier_id,)))}
+
+
+@admin_router.delete("/suppliers/{supplier_id}")
+def delete_supplier(supplier_id: int):
+ execute("DELETE FROM suppliers WHERE id=%s", (supplier_id,))
+ return {"ok": True}
+
+
+# --- Marketing ---
+
+@admin_router.get("/mentions")
+def list_mentions(platform: str = "all"):
+ if platform == "all":
+ rows = fetch_all("SELECT * FROM social_mentions ORDER BY created_at DESC LIMIT 200")
+ else:
+ rows = fetch_all(
+ "SELECT * FROM social_mentions WHERE platform=%s ORDER BY created_at DESC LIMIT 200",
+ (platform,),
+ )
+ evaluate_agent_rules()
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.post("/mentions")
+def create_mention(body: MentionBody):
+ text = body.text.strip()
+ if not text:
+ raise HTTPException(400, "text required")
+ score = sentiment_score(text)
+ row = fetch_one(
+ "INSERT INTO social_mentions (platform, text, sentiment_score) VALUES (%s,%s,%s) RETURNING id",
+ (body.platform, text, score),
+ )
+ evaluate_agent_rules(row["id"])
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_mentions WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.delete("/mentions/{mention_id}")
+def delete_mention(mention_id: int):
+ execute("DELETE FROM social_mentions WHERE id=%s", (mention_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/accounts")
+def list_accounts():
+ return {"items": _serialize_rows(fetch_all("SELECT * FROM social_accounts ORDER BY platform, username"))}
+
+
+@admin_router.post("/accounts")
+def create_account(body: AccountBody):
+ row = fetch_one(
+ "INSERT INTO social_accounts (platform, username, is_active) VALUES (%s,%s,%s) RETURNING id",
+ (body.platform, body.username.strip(), body.is_active),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM social_accounts WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.delete("/accounts/{account_id}")
+def delete_account(account_id: int):
+ execute("DELETE FROM social_accounts WHERE id=%s", (account_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/scheduled-posts")
+def list_scheduled_posts():
+ rows = fetch_all(
+ """SELECT sp.*, sa.platform, sa.username FROM scheduled_posts sp
+ JOIN social_accounts sa ON sp.account_id = sa.id
+ ORDER BY sp.scheduled_time ASC"""
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.post("/scheduled-posts")
+def create_scheduled_post(body: ScheduledPostBody):
+ row = fetch_one(
+ "INSERT INTO scheduled_posts (account_id, content, scheduled_time, status) VALUES (%s,%s,%s,%s) RETURNING id",
+ (body.account_id, body.content.strip(), body.scheduled_time, body.status),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM scheduled_posts WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.patch("/scheduled-posts/{post_id}/status")
+def update_scheduled_status(post_id: int, body: PostStatusBody):
+ allowed = {"pending", "approved", "posted", "rejected", "published", "cancelled"}
+ if body.status not in allowed:
+ raise HTTPException(400, "invalid status")
+ if body.status in ("posted", "published"):
+ execute(
+ "UPDATE scheduled_posts SET status=%s, posted_at=NOW() WHERE id=%s",
+ (body.status, post_id),
+ )
+ else:
+ execute("UPDATE scheduled_posts SET status=%s WHERE id=%s", (body.status, post_id))
+ return {"ok": True}
+
+
+@admin_router.put("/scheduled-posts/{post_id}")
+def update_scheduled_post(post_id: int, body: ScheduledPostBody):
+ execute(
+ "UPDATE scheduled_posts SET account_id=%s, content=%s, scheduled_time=%s, status=%s WHERE id=%s",
+ (body.account_id, body.content, body.scheduled_time, body.status, post_id),
+ )
+ return {"ok": True}
+
+
+@admin_router.delete("/scheduled-posts/{post_id}")
+def delete_scheduled_post(post_id: int):
+ execute("DELETE FROM scheduled_posts WHERE id=%s", (post_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/scheduled-posts/publish-due")
+def publish_due_scheduled_posts():
+ """Mark due scheduled posts as published (marketing cron)."""
+ rows = fetch_all(
+ """
+ SELECT id, content FROM scheduled_posts
+ WHERE status = 'scheduled' AND scheduled_time <= NOW()
+ ORDER BY scheduled_time ASC
+ LIMIT 50
+ """
+ )
+ published = []
+ for row in rows:
+ pid = int(row["id"])
+ execute(
+ "UPDATE scheduled_posts SET status = 'published', posted_at = NOW() WHERE id = %s",
+ (pid,),
+ )
+ try:
+ execute(
+ """
+ INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ """,
+ (
+ "marketing",
+ "marketing",
+ "post_published",
+ f"Scheduled post #{pid} gepubliceerd",
+ (row.get("content") or "")[:500],
+ "completed",
+ "cron",
+ json.dumps({"post_id": pid}),
+ ),
+ )
+ except Exception:
+ pass
+ published.append(pid)
+ return {"ok": True, "published": published, "count": len(published)}
+
+
+@admin_router.get("/agent-rules")
+def list_agent_rules():
+ evaluate_agent_rules()
+ return {"items": _serialize_rows(fetch_all("SELECT * FROM agent_rules ORDER BY id"))}
+
+
+@admin_router.post("/agent-rules")
+def create_agent_rule(body: AgentRuleBody):
+ row = fetch_one(
+ """INSERT INTO agent_rules (name, condition_type, threshold, action, is_active)
+ VALUES (%s,%s,%s,%s,%s) RETURNING id""",
+ (body.name, body.condition_type, body.threshold, body.action, body.is_active),
+ )
+ return {"ok": True, "item": _serialize(fetch_one("SELECT * FROM agent_rules WHERE id=%s", (row["id"],)))}
+
+
+@admin_router.patch("/agent-rules/{rule_id}/toggle")
+def toggle_agent_rule(rule_id: int):
+ execute("UPDATE agent_rules SET is_active = NOT is_active WHERE id=%s", (rule_id,))
+ return {"ok": True}
+
+
+@admin_router.delete("/agent-rules/{rule_id}")
+def delete_agent_rule(rule_id: int):
+ execute("DELETE FROM agent_rules WHERE id=%s", (rule_id,))
+ return {"ok": True}
+
+
+@admin_router.get("/agent-logs")
+def list_agent_logs(limit: int = 100):
+ limit = max(1, min(limit, 500))
+ rows = fetch_all(
+ """SELECT al.*, ar.name AS rule_name FROM agent_logs al
+ LEFT JOIN agent_rules ar ON al.rule_id = ar.id
+ ORDER BY al.created_at DESC LIMIT %s""",
+ (limit,),
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.get("/analytics/social")
+def social_analytics():
+ account_stats = fetch_all(
+ """SELECT sa.platform, sa.username, SUM(sa2.impressions) AS impressions,
+ SUM(sa2.engagements) AS engagements, SUM(sa2.reach) AS reach
+ FROM social_analytics sa2
+ JOIN social_accounts sa ON sa2.account_id = sa.id
+ WHERE sa2.date > CURRENT_DATE - interval '7 days'
+ GROUP BY sa.platform, sa.username ORDER BY impressions DESC"""
+ )
+ daily = fetch_all(
+ """SELECT date, SUM(impressions) AS impressions, SUM(engagements) AS engagements
+ FROM social_analytics WHERE date > CURRENT_DATE - interval '7 days'
+ GROUP BY date ORDER BY date"""
+ )
+ mention_stats = fetch_one(
+ "SELECT COUNT(*) AS cnt, COALESCE(AVG(sentiment_score),0) AS avg_sentiment FROM social_mentions"
+ )
+ return {
+ "account_stats": _serialize_rows(account_stats),
+ "daily_stats": _serialize_rows(daily),
+ "mentions": _serialize(mention_stats),
+ }
+
+
+# --- Monitor ---
+
+@admin_router.get("/monitor/sites")
+def list_monitor_sites():
+ rows = fetch_all(
+ """SELECT id, url, name, last_hash, last_crawled, is_active FROM monitored_sites
+ ORDER BY last_crawled DESC NULLS LAST"""
+ )
+ active = fetch_one("SELECT COUNT(*) AS c FROM monitored_sites WHERE is_active = TRUE")
+ changes_24h = fetch_one(
+ "SELECT COUNT(*) AS c FROM page_changes WHERE changed_at > NOW() - interval '24 hours'"
+ )
+ return {
+ "items": _serialize_rows(rows),
+ "stats": {
+ "active_sites": int(active["c"]) if active else 0,
+ "changes_24h": int(changes_24h["c"]) if changes_24h else 0,
+ },
+ }
+
+
+@admin_router.post("/monitor/sites")
+def create_monitor_site(body: SiteBody):
+ try:
+ item = add_site(body.url, body.name)
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ for k, v in list(item.items()):
+ if hasattr(v, "isoformat"):
+ item[k] = v.isoformat()
+ try:
+ log_agent_event(
+ "browser",
+ "monitor_site_added",
+ f"Site toegevoegd: {(body.name or body.url)[:80]}",
+ body.url,
+ channel="monitor",
+ metadata={"url": body.url, "name": body.name, "site_id": item.get("id")},
+ )
+ except Exception:
+ pass
+ return {"ok": True, "item": item}
+
+
+@admin_router.delete("/monitor/sites/{site_id}")
+def delete_monitor_site(site_id: int, hard: bool = False):
+ remove_site(site_id, soft=not hard)
+ return {"ok": True}
+
+
+@admin_router.patch("/monitor/sites/{site_id}/toggle")
+def toggle_monitor_site(site_id: int):
+ execute("UPDATE monitored_sites SET is_active = NOT is_active WHERE id=%s", (site_id,))
+ return {"ok": True}
+
+
+@admin_router.post("/monitor/trigger-crawl")
+def monitor_trigger_crawl(body: CrawlBody = CrawlBody()):
+ result = trigger_crawl(body.site_id)
+ summary_bits = []
+ for r in result.get("results") or []:
+ if r.get("status") == "ERROR":
+ summary_bits.append(f"{r.get('name') or r.get('url')}: fout")
+ else:
+ summary_bits.append(
+ f"{r.get('title') or r.get('name')}: {r.get('word_count', 0)} woorden"
+ )
+ try:
+ log_agent_event(
+ "browser",
+ "monitor_crawl",
+ f"Crawl klaar — {result.get('sites', 0)} site(s), {result.get('changed', 0)} wijziging(en)",
+ "; ".join(summary_bits)[:800],
+ channel="monitor",
+ metadata={"site_id": body.site_id, **{k: v for k, v in result.items() if k != "results"}},
+ )
+ except Exception:
+ pass
+ return result
+
+
+@admin_router.get("/monitor/parse-results")
+def monitor_parse_results(site_id: int | None = None, limit: int = 20):
+ items = list_parse_results(site_id=site_id, limit=limit)
+ return {"items": items, "count": len(items)}
+
+
+@admin_router.get("/monitor/intelligence")
+def monitor_intelligence(site_id: int | None = None, q: str | None = None, limit: int = 100):
+ data = build_parse_intelligence(site_id=site_id, query=q, limit=min(limit, 100))
+ return data
+
+
+@admin_router.get("/monitor/parse-results/{page_id}")
+def monitor_parse_page_detail(page_id: int):
+ page = get_parse_page(page_id)
+ if not page:
+ raise HTTPException(404, "Parse result not found")
+ return {"page": page}
+
+
+@admin_router.get("/monitor/page-changes")
+def monitor_page_changes(limit: int = 50):
+ rows = fetch_all(
+ """SELECT pc.*, ms.url, ms.name FROM page_changes pc
+ JOIN monitored_sites ms ON pc.site_id = ms.id
+ ORDER BY pc.changed_at DESC LIMIT %s""",
+ (max(1, min(limit, 200)),),
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+@admin_router.get("/monitor/crawl-logs")
+def monitor_crawl_logs(limit: int = 50):
+ rows = fetch_all(
+ """SELECT cl.*, ms.url, ms.name FROM crawl_logs cl
+ LEFT JOIN monitored_sites ms ON cl.site_id = ms.id
+ ORDER BY cl.logged_at DESC LIMIT %s""",
+ (max(1, min(limit, 200)),),
+ )
+ return {"items": _serialize_rows(rows)}
+
+
+# --- Agent events (approvals) ---
+
+@admin_router.patch("/agent-events/{event_id}/status")
+def patch_agent_event_status(event_id: int, body: PostStatusBody):
+ if body.status not in ("approved", "rejected", "needs_approval", "completed"):
+ raise HTTPException(400, "invalid status")
+ execute("UPDATE agent_events SET status=%s WHERE id=%s", (body.status, event_id))
+ return {"ok": True}
+
+
+@admin_router.post("/delegate")
+async def delegate_task(body: DelegateBody):
+ msg = f"[{body.agent}] {body.task}"
+ result = await herman_service.chat(msg)
+ return {"ok": True, "result": result}
+
+
+# --- AI ---
+
+async def _generate_social(prompt: str) -> str:
+ try:
+ return await llm_router.generate(prompt)
+ except Exception as exc:
+ raise HTTPException(502, f"LLM error: {exc}") from exc
+
+
+@ai_router.post("/generate-content")
+async def ai_generate_content(body: AIGenerateBody):
+ topic = body.topic.strip()
+ if not topic:
+ raise HTTPException(400, "topic required")
+ prompt = (
+ f"Schrijf een korte social media post voor {body.platform} over: {topic}. "
+ f"Toon: {body.tone}. Maximaal 280 tekens. Alleen de posttekst, geen uitleg."
+ )
+ content = await _generate_social(prompt)
+ return {"ok": True, "content": content}
+
+
+
+
+@ai_router.post("/generate-image")
+async def ai_generate_image(body: ImageGenerateBody):
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=620.0) as client:
+ r = await client.post(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate",
+ json=body.model_dump(),
+ )
+ r.raise_for_status()
+ data = r.json()
+ fn = data.get("filename", "")
+ sub = data.get("subfolder", "")
+ typ = data.get("type", "output")
+ data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}"
+ return data
+ except httpx.HTTPStatusError as exc:
+ detail = exc.response.text[:300] if exc.response else str(exc)
+ raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@ai_router.get("/generated-image")
+async def ai_generated_image(
+ filename: str,
+ subfolder: str = "",
+ type: str = "output",
+):
+ import httpx
+ from fastapi.responses import Response
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/images/view",
+ params={"filename": filename, "subfolder": subfolder, "type": type},
+ )
+ r.raise_for_status()
+ media = r.headers.get("content-type", "image/png")
+ return Response(content=r.content, media_type=media)
+ except Exception as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+
+
+@ai_router.post("/generate-image/start")
+async def ai_generate_image_start(body: ImageStartBody):
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.post(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/images/generate/start",
+ json=body.model_dump(),
+ )
+ r.raise_for_status()
+ return r.json()
+ except httpx.HTTPStatusError as exc:
+ detail = exc.response.text[:300] if exc.response else str(exc)
+ raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=detail) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@ai_router.get("/generate-image/progress/{prompt_id}")
+async def ai_generate_image_progress(prompt_id: str):
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/images/progress/{prompt_id}")
+ r.raise_for_status()
+ data = r.json()
+ result = data.get("result") or {}
+ if data.get("status") == "done" and result.get("filename"):
+ fn = result["filename"]
+ sub = result.get("subfolder", "")
+ typ = result.get("type", "output")
+ data["proxy_url"] = f"/api/ai/generated-image?filename={fn}&subfolder={sub}&type={typ}"
+ return data
+ except httpx.HTTPStatusError as exc:
+ raise HTTPException(status_code=exc.response.status_code if exc.response else 502, detail=exc.response.text[:300]) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+@ai_router.post("/generate-post-draft")
+async def ai_generate_post_draft(body: AIPostDraftBody):
+ platform = body.platform
+ if body.account_id:
+ acc = fetch_one("SELECT platform, username FROM social_accounts WHERE id=%s", (body.account_id,))
+ if acc:
+ platform = f"{acc['platform']} (@{acc['username']})"
+ prompt = (
+ f"Schrijf een geplande social post voor {platform} over: {body.topic.strip()}. "
+ f"Toon: {body.tone}. Geef titel + body."
+ )
+ content = await _generate_social(prompt)
+ return {"ok": True, "content": content, "platform": platform}
+
+
+@herman_api.post("/chat")
+async def herman_chat_api(body: HermanChatBody):
+ if not body.message.strip():
+ raise HTTPException(400, "message required")
+ result = await herman_service.chat(body.message.strip(), channel=(body.channel or "cockpit").strip())
+ return {"ok": True, **result}
+
+
+@voice_api.post("/transcribe")
+async def voice_transcribe(file: UploadFile = File(...)):
+ url = "http://10.4.7.19:8877/v1/audio/transcriptions"
+ data = await file.read()
+ if not data:
+ raise HTTPException(400, "empty file")
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ resp = await client.post(
+ url,
+ files={"file": (file.filename or "audio.webm", data, file.content_type or "audio/webm")},
+ data={"model": "Systran/faster-whisper-base", "language": "nl"},
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ return {"text": data.get("text", ""), "raw": data}
+ except httpx.HTTPError as exc:
+ raise HTTPException(502, f"transcribe proxy failed: {exc}") from exc
+
+# --- Document word analytics & sentiment ---
+
+@admin_router.get("/documents/summary")
+def documents_summary():
+ try:
+ row = fetch_one(
+ """
+ SELECT COUNT(*) AS documents,
+ COALESCE(SUM(word_count), 0) AS total_words,
+ COALESCE(AVG(sentiment_compound), 0) AS avg_sentiment
+ FROM document_analytics
+ """
+ )
+ unique = fetch_one(
+ "SELECT COUNT(DISTINCT lemma) AS unique_words FROM document_word_counts WHERE NOT is_stopword"
+ )
+ sentiment = fetch_all(
+ """
+ SELECT sentiment_label, COUNT(*) AS cnt
+ FROM document_analytics
+ GROUP BY sentiment_label
+ """
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {
+ "ok": True,
+ "summary": {
+ "documents": int(row["documents"] or 0) if row else 0,
+ "total_words": int(row["total_words"] or 0) if row else 0,
+ "avg_sentiment": round(float(row["avg_sentiment"] or 0), 4) if row else 0,
+ "unique_words": int(unique["unique_words"] or 0) if unique else 0,
+ "sentiment": {r["sentiment_label"]: int(r["cnt"]) for r in sentiment},
+ },
+ }
+
+
+@admin_router.get("/documents/words")
+def documents_words(
+ q: str | None = None,
+ limit: int = 40,
+ stopwords: bool = False,
+):
+ limit = max(1, min(limit, 200))
+ clauses = []
+ params: list = []
+ if not stopwords:
+ clauses.append("NOT is_stopword")
+ if q and q.strip():
+ clauses.append("lemma ILIKE %s")
+ params.append(f"%{q.strip()}%")
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(limit)
+ try:
+ rows = fetch_all(
+ f"""
+ SELECT lemma, MAX(token) AS token, SUM(count) AS total_count,
+ COUNT(DISTINCT storage_path) AS document_count
+ FROM document_word_counts
+ {where}
+ GROUP BY lemma
+ ORDER BY total_count DESC
+ LIMIT %s
+ """,
+ tuple(params),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "items": [_serialize(r) for r in rows]}
+
+
+@admin_router.get("/documents/list")
+def documents_list(limit: int = 50, label: str | None = None):
+ limit = max(1, min(limit, 200))
+ clauses: list[str] = []
+ params: list[Any] = []
+ if label and label.strip():
+ clauses.append("%s = ANY(user_labels)")
+ params.append(label.strip().lower())
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
+ params.append(limit)
+ try:
+ rows = fetch_all(
+ f"""
+ SELECT filename, storage_path, doc_type, language, word_count, unique_lemmas,
+ sentiment_label, sentiment_compound, sentiment_positive,
+ sentiment_negative, sentiment_neutral, sentiment_subjectivity,
+ extraction_method, analyzed_at,
+ COALESCE(user_labels, '{{}}') AS user_labels, label_notes, labeled_at
+ FROM document_analytics
+ {where}
+ ORDER BY analyzed_at DESC
+ LIMIT %s
+ """,
+ tuple(params),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "items": [_serialize(r) for r in rows]}
+
+
+class DocumentLabelsBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ labels: list[str] = Field(default_factory=list)
+ notes: str | None = None
+
+
+class PhotoLabelsBody(BaseModel):
+ labels: list[str] = Field(default_factory=list)
+ notes: str | None = None
+
+
+def _normalize_labels(raw: list[str]) -> list[str]:
+ seen: set[str] = set()
+ out: list[str] = []
+ for item in raw:
+ tag = (item or "").strip().lower()
+ if not tag or tag in seen:
+ continue
+ seen.add(tag)
+ out.append(tag[:64])
+ return out
+
+
+@admin_router.patch("/documents/labels")
+def documents_patch_labels(body: DocumentLabelsBody) -> dict[str, Any]:
+ labels = _normalize_labels(body.labels)
+ notes = (body.notes or "").strip() or None
+ try:
+ row = fetch_one(
+ """
+ UPDATE document_analytics
+ SET user_labels = %s, label_notes = %s, labeled_at = NOW()
+ WHERE storage_path = %s
+ RETURNING filename, storage_path, user_labels, label_notes, labeled_at
+ """,
+ (labels, notes, body.storage_path),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ execute(
+ """
+ INSERT INTO document_analytics (storage_path, filename, user_labels, label_notes, labeled_at)
+ VALUES (%s, %s, %s, %s, NOW())
+ ON CONFLICT (storage_path) DO UPDATE
+ SET user_labels = EXCLUDED.user_labels,
+ label_notes = EXCLUDED.label_notes,
+ labeled_at = NOW()
+ """,
+ (body.storage_path, body.storage_path.split("/")[-1], labels, notes),
+ )
+ row = fetch_one(
+ """
+ SELECT filename, storage_path, user_labels, label_notes, labeled_at
+ FROM document_analytics WHERE storage_path = %s
+ """,
+ (body.storage_path,),
+ )
+ return {"ok": True, "document": _serialize(row)}
+
+
+@admin_router.patch("/photos/{photo_id}/labels")
+def photos_patch_labels(photo_id: int, body: PhotoLabelsBody) -> dict[str, Any]:
+ labels = _normalize_labels(body.labels)
+ notes = (body.notes or "").strip() or None
+ try:
+ row = fetch_one(
+ """
+ UPDATE photo_imports
+ SET user_labels = %s, label_notes = %s, labeled_at = NOW()
+ WHERE id = %s
+ RETURNING id, filename, storage_path, user_labels, label_notes, labeled_at
+ """,
+ (labels, notes, photo_id),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ raise HTTPException(404, "Photo not found")
+ return {"ok": True, "photo": _serialize(row)}
+
+
+@admin_router.get("/labels/vocabulary")
+def labels_vocabulary(limit: int = 80) -> dict[str, Any]:
+ limit = max(1, min(limit, 200))
+ try:
+ rows = fetch_all(
+ """
+ SELECT label, COUNT(*) AS usage_count FROM (
+ SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM document_analytics
+ UNION ALL
+ SELECT unnest(COALESCE(user_labels, '{}')) AS label FROM photo_imports
+ ) t
+ WHERE label IS NOT NULL AND label <> ''
+ GROUP BY label
+ ORDER BY usage_count DESC, label ASC
+ LIMIT %s
+ """,
+ (limit,),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "labels": [_serialize(r) for r in rows]}
+
+
+PHOTO_LABEL_TAXONOMY = [
+ {"id": "productfoto", "label": "Productfoto", "emoji": "📦", "color": "#00e5ff"},
+ {"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
+ {"id": "verpakking", "label": "Verpakking", "emoji": "📋", "color": "#b8ff3c"},
+ {"id": "halal", "label": "Halal certificaat", "emoji": "✅", "color": "#4ade80"},
+ {"id": "recept", "label": "Recept / technisch", "emoji": "🧪", "color": "#a78bfa"},
+ {"id": "factuur", "label": "Factuur / bon", "emoji": "🧾", "color": "#fb7185"},
+ {"id": "etiket", "label": "Etiket", "emoji": "🏷️", "color": "#f97316"},
+ {"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
+ {"id": "schap", "label": "Schap / retail", "emoji": "🛒", "color": "#22d3ee"},
+ {"id": "menu", "label": "Menu / horeca", "emoji": "🍽️", "color": "#fbbf24"},
+ {"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
+]
+
+DOCUMENT_LABEL_TAXONOMY = [
+ {"id": "contract", "label": "Contract", "emoji": "📄", "color": "#00e5ff"},
+ {"id": "presentatie", "label": "Presentatie", "emoji": "📊", "color": "#a78bfa"},
+ {"id": "rapport", "label": "Rapport", "emoji": "📈", "color": "#4ade80"},
+ {"id": "halal", "label": "Halal / certificaat", "emoji": "✅", "color": "#22c55e"},
+ {"id": "prijslijst", "label": "Prijslijst", "emoji": "💰", "color": "#ffd700"},
+ {"id": "marketing", "label": "Marketing", "emoji": "📣", "color": "#38bdf8"},
+ {"id": "juridisch", "label": "Juridisch", "emoji": "⚖️", "color": "#fb7185"},
+ {"id": "financieel", "label": "Financieel", "emoji": "🧾", "color": "#f97316"},
+ {"id": "product", "label": "Product info", "emoji": "📦", "color": "#b8ff3c"},
+ {"id": "overig", "label": "Overig", "emoji": "📁", "color": "#94a3b8"},
+]
+
+
+@admin_router.get("/labels/taxonomy")
+def labels_taxonomy() -> dict[str, Any]:
+ return {
+ "ok": True,
+ "photo": PHOTO_LABEL_TAXONOMY,
+ "document": DOCUMENT_LABEL_TAXONOMY,
+ }
+
+
+class DoclingConvertBody(BaseModel):
+ path: str = Field(..., min_length=1)
+ ocr: bool = True
+ tables: bool = True
+ page_images: bool = False
+ picture_images: bool = True
+ force_full_page_ocr: bool = False
+ formats: list[str] = Field(default_factory=lambda: ["markdown", "html", "json", "text", "doctags", "yaml"])
+
+
+class DoclingBatchBody(BaseModel):
+ paths: list[str] = Field(default_factory=list)
+ limit: int = Field(default=10, ge=1, le=50)
+ ext: str | None = None
+ ocr: bool = True
+ tables: bool = True
+ page_images: bool = False
+ picture_images: bool = True
+ force_full_page_ocr: bool = False
+ formats: list[str] = Field(default_factory=lambda: ["markdown", "json"])
+
+
+@admin_router.get("/docling/capabilities")
+async def docling_capabilities_proxy() -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/capabilities")
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ return {"ok": False, "installed": False, "error": str(exc)}
+
+
+@admin_router.post("/docling/convert")
+async def docling_convert_proxy(body: DoclingConvertBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=600.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/convert", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/docling/batch")
+async def docling_batch_proxy(body: DoclingBatchBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=900.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/batch", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/docling/history")
+async def docling_history_proxy(limit: int = 30) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/history", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ return {"ok": False, "items": [], "error": str(exc)}
+
+
+@admin_router.get("/docling/result")
+async def docling_result_proxy(path: str, file_sig: str | None = None) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ params: dict[str, str] = {"path": path}
+ if file_sig:
+ params["file_sig"] = file_sig
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/docling/result", params=params)
+ if r.status_code == 404:
+ return {"ok": False, "result": None}
+ r.raise_for_status()
+ return r.json()
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+class WorkspaceBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ content: str = ""
+ source_format: str = "markdown"
+
+
+class NasWriteBody(BaseModel):
+ path: str = Field(..., min_length=1)
+ content: str = ""
+ subdir: str = "Telegram/Exports"
+
+
+class PptxSlideBody(BaseModel):
+ title: str = ""
+ bullets: list[str] = Field(default_factory=list)
+
+
+class PptxCreateBody(BaseModel):
+ title: str = Field(..., min_length=1, max_length=200)
+ subtitle: str = Field(default="Foodlinkk", max_length=200)
+ slides: list[PptxSlideBody] = Field(default_factory=list)
+ filename: str = ""
+
+
+class DocumentsChatMessage(BaseModel):
+ role: str = Field(..., pattern="^(user|assistant)$")
+ content: str = ""
+
+
+class DocumentsChatBody(BaseModel):
+ message: str = Field(..., min_length=1, max_length=8000)
+ path_prefix: str = ""
+ client_id: int | None = None
+ project_id: int | None = None
+ use_herman: bool = False
+ llm_provider_id: int | None = None
+ history: list[DocumentsChatMessage] = Field(default_factory=list)
+
+
+class DocumentLinkBody(BaseModel):
+ storage_path: str = Field(..., min_length=1)
+ client_id: int | None = None
+ project_id: int | None = None
+ is_folder: bool = False
+ notes: str = ""
+
+
+@admin_router.get("/docling/workspace")
+def docling_workspace_get(path: str) -> dict[str, Any]:
+ try:
+ row = fetch_one(
+ "SELECT storage_path, source_format, content, updated_at FROM document_workspace WHERE storage_path = %s",
+ (path,),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ if not row:
+ return {"ok": True, "workspace": None}
+ return {"ok": True, "workspace": _serialize(row)}
+
+
+@admin_router.put("/docling/workspace")
+def docling_workspace_put(body: WorkspaceBody) -> dict[str, Any]:
+ try:
+ row = fetch_one(
+ """
+ INSERT INTO document_workspace (storage_path, source_format, content, updated_at)
+ VALUES (%s, %s, %s, NOW())
+ ON CONFLICT (storage_path) DO UPDATE
+ SET source_format = EXCLUDED.source_format,
+ content = EXCLUDED.content,
+ updated_at = NOW()
+ RETURNING storage_path, source_format, content, updated_at
+ """,
+ (body.storage_path, body.source_format, body.content),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "workspace": _serialize(row)}
+
+
+@admin_router.get("/docling/nas-read")
+async def docling_nas_read_proxy(path: str) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/docling/nas/read",
+ params={"path": path},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/documents/nas-file")
+async def documents_nas_file_proxy(path: str):
+ from fastapi.responses import Response
+
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/docling/nas/file",
+ params={"path": path},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:300])
+ media = r.headers.get("content-type", "application/octet-stream")
+ return Response(
+ content=r.content,
+ media_type=media,
+ headers={"Content-Disposition": f'inline; filename="{path.split("/")[-1]}"'},
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/docling/save-nas")
+async def docling_save_nas(body: NasWriteBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/docling/nas/write", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/pptx/create")
+async def pptx_create_proxy(body: PptxCreateBody) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ payload = {
+ "title": body.title,
+ "subtitle": body.subtitle,
+ "filename": body.filename,
+ "slides": [{"title": s.title, "bullets": s.bullets} for s in body.slides],
+ }
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{doc_url.rstrip('/')}/convert/create-pptx", json=payload)
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.post("/pptx/pdf-to-pptx")
+async def pptx_from_pdf_proxy(
+ path: str,
+ mode: str = "smart",
+ max_pages: int = 50,
+) -> dict[str, Any]:
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/convert/pdf-to-pptx",
+ params={"path": path, "mode": mode, "max_pages": max_pages},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(r.status_code, r.text[:500])
+ return r.json()
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/pptx/download")
+async def pptx_download_proxy(path: str):
+ from fastapi.responses import Response
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/convert/pdf-to-pptx/download",
+ params={"path": path},
+ )
+ if r.status_code != 200:
+ raise HTTPException(r.status_code, r.text[:300])
+ return Response(
+ content=r.content,
+ media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ headers={"Content-Disposition": f'attachment; filename="{path.split("/")[-1]}"'},
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(502, str(exc)) from exc
+
+
+@admin_router.get("/documents/{storage_path:path}/words")
+def document_words(storage_path: str, limit: int = 100, stopwords: bool = False):
+ limit = max(1, min(limit, 500))
+ clauses = ["storage_path = %s"]
+ params: list = [storage_path]
+ if not stopwords:
+ clauses.append("NOT is_stopword")
+ try:
+ rows = fetch_all(
+ f"""
+ SELECT lemma, token, count, pos_tag, is_stopword, language
+ FROM document_word_counts
+ WHERE {' AND '.join(clauses)}
+ ORDER BY count DESC
+ LIMIT %s
+ """,
+ tuple(params + [limit]),
+ )
+ except Exception as exc:
+ raise HTTPException(500, str(exc)) from exc
+ return {"ok": True, "items": [_serialize(r) for r in rows]}
+
+BROWSER_AGENT_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790")
+
+
+class BrowserBrowseBody(BaseModel):
+ url: str
+ task: Optional[str] = None
+ site_id: Optional[int] = None
+ wait_seconds: float = 4.0
+
+
+@admin_router.post("/browser/browse")
+async def browser_browse(body: BrowserBrowseBody) -> dict[str, Any]:
+ import httpx
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse", json=body.model_dump())
+ r.raise_for_status()
+ data = r.json()
+ except httpx.HTTPStatusError as exc:
+ detail = exc.response.text[:500]
+ raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+ try:
+ meta = data.get("session") or data
+ steps = (meta.get("metadata") or {}).get("steps") or []
+ log_agent_event(
+ "browser",
+ "browse",
+ f"Browse: {(meta.get('title') or body.url)[:100]}",
+ body.task or body.url,
+ channel="browser",
+ metadata={
+ "url": body.url,
+ "final_url": meta.get("final_url"),
+ "session_id": meta.get("id"),
+ "steps": steps[:12],
+ },
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.get("/browser/sessions")
+async def browser_sessions(limit: int = 20) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/browser/sessions/{session_id}")
+async def browser_session_detail(session_id: int, include_screenshot: bool = True) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(
+ f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}",
+ params={"include_screenshot": include_screenshot},
+ )
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/browser/live/screenshot.jpg")
+async def browser_live_screenshot():
+ import httpx
+ from fastapi.responses import Response
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/live/screenshot.jpg")
+ if r.status_code == 404:
+ return Response(status_code=404)
+ return Response(content=r.content, media_type="image/jpeg")
+ except Exception:
+ return Response(status_code=404)
+
+
+@admin_router.get("/browser/sessions/{session_id}/screenshot.jpg")
+async def browser_session_screenshot(session_id: int):
+ import httpx
+ from fastapi.responses import Response
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/screenshot.jpg")
+ if r.status_code != 200:
+ raise HTTPException(status_code=r.status_code, detail="Screenshot not found")
+ return Response(content=r.content, media_type="image/jpeg")
+
+
+
+class BrowserInstructBody(BaseModel):
+ url: str
+ instruction: str
+ wait_seconds: float = 5.0
+
+
+@admin_router.post("/browser/instruct")
+async def browser_instruct(body: BrowserInstructBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=180.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/instruct", json=body.model_dump())
+ r.raise_for_status()
+ data = r.json()
+ try:
+ meta = data.get("session") or data
+ steps = (meta.get("metadata") or {}).get("steps") or [body.instruction[:200]]
+ log_agent_event(
+ "browser",
+ "browse_instruct",
+ f"Instructie: {body.instruction[:80]}",
+ " · ".join(steps[:8])[:500],
+ channel="browser",
+ metadata={"url": body.url, "instruction": body.instruction, "session_id": meta.get("id")},
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.get("/browser/sessions/{session_id}/ocr")
+async def browser_session_ocr(session_id: int) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/sessions/{session_id}/ocr")
+ r.raise_for_status()
+ return r.json()
+
+class BrowserVncBody(BaseModel):
+ url: str
+ instruction: Optional[str] = None
+ wait_seconds: float = 4.0
+
+
+class BrowserExtractFullBody(BaseModel):
+ url: str
+ instruction: Optional[str] = None
+ wait_seconds: float = 4.0
+ scroll_pages: int = 6
+ site_id: Optional[int] = None
+ also_vnc: bool = True
+
+
+class PhotoUploadBody(BaseModel):
+ image_b64: str
+ source: str = "cockpit"
+ filename: Optional[str] = None
+ storage_path: Optional[str] = None
+ session_id: Optional[int] = None
+
+
+@admin_router.post("/browser/vnc-navigate")
+async def browser_vnc_navigate(body: BrowserVncBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/navigate", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:500])
+ data = r.json()
+ try:
+ log_agent_event(
+ "browser",
+ "vnc_navigate",
+ f"VNC → {body.url[:100]}",
+ body.instruction or "navigate",
+ channel="browser-vnc",
+ metadata={"url": body.url, "instruction": body.instruction},
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.post("/browser/extract-full")
+async def browser_extract_full(body: BrowserExtractFullBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/browse/extract-full", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:500])
+ data = r.json()
+ try:
+ meta = data.get("session") or data
+ log_agent_event(
+ "browser",
+ "extract_full",
+ f"Extract: {(meta.get('title') or body.url)[:100]}",
+ body.instruction or body.url,
+ channel="browser",
+ metadata={"url": body.url, "session_id": meta.get("id"), "scroll_pages": body.scroll_pages},
+ )
+ except Exception:
+ pass
+ return data
+
+
+@admin_router.get("/photos")
+async def photos_list(limit: int = 30) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/photos/{photo_id}")
+async def photos_detail(photo_id: int) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}")
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/photos/{photo_id}/image.jpg")
+async def photos_image(photo_id: int):
+ import httpx
+ from fastapi.responses import Response
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/image.jpg")
+ if r.status_code != 200:
+ raise HTTPException(status_code=r.status_code)
+ return Response(content=r.content, media_type="image/jpeg")
+
+
+@admin_router.get("/photos/{photo_id}/detections")
+async def photos_detections(photo_id: int) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/{photo_id}/detections")
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.post("/photos/analyze")
+async def photos_analyze(body: PhotoUploadBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(f"{BROWSER_AGENT_URL.rstrip('/')}/photos/analyze", json=body.model_dump())
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:500])
+ return r.json()
+
+
+
+
+@admin_router.get("/documents/share-files")
+async def documents_share_files(
+ limit: int = 200,
+ ext: Optional[str] = None,
+) -> dict[str, Any]:
+ """Alle bestanden op NAS share — incl. pptx, pdf, etc."""
+ import httpx
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ limit = max(1, min(limit, 1000))
+ params: dict[str, Any] = {"limit": limit}
+ if ext:
+ params["ext"] = ext
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/nas/files", params=params)
+ r.raise_for_status()
+ data = r.json()
+ return {"ok": True, **data}
+ except Exception as exc:
+ return {"ok": False, "items": [], "error": str(exc)}
+
+
+@admin_router.post("/documents/trigger-scan")
+async def documents_trigger_scan(force: bool = False) -> dict[str, Any]:
+ """Start doc-ingest scan zodat nieuwe share-bestanden worden herkend."""
+ import httpx
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/ingest/scan",
+ params={"force": "true" if force else "false"},
+ )
+ r.raise_for_status()
+ return {"ok": True, **r.json()}
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@admin_router.get("/documents/search")
+async def documents_search(
+ q: str,
+ limit: int = 8,
+) -> dict[str, Any]:
+ """Semantisch zoeken in NAS-documenten (Chroma RAG)."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/search",
+ params={"q": q, "limit": max(1, min(limit, 20))},
+ )
+ r.raise_for_status()
+ return {"ok": True, **r.json()}
+ except Exception as exc:
+ return {"ok": False, "results": [], "error": str(exc)}
+
+
+@admin_router.get("/documents/nas-diagnostics")
+async def documents_nas_diagnostics() -> dict[str, Any]:
+ """NAS mount status + zichtbare vs. ontbrekende bestanden."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ out: dict[str, Any] = {"ok": True}
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ h = await client.get(f"{doc_url.rstrip('/')}/health")
+ h.raise_for_status()
+ out["health"] = h.json()
+ f = await client.get(f"{doc_url.rstrip('/')}/nas/files", params={"limit": 1000})
+ f.raise_for_status()
+ files = f.json()
+ out["visible_files"] = files.get("total", 0)
+ out["sample_paths"] = [i.get("path") for i in (files.get("items") or [])[:8]]
+ except Exception as exc:
+ out["ok"] = False
+ out["error"] = str(exc)
+ out["hint"] = (
+ "Zie je mappen (CUCINA/Foodlinkk) maar weinig bestanden? "
+ "Geef gebruiker aissa Read/Write op de Synology share + submappen in DSM."
+ )
+ return out
+
+
+@admin_router.post("/documents/chat")
+async def documents_chat(body: DocumentsChatBody) -> dict[str, Any]:
+ """Chat met NAS-data via RAG + Ollama (snel) of optioneel Herman."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ message = body.message.strip()
+ sources: list[str] = []
+ context = ""
+
+ linked_paths: list[str] = []
+ if body.client_id:
+ rows = fetch_all(
+ "SELECT storage_path, is_folder FROM document_links WHERE client_id = %s",
+ (body.client_id,),
+ )
+ linked_paths = [r["storage_path"] for r in rows if r.get("storage_path")]
+ if body.project_id:
+ rows = fetch_all(
+ "SELECT storage_path, is_folder FROM document_links WHERE project_id = %s",
+ (body.project_id,),
+ )
+ linked_paths.extend([r["storage_path"] for r in rows if r.get("storage_path")])
+
+ try:
+ async with httpx.AsyncClient(timeout=45.0) as client:
+ r = await client.get(
+ f"{doc_url.rstrip('/')}/context",
+ params={"q": message, "limit": 8},
+ )
+ r.raise_for_status()
+ data = r.json()
+ context = (data.get("context") or "").strip()
+ sources = [s for s in (data.get("sources") or []) if s]
+ except Exception:
+ context = ""
+ sources = []
+
+ scope_prefix = (body.path_prefix or "").strip().lstrip("/")
+ if linked_paths or scope_prefix:
+ try:
+ async with httpx.AsyncClient(timeout=45.0) as client:
+ sr = await client.get(
+ f"{doc_url.rstrip('/')}/search",
+ params={"q": message, "limit": 12},
+ )
+ sr.raise_for_status()
+ hits = sr.json().get("results") or []
+
+ def _in_scope(path: str) -> bool:
+ if not path:
+ return False
+ if scope_prefix and (path == scope_prefix or path.startswith(scope_prefix + "/")):
+ return True
+ for lp in linked_paths:
+ if path == lp or path.startswith(lp.rstrip("/") + "/"):
+ return True
+ return False
+
+ scoped = [h for h in hits if _in_scope(h.get("path") or "")]
+ if scoped:
+ parts = [f"[{h.get('filename', 'doc')}] {(h.get('text') or '')[:1200]}" for h in scoped[:8]]
+ context = "\n\n".join(parts)
+ sources = [h.get("path") for h in scoped if h.get("path")]
+ except Exception:
+ pass
+
+ if len(context) > 6000:
+ context = context[:6000] + "\n…(context ingekort voor snellere LLM)"
+
+ if body.use_herman:
+ try:
+ scoped_msg = message
+ if scope_prefix:
+ scoped_msg = f"[Scope: {scope_prefix}]\n{message}"
+ result = await herman_service.chat(scoped_msg, channel="documents")
+ result["rag_sources"] = result.get("rag_sources") or sources
+ result["ok"] = True
+ result["context_used"] = bool(context)
+ return result
+ except Exception as exc:
+ pass
+
+ client_note = ""
+ if body.client_id:
+ c = fetch_one("SELECT name FROM clients WHERE id = %s", (body.client_id,))
+ if c:
+ client_note = f"\nKlantcontext: {c['name']} (ID {body.client_id})"
+
+ system = (
+ "Je bent Herman, Foodlinkk document-assistent en second brain. Beantwoord in het Nederlands "
+ "op basis van de NAS-documentfragmenten hieronder. Citeer bronbestanden als [bestandsnaam]. "
+ "Als het antwoord niet in de context staat, zeg dat eerlijk."
+ f"{client_note}\n\nDOCUMENT CONTEXT:\n{context or '(geen documenten gevonden — geef algemeen antwoord)'}"
+ )
+ messages: list[dict[str, str]] = [{"role": "system", "content": system}]
+ for h in body.history[-6:]:
+ messages.append({"role": h.role, "content": h.content})
+ messages.append({"role": "user", "content": message})
+ try:
+ reply, llm_meta = await llm_router.chat_messages(
+ messages, timeout=100.0, provider_id=body.llm_provider_id
+ )
+ except Exception as exc:
+ raise HTTPException(502, f"LLM niet bereikbaar: {exc}") from exc
+
+ label = llm_meta.get("provider_label") or "LLM"
+ model = llm_meta.get("model") or ""
+ return {
+ "ok": True,
+ "agent": "knowledge",
+ "agent_label": f"Herman · NAS RAG · {label}",
+ "reply": reply,
+ "rag_sources": sources,
+ "context_used": bool(context),
+ "delegated_agents": ["knowledge"],
+ "llm_provider_id": llm_meta.get("provider_id"),
+ "llm_provider_type": llm_meta.get("provider_type"),
+ "llm_model": model,
+ }
+
+
+@admin_router.get("/documents/nas-images")
+async def documents_nas_images(limit: int = 40) -> dict[str, Any]:
+ import httpx
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ exts = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp")
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{doc_url.rstrip('/')}/nas/list", params={"limit": 500})
+ r.raise_for_status()
+ data = r.json()
+ files = data.get("files") or []
+ images = [f for f in files if (f.get("path") or "").lower().endswith(exts)]
+ return {"ok": True, "items": images[:limit], "total": len(images)}
+ except Exception as exc:
+ return {"ok": False, "items": [], "error": str(exc)}
+
+
+@admin_router.post("/documents/nas-ocr")
+async def documents_nas_ocr(
+ path: str | None = None,
+ force: bool = False,
+) -> dict[str, Any]:
+ """OCR alle NAS-afbeeldingen (jpg/png) + plaatjes uit pdf/docx via doc-ingest → browser-agent."""
+ doc_url = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+ params: dict[str, str] = {"force": "true" if force else "false"}
+ if path:
+ params["path"] = path
+ try:
+ async with httpx.AsyncClient(timeout=600.0) as client:
+ r = await client.post(
+ f"{doc_url.rstrip('/')}/ingest/extract-images",
+ params=params,
+ )
+ r.raise_for_status()
+ data = r.json()
+ try:
+ log_agent_event(
+ "browser",
+ "nas_ocr_batch",
+ f"NAS OCR: {data.get('images_extracted', 0)} afbeeldingen",
+ path or "alle NAS-bestanden",
+ channel="documents",
+ metadata={"path": path, "force": force, **data},
+ )
+ except Exception:
+ pass
+ return {"ok": True, **data}
+ except Exception as exc:
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
+
+
+@admin_router.get("/browser/vnc/screenshot.jpg")
+async def browser_vnc_screenshot():
+ import httpx
+ from fastapi.responses import Response
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/vnc/screenshot.jpg")
+ if r.status_code != 200:
+ return Response(status_code=404)
+ return Response(content=r.content, media_type="image/jpeg")
+ except Exception:
+ return Response(status_code=404)
+
+# --- Hermes / Telegram second brain proxies ---
+class HermesSearchBody(BaseModel):
+ query: str = Field(..., min_length=2)
+ chat_id: Optional[int] = None
+ limit: int = Field(default=15, ge=1, le=30)
+
+
+@admin_router.get("/hermes/stats")
+async def hermes_stats_proxy() -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats")
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/conversations")
+async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/feed")
+async def hermes_feed_proxy(
+ chat_id: Optional[int] = None,
+ limit: int = 80,
+ offset: int = 0,
+) -> dict[str, Any]:
+ import httpx
+ params = {"limit": limit, "offset": offset}
+ if chat_id is not None:
+ params["chat_id"] = chat_id
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params)
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/graph")
+async def hermes_graph_global(limit: int = 100) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.get("/hermes/graph/{chat_id}")
+async def hermes_graph_chat(chat_id: int, limit: int = 80) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/graph/{chat_id}", params={"limit": limit})
+ r.raise_for_status()
+ return r.json()
+
+
+@admin_router.post("/hermes/search")
+async def hermes_search_proxy(body: HermesSearchBody) -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ r = await client.post(
+ f"{settings.TOOLS_API_URL.rstrip('/')}/brain/search",
+ json=body.model_dump(),
+ )
+ r.raise_for_status()
+ return r.json()
+
+# --- Hermes PA live + users + control (added by deploy) ---
+HERMES_CONTROL_URL = os.getenv("HERMES_CONTROL_URL", "http://10.4.7.19:8799").rstrip("/")
+
+
+@admin_router.get("/hermes/pa/live")
+async def hermes_pa_live_proxy() -> dict[str, Any]:
+ import httpx
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ r = await client.get(f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live")
+ r.raise_for_status()
+ live = r.json()
+ try:
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ ctrl = await client.get(f"{HERMES_CONTROL_URL}/status")
+ if ctrl.status_code == 200:
+ live["hermes"] = ctrl.json()
+ except Exception:
+ live["hermes"] = {"online": False}
+ return live
+
+
+@admin_router.get("/hermes/pa/live/{label}/screenshot.jpg")
+async def hermes_pa_slot_screenshot(label: str):
+ import httpx
+ from fastapi.responses import Response
+ async with httpx.AsyncClient(timeout=20.0) as client:
+ r = await client.get(
+ f"{BROWSER_AGENT_URL.rstrip('/')}/pa/live/{label}/screenshot.jpg"
+ )
+ if r.status_code != 200:
+ raise HTTPException(status_code=r.status_code, detail="No screenshot")
+ return Response(content=r.content, media_type="image/jpeg")
+
+
+@admin_router.get("/hermes/users")
+async def hermes_users_proxy() -> dict[str, Any]:
+ import httpx
+ users: list[dict[str, Any]] = []
+ try:
+ async with httpx.AsyncClient(timeout=8.0) as client:
+ r = await client.get(f"{HERMES_CONTROL_URL}/users")
+ if r.status_code == 200:
+ return r.json()
+ except Exception:
+ pass
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ conv = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": 20})
+ items = conv.json().get("items") or [] if conv.status_code == 200 else []
+ default = [
+ {"chat_id": 8859782446, "name": "Aïssa", "role": "CEO", "allowed": True, "pa_mode": False, "online": True},
+ {"chat_id": 789036463, "name": "Mo", "role": "CTO", "allowed": True, "pa_mode": False, "online": False},
+ ]
+ by_id = {u["chat_id"]: u for u in default}
+ for c in items:
+ cid = c.get("chat_id")
+ if cid in by_id:
+ by_id[cid]["message_count"] = c.get("message_count")
+ by_id[cid]["last_message_at"] = c.get("last_message_at")
+ by_id[cid]["online"] = True
+ else:
+ by_id[cid] = {
+ "chat_id": cid,
+ "name": c.get("user_name") or str(cid),
+ "role": c.get("user_role") or "user",
+ "allowed": True,
+ "pa_mode": False,
+ "online": True,
+ "message_count": c.get("message_count"),
+ "last_message_at": c.get("last_message_at"),
+ }
+ return {"users": list(by_id.values())}
+
+
+@admin_router.post("/hermes/actions/{action}")
+async def hermes_action_proxy(action: str) -> dict[str, Any]:
+ import httpx
+ allowed = {"evening-briefing", "morning-briefing", "resume-jobs", "test-briefing"}
+ if action not in allowed:
+ raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
+ async with httpx.AsyncClient(timeout=180.0) as client:
+ r = await client.post(f"{HERMES_CONTROL_URL}/actions/{action}")
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:300])
+ return r.json()
+
+
diff --git a/cockpit/app/services/agent_approvals.py b/cockpit/app/services/agent_approvals.py
index 99cc8ac..5a3b20c 100644
--- a/cockpit/app/services/agent_approvals.py
+++ b/cockpit/app/services/agent_approvals.py
@@ -191,3 +191,48 @@ def require_approved(request_id: int) -> dict[str, Any]:
if req.get("status") != "approved":
raise PermissionError(f"Request status is {req.get('status')}, approval required")
return req
+
+
+def _auto_approve_policy() -> dict[str, bool]:
+ try:
+ row = fetch_one("SELECT value FROM app_settings WHERE key = 'sysops_auto_approve'")
+ if row and row.get("value"):
+ val = row["value"]
+ if isinstance(val, str):
+ return json.loads(val)
+ return dict(val)
+ except Exception:
+ pass
+ return {"maintenance_scan": True, "config_backup": True}
+
+
+def try_auto_approve_and_execute(request_id: int) -> dict[str, Any] | None:
+ """Auto-approve low-risk SysOps requests when policy allows."""
+ req = get_request(request_id)
+ if not req or req.get("status") != "pending":
+ return None
+ key = str(req.get("agent_key") or "").lower()
+ action = str(req.get("action_type") or "")
+ policy = _auto_approve_policy()
+ if key != "sysops" or not policy.get(action):
+ return None
+ approved = approve_request(request_id, approved_by="auto_policy")
+ import os
+
+ import httpx
+
+ tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
+ result_payload: dict[str, Any] = {}
+ try:
+ if action == "config_backup":
+ with httpx.Client(timeout=180.0) as client:
+ resp = client.post(f"{tools_url}/ops/backup/run", json={"approval_request_id": request_id})
+ result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
+ elif action == "maintenance_scan":
+ with httpx.Client(timeout=120.0) as client:
+ resp = client.post(f"{tools_url}/ops/maintenance/scan")
+ result_payload = resp.json() if resp.status_code < 500 else {"ok": False, "detail": resp.text}
+ except Exception as exc:
+ result_payload = {"ok": False, "detail": str(exc)}
+ executed = mark_executed(request_id, result_payload)
+ return {"approved": approved, "executed": executed, "auto": True}
diff --git a/cockpit/app/services/agent_events_log.py b/cockpit/app/services/agent_events_log.py
new file mode 100644
index 0000000..3af4dbd
--- /dev/null
+++ b/cockpit/app/services/agent_events_log.py
@@ -0,0 +1,48 @@
+"""Log agent activity to agent_events for terminals and audit."""
+from __future__ import annotations
+
+import json
+from typing import Any, Optional
+
+from app.db import fetch_one
+from app.services.agent_names import normalize_agent_key
+
+
+def log_agent_event(
+ agent_name: str,
+ event_type: str,
+ title: str,
+ body: str = "",
+ *,
+ agent_type: Optional[str] = None,
+ status: str = "completed",
+ channel: str = "cockpit",
+ metadata: Optional[dict[str, Any]] = None,
+) -> Optional[dict[str, Any]]:
+ key = normalize_agent_key(agent_name)
+ if not key:
+ return None
+ meta = json.dumps(metadata or {})
+ row = fetch_one(
+ """
+ INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ RETURNING id, agent_name, event_type, title, body, status, channel, metadata, created_at
+ """,
+ (
+ key,
+ agent_type or key,
+ event_type,
+ title[:255],
+ (body or "")[:8000],
+ status,
+ channel,
+ meta,
+ ),
+ )
+ if not row:
+ return None
+ out = dict(row)
+ if out.get("created_at") and hasattr(out["created_at"], "isoformat"):
+ out["created_at"] = out["created_at"].isoformat()
+ return out
diff --git a/cockpit/app/services/agent_integration.py b/cockpit/app/services/agent_integration.py
new file mode 100644
index 0000000..32f175d
--- /dev/null
+++ b/cockpit/app/services/agent_integration.py
@@ -0,0 +1,140 @@
+"""Agent handoffs and collaboration matrix."""
+from __future__ import annotations
+
+import json
+import uuid
+from typing import Any, Optional
+
+from app.db import execute, fetch_all, fetch_one
+from app.services.agent_names import normalize_agent_key
+
+
+def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ elif k == "correlation_id" and v is not None:
+ out[k] = str(v)
+ return out
+
+
+def list_collaboration() -> list[dict[str, Any]]:
+ rows = fetch_all(
+ "SELECT from_agent, to_agent, handoff_type, description FROM agent_collaboration ORDER BY from_agent, to_agent"
+ )
+ return [dict(r) for r in rows]
+
+
+def create_handoff(
+ from_agent: str,
+ to_agent: str,
+ *,
+ handoff_type: str = "partner",
+ payload: dict[str, Any] | None = None,
+ correlation_id: str | None = None,
+ status: str = "completed",
+) -> dict[str, Any]:
+ src = normalize_agent_key(from_agent)
+ dst = normalize_agent_key(to_agent)
+ if not src or not dst:
+ raise ValueError("from_agent and to_agent required")
+ cid = correlation_id or str(uuid.uuid4())
+ try:
+ uuid.UUID(str(cid))
+ except ValueError:
+ cid = str(uuid.uuid4())
+ row = fetch_one(
+ """
+ INSERT INTO agent_handoffs (correlation_id, from_agent, to_agent, handoff_type, payload, status, completed_at)
+ VALUES (%s::uuid, %s, %s, %s, %s::jsonb, %s, CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END)
+ RETURNING *
+ """,
+ (cid, src, dst, handoff_type, json.dumps(payload or {}), status, status),
+ )
+ handoff = _serialize(row) or {}
+ meta = {
+ "correlation_id": cid,
+ "handoff_id": handoff.get("id"),
+ "target_agent": dst,
+ "source_agent": src,
+ "handoff_type": handoff_type,
+ }
+ _log_handoff_events(src, dst, handoff_type, payload or {}, meta, cid)
+ return handoff
+
+
+def _log_handoff_events(
+ src: str,
+ dst: str,
+ handoff_type: str,
+ payload: dict[str, Any],
+ meta: dict[str, Any],
+ cid: str,
+) -> None:
+ title_out = f"{src} → {dst}: {handoff_type}"
+ title_in = f"Handoff van {src}: {handoff_type}"
+ body = json.dumps(payload)[:2000] if payload else ""
+ for agent, etype, title, extra in (
+ (src, "handoff_out", title_out, {"target_agent": dst}),
+ (dst, "handoff_in", title_in, {"source_agent": src}),
+ ):
+ try:
+ execute(
+ """
+ INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ """,
+ (
+ agent,
+ "agent_handoff",
+ etype,
+ title[:255],
+ body,
+ "completed",
+ "agents",
+ json.dumps({**meta, **extra}),
+ ),
+ )
+ except Exception:
+ pass
+
+
+def recent_handoffs(hours: int = 6) -> list[dict[str, Any]]:
+ rows = fetch_all(
+ """
+ SELECT * FROM agent_handoffs
+ WHERE created_at >= NOW() - make_interval(hours => %s)
+ ORDER BY created_at DESC
+ LIMIT 200
+ """,
+ (max(1, min(hours, 168)),),
+ )
+ return [_serialize(r) for r in rows if r]
+
+
+def peer_edges_live(hours: int = 6) -> list[dict[str, Any]]:
+ rows = fetch_all(
+ """
+ SELECT from_agent, to_agent, handoff_type, COUNT(*) AS weight,
+ MAX(correlation_id::text) AS correlation_id
+ FROM agent_handoffs
+ WHERE created_at >= NOW() - make_interval(hours => %s)
+ AND status = 'completed'
+ GROUP BY from_agent, to_agent, handoff_type
+ """,
+ (max(1, min(hours, 168)),),
+ )
+ return [
+ {
+ "source": str(r["from_agent"]),
+ "target": str(r["to_agent"]),
+ "type": "live",
+ "handoff_type": r.get("handoff_type"),
+ "weight": int(r["weight"] or 1),
+ "correlation_id": r.get("correlation_id"),
+ }
+ for r in rows
+ ]
diff --git a/cockpit/app/services/agent_names.py b/cockpit/app/services/agent_names.py
new file mode 100644
index 0000000..359feb4
--- /dev/null
+++ b/cockpit/app/services/agent_names.py
@@ -0,0 +1,22 @@
+"""Normalize legacy agent_name values to agent_souls keys."""
+from __future__ import annotations
+
+AGENT_NAME_MAP: dict[str, str] = {
+ "retail_360": "retail",
+ "retail_scraper": "retail",
+ "retail_crm": "retail",
+ "retail_intel": "retail",
+ "rss_feeds": "marketing",
+ "wholesale_scraper": "sourcing",
+ "halal_registry": "halal",
+ "branch_scraper": "sourcing",
+ "hermes": "herman",
+ "herman_delegate": "herman",
+}
+
+
+def normalize_agent_key(name: str | None) -> str:
+ key = (name or "").strip().lower()
+ if not key:
+ return ""
+ return AGENT_NAME_MAP.get(key, key)
diff --git a/cockpit/app/services/agent_terminal.py b/cockpit/app/services/agent_terminal.py
new file mode 100644
index 0000000..76ee0ea
--- /dev/null
+++ b/cockpit/app/services/agent_terminal.py
@@ -0,0 +1,456 @@
+"""Interactive agent terminal — parse and execute whitelisted commands."""
+from __future__ import annotations
+
+import json
+import os
+from typing import Any, Optional
+
+import httpx
+
+from app.db import fetch_all, fetch_one
+from app.services import agent_integration, agent_souls, herman, webbuilder_agent
+from app.services.agent_events_log import log_agent_event
+from app.services.agent_names import normalize_agent_key
+
+TOOLS_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
+BROWSER_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790").rstrip("/")
+
+Line = dict[str, Any]
+
+GLOBAL_HELP = [
+ "help — dit overzicht",
+ "status — huidige status & taak",
+ "history — laatste events (in terminal)",
+ "handoff [notitie]",
+ "say — log een notitie",
+ "ask — AI-antwoord in rol van deze agent",
+]
+
+AGENT_HELP: dict[str, list[str]] = {
+ "herman": [
+ "briefing — dagrapport genereren",
+ " — vraag / opdracht aan Herman",
+ ],
+ "browser": [
+ "monitor add — site toevoegen aan monitor",
+ "monitor list — actieve sites",
+ "browse — pagina openen",
+ "crawl — alle sites crawlen + parse-overzicht",
+ "parse — laatste parse-resultaten",
+ "parse — parse van specifieke site",
+ "intel — hype & trend analyse (samenvatting)",
+ "intel — filter analyse",
+ ],
+ "research": ["run — research pipeline starten", "briefs — recente briefs"],
+ "retail": ["rss — retail RSS verversen", "scores — halal opportunity scores"],
+ "sysops": [
+ "backup — config backup (approval)",
+ "scan — maintenance scan",
+ "topology — infra overzicht",
+ "status — sysops status",
+ ],
+ "marketing": ["reco — aanbevelingen genereren"],
+ "packaging": ["design — packaging opdracht (via Herman)"],
+ "webbuilder": [
+ "build — website bouwen (agy op Hermes)",
+ "projects — bestaande website-projecten",
+ "status [project] — build-status",
+ "preview [project] — preview-URL",
+ ],
+ "email": ["sync — inbox sync trigger"],
+ "finance": ["margins — marge-overzicht (placeholder)"],
+}
+
+
+def _line(msg: str, *, detail: str = "", line_type: str = "output") -> Line:
+ return {"type": line_type, "message": msg, "detail": detail}
+
+
+def _help_lines(key: str) -> list[Line]:
+ lines = [_line("Beschikbare commando's:", line_type="output")]
+ for h in GLOBAL_HELP:
+ lines.append(_line(" " + h, line_type="output"))
+ extra = AGENT_HELP.get(key, [])
+ if extra:
+ lines.append(_line(f"— {key} —", line_type="output"))
+ for h in extra:
+ lines.append(_line(" " + h, line_type="output"))
+ return lines
+
+
+def _status_lines(key: str, soul: dict[str, Any]) -> list[Line]:
+ recent = soul.get("recent_events") or []
+ last = recent[0] if recent else {}
+ lines = [
+ _line(f"{soul.get('display_name') or key} · {soul.get('role_title') or 'agent'}"),
+ _line(f"Status: {last.get('status') or soul.get('current_status') or 'standby'}"),
+ _line(f"Taak: {last.get('title') or soul.get('current_task') or '—'}"),
+ _line(f"Events totaal: {soul.get('event_count') or 0}"),
+ ]
+ resp = (soul.get("responsibilities") or "").strip()
+ if resp:
+ lines.append(_line(resp[:220], detail=resp if len(resp) > 220 else ""))
+ return lines
+
+
+async def _tools_post(path: str, payload: dict | None = None, timeout: float = 180.0) -> dict[str, Any]:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.post(f"{TOOLS_URL}{path}", json=payload or {})
+ try:
+ data = resp.json()
+ except Exception:
+ data = {"ok": False, "detail": resp.text[:500]}
+ if resp.status_code >= 400:
+ data.setdefault("ok", False)
+ data.setdefault("detail", resp.text[:500])
+ return data
+
+
+async def _tools_get(path: str, timeout: float = 60.0) -> dict[str, Any]:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.get(f"{TOOLS_URL}{path}")
+ try:
+ return resp.json()
+ except Exception:
+ return {"ok": False, "detail": resp.text[:500]}
+
+
+async def _browser_post(path: str, payload: dict, timeout: float = 120.0) -> dict[str, Any]:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.post(f"{BROWSER_URL}{path}", json=payload)
+ try:
+ return resp.json()
+ except Exception:
+ return {"ok": False, "detail": resp.text[:500]}
+
+
+def _handoff(src: str, dst_raw: str, htype: str, note: str) -> list[Line]:
+ dst = normalize_agent_key(dst_raw)
+ if not dst:
+ return [_line("Onbekende agent: " + dst_raw, line_type="error")]
+ payload = {"note": note} if note else {}
+ try:
+ agent_integration.create_handoff(src, dst, handoff_type=htype, payload=payload)
+ return [_line(f"Handoff → {dst} ({htype})" + (f": {note}" if note else ""))]
+ except Exception as exc:
+ return [_line("Handoff mislukt: " + str(exc), line_type="error")]
+
+
+async def _agent_ask(key: str, soul: dict[str, Any], question: str) -> list[Line]:
+ name = soul.get("display_name") or key
+ role = soul.get("role_title") or ""
+ prompt = (
+ f"Je bent {name} ({key}), {role}. "
+ f"Beantwoord kort en actiegericht in het Nederlands.\n\nOpdracht: {question}"
+ )
+ result = await herman.chat(prompt, channel=f"terminal:{key}")
+ reply = (result.get("reply") or "").strip()
+ delegated = result.get("delegated_agents") or []
+ lines = [_line(reply or "(geen antwoord)", detail=reply)]
+ if delegated:
+ lines.append(_line("Gedelegeerd: " + ", ".join(delegated), line_type="handoff_out"))
+ log_agent_event(
+ key,
+ "terminal_out",
+ reply[:255] if reply else "Antwoord",
+ reply[:4000],
+ channel="terminal",
+ metadata={"delegated": delegated, "question": question[:500]},
+ )
+ return lines
+
+
+def _format_crawl_results(result: dict[str, Any]) -> list[Line]:
+ if result.get("message") and not result.get("results"):
+ return [_line(result["message"], line_type="output")]
+ lines: list[Line] = [
+ _line(
+ f"Crawl klaar — {result.get('sites', 0)} site(s), "
+ f"{result.get('changed', 0)} wijziging(en), {result.get('errors', 0)} fout(en)"
+ )
+ ]
+ for r in result.get("results") or []:
+ if r.get("status") == "ERROR":
+ lines.append(_line(f"✗ {r.get('name') or r.get('url')}: {r.get('error')}", line_type="error"))
+ continue
+ flag = "△" if r.get("changed") else "✓"
+ lines.append(
+ _line(
+ f"{flag} {r.get('title') or r.get('name')} — {r.get('word_count', 0)} woorden, "
+ f"{r.get('links_count', 0)} links",
+ detail=(r.get("excerpt") or "")[:200],
+ )
+ )
+ headings = r.get("headings") or []
+ if headings:
+ lines.append(_line(" H: " + " · ".join(headings[:4])[:180], line_type="output"))
+ for link in (r.get("links_sample") or [])[:3]:
+ lbl = link.get("label") or link.get("href") or ""
+ lines.append(_line(" → " + lbl[:70], line_type="output"))
+ return lines
+
+
+def _format_parse_items(items: list[dict[str, Any]]) -> list[Line]:
+ if not items:
+ return [_line("Geen parse-resultaten — voer eerst crawl uit.")]
+ lines: list[Line] = [_line(f"Parse-overzicht ({len(items)} pagina's):")]
+ for p in items:
+ lines.append(
+ _line(
+ f"#{p.get('site_id') or '—'} {p.get('title') or p.get('url')} — "
+ f"{p.get('word_count', 0)} woorden",
+ detail=(p.get("excerpt") or "")[:200],
+ )
+ )
+ if p.get("meta_description"):
+ lines.append(_line(" desc: " + str(p["meta_description"])[:120], line_type="output"))
+ for h in (p.get("headings") or [])[:3]:
+ lines.append(_line(" · " + h[:90], line_type="output"))
+ return lines
+
+
+async def _run_agent_command(key: str, soul: dict[str, Any], cmd: str, args: list[str], raw: str) -> list[Line]:
+ if key == "herman":
+ if cmd == "briefing":
+ try:
+ content = await herman.generate_briefing()
+ preview = (content or "")[:400]
+ log_agent_event("herman", "terminal_out", "Briefing gegenereerd", content[:4000], channel="terminal")
+ return [_line("CEO briefing gegenereerd.", detail=preview)]
+ except Exception as exc:
+ return [_line("Briefing mislukt: " + str(exc), line_type="error")]
+ result = await herman.chat(raw, channel="terminal")
+ reply = (result.get("reply") or "").strip()
+ delegated = result.get("delegated_agents") or []
+ lines = [_line(reply or "(geen antwoord)", detail=reply)]
+ if delegated:
+ lines.append(_line("→ " + ", ".join(delegated), line_type="handoff_out"))
+ log_agent_event("herman", "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal")
+ return lines
+
+ if key == "browser":
+ if cmd == "monitor" and args and args[0].lower() == "add" and len(args) >= 2:
+ url = args[1]
+ from app.services.monitor import add_site
+
+ site = add_site(url, name=url[:80])
+ log_agent_event(
+ "browser",
+ "monitor_site_added",
+ f"Terminal: site toegevoegd {url[:120]}",
+ "",
+ channel="terminal",
+ metadata={"url": url, "site_id": site.get("id")},
+ )
+ return [_line(f"Monitor: {url} toegevoegd (id {site.get('id')})")]
+ if cmd == "monitor" and args and args[0].lower() == "list":
+ rows = fetch_all(
+ "SELECT id, url, is_active FROM monitored_sites ORDER BY id DESC LIMIT 15"
+ )
+ if not rows:
+ return [_line("Geen monitor-sites.")]
+ return [_line(f"#{r['id']} {'✓' if r.get('is_active') else '○'} {r['url']}") for r in rows]
+ if cmd == "browse" and args:
+ url = args[0]
+ data = await _browser_post("/browse", {"url": url, "purpose": "terminal"})
+ log_agent_event("browser", "browse", f"Terminal browse: {url[:120]}", json.dumps(data)[:500], channel="terminal")
+ title = data.get("title") or data.get("url") or url
+ return [_line(f"Browse OK: {title}")]
+ if cmd == "crawl":
+ from app.services.monitor import trigger_crawl
+
+ site_id = int(args[0]) if args and str(args[0]).isdigit() else None
+ result = trigger_crawl(site_id)
+ log_agent_event(
+ "browser",
+ "monitor_crawl",
+ f"Terminal crawl ({result.get('sites', 0)} sites)",
+ json.dumps([{k: r.get(k) for k in ("url", "title", "word_count", "status")} for r in result.get("results", [])])[:800],
+ channel="terminal",
+ )
+ return _format_crawl_results(result)
+ if cmd == "parse":
+ from app.services.monitor import list_parse_results
+
+ sid = int(args[0]) if args and str(args[0]).isdigit() else None
+ return _format_parse_items(list_parse_results(site_id=sid, limit=10))
+ if cmd == "intel":
+ from app.services.monitor import build_parse_intelligence
+
+ q = " ".join(args) if args else None
+ data = build_parse_intelligence(query=q, limit=20)
+ lines = [
+ _line(
+ f"Analyse: {data['summary']['pages']} pagina's · "
+ f"{data['summary']['total_words']} woorden · "
+ f"{data['summary']['themes_detected']} trend-termen"
+ ),
+ _line("Open Browser → tab Parse Analyse voor volledig overzicht", line_type="output"),
+ ]
+ for t in (data.get("hype_terms") or [])[:12]:
+ flag = "★ " if t.get("cross_site") else ""
+ lines.append(
+ _line(f"{flag}{t['term']} ({t['score']}) — {', '.join(t.get('sites') or [])[:3]}", line_type="output")
+ )
+ return lines
+
+ if key == "research":
+ if cmd == "run":
+ data = await _tools_post("/research/run")
+ ok = data.get("ok", True)
+ log_agent_event("research", "terminal_action", "Research run (terminal)", json.dumps(data)[:500], channel="terminal")
+ return [_line("Research pipeline: " + ("OK" if ok else "fout"), detail=json.dumps(data)[:300])]
+ if cmd == "briefs":
+ data = await _tools_get("/research/briefs")
+ items = data if isinstance(data, list) else data.get("items") or []
+ if not items:
+ return [_line("Geen briefs.")]
+ return [_line(str(b.get("title") or b.get("id") or b))[:120] for b in items[:8]]
+
+ if key == "retail":
+ if cmd == "rss":
+ data = await _tools_post("/retail/rss/refresh")
+ log_agent_event("retail", "terminal_action", "RSS refresh (terminal)", json.dumps(data)[:300], channel="terminal")
+ return [_line("RSS verversd.", detail=json.dumps(data)[:200])]
+ if cmd == "scores":
+ data = await _tools_post("/retail/compute-opportunities")
+ return [_line("Opportunity scores bijgewerkt.", detail=json.dumps(data)[:200])]
+
+ if key == "sysops":
+ if cmd == "backup":
+ data = await _tools_post("/ops/backup/run")
+ log_agent_event("sysops", "terminal_action", "Backup (terminal)", json.dumps(data)[:400], channel="terminal")
+ return [_line("Backup uitgevoerd.", detail=json.dumps(data)[:300])]
+ if cmd == "scan":
+ data = await _tools_post("/ops/maintenance/scan")
+ return [_line("Maintenance scan klaar.", detail=json.dumps(data)[:300])]
+ if cmd == "topology":
+ data = await _tools_get("/ops/topology")
+ return [_line("Topology geladen.", detail=json.dumps(data)[:400])]
+ if cmd == "status":
+ data = await _tools_get("/ops/status")
+ return [_line(json.dumps(data)[:350], detail=json.dumps(data)[:800])]
+
+ if key == "marketing" and cmd == "reco":
+ data = await _tools_post("/recommendations/generate")
+ return [_line("Aanbevelingen gegenereerd.", detail=json.dumps(data)[:300])]
+
+ if key == "packaging" and cmd == "design":
+ brief = " ".join(args) or raw
+ result = await herman.chat(f"Packaging design: {brief}", channel="terminal:packaging")
+ reply = (result.get("reply") or "").strip()
+ return [_line(reply, detail=reply)]
+
+ if key == "webbuilder":
+ if cmd == "build":
+ brief = rest or raw
+ if not brief:
+ return [_line("Gebruik: build ", line_type="error")]
+ try:
+ outcome = await webbuilder_agent.generate_from_message(brief, channel="terminal", wait=False)
+ proj = outcome.get("project") or "?"
+ preview = outcome.get("preview_url") or "—"
+ return [
+ _line(f"Build gestart: {proj}"),
+ _line(f"Preview: {preview}", line_type="output"),
+ _line("Volg voortgang live in dit terminal-venster.", line_type="output"),
+ ]
+ except Exception as exc:
+ return [_line("Build mislukt: " + str(exc), line_type="error")]
+ if cmd == "projects":
+ data = await webbuilder_agent.list_projects()
+ items = data.get("items") or []
+ if not items:
+ return [_line("Geen projecten op Hermes/NAS.")]
+ return [
+ _line(
+ f"{p.get('slug')} — {p.get('files', 0)} bestand(en)"
+ + (" · index.html ✓" if p.get("has_index") else "")
+ )
+ for p in items
+ ]
+ if cmd == "status":
+ slug = args[0] if args else ""
+ if not slug:
+ return [_line("Gebruik: status ", line_type="error")]
+ st = await webbuilder_agent.get_build_status(slug)
+ lines = [_line(f"{st.get('project')} · {st.get('status')} — {st.get('message') or ''}")]
+ tail = (st.get("log_tail") or "")[:300]
+ if tail:
+ lines.append(_line(tail, line_type="output"))
+ return lines
+ if cmd == "preview":
+ slug = args[0] if args else ""
+ if slug:
+ st = await webbuilder_agent.get_build_status(slug)
+ url = st.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
+ else:
+ url = webbuilder_agent.HERMES_PREVIEW_BASE
+ return [_line(f"Preview: {url}")]
+
+ return await _agent_ask(key, soul, raw)
+
+
+async def execute_terminal_command(
+ agent_key: str,
+ command: str,
+ *,
+ issued_by: str = "ceo",
+) -> dict[str, Any]:
+ key = normalize_agent_key(agent_key)
+ soul = agent_souls.get_soul(key)
+ if not soul:
+ raise ValueError("Agent niet gevonden")
+
+ raw = (command or "").strip()
+ if not raw:
+ raise ValueError("Leeg commando")
+
+ log_agent_event(
+ key,
+ "terminal_in",
+ raw[:255],
+ "",
+ channel="terminal",
+ metadata={"issued_by": issued_by, "command": raw[:500]},
+ )
+
+ parts = raw.split()
+ cmd = parts[0].lower()
+ args = parts[1:]
+ rest = " ".join(args)
+
+ if cmd in ("help", "?", "h"):
+ lines = _help_lines(key)
+ elif cmd == "status":
+ lines = _status_lines(key, soul)
+ elif cmd == "handoff":
+ if len(args) < 2:
+ lines = [_line("Gebruik: handoff [notitie]", line_type="error")]
+ else:
+ note = " ".join(args[2:]) if len(args) > 2 else ""
+ lines = _handoff(key, args[0], args[1], note)
+ elif cmd == "say":
+ if not rest:
+ lines = [_line("Gebruik: say ", line_type="error")]
+ else:
+ log_agent_event(key, "terminal_note", rest[:255], rest, channel="terminal")
+ lines = [_line("Genoteerd.")]
+ elif cmd == "ask":
+ if not rest:
+ lines = [_line("Gebruik: ask ", line_type="error")]
+ else:
+ lines = await _agent_ask(key, soul, rest)
+ elif cmd == "history":
+ events = agent_souls.list_agent_events(key, limit=8)
+ if not events:
+ lines = [_line("Geen history.")]
+ else:
+ lines = []
+ for ev in reversed(events):
+ t = (ev.get("created_at") or "")[11:19] or "--:--"
+ lines.append(_line(f"{t} {ev.get('title') or ev.get('event_type')}"))
+ else:
+ lines = await _run_agent_command(key, soul, cmd, args, raw)
+
+ return {"ok": True, "agent_key": key, "command": raw, "lines": lines}
diff --git a/cockpit/app/services/auto_ingest.py b/cockpit/app/services/auto_ingest.py
new file mode 100644
index 0000000..6e19f79
--- /dev/null
+++ b/cockpit/app/services/auto_ingest.py
@@ -0,0 +1,130 @@
+"""Automatische NAS + second-brain synchronisatie."""
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+from typing import Any
+
+import httpx
+
+from app.db import execute, fetch_all, fetch_one
+
+log = logging.getLogger("cockpit.auto_ingest")
+
+DOC_INGEST_URL = os.getenv("DOC_INGEST_URL", "http://10.4.7.19:8750")
+TOOLS_API_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
+
+
+def _log_run(source: str, status: str, details: dict[str, Any]) -> None:
+ try:
+ execute(
+ "INSERT INTO ingest_automation_log (source, status, details) VALUES (%s, %s, %s::jsonb)",
+ (source, status, json.dumps(details)),
+ )
+ except Exception as exc:
+ log.warning("ingest log failed: %s", exc)
+
+
+def _brain_chat_id_for_client(client_id: int) -> int:
+ return -int(client_id)
+
+
+async def sync_nas_to_brain(limit: int = 40) -> dict[str, Any]:
+ """Indexeer geanalyseerde documenten in second brain (pgvector) per klant."""
+ rows = fetch_all(
+ """
+ SELECT da.storage_path, da.filename, da.doc_type, da.sentiment_label,
+ da.word_count, dl.client_id, c.name AS client_name
+ FROM document_analytics da
+ LEFT JOIN document_links dl ON (
+ da.storage_path = dl.storage_path
+ OR da.storage_path LIKE dl.storage_path || '/%%'
+ ) AND dl.is_folder = TRUE
+ LEFT JOIN document_links dl2 ON da.storage_path = dl2.storage_path AND dl2.is_folder = FALSE
+ LEFT JOIN clients c ON c.id = COALESCE(dl2.client_id, dl.client_id)
+ WHERE da.storage_path IS NOT NULL
+ ORDER BY da.analyzed_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (max(1, min(limit, 200)),),
+ )
+ synced = 0
+ errors = 0
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ for row in rows:
+ path = row.get("storage_path") or ""
+ if not path:
+ continue
+ client_id = row.get("client_id")
+ chat_id = _brain_chat_id_for_client(client_id) if client_id else 888_000_001
+ text = (
+ f"NAS document: {row.get('filename') or path}\n"
+ f"Pad: {path}\nType: {row.get('doc_type')}\n"
+ f"Sentiment: {row.get('sentiment_label')}\nWoorden: {row.get('word_count')}"
+ )
+ msg_hash = int(hashlib.md5(path.encode()).hexdigest()[:8], 16)
+ try:
+ r = await client.post(
+ f"{TOOLS_API_URL.rstrip('/')}/brain/messages",
+ json={
+ "chat_id": chat_id,
+ "direction": "inbound",
+ "role": "system",
+ "content_type": "document",
+ "content_text": text[:4000],
+ "agent_name": "auto-ingest",
+ "embed": True,
+ "chat_type": "client_brain" if client_id else "nas_corpus",
+ "user_name": row.get("client_name") or "NAS",
+ "content_json": {"storage_path": path, "client_id": client_id},
+ "telegram_message_id": msg_hash,
+ },
+ )
+ if r.status_code < 400:
+ synced += 1
+ else:
+ errors += 1
+ except Exception as exc:
+ log.warning("brain sync %s: %s", path, exc)
+ errors += 1
+ return {"synced": synced, "errors": errors, "candidates": len(rows)}
+
+
+async def run_full_auto_sync(force_scan: bool = False) -> dict[str, Any]:
+ """Volledige pipeline: NAS scan → Chroma RAG → brain embeddings."""
+ out: dict[str, Any] = {"ok": True, "steps": {}}
+ try:
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ r = await client.post(
+ f"{DOC_INGEST_URL.rstrip('/')}/ingest/scan",
+ params={"force": "true" if force_scan else "false"},
+ )
+ r.raise_for_status()
+ out["steps"]["nas_scan"] = r.json()
+ except Exception as exc:
+ out["steps"]["nas_scan"] = {"error": str(exc)}
+ brain = await sync_nas_to_brain()
+ out["steps"]["brain_sync"] = brain
+ status = "ok" if not out["steps"].get("nas_scan", {}).get("error") else "partial"
+ _log_run("auto_sync", status, out)
+ out["last_logged"] = True
+ return out
+
+
+def last_auto_sync() -> dict[str, Any] | None:
+ row = fetch_one(
+ "SELECT source, status, details, created_at FROM ingest_automation_log ORDER BY created_at DESC LIMIT 1"
+ )
+ if not row:
+ return None
+ d = dict(row)
+ if hasattr(d.get("created_at"), "isoformat"):
+ d["created_at"] = d["created_at"].isoformat()
+ if isinstance(d.get("details"), str):
+ try:
+ d["details"] = json.loads(d["details"])
+ except Exception:
+ pass
+ return d
diff --git a/cockpit/app/services/briefing.py b/cockpit/app/services/briefing.py
index 1bb11e5..8c019d4 100644
--- a/cockpit/app/services/briefing.py
+++ b/cockpit/app/services/briefing.py
@@ -7,7 +7,7 @@ from typing import Any
from app.config import settings
from app.db import execute, fetch_all, fetch_one
-from app.services import market_stocks, ollama
+from app.services import market_stocks, llm_router
def _safe_count(table: str, where: str = "", params: tuple = ()) -> int:
@@ -166,6 +166,16 @@ def _collect_pipeline_events(data: dict[str, Any]) -> None:
except Exception:
data["recent_events"] = []
+ try:
+ data["recent_handoffs"] = fetch_all(
+ """SELECT from_agent, to_agent, handoff_type, status, created_at, correlation_id::text
+ FROM agent_handoffs
+ WHERE created_at >= NOW() - INTERVAL '24 hours'
+ ORDER BY created_at DESC LIMIT 20"""
+ )
+ except Exception:
+ data["recent_handoffs"] = []
+
try:
data["pending_items"] = fetch_all(
"""SELECT agent_name, title, event_type, created_at
@@ -565,6 +575,13 @@ def _build_activity_log(data: dict[str, Any]) -> list[str]:
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(f"⚡ {row.get('agent_name')}: {row.get('title')} ({ts_s})")
+ for row in (data.get("recent_handoffs") or [])[:8]:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ lines.append(
+ f"🔗 {row.get('from_agent')} → {row.get('to_agent')}: {row.get('handoff_type')} ({ts_s})"
+ )
+
return lines[:25]
@@ -644,6 +661,15 @@ def build_template_report(data: dict[str, Any]) -> str:
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
+ if data.get("recent_handoffs"):
+ lines.extend(["", "## Agent samenwerking (handoffs 24u)"])
+ for row in data["recent_handoffs"][:15]:
+ ts = row.get("created_at")
+ ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
+ lines.append(
+ f"- {row.get('from_agent')} → {row.get('to_agent')} ({row.get('handoff_type')}) {ts_s}"
+ )
+
if data.get("pending_items"):
lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
@@ -683,7 +709,7 @@ async def _ai_executive_summary(data: dict[str, Any]) -> str:
"Schrijf warm, professioneel, actionable."
)
try:
- return await ollama.generate(prompt, system=system, timeout=120.0)
+ return await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
diff --git a/cockpit/app/services/client_360.py b/cockpit/app/services/client_360.py
new file mode 100644
index 0000000..6ed0c44
--- /dev/null
+++ b/cockpit/app/services/client_360.py
@@ -0,0 +1,147 @@
+"""Klant 360° — geaggregeerde data uit NAS, CRM, retail, trends."""
+from __future__ import annotations
+
+from typing import Any
+
+from app.db import fetch_all, fetch_one
+from app.services import projects as projects_svc
+
+
+def _serialize_row(row: dict | None) -> dict | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ return out
+
+
+def _serialize_rows(rows: list) -> list[dict]:
+ return [_serialize_row(r) for r in rows if r]
+
+
+def get_client_360(client_id: int) -> dict[str, Any]:
+ client = fetch_one("SELECT * FROM clients WHERE id = %s", (client_id,))
+ if not client:
+ return {"ok": False, "error": "Client not found"}
+
+ projs = projects_svc.list_projects(client_id=client_id, limit=50)
+ links = fetch_all(
+ """
+ SELECT dl.*, p.name AS project_name
+ FROM document_links dl
+ LEFT JOIN cockpit_projects p ON p.id = dl.project_id
+ WHERE dl.client_id = %s
+ ORDER BY dl.created_at DESC
+ """,
+ (client_id,),
+ )
+ paths = [l["storage_path"] for l in links if l.get("storage_path")]
+ docs: list[dict] = []
+ if paths:
+ placeholders = ",".join(["%s"] * len(paths))
+ docs = fetch_all(
+ f"""
+ SELECT filename, storage_path, doc_type, word_count, sentiment_label,
+ sentiment_compound, analyzed_at, user_labels
+ FROM document_analytics
+ WHERE storage_path IN ({placeholders})
+ OR storage_path LIKE ANY (
+ SELECT dl.storage_path || '/%%' FROM document_links dl
+ WHERE dl.client_id = %s AND dl.is_folder = TRUE
+ )
+ ORDER BY analyzed_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ tuple(paths + [client_id]),
+ )
+ sentiment_rows = fetch_all(
+ """
+ SELECT sentiment_label, COUNT(*) AS n, AVG(sentiment_compound) AS avg_c
+ FROM document_analytics da
+ WHERE EXISTS (
+ SELECT 1 FROM document_links dl
+ WHERE dl.client_id = %s
+ AND (da.storage_path = dl.storage_path
+ OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
+ )
+ GROUP BY sentiment_label
+ """,
+ (client_id,),
+ )
+ top_words = fetch_all(
+ """
+ SELECT dwc.lemma, SUM(dwc.count) AS total
+ FROM document_word_counts dwc
+ JOIN document_analytics da ON da.storage_path = dwc.storage_path
+ WHERE EXISTS (
+ SELECT 1 FROM document_links dl
+ WHERE dl.client_id = %s
+ AND (da.storage_path = dl.storage_path
+ OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
+ )
+ AND NOT dwc.is_stopword
+ GROUP BY dwc.lemma
+ ORDER BY total DESC
+ LIMIT 25
+ """,
+ (client_id,),
+ )
+ deals = fetch_all(
+ "SELECT id, title, value, stage, next_action, deadline FROM deals WHERE client_id = %s ORDER BY updated_at DESC LIMIT 15",
+ (client_id,),
+ )
+ stores = fetch_all(
+ """
+ SELECT s.id, s.name, s.chain, s.city, s.partnership_status, s.halal_certified
+ FROM client_supermarket_links l
+ JOIN supermarkets s ON s.id = l.supermarket_id
+ WHERE l.client_id = %s
+ LIMIT 30
+ """,
+ (client_id,),
+ )
+ trends = fetch_all(
+ """
+ SELECT DATE_TRUNC('month', da.analyzed_at) AS month,
+ COUNT(*) AS docs,
+ AVG(da.sentiment_compound) AS avg_sentiment,
+ SUM(da.word_count) AS words
+ FROM document_analytics da
+ WHERE da.analyzed_at IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM document_links dl
+ WHERE dl.client_id = %s
+ AND (da.storage_path = dl.storage_path
+ OR (dl.is_folder AND da.storage_path LIKE dl.storage_path || '/%%'))
+ )
+ GROUP BY 1
+ ORDER BY 1 DESC
+ LIMIT 12
+ """,
+ (client_id,),
+ )
+ for t in trends:
+ if hasattr(t.get("month"), "isoformat"):
+ t["month"] = t["month"].isoformat()
+
+ return {
+ "ok": True,
+ "client": _serialize_row(client),
+ "projects": projs,
+ "links": _serialize_rows(links),
+ "documents": _serialize_rows(docs),
+ "sentiment_breakdown": _serialize_rows(sentiment_rows),
+ "top_words": _serialize_rows(top_words),
+ "deals": _serialize_rows(deals),
+ "stores": _serialize_rows(stores),
+ "monthly_trends": trends,
+ "stats": {
+ "linked_paths": len(links),
+ "documents": len(docs),
+ "projects": len(projs),
+ "stores": len(stores),
+ "deals": len(deals),
+ },
+ }
diff --git a/cockpit/app/services/docker-compose.yml b/cockpit/app/services/docker-compose.yml
new file mode 100644
index 0000000..00ff4e4
--- /dev/null
+++ b/cockpit/app/services/docker-compose.yml
@@ -0,0 +1,152 @@
+version: '3.8'
+services:
+ redis:
+ image: redis:7-alpine
+ container_name: foodlinkk_redis
+ ports:
+ - "6379:6379"
+ restart: unless-stopped
+
+
+ tools-api:
+ build: ./tools-api
+ container_name: foodlinkk_tools_api
+ environment:
+ DB_HOST: foodlinkk_db
+ DB_USER: aissa
+ DB_PASSWORD: Foodlinkk#2026
+ DB_NAME: foodlinkk
+ BACKUP_ROOT: /data/backup-root
+ GITEA_BACKUP_REMOTE: http://sysops:Foodlinkk%23SysOps2026@gitea:3001/aissa/foodlinkk-command-center.git
+ GITEA_SYSOPS_USER: sysops
+ GITEA_SYSOPS_EMAIL: sysops@foodlinkk.local
+ GITEA_SYSOPS_NAME: sysops
+ GITEA_BACKUP_BRANCH: main
+ PROXMOX_HOST: 10.4.7.14
+ VM106_IP: 10.4.7.18
+ OLLAMA_URL: http://10.4.7.19:11434
+ OLLAMA_MODEL: qwen3:8b
+ CHROMA_HOST: 10.4.7.19
+ CHROMA_PORT: "8000"
+ DOC_INGEST_URL: http://10.4.7.19:8750
+ BROWSER_USE_URL: http://browser-agent:7790
+ BROWSER_AGENT_URL: http://browser-agent:7790
+ COMFYUI_URL: http://10.4.7.18:8188
+ SMTP_HOST: ${SMTP_HOST:-}
+ SMTP_PORT: ${SMTP_PORT:-587}
+ SMTP_USER: ${SMTP_USER:-}
+ SMTP_PASS: ${SMTP_PASS:-}
+ SMTP_FROM: ${SMTP_FROM:-}
+ ports:
+ - "8700:8700"
+ volumes:
+ - /home/aissa/foodlinkk-command-center:/data/backup-root:ro
+ extra_hosts:
+ - "foodlinkk_db:10.4.7.18"
+ restart: unless-stopped
+
+ cockpit:
+ build: ./cockpit
+ container_name: foodlinkk_cockpit
+ environment:
+ DB_HOST: foodlinkk_db
+ DB_USER: aissa
+ DB_PASSWORD: Foodlinkk#2026
+ DB_NAME: foodlinkk
+ VNC_CDP_URL: http://10.4.7.18:9223
+ OLLAMA_URL: http://10.4.7.19:11434
+ OLLAMA_MODEL: qwen3:8b
+ TOOLS_API_URL: http://tools-api:8700
+ CHROMA_HOST: 10.4.7.19
+ CHROMA_PORT: "8000"
+ DOC_INGEST_URL: http://10.4.7.19:8750
+ BROWSER_AGENT_URL: http://browser-agent:7790
+ COMFYUI_URL: http://10.4.7.18:8188
+ HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
+ NAS_CLIENTS_ROOT: /data/nas-clients
+ NAS_SHARE_ROOT: /data/nas-share
+ ports:
+ - "8600:8600"
+ volumes:
+ - /home/aissa/nas-clients:/data/nas-clients
+ - /mnt/synology:/data/nas-share:ro
+ depends_on:
+ - tools-api
+ extra_hosts:
+ - "foodlinkk_db:10.4.7.18"
+ restart: unless-stopped
+
+ browser-agent:
+ build: ./browser-agent
+ container_name: foodlinkk_browser_agent
+ environment:
+ DB_HOST: foodlinkk_db
+ DB_USER: aissa
+ DB_PASSWORD: Foodlinkk#2026
+ DB_NAME: foodlinkk
+ VNC_CDP_URL: http://10.4.7.18:9223
+ ports:
+ - "7790:7790"
+ extra_hosts:
+ - "foodlinkk_db:10.4.7.18"
+ restart: unless-stopped
+ shm_size: "1gb"
+
+ gitea:
+ image: gitea/gitea:1.21-rootless
+ container_name: foodlinkk_gitea
+ environment:
+ GITEA__database__DB_TYPE: sqlite3
+ GITEA__server__ROOT_URL: http://10.4.7.18:3001/
+ GITEA__server__HTTP_PORT: 3001
+ GITEA__security__INSTALL_LOCK: "true"
+ GITEA__security__SECRET_KEY: foodlinkk-gitea-secret-2026
+ GITEA__service__DISABLE_REGISTRATION: "true"
+ ports:
+ - "3001:3001"
+ volumes:
+ - gitea_data:/var/lib/gitea
+ restart: unless-stopped
+
+ email-agent:
+ build: ./email-agent
+ container_name: foodlinkk_email_agent
+ environment:
+ DB_HOST: foodlinkk_db
+ DB_USER: aissa
+ DB_PASSWORD: Foodlinkk#2026
+ DB_NAME: foodlinkk
+ TOOLS_API_URL: http://tools-api:8700
+ SYNC_INTERVAL_SEC: 300
+ ports:
+ - "8801:8801"
+ depends_on:
+ - tools-api
+ extra_hosts:
+ - "foodlinkk_db:10.4.7.18"
+ restart: unless-stopped
+
+ prometheus:
+ image: prom/prometheus:v2.53.0
+ container_name: foodlinkk_prometheus
+ volumes:
+ - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ ports:
+ - "9090:9090"
+ restart: unless-stopped
+
+ grafana:
+ image: grafana/grafana:11.1.0
+ container_name: foodlinkk_grafana
+ environment:
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: Foodlinkk#2026
+ GF_SERVER_ROOT_URL: http://10.4.7.18:3002
+ ports:
+ - "3002:3000"
+ depends_on:
+ - prometheus
+ restart: unless-stopped
+
+volumes:
+ gitea_data:
diff --git a/cockpit/app/services/documents-v2.js b/cockpit/app/services/documents-v2.js
new file mode 100644
index 0000000..716e571
--- /dev/null
+++ b/cockpit/app/services/documents-v2.js
@@ -0,0 +1,1271 @@
+/* Documents hub v2 — NAS, labels, Docling */
+(function () {
+ const D = window.__DOC_DATA || {};
+ const topWords = D.topWords || [];
+ const summary = D.summary || {};
+ const allDocuments = D.documents || [];
+
+ let wordsChartInst = null;
+ let sentimentChartInst = null;
+
+ window.documentsDash = function documentsDash() {
+ return {
+ activeTab: 'overview',
+ filterQuery: '',
+ filterSentiment: '',
+ filterType: '',
+ filterLabel: '',
+ filteredDocs: allDocuments,
+ docTypes: [...new Set(allDocuments.map((d) => d.doc_type).filter(Boolean))],
+ labelVocabulary: [],
+ photoTaxonomy: [],
+ documentTaxonomy: [],
+ photoFilterLabel: '',
+ activeDoc: null,
+ docSelectedLabels: [],
+ docLabelsInput: '',
+ docNotesInput: '',
+ photos: [],
+ activePhoto: null,
+ photoModalOpen: false,
+ modalPhotoLabels: [],
+ activeOcr: '',
+ photoLabelsInput: '',
+ photoNotesInput: '',
+ activeItems: [],
+ detections: [],
+ liveRefresh: false,
+ liveTimer: null,
+ nasOcrBusy: false,
+ doclingStatus: 'Docling laden…',
+ doclingFiles: [],
+ doclingHistory: [],
+ doclingFilter: '',
+ doclingExtFilter: '',
+ doclingSelected: '',
+ doclingOpts: { ocr: true, tables: true, page_images: false, picture_images: true, force_full_page_ocr: false },
+ doclingFormats: ['markdown', 'html', 'json', 'text', 'doctags', 'yaml'],
+ doclingExportFormats: ['markdown', 'html', 'json', 'text', 'doctags', 'yaml'],
+ doclingBusy: false,
+ doclingResult: null,
+ doclingExports: {},
+ doclingTables: [],
+ doclingPictures: [],
+ doclingMeta: {},
+ doclingExportTab: 'markdown',
+ doclingPreviewSearch: '',
+ doclingFileStatus: {},
+ nasFileTotal: 0,
+ nasLoading: false,
+ doclingEditMode: true,
+ doclingEditContent: '',
+ doclingAutoConvert: true,
+ dlOptionsOpen: false,
+ doclingFileLoading: false,
+ doclingFileKind: '',
+ doclingSupportedSuffixes: [],
+ dlExtGroups: [
+ { ext: '', label: 'Alles', icon: '' },
+ { ext: '.pdf', label: 'PDF', icon: '📕' },
+ { ext: '.docx', label: 'Word', icon: '📝' },
+ { ext: '.doc', label: 'DOC', icon: '📝' },
+ { ext: '.xlsx', label: 'Excel', icon: '📊' },
+ { ext: '.xls', label: 'XLS', icon: '📊' },
+ { ext: '.pptx', label: 'PPT', icon: '📽️' },
+ { ext: '.ppt', label: 'PPT', icon: '📽️' },
+ { ext: '.txt', label: 'Tekst', icon: '📄' },
+ { ext: '.md', label: 'MD', icon: '📄' },
+ { ext: '.csv', label: 'CSV', icon: '📋' },
+ { ext: '.html', label: 'HTML', icon: '🌐' },
+ { ext: '.json', label: 'JSON', icon: '{}' },
+ { ext: '.png', label: 'IMG', icon: '🖼️' },
+ ],
+ pptTitle: 'Foodlinkk Presentatie',
+ pptSubtitle: 'Foodlinkk',
+ pptFilename: '',
+ pptSlides: [],
+ pptSourcePdf: '',
+ pptLastOutput: '',
+ pptBusy: false,
+ pptDownloadPath: '',
+ chatMessages: [],
+ chatInput: '',
+ chatBusy: false,
+ chatStatus: 'RAG laden…',
+ chatSearchQ: '',
+ chatSearchHits: [],
+ chatScopeFile: false,
+ chatScopeClient: true,
+ chatUseHerman: false,
+ chatReindexing: false,
+ llmProviders: [],
+ chatLlmProviderId: '',
+ nasDiagHint: '',
+ autoSyncBusy: false,
+ autoSyncLabel: 'Second brain: —',
+ autoSyncTimer: null,
+ linkClients: [],
+ linkClientId: '',
+ linkProjectId: '',
+ linkPathInput: '',
+ linkProjects: [],
+ client360: { stats: {}, links: [], sentiment_breakdown: [], monthly_trends: [], top_words: [], stores: [], deals: [] },
+
+ init() {
+ try {
+ this.applyFilters();
+ this.renderCharts(window.__vizMode || 'neo-bars');
+ this.loadPhotos();
+ this.loadLabelVocabulary();
+ this.loadTaxonomy();
+ this.loadShareFiles(false);
+ this.loadLinkClients();
+ this.loadLlmProviders();
+ this.refreshAutoSyncStatus();
+ document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
+ if (window.DocHubTabs) {
+ window.DocHubTabs.attachAlpine(this);
+ }
+ } catch (e) {
+ console.error('documentsDash init:', e);
+ Cockpit.toast('Documenten UI gedeeltelijk geladen — ververs de pagina (Ctrl+F5)', 'error');
+ }
+ },
+
+ switchTab(tab) {
+ if (window.DocHubTabs) {
+ window.DocHubTabs.show(tab);
+ } else {
+ this.activeTab = tab;
+ }
+ },
+
+ async loadLlmProviders() {
+ try {
+ const data = await Cockpit.api('/api/settings/llm');
+ this.llmProviders = data.providers || [];
+ const def = data.default || this.llmProviders.find((p) => p.is_default);
+ if (def && !this.chatLlmProviderId) this.chatLlmProviderId = def.id;
+ const banner = document.getElementById('doc-hub-status');
+ if (banner && def && def.provider_type === 'ollama') {
+ banner.style.display = 'block';
+ banner.textContent = 'Tip: Ollama is traag op CPU (~2 min). Voeg DeepSeek/Gemini toe via Instellingen → AI / LLM voor snelle chat & RAG.';
+ }
+ const cloud = this.llmProviders.find((p) => p.provider_type !== 'ollama' && p.api_key_set);
+ if (cloud && def && def.provider_type === 'ollama') {
+ this.chatLlmProviderId = cloud.id;
+ }
+ } catch (e) {}
+ },
+
+ onChatProviderChange() {
+ const p = this.llmProviders.find((row) => Number(row.id) === Number(this.chatLlmProviderId));
+ this.chatStatus = p ? (p.label + ' · ' + p.model) : 'Standaard LLM';
+ },
+
+ filteredPhotos() {
+ if (!this.photoFilterLabel) return this.photos;
+ return this.photos.filter((p) => (p.user_labels || []).includes(this.photoFilterLabel));
+ },
+
+ mergeShareItems(items) {
+ const byPath = new Map(allDocuments.map((d) => [d.storage_path, d]));
+ (items || []).forEach((f) => {
+ const path = f.path || f.storage_path;
+ if (!path) return;
+ if (byPath.has(path)) {
+ const ex = byPath.get(path);
+ if (!ex.analyzed_at && f.modified_at) ex.modified_at = f.modified_at;
+ return;
+ }
+ const row = {
+ filename: f.filename || path.split('/').pop(),
+ storage_path: path,
+ doc_type: f.doc_type || 'general',
+ word_count: f.word_count || 0,
+ sentiment_label: f.sentiment_label || 'neutral',
+ sentiment_compound: f.sentiment_compound || 0,
+ analyzed_at: f.analyzed_at || '',
+ modified_at: f.modified_at || '',
+ user_labels: f.user_labels || [],
+ label_notes: f.label_notes || '',
+ from_share: !f.analyzed_at,
+ };
+ allDocuments.push(row);
+ byPath.set(path, row);
+ });
+ this.docTypes = [...new Set(allDocuments.map((d) => d.doc_type).filter(Boolean))];
+ this.applyFilters();
+ },
+
+ async loadShareFiles(showToast) {
+ try {
+ const r = await fetch('/api/admin/documents/share-files?limit=300');
+ const data = await r.json();
+ if (data.ok !== false) {
+ this.mergeShareItems(data.items || []);
+ this.nasFileTotal = data.total || (data.items || []).length;
+ if (showToast) Cockpit.toast((this.nasFileTotal || 0) + ' bestanden op NAS', 'success');
+ } else if (showToast) {
+ Cockpit.toast(data.error || 'NAS laden mislukt', 'error');
+ }
+ } catch (e) {
+ if (showToast) Cockpit.toast('NAS bestanden laden mislukt', 'error');
+ }
+ },
+
+ async refreshShare(showToast) {
+ try {
+ if (showToast !== false) Cockpit.toast('NAS scan gestart (~30s)…', 'info');
+ const scanCtrl = new AbortController();
+ const scanTimer = setTimeout(() => scanCtrl.abort(), 90000);
+ try {
+ await fetch('/api/admin/documents/trigger-scan', { method: 'POST', signal: scanCtrl.signal });
+ } finally {
+ clearTimeout(scanTimer);
+ }
+ await this.loadShareFiles(showToast !== false);
+ } catch (e) {
+ if (showToast !== false) Cockpit.toast('Share scan mislukt — toon cache', 'error');
+ await this.loadShareFiles(false);
+ }
+ },
+
+ applyFilters() {
+ const q = this.filterQuery.toLowerCase();
+ this.filteredDocs = allDocuments.filter((d) => {
+ if (this.filterSentiment && d.sentiment_label !== this.filterSentiment) return false;
+ if (this.filterType && d.doc_type !== this.filterType) return false;
+ if (this.filterLabel && !(d.user_labels || []).includes(this.filterLabel)) return false;
+ if (q) {
+ const hay = ((d.filename || '') + ' ' + (d.storage_path || '') + ' ' + (d.user_labels || []).join(' ')).toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ return true;
+ });
+ },
+
+ async loadLabelVocabulary() {
+ try {
+ const r = await fetch('/api/admin/labels/vocabulary?limit=60');
+ const data = await r.json();
+ if (data.ok) this.labelVocabulary = data.labels || [];
+ } catch (e) {}
+ },
+
+ async loadTaxonomy() {
+ try {
+ const r = await fetch('/api/admin/labels/taxonomy');
+ const data = await r.json();
+ if (data.ok) {
+ this.photoTaxonomy = data.photo || [];
+ this.documentTaxonomy = data.document || [];
+ }
+ } catch (e) {}
+ },
+
+ openDocLabel(doc) {
+ this.activeDoc = doc;
+ this.docSelectedLabels = [...(doc.user_labels || [])];
+ this.docLabelsInput = '';
+ this.docNotesInput = doc.label_notes || '';
+ },
+
+ toggleDocLabel(id) {
+ const idx = this.docSelectedLabels.indexOf(id);
+ if (idx >= 0) this.docSelectedLabels.splice(idx, 1);
+ else this.docSelectedLabels.push(id);
+ },
+
+ async saveDocLabels() {
+ if (!this.activeDoc) return;
+ const labels = [...this.docSelectedLabels];
+ this.docLabelsInput.split(',').map((s) => s.trim()).filter(Boolean).forEach((t) => {
+ if (!labels.includes(t)) labels.push(t);
+ });
+ try {
+ const r = await Cockpit.api('/documents/labels', {
+ method: 'PATCH',
+ body: JSON.stringify({
+ storage_path: this.activeDoc.storage_path,
+ labels,
+ notes: this.docNotesInput || null,
+ }),
+ });
+ const saved = r.document || {};
+ this.activeDoc.user_labels = saved.user_labels || labels;
+ this.activeDoc.label_notes = saved.label_notes || this.docNotesInput;
+ const idx = allDocuments.findIndex((d) => d.storage_path === this.activeDoc.storage_path);
+ if (idx >= 0) {
+ allDocuments[idx].user_labels = this.activeDoc.user_labels;
+ allDocuments[idx].label_notes = this.activeDoc.label_notes;
+ }
+ this.applyFilters();
+ await this.loadLabelVocabulary();
+ Cockpit.toast('Document gelabeld', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ toggleModalLabel(id) {
+ const idx = this.modalPhotoLabels.indexOf(id);
+ if (idx >= 0) this.modalPhotoLabels.splice(idx, 1);
+ else this.modalPhotoLabels.push(id);
+ },
+
+ async openPhotoModal(id) {
+ this.activePhoto = id;
+ this.photoModalOpen = true;
+ document.body.style.overflow = 'hidden';
+ this.modalPhotoLabels = [];
+ const p = this.photos.find((x) => x.id === id);
+ if (p && p.user_labels) this.modalPhotoLabels = [...p.user_labels];
+ this.photoLabelsInput = '';
+ this.photoNotesInput = p ? (p.label_notes || '') : '';
+ this.activeOcr = p ? (p.ocr_preview || '') : '';
+ try {
+ const r = await Cockpit.api('/photos/' + id + '/detections');
+ this.detections = r.detections || [];
+ this.activeItems = r.extracted_items || [];
+ this.activeOcr = r.ocr_text || this.activeOcr;
+ this.modalPhotoLabels = [...(r.user_labels || this.modalPhotoLabels)];
+ this.photoNotesInput = r.label_notes || this.photoNotesInput;
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ closePhotoModal() {
+ this.photoModalOpen = false;
+ document.body.style.overflow = '';
+ },
+
+ async saveModalPhotoLabels() {
+ if (!this.activePhoto) return;
+ const labels = [...this.modalPhotoLabels];
+ this.photoLabelsInput.split(',').map((s) => s.trim()).filter(Boolean).forEach((t) => {
+ if (!labels.includes(t)) labels.push(t);
+ });
+ if (!labels.length) return Cockpit.toast('Kies minimaal één label-type', 'error');
+ try {
+ const r = await Cockpit.api('/photos/' + this.activePhoto + '/labels', {
+ method: 'PATCH',
+ body: JSON.stringify({ labels, notes: this.photoNotesInput || null }),
+ });
+ const saved = r.photo || {};
+ const p = this.photos.find((x) => x.id === this.activePhoto);
+ if (p) {
+ p.user_labels = saved.user_labels || labels;
+ p.label_notes = saved.label_notes || this.photoNotesInput;
+ }
+ await this.loadLabelVocabulary();
+ this.closePhotoModal();
+ Cockpit.toast('Foto gelabeld', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ labelDisplayName(id) {
+ const t = this.photoTaxonomy.find((x) => x.id === id);
+ return t ? t.label : id;
+ },
+
+ async batchNasOcr(force) {
+ if (this.nasOcrBusy) return;
+ this.nasOcrBusy = true;
+ Cockpit.toast('NAS OCR gestart…', 'info');
+ try {
+ const url = '/api/admin/documents/nas-ocr' + (force ? '?force=true' : '');
+ const r = await fetch(url, { method: 'POST' });
+ const data = await r.json();
+ if (!r.ok || !data.ok) throw new Error(data.detail || data.error || 'OCR mislukt');
+ Cockpit.toast((data.images_extracted || 0) + ' afbeeldingen ge-OCR\'d', 'success');
+ await this.loadPhotos();
+ } catch (e) {
+ Cockpit.toast(e.message || 'NAS OCR mislukt', 'error');
+ } finally {
+ this.nasOcrBusy = false;
+ }
+ },
+
+ renderCharts(mode) {
+ mode = mode || window.__vizMode || 'neo-bars';
+ if (typeof Chart === 'undefined') return;
+ if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
+ if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
+ const dash = this;
+ const wc = document.getElementById('wordsChart');
+ const sc = document.getElementById('sentimentChart');
+ if (!wc || !sc) return;
+ wordsChartInst = new Chart(wc, {
+ type: 'bar',
+ data: {
+ labels: topWords.slice(0, 10).map((w) => w.lemma),
+ datasets: [{
+ data: topWords.slice(0, 10).map((w) => w.total_count),
+ backgroundColor: 'rgba(0,229,255,0.75)',
+ borderWidth: 0,
+ borderRadius: 6,
+ }],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ onClick: (_, els) => {
+ if (els[0]) {
+ dash.filterQuery = topWords[els[0].index].lemma;
+ dash.applyFilters();
+ }
+ },
+ scales: {
+ y: { beginAtZero: true, ticks: { color: '#64748b', font: { size: 9 } } },
+ x: { ticks: { color: '#64748b', font: { size: 9 } } },
+ },
+ },
+ });
+ sentimentChartInst = new Chart(sc, {
+ type: 'doughnut',
+ data: {
+ labels: ['Positief', 'Neutraal', 'Negatief'],
+ datasets: [{
+ data: [summary.positive, summary.neutral, summary.negative],
+ backgroundColor: ['#4ade80', '#64748b', '#fb7185'],
+ borderWidth: 0,
+ }],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { labels: { color: '#94a3b8', font: { size: 9 } } } },
+ onClick: (_, els) => {
+ if (!els[0]) return;
+ dash.filterSentiment = ['positive', 'neutral', 'negative'][els[0].index];
+ dash.applyFilters();
+ },
+ },
+ });
+ },
+
+ async loadPhotos() {
+ try {
+ const r = await Cockpit.api('/photos?limit=80');
+ this.photos = r.photos || [];
+ } catch (e) {}
+ },
+
+ toggleLive() {
+ if (this.liveTimer) clearInterval(this.liveTimer);
+ if (this.liveRefresh) {
+ this.liveTimer = setInterval(() => {
+ this.loadPhotos();
+ this.refreshShare(false);
+ }, 15000);
+ }
+ },
+
+ async uploadPhoto(ev) {
+ const file = ev.target.files && ev.target.files[0];
+ if (!file) return;
+ const b64 = await new Promise((res, rej) => {
+ const r = new FileReader();
+ r.onload = () => res(r.result.split(',')[1]);
+ r.onerror = rej;
+ r.readAsDataURL(file);
+ });
+ try {
+ await Cockpit.api('/photos/analyze', {
+ method: 'POST',
+ body: JSON.stringify({ image_b64: b64, source: 'upload', filename: file.name }),
+ });
+ Cockpit.toast('Foto geanalyseerd', 'success');
+ await this.loadPhotos();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ ev.target.value = '';
+ },
+
+ /* ── Docling ── */
+ doclingFilteredFiles() {
+ const q = (this.doclingFilter || '').toLowerCase();
+ return this.doclingFiles.filter((f) => {
+ if (this.doclingExtFilter) {
+ const ext = this.doclingExtFilter.startsWith('.') ? this.doclingExtFilter : '.' + this.doclingExtFilter;
+ const pathExt = this.fileExtension(f.path);
+ if (ext === '.png') {
+ if (!['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif'].includes(pathExt)) return false;
+ } else if (pathExt !== ext.toLowerCase()) return false;
+ }
+ if (!q) return true;
+ return ((f.path || '') + ' ' + (f.filename || '')).toLowerCase().includes(q);
+ });
+ },
+
+ fileExtension(path) {
+ const p = (path || '').toLowerCase();
+ const i = p.lastIndexOf('.');
+ return i >= 0 ? p.slice(i) : '';
+ },
+
+ fileIcon(path) {
+ const ext = this.fileExtension(path);
+ const map = {
+ '.pdf': '📕', '.docx': '📝', '.doc': '📝', '.xlsx': '📊', '.xls': '📊',
+ '.pptx': '📽️', '.ppt': '📽️', '.txt': '📄', '.md': '📄', '.csv': '📋',
+ '.html': '🌐', '.htm': '🌐', '.json': '{}', '.xml': '📰', '.yaml': '⚙️',
+ '.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.webp': '🖼️', '.gif': '🖼️',
+ };
+ return map[ext] || '📁';
+ },
+
+ fileKind(path) {
+ const ext = this.fileExtension(path);
+ if (this.isPlainTextExt(ext)) return 'text';
+ if (this.isImageExt(ext)) return 'image';
+ if (ext === '.pdf') return 'pdf';
+ if (['.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt'].includes(ext)) return 'office';
+ return 'other';
+ },
+
+ fileKindLabel(path) {
+ const k = this.fileKind(path);
+ return { text: 'Tekst', image: 'Afbeelding', pdf: 'PDF', office: 'Office', other: 'Bestand' }[k] || 'Bestand';
+ },
+
+ isPlainTextExt(ext) {
+ return ['.txt', '.md', '.csv', '.json', '.xml', '.html', '.htm', '.yaml', '.yml', '.log', '.rtf'].includes(ext);
+ },
+
+ isImageExt(ext) {
+ return ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif'].includes(ext);
+ },
+
+ isDoclingSupported(path) {
+ const ext = this.fileExtension(path);
+ if (!ext) return false;
+ const supported = this.doclingSupportedSuffixes.length
+ ? this.doclingSupportedSuffixes
+ : ['.pdf', '.docx', '.pptx', '.xlsx', '.html', '.htm', '.md', '.txt', '.png', '.jpg', '.jpeg', '.webp', '.tiff', '.tif', '.bmp', '.gif'];
+ return supported.includes(ext);
+ },
+
+ pptPdfFiles() {
+ return this.doclingFiles.filter((f) => (f.path || '').toLowerCase().endsWith('.pdf'));
+ },
+
+ pptPptxFiles() {
+ return this.doclingFiles.filter((f) => (f.path || '').toLowerCase().endsWith('.pptx'));
+ },
+
+ async loadAllNasFiles(showToast) {
+ this.nasLoading = true;
+ try {
+ if (showToast) Cockpit.toast('NAS bestanden laden…', 'info');
+ const r = await fetch('/api/admin/documents/share-files?limit=500');
+ const data = await r.json();
+ const byPath = new Map();
+ allDocuments.forEach((d) => {
+ byPath.set(d.storage_path, {
+ path: d.storage_path,
+ filename: d.filename,
+ word_count: d.word_count,
+ sentiment_label: d.sentiment_label,
+ user_labels: d.user_labels || [],
+ });
+ });
+ (data.items || []).forEach((f) => {
+ const path = f.path || f.storage_path;
+ if (!path) return;
+ const ex = byPath.get(path) || {};
+ byPath.set(path, {
+ ...ex,
+ path,
+ filename: f.filename || path.split('/').pop(),
+ size: f.size,
+ modified_at: f.modified_at,
+ });
+ });
+ this.doclingFiles = Array.from(byPath.values()).sort((a, b) =>
+ (a.filename || '').localeCompare(b.filename || ''),
+ );
+ this.nasFileTotal = data.total || this.doclingFiles.length;
+ if (showToast) Cockpit.toast(this.nasFileTotal + ' bestanden op NAS', 'success');
+ } catch (e) {
+ if (showToast) Cockpit.toast('NAS laden mislukt', 'error');
+ } finally {
+ this.nasLoading = false;
+ }
+ },
+
+ fileDoclingStatus(path) {
+ return this.doclingFileStatus[path] || '';
+ },
+
+ selectDoclingFile(path) {
+ this.openDoclingFile(path);
+ },
+
+ async openDoclingFile(path) {
+ if (!path || (this.doclingFileLoading && this.doclingSelected === path)) return;
+ this.doclingSelected = path;
+ this.doclingFileKind = this.fileKind(path);
+ this.doclingEditMode = true;
+ this.doclingFileLoading = true;
+ this.doclingResult = null;
+ this.doclingExports = {};
+ this.doclingTables = [];
+ this.doclingPictures = [];
+ this.doclingEditContent = '';
+
+ const ext = this.fileExtension(path);
+ if (this.doclingFileKind === 'pdf') this.doclingExportTab = 'original';
+ else if (this.doclingFileKind === 'image') this.doclingExportTab = 'original';
+ else this.doclingExportTab = 'rendered';
+
+ try {
+ const hasDraft = await this.loadWorkspace(path);
+ if (hasDraft) {
+ this.doclingFileLoading = false;
+ return;
+ }
+
+ if (this.isPlainTextExt(ext)) {
+ await this.loadNasRawText(path);
+ this.doclingFileLoading = false;
+ return;
+ }
+
+ if (this.doclingFileKind === 'image') {
+ this.doclingFileLoading = false;
+ if (this.doclingAutoConvert && this.isDoclingSupported(path)) {
+ await this.runDoclingConvert(true);
+ }
+ return;
+ }
+
+ if (this.doclingFileKind === 'pdf') {
+ const cached = await this.loadDoclingCached();
+ this.doclingFileLoading = false;
+ if (!cached && this.doclingAutoConvert) await this.runDoclingConvert(true);
+ return;
+ }
+
+ const cached = await this.loadDoclingCached();
+ if (cached) {
+ this.doclingFileLoading = false;
+ return;
+ }
+
+ if (this.doclingAutoConvert && this.isDoclingSupported(path)) {
+ this.doclingFileLoading = false;
+ await this.runDoclingConvert(true);
+ } else if (!this.isDoclingSupported(path)) {
+ this.doclingEditContent = '# ' + path.split('/').pop() + '\n\nDit bestandstype wordt niet ondersteund voor Docling-conversie.\nOpen het origineel via de knop rechtsboven.';
+ Cockpit.toast('Geen Docling-support voor ' + ext, 'info');
+ }
+ } catch (e) {
+ Cockpit.toast(e.message || 'Laden mislukt', 'error');
+ } finally {
+ this.doclingFileLoading = false;
+ }
+ },
+
+ async loadNasRawText(path) {
+ const data = await Cockpit.api('/docling/nas-read?path=' + encodeURIComponent(path));
+ this.doclingEditContent = data.content || '';
+ this.doclingExports = { markdown: this.doclingEditContent, text: this.doclingEditContent };
+ this.doclingResult = { status: 'raw' };
+ this.doclingExportTab = 'rendered';
+ },
+
+ async loadWorkspace(path) {
+ if (!path) return false;
+ try {
+ const data = await Cockpit.api('/docling/workspace?path=' + encodeURIComponent(path));
+ if (data.workspace && data.workspace.content) {
+ this.doclingEditContent = data.workspace.content;
+ return true;
+ }
+ } catch (e) {}
+ const md = this.doclingExports.markdown;
+ if (typeof md === 'string') this.doclingEditContent = md;
+ return false;
+ },
+
+ async saveWorkspace() {
+ if (!this.doclingSelected) return;
+ try {
+ await Cockpit.api('/docling/workspace', {
+ method: 'PUT',
+ body: JSON.stringify({
+ storage_path: this.doclingSelected,
+ content: this.doclingEditContent,
+ source_format: 'markdown',
+ }),
+ });
+ if (this.doclingExports) this.doclingExports.markdown = this.doclingEditContent;
+ Cockpit.toast('Concept opgeslagen', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async saveToNas() {
+ if (!this.doclingSelected || !this.doclingEditContent) return;
+ try {
+ const data = await Cockpit.api('/docling/save-nas', {
+ method: 'POST',
+ body: JSON.stringify({
+ path: this.doclingSelected,
+ content: this.doclingEditContent,
+ subdir: 'Telegram/Exports',
+ }),
+ });
+ Cockpit.toast('Opgeslagen op NAS: ' + (data.path || data.filename), 'success');
+ await this.loadAllNasFiles(false);
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ sendToPowerpoint() {
+ this.activeTab = 'powerpoint';
+ this.importMarkdownToSlides(this.doclingEditContent || this.doclingExports.markdown || '');
+ },
+
+ async initPowerpoint() {
+ await this.loadAllNasFiles(false);
+ if (!this.pptSlides.length) {
+ this.pptSlides = [{ title: 'Intro', bullets: [], bulletsText: '' }];
+ }
+ },
+
+ markdownToSlides(md) {
+ const slides = [];
+ let current = { title: '', bullets: [], bulletsText: '' };
+ (md || '').split('\n').forEach((line) => {
+ const t = line.trim();
+ if (t.startsWith('## ')) {
+ if (current.title || current.bullets.length) slides.push({ ...current });
+ current = { title: t.slice(3).trim(), bullets: [], bulletsText: '' };
+ } else if (t.startsWith('# ') && !current.title) {
+ current.title = t.slice(2).trim();
+ } else if (/^[-*]\s/.test(t)) {
+ current.bullets.push(t.replace(/^[-*]\s+/, ''));
+ current.bulletsText = current.bullets.join('\n');
+ } else if (t.length > 20 && !t.startsWith('|')) {
+ current.bullets.push(t);
+ current.bulletsText = current.bullets.join('\n');
+ }
+ });
+ if (current.title || current.bullets.length) slides.push(current);
+ return slides.length ? slides : [{ title: 'Inhoud', bullets: [(md || '').slice(0, 500)], bulletsText: (md || '').slice(0, 500) }];
+ },
+
+ importMarkdownToSlides(md) {
+ const text = md || this.doclingEditContent || this.doclingExports.markdown || '';
+ if (!text) return Cockpit.toast('Geen markdown — eerst Docling convert', 'info');
+ this.pptSlides = this.markdownToSlides(text);
+ const firstLine = (text.split('\n').find((l) => l.startsWith('# ')) || '').replace(/^#\s+/, '');
+ if (firstLine) this.pptTitle = firstLine.slice(0, 200);
+ Cockpit.toast(this.pptSlides.length + ' slides gegenereerd', 'success');
+ },
+
+ addPptSlide() {
+ this.pptSlides.push({ title: 'Nieuwe slide', bullets: [], bulletsText: '' });
+ },
+
+ removePptSlide(idx) {
+ this.pptSlides.splice(idx, 1);
+ },
+
+ syncSlideBullets(slide) {
+ slide.bullets = (slide.bulletsText || '').split('\n').map((s) => s.trim()).filter(Boolean);
+ },
+
+ async createPptx() {
+ if (!this.pptSlides.length) return;
+ this.pptSlides.forEach((s) => this.syncSlideBullets(s));
+ this.pptBusy = true;
+ try {
+ const data = await Cockpit.api('/pptx/create', {
+ method: 'POST',
+ body: JSON.stringify({
+ title: this.pptTitle || 'Presentatie',
+ subtitle: this.pptSubtitle || 'Foodlinkk',
+ filename: this.pptFilename || this.pptTitle,
+ slides: this.pptSlides.map((s) => ({ title: s.title, bullets: s.bullets })),
+ }),
+ });
+ this.pptLastOutput = data.relative_output || data.output || data.path || 'klaar';
+ Cockpit.toast('PowerPoint aangemaakt', 'success');
+ await this.loadAllNasFiles(false);
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.pptBusy = false;
+ }
+ },
+
+ async convertPdfToPptx() {
+ if (!this.pptSourcePdf) return;
+ this.pptBusy = true;
+ Cockpit.toast('PDF → PPTX conversie…', 'info');
+ try {
+ const data = await Cockpit.api('/pptx/pdf-to-pptx?path=' + encodeURIComponent(this.pptSourcePdf) + '&mode=smart');
+ this.pptLastOutput = data.relative_output || data.output || '';
+ Cockpit.toast('PPTX gegenereerd', 'success');
+ await this.loadAllNasFiles(false);
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.pptBusy = false;
+ }
+ },
+
+ async loadDoclingHistory() {
+ try {
+ const data = await Cockpit.api('/docling/history?limit=40');
+ this.doclingHistory = data.items || [];
+ const statusMap = {};
+ (this.doclingHistory || []).forEach((h) => {
+ if (!statusMap[h.storage_path]) statusMap[h.storage_path] = h.status;
+ });
+ this.doclingFileStatus = statusMap;
+ } catch (e) {}
+ },
+
+ async initDocling() {
+ if (!this.doclingFiles.length) await this.loadAllNasFiles(false);
+ else this.nasLoading = false;
+ try {
+ const caps = await Cockpit.api('/docling/capabilities');
+ if (caps.installed) {
+ this.doclingStatus = 'Docling actief · ' + (caps.supported_suffixes || []).length + ' formaten · klik = open + convert';
+ if (caps.export_formats) this.doclingExportFormats = caps.export_formats;
+ if (caps.supported_suffixes) this.doclingSupportedSuffixes = caps.supported_suffixes;
+ } else {
+ this.doclingStatus = 'Docling niet geïnstalleerd';
+ }
+ } catch (e) {
+ this.doclingStatus = 'Docling offline: ' + e.message;
+ }
+ await this.loadDoclingHistory();
+ },
+
+ doclingPayload() {
+ return {
+ path: this.doclingSelected,
+ ocr: this.doclingOpts.ocr,
+ tables: this.doclingOpts.tables,
+ page_images: this.doclingOpts.page_images,
+ picture_images: this.doclingOpts.picture_images,
+ force_full_page_ocr: this.doclingOpts.force_full_page_ocr,
+ formats: this.doclingFormats.length ? this.doclingFormats : ['markdown', 'json'],
+ };
+ },
+
+ applyDoclingResult(data) {
+ const src = data.result || data;
+ this.doclingResult = src;
+ this.doclingExports = src.exports || data.exports || {};
+ this.doclingTables = src.tables_data || data.tables || [];
+ this.doclingPictures = src.pictures || data.pictures || [];
+ this.doclingMeta = {
+ page_count: src.page_count || data.page_count || 0,
+ metadata: src.metadata || data.metadata || {},
+ status: src.status || 'done',
+ };
+ const keys = this.doclingAvailableExports();
+ this.doclingExportTab = keys.includes('markdown') ? 'rendered' : (keys[0] || 'rendered');
+ if (typeof this.doclingExports.markdown === 'string') {
+ this.doclingEditContent = this.doclingExports.markdown;
+ }
+ },
+
+ doclingAvailableExports() {
+ return Object.keys(this.doclingExports || {}).filter((k) => this.doclingExports[k] != null);
+ },
+
+ doclingPreviewText() {
+ let val = this.doclingExports[this.doclingExportTab];
+ if (val == null) return '';
+ if (typeof val === 'string') val = val;
+ else if (typeof val === 'object' && val.error) return 'Error: ' + val.error;
+ else val = JSON.stringify(val, null, 2);
+ const q = (this.doclingPreviewSearch || '').trim().toLowerCase();
+ if (!q || typeof val !== 'string') return val;
+ const lines = val.split('\n');
+ const hits = lines.filter((l) => l.toLowerCase().includes(q));
+ return hits.length ? hits.join('\n') : '(geen matches voor "' + q + '")';
+ },
+
+ doclingPreviewHtml() {
+ const val = this.doclingExports.html;
+ return typeof val === 'string' ? val : '';
+ },
+
+ doclingRenderedMarkdown() {
+ const md = this.doclingExports.markdown;
+ if (typeof md !== 'string') return '';
+ return md
+ .replace(/^### (.*)$/gm, '$1
')
+ .replace(/^## (.*)$/gm, '$1
')
+ .replace(/^# (.*)$/gm, '$1
')
+ .replace(/\*\*(.*?)\*\*/g, '$1')
+ .replace(/\n\n/g, '
')
+ .replace(/^/, '
')
+ .replace(/$/, '
');
+ },
+
+ async copyDoclingExport() {
+ const text = this.doclingExportTab === 'html'
+ ? this.doclingPreviewHtml()
+ : this.doclingPreviewText();
+ try {
+ await navigator.clipboard.writeText(text);
+ Cockpit.toast('Gekopieerd', 'success');
+ } catch (e) {
+ Cockpit.toast('Kopiëren mislukt', 'error');
+ }
+ },
+
+ downloadDoclingExport() {
+ const tab = this.doclingExportTab;
+ let content = tab === 'html' ? this.doclingPreviewHtml() : this.doclingPreviewText();
+ if (!content) return Cockpit.toast('Geen export', 'info');
+ const ext = tab === 'json' ? 'json' : tab === 'html' ? 'html' : tab === 'yaml' ? 'yaml' : 'md';
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = (this.doclingSelected || 'export').split('/').pop().replace(/\.[^.]+$/, '') + '.' + ext;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ },
+
+ async runDoclingConvert(silent) {
+ if (!this.doclingSelected || this.doclingBusy) return;
+ this.doclingBusy = true;
+ if (!silent) Cockpit.toast('Docling converteert…', 'info');
+ try {
+ const data = await Cockpit.api('/docling/convert', {
+ method: 'POST',
+ body: JSON.stringify(this.doclingPayload()),
+ });
+ this.applyDoclingResult(data);
+ await this.loadDoclingHistory();
+ if (!silent) Cockpit.toast('Klaar · ' + (data.page_count || 0) + ' pagina\'s · ' + (this.doclingTables.length || 0) + ' tabellen', 'success');
+ } catch (e) {
+ if (!silent) Cockpit.toast(e.message, 'error');
+ else Cockpit.toast('Convert mislukt: ' + e.message, 'error');
+ } finally {
+ this.doclingBusy = false;
+ }
+ },
+
+ async runDoclingBatch() {
+ if (this.doclingBusy) return;
+ this.doclingBusy = true;
+ try {
+ const data = await Cockpit.api('/docling/batch', {
+ method: 'POST',
+ body: JSON.stringify({
+ limit: 10,
+ ext: this.doclingExtFilter || null,
+ formats: this.doclingFormats,
+ ...this.doclingOpts,
+ }),
+ });
+ await this.loadDoclingHistory();
+ Cockpit.toast((data.converted || 0) + ' / ' + (data.total || 0) + ' geconverteerd', 'success');
+ if (this.doclingSelected && data.results) {
+ const hit = (data.results || []).find((r) => r.storage_path === this.doclingSelected && r.ok);
+ if (hit) this.applyDoclingResult(hit);
+ }
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.doclingBusy = false;
+ }
+ },
+
+ async loadDoclingCached() {
+ if (!this.doclingSelected) return false;
+ try {
+ const data = await Cockpit.api('/docling/result?path=' + encodeURIComponent(this.doclingSelected));
+ if (data.ok && data.result) {
+ this.applyDoclingResult(data.result);
+ return true;
+ }
+ } catch (e) {}
+ return false;
+ },
+
+ openHistoryItem(item) {
+ if (!item || !item.storage_path) return;
+ this.openDoclingFile(item.storage_path);
+ },
+
+ stat360(key) {
+ const stats = this.client360 && this.client360.stats;
+ if (!stats || stats[key] == null) return 0;
+ return stats[key];
+ },
+
+ linkedClientName() {
+ const id = Number(this.linkClientId);
+ if (!id) return '';
+ const c = (this.linkClients || []).find((row) => Number(row.id) === id);
+ return 'Klant: ' + (c && c.name ? c.name : '—');
+ },
+
+ async initDocChat() {
+ await this.loadLlmProviders();
+ const p = this.llmProviders.find((row) => Number(row.id) === Number(this.chatLlmProviderId));
+ this.chatStatus = p ? (p.label + ' · ' + p.model) : 'LLM laden…';
+ await this.loadLinkClients();
+ try {
+ const d = await Cockpit.api('/documents/nas-diagnostics');
+ const vis = d.visible_files || 0;
+ this.chatStatus = vis + ' bestanden zichtbaar · Chroma RAG actief';
+ if (vis < 30) {
+ this.nasDiagHint = d.hint || 'Weinig bestanden zichtbaar op NAS — controleer Synology rechten voor map CUCINA/Foodlinkk.';
+ } else {
+ this.nasDiagHint = '';
+ }
+ } catch (e) {
+ this.chatStatus = 'Diagnostics offline';
+ }
+ },
+
+ async reindexNas() {
+ this.chatReindexing = true;
+ Cockpit.toast('NAS indexeren voor RAG…', 'info');
+ try {
+ await Cockpit.api('/documents/trigger-scan', { method: 'POST' });
+ Cockpit.toast('Index scan gestart — Herman kan zo meer documenten zien', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.chatReindexing = false;
+ }
+ },
+
+ async runChatSearch() {
+ const q = (this.chatSearchQ || '').trim();
+ if (!q) return;
+ try {
+ const data = await Cockpit.api('/documents/search?q=' + encodeURIComponent(q) + '&limit=8');
+ this.chatSearchHits = data.results || [];
+ } catch (e) {
+ Cockpit.toast('Zoeken mislukt', 'error');
+ }
+ },
+
+ async sendDocChat() {
+ const msg = (this.chatInput || '').trim();
+ if (!msg || this.chatBusy) return;
+ this.chatBusy = true;
+ this.chatStatus = 'Bezig met denken… (kan ~1–2 min duren op CPU)';
+ this.chatMessages.push({ role: 'user', content: msg });
+ const savedInput = this.chatInput;
+ this.chatInput = '';
+ this.$nextTick(() => {
+ const log = document.getElementById('doc-chat-log');
+ if (log) log.scrollTop = log.scrollHeight;
+ });
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 110000);
+ try {
+ const history = this.chatMessages.slice(-8, -1).map((m) => ({ role: m.role, content: m.content }));
+ const payload = {
+ message: msg,
+ use_herman: !!this.chatUseHerman,
+ llm_provider_id: this.chatLlmProviderId ? Number(this.chatLlmProviderId) : null,
+ history,
+ path_prefix: this.chatScopeFile && this.doclingSelected ? this.doclingSelected : '',
+ client_id: this.chatScopeClient && this.linkClientId ? Number(this.linkClientId) : null,
+ project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
+ };
+ const data = await Cockpit.api('/documents/chat', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ signal: ctrl.signal,
+ });
+ if (!data.ok && !data.reply) throw new Error(data.detail || data.error || 'Chat mislukt');
+ this.chatMessages.push({
+ role: 'assistant',
+ content: data.reply || '(geen antwoord)',
+ agent: data.agent_label || 'Herman',
+ sources: data.rag_sources || [],
+ });
+ this.chatStatus = (data.rag_sources && data.rag_sources.length)
+ ? data.rag_sources.length + ' bronnen gebruikt'
+ : 'Antwoord ontvangen';
+ } catch (e) {
+ const errMsg = e.name === 'AbortError'
+ ? 'Timeout na 3 min — probeer een kortere vraag of zet Herman orchestrator uit'
+ : (e.message || e);
+ this.chatMessages.push({ role: 'assistant', content: 'Fout: ' + errMsg, agent: 'Systeem', sources: [] });
+ this.chatInput = savedInput;
+ this.chatStatus = 'Fout bij chat';
+ } finally {
+ clearTimeout(timer);
+ this.chatBusy = false;
+ this.$nextTick(() => {
+ const log = document.getElementById('doc-chat-log');
+ if (log) log.scrollTop = log.scrollHeight;
+ });
+ }
+ },
+
+ async loadLinkClients() {
+ try {
+ const data = await Cockpit.api('/clients');
+ this.linkClients = data.items || [];
+ } catch (e) {}
+ },
+
+ linkProjectsForClient() {
+ if (!this.linkClientId) return [];
+ return (this.client360.projects || []).length
+ ? this.client360.projects
+ : this.linkProjects.filter((p) => p.client_id === Number(this.linkClientId));
+ },
+
+ async initAnalytics360() {
+ await this.loadLinkClients();
+ if (this.doclingSelected) this.linkPathInput = this.doclingSelected;
+ if (this.linkClientId) await this.loadClient360();
+ await this.refreshAutoSyncStatus();
+ },
+
+ async loadClient360() {
+ if (!this.linkClientId) return;
+ try {
+ const data = await Cockpit.api('/clients/' + this.linkClientId + '/360');
+ if (!data.ok) throw new Error(data.error || '360 laden mislukt');
+ this.client360 = data;
+ this.linkProjects = data.projects || [];
+ } catch (e) {
+ Cockpit.toast('360: ' + (e.message || e), 'error');
+ }
+ },
+
+ async linkSelectedFile() {
+ const path = (this.linkPathInput || this.doclingSelected || '').trim();
+ if (!path || !this.linkClientId) return;
+ try {
+ await Cockpit.api('/documents/links', {
+ method: 'POST',
+ body: JSON.stringify({
+ storage_path: path,
+ client_id: Number(this.linkClientId),
+ project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
+ is_folder: false,
+ }),
+ });
+ Cockpit.toast('Bestand gekoppeld', 'success');
+ await this.loadClient360();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async linkSelectedFolder() {
+ let path = (this.linkPathInput || this.doclingSelected || '').trim();
+ if (!path || !this.linkClientId) return;
+ if (!path.endsWith('/')) path = path.replace(/\/[^/]+$/, '') || path;
+ try {
+ await Cockpit.api('/documents/links', {
+ method: 'POST',
+ body: JSON.stringify({
+ storage_path: path,
+ client_id: Number(this.linkClientId),
+ project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
+ is_folder: true,
+ }),
+ });
+ Cockpit.toast('Map gekoppeld: ' + path, 'success');
+ await this.loadClient360();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async unlinkDocument(linkId) {
+ try {
+ await Cockpit.api('/documents/links/' + linkId, { method: 'DELETE' });
+ await this.loadClient360();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async runAutoSync(force) {
+ this.autoSyncBusy = true;
+ try {
+ const data = await Cockpit.api('/brain/auto-sync' + (force ? '?force=true' : ''), { method: 'POST' });
+ const brain = (data.steps || {}).brain_sync || {};
+ Cockpit.toast('Sync: ' + (brain.synced || 0) + ' docs → second brain', 'success');
+ await this.refreshAutoSyncStatus();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.autoSyncBusy = false;
+ }
+ },
+
+ async refreshAutoSyncStatus() {
+ try {
+ const data = await Cockpit.api('/brain/auto-sync/status');
+ const last = data.last;
+ if (last && last.created_at) {
+ this.autoSyncLabel = 'Laatste sync: ' + last.created_at.slice(0, 16) + ' (' + last.status + ')';
+ }
+ } catch (e) {}
+ },
+
+ startAutoSyncLoop() {
+ if (this.autoSyncTimer) clearInterval(this.autoSyncTimer);
+ this.autoSyncTimer = setInterval(() => this.runAutoSync(false), 10 * 60 * 1000);
+ },
+ };
+ };
+
+ window.wordSearch = function wordSearch() {
+ return {
+ query: '',
+ includeStopwords: false,
+ results: topWords.slice(0, 20),
+ loading: false,
+ async search() {
+ this.loading = true;
+ try {
+ const params = new URLSearchParams({ limit: '40', stopwords: this.includeStopwords ? 'true' : 'false' });
+ if (this.query.trim()) params.set('q', this.query.trim());
+ const res = await fetch('/api/admin/documents/words?' + params);
+ this.results = (await res.json()).items || [];
+ } catch (e) {
+ Cockpit.toast('Woord zoeken mislukt', 'error');
+ } finally {
+ this.loading = false;
+ }
+ },
+ init() {
+ this.search();
+ },
+ };
+ };
+
+ function registerDocumentsDash() {
+ if (window.Alpine && window.documentsDash) {
+ Alpine.data('documentsDash', window.documentsDash);
+ }
+ }
+ document.addEventListener('alpine:init', registerDocumentsDash);
+ if (window.Alpine) registerDocumentsDash();
+})();
diff --git a/cockpit/app/services/excel_import.py b/cockpit/app/services/excel_import.py
new file mode 100644
index 0000000..e5bbf10
--- /dev/null
+++ b/cockpit/app/services/excel_import.py
@@ -0,0 +1,145 @@
+"""Parse Succes Sheet revenue tab — runs in cockpit with NAS mount."""
+from __future__ import annotations
+
+import os
+import re
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+NAS_SHARE_ROOT = Path(os.getenv("NAS_SHARE_ROOT", "/data/nas-share"))
+DEFAULT_FILE = os.getenv("CEO_EXCEL_PATH", "Succes Sheet .xlsx")
+DEFAULT_SHEET = os.getenv("CEO_EXCEL_SHEET", "Projects next steps revenue")
+
+_FILL_STYLE = {
+ "FF92D050": "green",
+ "FFFF0000": "red",
+ "FFFFC000": "orange",
+ "FFFFFF00": "yellow",
+ "FFBDD7EE": "blue",
+ "FFDDEBF7": "blue",
+ "FF0070C0": "blue",
+}
+
+
+def _num(val: Any) -> float | None:
+ if val is None or val == "":
+ return None
+ try:
+ return float(val)
+ except (TypeError, ValueError):
+ return None
+
+
+def _txt(val: Any) -> str:
+ if val is None:
+ return ""
+ return str(val).strip()
+
+
+def _cell_fill_style(cell) -> str:
+ try:
+ if not cell or not cell.fill or cell.fill.fill_type != "solid":
+ return "white"
+ rgb = cell.fill.fgColor.rgb if cell.fill.fgColor else None
+ if not rgb or rgb in ("00000000", "FFFFFFFF", "00FFFFFF"):
+ return "white"
+ key = rgb[-8:].upper() if len(rgb) >= 8 else rgb.upper()
+ if key in _FILL_STYLE:
+ return _FILL_STYLE[key]
+ short = key[-6:]
+ for k, v in _FILL_STYLE.items():
+ if k.endswith(short):
+ return v
+ return "white"
+ except Exception:
+ return "white"
+
+
+def parse_revenue_sheet(
+ rel_path: str = DEFAULT_FILE,
+ sheet_name: str | None = DEFAULT_SHEET,
+ nas_root: Path | None = None,
+) -> dict[str, Any]:
+ try:
+ from openpyxl import load_workbook
+ except ImportError as exc:
+ return {"ok": False, "error": f"openpyxl not installed: {exc}"}
+
+ root = nas_root or NAS_SHARE_ROOT
+ full = root / rel_path.lstrip("/")
+ if not full.is_file():
+ return {"ok": False, "error": f"File not found: {full}"}
+
+ st = full.stat()
+ wb = load_workbook(full, read_only=False, data_only=True)
+ names = wb.sheetnames
+ target_name = sheet_name
+ if not target_name:
+ for n in names:
+ if "project" in n.lower() and "revenue" in n.lower():
+ target_name = n
+ break
+ if not target_name and len(names) > 2:
+ target_name = names[2]
+ if not target_name:
+ return {"ok": False, "error": "Sheet not found", "sheetnames": names}
+
+ ws = wb[target_name]
+ rows = list(ws.iter_rows(values_only=False))
+
+ goals: dict[str, str] = {"vision_text": "", "horizon_text": "", "mid_text": "", "tagline": ""}
+ if rows:
+ r0 = rows[0]
+ goals["vision_text"] = _txt(r0[0].value if len(r0) > 0 else "")
+ goals["horizon_text"] = _txt(r0[1].value if len(r0) > 1 else "")
+ goals["mid_text"] = _txt(r0[2].value if len(r0) > 2 else "")
+ goals["tagline"] = _txt(r0[4].value if len(r0) > 4 else (_txt(r0[3].value if len(r0) > 3 else "")))
+
+ projects: list[dict[str, Any]] = []
+ sort_order = 0
+ for idx, row in enumerate(rows):
+ if idx <= 1:
+ continue
+ cells = list(row) if row else []
+ name = _txt(cells[1].value if len(cells) > 1 else "")
+ if not name:
+ continue
+ margin_month = _num(cells[2].value if len(cells) > 2 else None)
+ margin_year = _num(cells[3].value if len(cells) > 3 else None)
+ next_steps = _txt(cells[4].value if len(cells) > 4 else "")
+ target_extra = _num(cells[5].value if len(cells) > 5 else None)
+ row_style = _cell_fill_style(cells[1] if len(cells) > 1 else None)
+ category = "deal" if margin_month is not None or margin_year is not None else "initiative"
+ if row_style == "yellow" or re.search(r"foodlinkk|linknbit|subsid|total earnings|loonkosten", name, re.I):
+ category = "strategic"
+ projects.append(
+ {
+ "name": name,
+ "category": category,
+ "margin_month": margin_month,
+ "margin_year": margin_year,
+ "target_revenue": target_extra or margin_year,
+ "next_steps": next_steps,
+ "status": "active",
+ "sort_order": sort_order,
+ "source_row": idx,
+ "row_style": row_style,
+ }
+ )
+ sort_order += 1
+
+ wb.close()
+ return {
+ "ok": True,
+ "source_file": rel_path,
+ "sheet_name": target_name,
+ "sheetnames": names,
+ "file_mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
+ "file_size": st.st_size,
+ "goals": goals,
+ "projects": projects,
+ "parsed_at": datetime.now(timezone.utc).isoformat(),
+ "project_count": len(projects),
+ }
diff --git a/cockpit/app/services/herman.py b/cockpit/app/services/herman.py
index 1e19b8f..a6a8c17 100644
--- a/cockpit/app/services/herman.py
+++ b/cockpit/app/services/herman.py
@@ -6,6 +6,8 @@ from app.config import settings
from app.db import execute, fetch_one
from app.services import ollama
from app.services import packaging_agent
+from app.services import webbuilder_agent
+from app.services import voice_export_actions
AGENTS: dict[str, dict[str, str]] = {
"marketing": {"name": "Marketing", "persona": "Social, brand voice, campaigns for Foodlinkk."},
@@ -49,7 +51,14 @@ def _extract_image_prompt(raw: str) -> str:
return t
-async def _log_event(agent_name: str, event_type: str, title: str, body: str, metadata: dict | None = None) -> None:
+async def _log_event(
+ agent_name: str,
+ event_type: str,
+ title: str,
+ body: str,
+ metadata: dict | None = None,
+ channel: str = "herman",
+) -> None:
payload = {
"agent_name": agent_name,
"agent_type": "herman_delegate",
@@ -58,7 +67,7 @@ async def _log_event(agent_name: str, event_type: str, title: str, body: str, me
"body": body,
"metadata": metadata or {},
"status": "completed",
- "channel": "herman",
+ "channel": channel,
}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
@@ -68,7 +77,7 @@ async def _log_event(agent_name: str, event_type: str, title: str, body: str, me
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
- (agent_name, "herman_delegate", event_type, title[:255], body, "completed", "herman", json.dumps(metadata or {})),
+ (agent_name, "herman_delegate", event_type, title[:255], body, "completed", channel, json.dumps(metadata or {})),
)
except Exception:
pass
@@ -83,7 +92,113 @@ def _pick_agent(raw: str) -> str:
return k
return "knowledge"
-async def chat(message: str) -> dict[str, Any]:
+def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dict] | None = None) -> list[dict[str, str]]:
+ steps: list[dict[str, str]] = []
+ for a in delegated:
+ key = (a or "").strip().lower()
+ if key and key != "herman":
+ steps.append({"agent": key, "status": "delegated", "message": routing_reason or "Aangestuurd door Herman"})
+ if extra:
+ steps.extend(extra)
+ return steps
+
+
+async def chat(
+ message: str,
+ channel: str = "cockpit",
+ session_id: str | None = None,
+ confirm_action_id: str | None = None,
+) -> dict[str, Any]:
+ use_voice_router = (
+ channel in voice_export_actions.VOICE_CHANNELS
+ or bool(session_id)
+ or voice_export_actions.wants_export_search(message)
+ or webbuilder_agent.wants_website(message)
+ )
+ if use_voice_router:
+ routed = await voice_export_actions.handle_voice_command(
+ message,
+ session_id=session_id,
+ confirm_action_id=confirm_action_id,
+ channel=channel,
+ )
+ if routed is not None:
+ await _log_event(
+ "herman",
+ "voice_command" if channel == "voice" else "browser_command",
+ (routed.get("reply") or "")[:120],
+ message[:2000],
+ {
+ "pending_action": routed.get("pending_action"),
+ "ui_actions": routed.get("ui_actions"),
+ "channel": channel,
+ },
+ channel=channel,
+ )
+ return routed
+
+ if webbuilder_agent.wants_website(message):
+ try:
+ outcome = await webbuilder_agent.generate_from_message(message, channel=channel, wait=False)
+ project = outcome.get("project") or "website"
+ preview = outcome.get("preview_url") or ""
+ reply_lines = [
+ f"Web Builder is gestart voor project **{project}**.",
+ f"NAS: {outcome.get('nas_path') or '—'}",
+ f"Preview (na afloop): {preview}",
+ "Volg de live terminal: Agents → Terminals → Web Builder.",
+ ]
+ reply = "\n".join(reply_lines)
+ await _log_event(
+ "herman",
+ "website_delegation",
+ f"Herman → Web Builder: {project}",
+ message[:2000],
+ {
+ "delegated": ["webbuilder"],
+ "delegated_agents": ["webbuilder"],
+ "project": project,
+ "preview_url": preview,
+ "channel": channel,
+ },
+ channel=channel,
+ )
+ delegated = ["webbuilder"]
+ return {
+ "agent": "webbuilder",
+ "agent_label": "Web Builder → Herman",
+ "reply": reply,
+ "delegated_agents": delegated,
+ "routing_reason": "Website-opdracht — Web Builder gestart op Hermes/agy",
+ "agent_steps": _agent_steps(delegated, "Website build gestart"),
+ "webbuilder_project": project,
+ "webbuilder_preview_url": preview,
+ "ui_actions": [
+ {
+ "type": "open_webbuilder_build",
+ "title": f"Website build — {project}",
+ "project": project,
+ "preview_url": preview,
+ "nas_path": outcome.get("nas_path") or "",
+ "agents_url": "/agents",
+ }
+ ],
+ }
+ except Exception as exc:
+ await _log_event(
+ "webbuilder",
+ "website_build_error",
+ "Website start mislukt",
+ str(exc)[:1500],
+ {"message": message[:500]},
+ )
+ return {
+ "agent": "webbuilder",
+ "agent_label": "Web Builder",
+ "reply": f"Web Builder kon niet starten: {exc}",
+ "delegated_agents": ["webbuilder"],
+ }
+
if packaging_agent.wants_packaging(message):
try:
outcome = await packaging_agent.generate_from_message(message)
@@ -112,7 +227,10 @@ async def chat(message: str) -> dict[str, Any]:
"pdf_url": outcome.get("pdf_url"),
"nas": outcome.get("nas"),
"for_herman": True,
+ "delegated": ["packaging"],
+ "channel": channel,
},
+ channel=channel,
)
await _log_event(
"herman",
@@ -123,14 +241,19 @@ async def chat(message: str) -> dict[str, Any]:
"source_agent": "packaging",
"packaging_id": outcome.get("packaging_id"),
"project_id": outcome.get("cockpit_project_id"),
+ "delegated": ["packaging"],
+ "channel": channel,
},
+ channel=channel,
)
+ delegated = ["packaging"]
return {
"agent": "packaging",
"agent_label": "Packaging → Herman",
"reply": reply,
- "delegated_agents": ["packaging", "herman"],
+ "delegated_agents": delegated,
"routing_reason": "Packaging-opdracht gedetecteerd — design gegenereerd en aan Herman gerapporteerd",
+ "agent_steps": _agent_steps(delegated, "Packaging design gegenereerd"),
"packaging_id": outcome.get("packaging_id"),
"packaging_studio_url": outcome.get("studio_url"),
"packaging_pdf_url": outcome.get("pdf_url"),
@@ -165,13 +288,16 @@ async def chat(message: str) -> dict[str, Any]:
img_type = data.get("type", "output")
proxy = f"/api/ai/generated-image?filename={filename}&subfolder={subfolder}&type={img_type}"
reply = f"Afbeelding gegenereerd voor: {prompt}"
- await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename})
+ await _log_event("design", "image_generated", "ComfyUI via Herman", prompt[:500], {"filename": filename, "channel": channel}, channel=channel)
+ delegated = ["design"]
return {
"agent": "design",
"agent_label": "Design",
"reply": reply,
"image_url": proxy,
"prompt": prompt,
+ "delegated_agents": delegated,
+ "agent_steps": _agent_steps(delegated, "Afbeelding gegenereerd"),
}
except Exception as exc:
return {
@@ -184,24 +310,29 @@ async def chat(message: str) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post(
f"{settings.HERMAN_ORCHESTRATOR_URL.rstrip('/')}/chat",
- json={"message": message, "agent": "default", "use_crm": True, "channel": "cockpit"},
+ json={"message": message, "agent": "default", "use_crm": True, "use_rag": True, "channel": channel},
)
r.raise_for_status()
data = r.json()
delegated = data.get("delegated_agents") or [data.get("agent", "herman")]
+ reason = data.get("routing_reason", "") or ""
await _log_event(
"herman",
- "openswarm_delegation",
+ "voice_delegation" if channel == "voice" else ("telegram_delegation" if channel == "telegram" else "openswarm_delegation"),
f"Herman → {', '.join(delegated)}",
message[:2000],
- {"delegated": delegated, "reason": data.get("routing_reason", "")},
+ {"delegated": delegated, "delegated_agents": delegated, "reason": reason, "channel": channel},
+ channel=channel,
)
return {
"agent": data.get("agent", "herman"),
"agent_label": data.get("agent_label", "Herman"),
"reply": data.get("reply", ""),
"delegated_agents": delegated,
- "routing_reason": data.get("routing_reason", ""),
+ "routing_reason": reason,
+ "agent_steps": _agent_steps(delegated, reason),
+ "rag_sources": data.get("rag_sources") or [],
+ "crm_loaded": data.get("crm_loaded", False),
}
except Exception as exc:
return {
diff --git a/cockpit/app/services/llm_router.py b/cockpit/app/services/llm_router.py
new file mode 100644
index 0000000..e41aa59
--- /dev/null
+++ b/cockpit/app/services/llm_router.py
@@ -0,0 +1,277 @@
+"""Unified LLM router — Ollama, DeepSeek, Gemini, Groq, OpenRouter, custom OpenAI-compatible."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import httpx
+
+from app.config import settings
+from app.db import execute, fetch_all, fetch_one
+from app.services import ollama
+
+# Preset catalog for Settings UI (signup links + default models)
+LLM_PRESETS: dict[str, dict[str, Any]] = {
+ "ollama": {
+ "label": "Ollama (lokaal)",
+ "api_base_url": "",
+ "models": ["qwen3:8b", "gemma3:12b", "llama3.2", "mistral"],
+ "needs_key": False,
+ "hint": "Geen API key — draait op je Ollama server.",
+ },
+ "deepseek": {
+ "label": "DeepSeek",
+ "api_base_url": "https://api.deepseek.com/v1",
+ "models": ["deepseek-chat", "deepseek-reasoner"],
+ "needs_key": True,
+ "signup_url": "https://platform.deepseek.com/",
+ "hint": "Goedkoop · sterk voor code en analyse.",
+ },
+ "gemini": {
+ "label": "Google Gemini",
+ "api_base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
+ "models": ["gemini-2.0-flash", "gemini-1.5-flash", "gemini-1.5-pro"],
+ "needs_key": True,
+ "signup_url": "https://aistudio.google.com/apikey",
+ "hint": "Gratis tier via Google AI Studio.",
+ },
+ "groq": {
+ "label": "Groq (snel · gratis tier)",
+ "api_base_url": "https://api.groq.com/openai/v1",
+ "models": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"],
+ "needs_key": True,
+ "signup_url": "https://console.groq.com/",
+ "hint": "Zeer snelle inference · gratis limiet.",
+ },
+ "openrouter": {
+ "label": "OpenRouter",
+ "api_base_url": "https://openrouter.ai/api/v1",
+ "models": [
+ "google/gemini-2.0-flash-exp:free",
+ "deepseek/deepseek-r1:free",
+ "meta-llama/llama-3.3-70b-instruct:free",
+ ],
+ "needs_key": True,
+ "signup_url": "https://openrouter.ai/",
+ "hint": "Veel gratis modellen via één API.",
+ },
+ "mistral": {
+ "label": "Mistral AI",
+ "api_base_url": "https://api.mistral.ai/v1",
+ "models": ["mistral-small-latest", "open-mistral-nemo"],
+ "needs_key": True,
+ "signup_url": "https://console.mistral.ai/",
+ "hint": "EU-hosted · gratis proef tier.",
+ },
+ "custom_openai": {
+ "label": "Custom OpenAI-compatible",
+ "api_base_url": "",
+ "models": [],
+ "needs_key": True,
+ "hint": "Elke API die /v1/chat/completions ondersteunt.",
+ },
+}
+
+
+def list_presets() -> list[dict[str, Any]]:
+ out = []
+ for key, meta in LLM_PRESETS.items():
+ row = dict(meta)
+ row["id"] = key
+ out.append(row)
+ return out
+
+
+def _mask_provider(row: dict[str, Any] | None) -> dict[str, Any] | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ if isinstance(out.get("extra_config"), str):
+ try:
+ out["extra_config"] = json.loads(out["extra_config"])
+ except Exception:
+ out["extra_config"] = {}
+ out["api_key_set"] = bool(row.get("api_key"))
+ out.pop("api_key", None)
+ preset = LLM_PRESETS.get(out.get("provider_type") or "", {})
+ out["preset_label"] = preset.get("label", out.get("provider_type"))
+ out["needs_key"] = preset.get("needs_key", True)
+ return out
+
+
+def list_providers() -> list[dict[str, Any]]:
+ rows = fetch_all("SELECT * FROM llm_providers ORDER BY is_default DESC, is_active DESC, id ASC")
+ return [_mask_provider(r) for r in rows if r]
+
+
+def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
+ if provider_id:
+ return fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
+ row = fetch_one(
+ "SELECT * FROM llm_providers WHERE is_default = TRUE ORDER BY id LIMIT 1"
+ )
+ if row:
+ return row
+ row = fetch_one(
+ "SELECT * FROM llm_providers WHERE is_active = TRUE ORDER BY id LIMIT 1"
+ )
+ if row:
+ return row
+ return fetch_one("SELECT * FROM llm_providers ORDER BY id LIMIT 1")
+
+
+def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
+ row = get_provider(provider_id)
+ if not row:
+ return {
+ "id": 0,
+ "label": "Ollama lokaal",
+ "provider_type": "ollama",
+ "api_base_url": settings.OLLAMA_URL,
+ "api_key": "",
+ "model": settings.OLLAMA_MODEL,
+ "extra_config": {},
+ }
+ return row
+
+
+async def chat_messages(
+ messages: list[dict[str, str]],
+ *,
+ provider_id: int | None = None,
+ model: str | None = None,
+ timeout: float = 120.0,
+) -> tuple[str, dict[str, Any]]:
+ """Returns (reply_text, meta dict with provider info)."""
+ prov = resolve_provider(provider_id)
+ ptype = (prov.get("provider_type") or "ollama").lower()
+ use_model = model or prov.get("model") or settings.OLLAMA_MODEL
+ ollama_timeout = min(timeout, 85.0) if ptype == "ollama" else timeout
+
+ if ptype == "ollama":
+ try:
+ reply = await ollama.chat_messages(messages, timeout=ollama_timeout, model=use_model)
+ except Exception as exc:
+ raise RuntimeError(
+ f"Ollama timeout/ offline ({exc}). "
+ "Voeg DeepSeek of Gemini toe via Instellingen → AI / LLM voor snelle cloud-chat."
+ ) from exc
+ return reply, {
+ "provider_id": prov.get("id"),
+ "provider_type": "ollama",
+ "provider_label": prov.get("label") or "Ollama",
+ "model": use_model,
+ }
+
+ api_key = (prov.get("api_key") or "").strip()
+ if not api_key:
+ raise RuntimeError(
+ f"Geen API key voor {prov.get('label') or ptype} — voeg key toe in Instellingen → AI / LLM"
+ )
+
+ base = (prov.get("api_base_url") or "").strip().rstrip("/")
+ if not base:
+ preset = LLM_PRESETS.get(ptype, {})
+ base = (preset.get("api_base_url") or "").rstrip("/")
+ if not base:
+ raise RuntimeError(f"Geen API URL voor provider {prov.get('label')}")
+
+ extra = prov.get("extra_config") or {}
+ if isinstance(extra, str):
+ try:
+ extra = json.loads(extra)
+ except Exception:
+ extra = {}
+
+ url = f"{base}/chat/completions"
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+ if ptype == "openrouter":
+ headers["HTTP-Referer"] = extra.get("referer", "https://foodlinkk.local")
+ headers["X-Title"] = extra.get("title", "Foodlinkk Command Center")
+
+ payload: dict[str, Any] = {
+ "model": use_model,
+ "messages": messages,
+ "temperature": float(extra.get("temperature", 0.4)),
+ "max_tokens": int(extra.get("max_tokens", 2048)),
+ }
+
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.post(url, headers=headers, json=payload)
+ if resp.status_code >= 400:
+ detail = resp.text[:500]
+ try:
+ detail = resp.json().get("error", {}).get("message", detail)
+ except Exception:
+ pass
+ raise RuntimeError(f"{prov.get('label')}: {detail}")
+ data = resp.json()
+ choices = data.get("choices") or []
+ if not choices:
+ raise RuntimeError(f"{prov.get('label')}: leeg antwoord")
+ content = (choices[0].get("message") or {}).get("content") or ""
+ return content.strip(), {
+ "provider_id": prov.get("id"),
+ "provider_type": ptype,
+ "provider_label": prov.get("label"),
+ "model": use_model,
+ }
+
+
+async def generate(
+ prompt: str,
+ system: str | None = None,
+ *,
+ provider_id: int | None = None,
+ model: str | None = None,
+ timeout: float = 120.0,
+) -> str:
+ messages: list[dict[str, str]] = []
+ if system:
+ messages.append({"role": "system", "content": system})
+ messages.append({"role": "user", "content": prompt})
+ reply, _meta = await chat_messages(
+ messages, provider_id=provider_id, model=model, timeout=timeout
+ )
+ return reply
+
+
+async def test_provider(provider_id: int) -> tuple[bool, str]:
+ prov = fetch_one("SELECT * FROM llm_providers WHERE id = %s", (provider_id,))
+ if not prov:
+ return False, "Provider niet gevonden"
+ try:
+ reply, meta = await chat_messages(
+ [{"role": "user", "content": "Antwoord met exact één woord: OK"}],
+ provider_id=provider_id,
+ timeout=60.0,
+ )
+ msg = f"{meta.get('provider_label')} · {meta.get('model')} — {reply[:80]}"
+ execute(
+ """UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
+ last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
+ ("ok", msg, provider_id),
+ )
+ return True, msg
+ except Exception as exc:
+ execute(
+ """UPDATE llm_providers SET last_test_status = %s, last_test_message = %s,
+ last_test_at = NOW(), updated_at = NOW() WHERE id = %s""",
+ ("error", str(exc)[:500], provider_id),
+ )
+ return False, str(exc)
+
+
+def set_default(provider_id: int) -> None:
+ execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
+ execute(
+ "UPDATE llm_providers SET is_default = TRUE, is_active = TRUE, updated_at = NOW() WHERE id = %s",
+ (provider_id,),
+ )
diff --git a/cockpit/app/services/monitor.py b/cockpit/app/services/monitor.py
index 6d5ec09..c8fea6d 100644
--- a/cockpit/app/services/monitor.py
+++ b/cockpit/app/services/monitor.py
@@ -1,14 +1,15 @@
from __future__ import annotations
import hashlib
+import json
import re
-import subprocess
-from urllib.parse import urlparse
+from typing import Any
+from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
-from app.db import execute, fetch_one, get_connection
+from app.db import execute, fetch_all, fetch_one, get_connection
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0"
@@ -45,6 +46,59 @@ def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
return None
+def _extract_parse_info(
+ html: str,
+ text: str,
+ title: str,
+ final_url: str,
+ base_url: str,
+) -> dict[str, Any]:
+ soup = BeautifulSoup(html or "", "html.parser")
+ headings: list[str] = []
+ for tag in soup.find_all(["h1", "h2", "h3"])[:12]:
+ t = re.sub(r"\s+", " ", (tag.get_text() or "").strip())
+ if t:
+ headings.append(t[:140])
+
+ links_sample: list[dict[str, str]] = []
+ seen_hrefs: set[str] = set()
+ for a in soup.find_all("a", href=True):
+ href = (a.get("href") or "").strip()
+ if not href or href.startswith("#") or href.lower().startswith("javascript:"):
+ continue
+ if not href.startswith("http"):
+ href = urljoin(base_url or final_url, href)
+ if href in seen_hrefs:
+ continue
+ seen_hrefs.add(href)
+ label = re.sub(r"\s+", " ", (a.get_text() or "").strip())[:90]
+ links_sample.append({"href": href, "label": label or href})
+ if len(links_sample) >= 10:
+ break
+
+ words = len(text.split()) if text else 0
+ excerpt = ""
+ if text:
+ excerpt = text[:320] + ("…" if len(text) > 320 else "")
+
+ meta_desc = ""
+ md = soup.find("meta", attrs={"name": "description"})
+ if md and md.get("content"):
+ meta_desc = str(md["content"]).strip()[:240]
+
+ return {
+ "title": title or "(geen titel)",
+ "final_url": final_url,
+ "word_count": words,
+ "char_count": len(text or ""),
+ "excerpt": excerpt,
+ "meta_description": meta_desc,
+ "headings": headings,
+ "links_count": len(seen_hrefs) if seen_hrefs else len(soup.find_all("a", href=True)),
+ "links_sample": links_sample,
+ }
+
+
def get_page_hash(url: str) -> str | None:
fetched = _fetch_page(url)
if not fetched:
@@ -53,20 +107,31 @@ def get_page_hash(url: str) -> str | None:
return hashlib.md5(text.encode("utf-8")).hexdigest()
-def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str, html: str) -> int | None:
- import json
+def _save_snapshot(
+ site_id: int,
+ url: str,
+ final_url: str,
+ title: str,
+ text: str,
+ html: str,
+ parse_info: dict[str, Any] | None = None,
+) -> int | None:
+ parse_info = parse_info or _extract_parse_info(html, text, title, final_url, url)
+ metadata = {"source": "monitor", "parse": parse_info}
+ links_json = json.dumps(parse_info.get("links_sample") or [])
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
- INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, crawled_at)
- VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, NOW())
+ INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, links, crawled_at)
+ VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW())
ON CONFLICT (url) DO UPDATE SET
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
- site_id=EXCLUDED.site_id, crawled_at=NOW()
+ site_id=EXCLUDED.site_id, metadata=EXCLUDED.metadata,
+ links=EXCLUDED.links, crawled_at=NOW()
RETURNING id
""",
(
@@ -76,7 +141,8 @@ def _save_snapshot(site_id: int, url: str, final_url: str, title: str, text: str
text[:50000],
html[:100000],
site_id,
- json.dumps({"source": "monitor"}),
+ json.dumps(metadata),
+ links_json,
),
)
page_id = cur.fetchone()[0]
@@ -125,7 +191,8 @@ def add_site(url: str, name: str) -> dict:
)
site_id = cur.fetchone()[0]
if fetched:
- _save_snapshot(site_id, url, final_url, title, text, html)
+ parse_info = _extract_parse_info(html, text, title, final_url, url)
+ _save_snapshot(site_id, url, final_url, title, text, html, parse_info)
row = fetch_one(
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
(site_id,),
@@ -142,61 +209,461 @@ def remove_site(site_id: int, soft: bool = True) -> None:
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
-def trigger_crawl(site_id: int | None = None) -> dict:
+def _crawl_one_site(site: dict[str, Any]) -> dict[str, Any]:
+ site_id = int(site["id"])
+ url = site["url"]
+ name = site.get("name") or url
+ result: dict[str, Any] = {
+ "site_id": site_id,
+ "url": url,
+ "name": name,
+ "status": "ERROR",
+ "changed": False,
+ "error": None,
+ }
+
+ fetched = _fetch_page(url)
+ if not fetched:
+ msg = f"Kan {url} niet bereiken"
+ execute(
+ "INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
+ (site_id, "ERROR", msg),
+ )
+ result["error"] = msg
+ return result
+
+ final_url, title, text, html = fetched
+ parse_info = _extract_parse_info(html, text, title, final_url, url)
+ new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
+ old_hash = site.get("last_hash")
+ changed = bool(old_hash and old_hash != new_hash)
+
+ if changed:
+ execute(
+ "INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
+ (site_id, old_hash, new_hash),
+ )
+ execute(
+ "INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
+ (site_id, "CHANGE", f"Wijziging op {url} — {title}"),
+ )
+ result["status"] = "CHANGE"
+ else:
+ execute(
+ "INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
+ (site_id, "OK", f"Crawl OK — {title} ({parse_info['word_count']} woorden)"),
+ )
+ result["status"] = "OK"
+
+ execute(
+ """
+ UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
+ """,
+ (new_hash, title, site_id),
+ )
+ snapshot_id = _save_snapshot(site_id, url, final_url, title, text, html, parse_info)
+
+ result.update(parse_info)
+ result["changed"] = changed
+ result["snapshot_id"] = snapshot_id
+ return result
+
+
+def trigger_crawl(site_id: int | None = None) -> dict[str, Any]:
+ if site_id:
+ row = fetch_one(
+ "SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
+ (site_id,),
+ )
+ sites = [dict(row)] if row else []
+ else:
+ sites = [
+ dict(r)
+ for r in fetch_all(
+ "SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE ORDER BY id"
+ )
+ ]
+
+ if not sites:
+ return {
+ "ok": True,
+ "method": "inline",
+ "sites": 0,
+ "changed": 0,
+ "errors": 0,
+ "results": [],
+ "message": "Geen actieve monitor-sites — voeg eerst een URL toe.",
+ }
+
+ results: list[dict[str, Any]] = []
+ changed = 0
+ errors = 0
+ for site in sites:
+ row = _crawl_one_site(site)
+ results.append(row)
+ if row.get("status") == "ERROR":
+ errors += 1
+ if row.get("changed"):
+ changed += 1
+
+ return {
+ "ok": errors < len(sites),
+ "method": "inline",
+ "sites": len(sites),
+ "changed": changed,
+ "errors": errors,
+ "results": results,
+ }
+
+
+def list_parse_results(site_id: int | None = None, limit: int = 20) -> list[dict[str, Any]]:
+ limit = max(1, min(limit, 50))
+ if site_id:
+ rows = fetch_all(
+ """
+ SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
+ cp.crawled_at, cp.site_id, ms.name AS site_name
+ FROM crawled_pages cp
+ LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
+ WHERE cp.site_id = %s
+ ORDER BY cp.crawled_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (site_id, limit),
+ )
+ else:
+ rows = fetch_all(
+ """
+ SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
+ cp.crawled_at, cp.site_id, ms.name AS site_name
+ FROM crawled_pages cp
+ LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
+ ORDER BY cp.crawled_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (limit,),
+ )
+
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ item = dict(row)
+ meta = item.get("metadata") or {}
+ if isinstance(meta, str):
+ try:
+ meta = json.loads(meta)
+ except Exception:
+ meta = {}
+ parse = (meta or {}).get("parse") or {}
+ content = item.get("content") or ""
+ if not parse.get("excerpt") and content:
+ parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
+ if not parse.get("word_count") and content:
+ parse["word_count"] = len(str(content).split())
+ links = item.get("links") or []
+ if isinstance(links, str):
+ try:
+ links = json.loads(links)
+ except Exception:
+ links = []
+ if not parse.get("links_sample") and links:
+ parse["links_sample"] = links
+ crawled = item.get("crawled_at")
+ if crawled is not None and hasattr(crawled, "isoformat"):
+ item["crawled_at"] = crawled.isoformat()
+ out.append(
+ {
+ "id": item.get("id"),
+ "site_id": item.get("site_id"),
+ "site_name": item.get("site_name"),
+ "url": item.get("url"),
+ "final_url": item.get("final_url"),
+ "title": item.get("title") or parse.get("title"),
+ "crawled_at": item.get("crawled_at"),
+ "word_count": parse.get("word_count", 0),
+ "excerpt": parse.get("excerpt", ""),
+ "meta_description": parse.get("meta_description", ""),
+ "headings": parse.get("headings") or [],
+ "links_count": parse.get("links_count", len(links)),
+ "links_sample": parse.get("links_sample") or links[:10],
+ }
+ )
+ return out
+
+
+NL_STOPWORDS = frozenset(
+ """
+ de het een en van in op te dat die dit voor met als zij ze er maar om ook al naar dan wel
+ kan zo nog uit over bij tot door na ons uw u uw je jij mij hem haar hun was zijn worden wordt
+ heb hebt heeft hebben had deed doen done the and or is are was were be been being a an to of in
+ for on at by from with about into through during before after above below between under again
+ further then once here there when where why how all each few more most other some such no nor
+ not only own same so than too very just don should now naar website home pagina menu contact
+ service cookie cookies privacy login inloggen registreren meer lees read click klik
+ """.split()
+)
+
+
+def _tokens(text: str, min_len: int = 4) -> list[str]:
+ if not text:
+ return []
+ raw = re.findall(r"[a-zA-Zà-üÀ-Ü0-9][a-zA-Zà-üÀ-Ü0-9\-]{2,}", text.lower())
+ return [t for t in raw if len(t) >= min_len and t not in NL_STOPWORDS and not t.isdigit()]
+
+
+def _normalize_page_row(row: dict[str, Any], *, content_limit: int = 8000) -> dict[str, Any]:
+ item = dict(row)
+ meta = item.get("metadata") or {}
+ if isinstance(meta, str):
+ try:
+ meta = json.loads(meta)
+ except Exception:
+ meta = {}
+ parse = (meta or {}).get("parse") or {}
+ content = str(item.get("content") or "")
+ if not parse.get("excerpt") and content:
+ parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
+ if not parse.get("word_count") and content:
+ parse["word_count"] = len(content.split())
+ links = item.get("links") or []
+ if isinstance(links, str):
+ try:
+ links = json.loads(links)
+ except Exception:
+ links = []
+ if not parse.get("links_sample") and links:
+ parse["links_sample"] = links
+ crawled = item.get("crawled_at")
+ if crawled is not None and hasattr(crawled, "isoformat"):
+ crawled = crawled.isoformat()
+ content_read = content[:content_limit]
+ if len(content) > content_limit:
+ content_read += "\n\n[… tekst ingekort — open volledige pagina voor alles …]"
+ return {
+ "id": item.get("id"),
+ "site_id": item.get("site_id"),
+ "site_name": item.get("site_name"),
+ "url": item.get("url"),
+ "final_url": item.get("final_url"),
+ "title": item.get("title") or parse.get("title"),
+ "crawled_at": crawled,
+ "word_count": parse.get("word_count", 0),
+ "char_count": parse.get("char_count", len(content)),
+ "excerpt": parse.get("excerpt", ""),
+ "meta_description": parse.get("meta_description", ""),
+ "headings": parse.get("headings") or [],
+ "links_count": parse.get("links_count", len(links)),
+ "links_sample": parse.get("links_sample") or links[:15],
+ "content_read": content_read,
+ "content_length": len(content),
+ "has_full_content": len(content) > 0,
+ }
+
+
+def get_parse_page(page_id: int) -> dict[str, Any] | None:
+ row = fetch_one(
+ """
+ SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
+ cp.crawled_at, cp.site_id, ms.name AS site_name
+ FROM crawled_pages cp
+ LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
+ WHERE cp.id = %s
+ """,
+ (page_id,),
+ )
+ if not row:
+ return None
+ page = _normalize_page_row(dict(row), content_limit=50000)
+ page["content_full"] = str(dict(row).get("content") or "")
+ return page
+
+
+def build_parse_intelligence(
+ site_id: int | None = None,
+ query: str | None = None,
+ limit: int = 30,
+) -> dict[str, Any]:
+ """Aggregate parsed pages for analysis — hype terms, trends, readable content."""
+ limit = max(1, min(limit, 100))
+ if site_id:
+ rows = fetch_all(
+ """
+ SELECT DISTINCT ON (cp.site_id)
+ cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
+ cp.crawled_at, cp.site_id, ms.name AS site_name
+ FROM crawled_pages cp
+ LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
+ WHERE cp.site_id = %s
+ ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
+ """,
+ (site_id,),
+ )
+ else:
+ rows = fetch_all(
+ """
+ SELECT DISTINCT ON (cp.site_id)
+ cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
+ cp.crawled_at, cp.site_id, ms.name AS site_name
+ FROM crawled_pages cp
+ LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
+ WHERE cp.site_id IS NOT NULL
+ ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
+ LIMIT %s
+ """,
+ (limit,),
+ )
+
+ pages = [_normalize_page_row(dict(r)) for r in rows]
+ q = (query or "").strip().lower()
+ if q:
+ pages = [
+ p
+ for p in pages
+ if q in (p.get("title") or "").lower()
+ or q in (p.get("content_read") or "").lower()
+ or q in (p.get("excerpt") or "").lower()
+ or any(q in h.lower() for h in p.get("headings") or [])
+ ]
+
+ changed_site_ids: set[int] = set()
+ recent_changes: list[dict[str, Any]] = []
try:
- cmd = ["docker", "exec", "foodlinkk_worker", "python", "-c", "import trigger"]
- subprocess.run(cmd, capture_output=True, timeout=120, check=False)
- return {"ok": True, "method": "worker"}
+ change_rows = fetch_all(
+ """
+ SELECT pc.site_id, pc.changed_at, ms.name, ms.url
+ FROM page_changes pc
+ JOIN monitored_sites ms ON ms.id = pc.site_id
+ WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
+ ORDER BY pc.changed_at DESC
+ LIMIT 30
+ """
+ )
+ for cr in change_rows:
+ sid = int(cr["site_id"])
+ changed_site_ids.add(sid)
+ ts = cr.get("changed_at")
+ if ts is not None and hasattr(ts, "isoformat"):
+ ts = ts.isoformat()
+ recent_changes.append(
+ {
+ "site_id": sid,
+ "site_name": cr.get("name"),
+ "url": cr.get("url"),
+ "changed_at": ts,
+ }
+ )
except Exception:
pass
- from app.db import fetch_all
+ term_scores: dict[str, dict[str, Any]] = {}
+ heading_counts: dict[str, dict[str, Any]] = {}
- if site_id:
- row = fetch_one(
- "SELECT id, url, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
- (site_id,),
- )
- sites = [row] if row else []
- else:
- sites = fetch_all(
- "SELECT id, url, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE"
- )
+ def bump_term(term: str, site_name: str, weight: int = 1) -> None:
+ if len(term) < 3:
+ return
+ bucket = term_scores.setdefault(term, {"term": term, "score": 0, "sites": set()})
+ bucket["score"] += weight
+ if site_name:
+ bucket["sites"].add(site_name)
- changed = 0
- with get_connection() as conn:
- with conn.cursor() as cur:
- for site in sites:
- fetched = _fetch_page(site["url"])
- if not fetched:
- cur.execute(
- "INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
- (site["id"], "ERROR", f"Cannot reach {site['url']}"),
- )
- continue
- final_url, title, text, html = fetched
- new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
- old_hash = site.get("last_hash")
- if old_hash and old_hash != new_hash:
- cur.execute(
- "INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
- (site["id"], old_hash, new_hash),
- )
- cur.execute(
- "INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
- (site["id"], "CHANGE", f"Change detected on {site['url']} — {title}"),
- )
- changed += 1
- else:
- cur.execute(
- "INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
- (site["id"], "OK", f"Crawl OK — {title}"),
- )
- cur.execute(
- """
- UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
- """,
- (new_hash, title, site["id"]),
- )
- _save_snapshot(site["id"], site["url"], final_url, title, text, html)
- return {"ok": True, "method": "inline", "changes": changed, "sites": len(sites)}
+ for page in pages:
+ site_name = page.get("site_name") or str(page.get("site_id") or "")
+ for tok in _tokens(page.get("title") or "", min_len=3):
+ bump_term(tok, site_name, 3)
+ for h in page.get("headings") or []:
+ hnorm = re.sub(r"\s+", " ", h.strip())[:80]
+ if len(hnorm) < 3:
+ continue
+ hc = heading_counts.setdefault(hnorm.lower(), {"label": hnorm, "count": 0, "sites": set()})
+ hc["count"] += 1
+ hc["sites"].add(site_name)
+ for tok in _tokens(h, min_len=3):
+ bump_term(tok, site_name, 4)
+ for tok in _tokens(page.get("content_read") or ""):
+ bump_term(tok, site_name, 1)
+ for link in page.get("links_sample") or []:
+ for tok in _tokens(link.get("label") or "", min_len=3):
+ bump_term(tok, site_name, 2)
+
+ hype_terms: list[dict[str, Any]] = []
+ for term, data in term_scores.items():
+ if data["score"] < 4:
+ continue
+ sites_list = sorted(data["sites"])
+ hype_terms.append(
+ {
+ "term": term,
+ "score": data["score"],
+ "site_count": len(sites_list),
+ "sites": sites_list[:5],
+ "cross_site": len(sites_list) >= 2,
+ }
+ )
+ hype_terms.sort(key=lambda x: (-x["score"], -x["site_count"], x["term"]))
+ hype_terms = hype_terms[:40]
+
+ heading_trends = []
+ for _key, data in heading_counts.items():
+ if data["count"] < 1:
+ continue
+ heading_trends.append(
+ {
+ "label": data["label"],
+ "count": data["count"],
+ "sites": sorted(data["sites"])[:6],
+ "cross_site": len(data["sites"]) >= 2,
+ }
+ )
+ heading_trends.sort(key=lambda x: (-x["count"], -len(x["sites"]), x["label"]))
+ heading_trends = heading_trends[:25]
+
+ top_term_set = {t["term"] for t in hype_terms[:15]}
+ food_signals = frozenset(
+ "halal vegan plantaardig biologisch bio trend nieuw actie aanbieding kip rund vlees vis "
+ "groente fruit snack curry kebab burger protein eiwit alternatief duurzaam premium "
+ "supermarkt retail assortiment prijs private label merk".split()
+ )
+
+ for page in pages:
+ signals: list[str] = []
+ wc = int(page.get("word_count") or 0)
+ sid = page.get("site_id")
+ if wc >= 1500:
+ signals.append("Rijke pagina — veel te analyseren")
+ elif wc >= 400:
+ signals.append("Normale pagina-dichtheid")
+ if sid in changed_site_ids:
+ signals.append("Recent gewijzigd — mogelijke hype/shift")
+
+ page_terms = set(_tokens((page.get("content_read") or "") + " " + " ".join(page.get("headings") or [])))
+ matched_hype = [t for t in top_term_set if t in page_terms]
+ food_hits = [t for t in page_terms if t in food_signals]
+ for t in matched_hype[:4]:
+ signals.append(f"Trend-term: {t}")
+ for t in food_hits[:3]:
+ if f"Trend-term: {t}" not in signals:
+ signals.append(f"Food-signaal: {t}")
+
+ if page.get("meta_description"):
+ signals.append("SEO meta beschikbaar")
+
+ page["signals"] = signals[:8]
+ page["hype_score"] = len(matched_hype) * 10 + len(food_hits) * 5 + (20 if sid in changed_site_ids else 0) + min(wc // 200, 15)
+ page["recently_changed"] = sid in changed_site_ids
+
+ pages.sort(key=lambda p: (-(p.get("hype_score") or 0), -(p.get("word_count") or 0)))
+
+ total_words = sum(int(p.get("word_count") or 0) for p in pages)
+ return {
+ "summary": {
+ "pages": len(pages),
+ "total_words": total_words,
+ "themes_detected": len(hype_terms),
+ "headings_unique": len(heading_trends),
+ "changes_7d": len(recent_changes),
+ "query": q or None,
+ },
+ "hype_terms": hype_terms,
+ "heading_trends": heading_trends,
+ "recent_changes": recent_changes[:12],
+ "pages": pages,
+ }
diff --git a/cockpit/app/services/ollama.py b/cockpit/app/services/ollama.py
index e5bef96..526f873 100644
--- a/cockpit/app/services/ollama.py
+++ b/cockpit/app/services/ollama.py
@@ -13,10 +13,10 @@ async def generate(prompt: str, system: str | None = None, timeout: float = 300.
return await chat_messages(messages, timeout=timeout)
-async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0) -> str:
+async def chat_messages(messages: list[dict[str, str]], timeout: float = 300.0, model: str | None = None) -> str:
url = f"{settings.OLLAMA_URL.rstrip('/')}/api/chat"
payload = {
- "model": settings.OLLAMA_MODEL,
+ "model": model or settings.OLLAMA_MODEL,
"messages": messages,
"think": False,
"stream": False,
diff --git a/cockpit/app/services/requirements.txt b/cockpit/app/services/requirements.txt
new file mode 100644
index 0000000..075b369
--- /dev/null
+++ b/cockpit/app/services/requirements.txt
@@ -0,0 +1,11 @@
+fastapi==0.115.6
+uvicorn[standard]==0.32.1
+jinja2==3.1.4
+python-multipart==0.0.12
+psycopg2-binary==2.9.9
+httpx==0.27.2
+websockets==13.1
+beautifulsoup4==4.12.3
+textblob==0.18.0.post0
+openpyxl>=3.1.0
+tweepy==4.15.0
diff --git a/cockpit/app/services/revenue_cockpit.py b/cockpit/app/services/revenue_cockpit.py
new file mode 100644
index 0000000..2be7bf9
--- /dev/null
+++ b/cockpit/app/services/revenue_cockpit.py
@@ -0,0 +1,424 @@
+"""Revenue Cockpit — DB operations, import, tracking, agent tasks."""
+from __future__ import annotations
+
+import json
+from datetime import date, datetime, timezone
+from typing import Any, Optional
+
+from app.db import execute, fetch_all, fetch_one
+from app.services import agent_integration
+from app.services.excel_import import DEFAULT_FILE, DEFAULT_SHEET, parse_revenue_sheet
+
+
+def fetch_excel_parse(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
+ result = parse_revenue_sheet(path, sheet)
+ if not result.get("ok"):
+ raise RuntimeError(result.get("error") or "Excel parse failed")
+ return result
+
+
+async def fetch_excel_parse_async(path: str = DEFAULT_FILE, sheet: str | None = DEFAULT_SHEET) -> dict[str, Any]:
+ return fetch_excel_parse(path, sheet)
+
+
+def _ser(row: dict[str, Any] | None) -> dict[str, Any] | None:
+ if not row:
+ return None
+ out = dict(row)
+ for k, v in list(out.items()):
+ if hasattr(v, "isoformat"):
+ out[k] = v.isoformat()
+ return out
+
+
+def _log_change(entity_type: str, entity_id: int, field: str, old: Any, new: Any, by: str = "ceo") -> None:
+ execute(
+ """
+ INSERT INTO revenue_change_log (entity_type, entity_id, field_name, old_value, new_value, changed_by)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ """,
+ (entity_type, entity_id, field, str(old) if old is not None else None, str(new) if new is not None else None, by),
+ )
+
+
+def import_from_parsed(parsed: dict[str, Any], imported_by: str = "ceo", replace: bool = True) -> dict[str, Any]:
+ if replace:
+ execute("DELETE FROM revenue_objectives WHERE project_id IN (SELECT id FROM revenue_projects)")
+ execute("DELETE FROM revenue_projects")
+ execute("UPDATE revenue_cockpit_goals SET is_active = FALSE WHERE is_active = TRUE")
+
+ goals = parsed.get("goals") or {}
+ g = fetch_one(
+ """
+ INSERT INTO revenue_cockpit_goals (vision_text, horizon_text, mid_text, tagline, is_active)
+ VALUES (%s, %s, %s, %s, TRUE)
+ RETURNING *
+ """,
+ (
+ goals.get("vision_text", ""),
+ goals.get("horizon_text", ""),
+ goals.get("mid_text", ""),
+ goals.get("tagline", ""),
+ ),
+ )
+
+ count = 0
+ for p in parsed.get("projects") or []:
+ row = fetch_one(
+ """
+ INSERT INTO revenue_projects
+ (name, category, margin_month, margin_year, target_revenue, next_steps, status, sort_order, metadata)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
+ RETURNING id
+ """,
+ (
+ p["name"],
+ p.get("category", "deal"),
+ p.get("margin_month"),
+ p.get("margin_year"),
+ p.get("target_revenue"),
+ p.get("next_steps"),
+ p.get("status", "active"),
+ p.get("sort_order", count),
+ json.dumps({
+ "source_row": p.get("source_row"),
+ "row_style": p.get("row_style", "white"),
+ "imported": True,
+ }),
+ ),
+ )
+ pid = row["id"] if row else None
+ if pid and p.get("next_steps"):
+ for i, line in enumerate([ln.strip() for ln in p["next_steps"].split("\n") if ln.strip()][:5]):
+ execute(
+ """
+ INSERT INTO revenue_objectives (project_id, title, description, status, sort_order)
+ VALUES (%s, %s, %s, 'open', %s)
+ """,
+ (pid, line[:255], p["next_steps"] if i == 0 else None, i),
+ )
+ count += 1
+
+ run = fetch_one(
+ """
+ INSERT INTO revenue_import_runs (source_file, sheet_name, rows_imported, goals_imported, imported_by, metadata)
+ VALUES (%s, %s, %s, TRUE, %s, %s::jsonb)
+ RETURNING *
+ """,
+ (
+ parsed.get("source_file", ""),
+ parsed.get("sheet_name", ""),
+ count,
+ imported_by,
+ json.dumps({"parsed_at": parsed.get("parsed_at"), "file_mtime": parsed.get("file_mtime")}),
+ ),
+ )
+ take_snapshot()
+ return {"ok": True, "projects_imported": count, "goals": _ser(g), "import_run": _ser(run)}
+
+
+def get_active_goals() -> dict[str, Any] | None:
+ return _ser(fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1"))
+
+
+def update_goals(data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
+ old = fetch_one("SELECT * FROM revenue_cockpit_goals WHERE is_active = TRUE ORDER BY id DESC LIMIT 1")
+ if not old:
+ return None
+ fields = ("vision_text", "horizon_text", "mid_text", "tagline")
+ sets, params = [], []
+ for k in fields:
+ if k in data:
+ sets.append(f"{k} = %s")
+ params.append(data[k])
+ if str(old.get(k)) != str(data[k]):
+ _log_change("goals", old["id"], k, old.get(k), data[k], changed_by)
+ if not sets:
+ return get_active_goals()
+ params.append(old["id"])
+ execute(
+ f"UPDATE revenue_cockpit_goals SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s",
+ tuple(params),
+ )
+ return get_active_goals()
+
+
+def list_projects(status: str | None = None) -> list[dict[str, Any]]:
+ clauses, params = [], []
+ if status:
+ clauses.append("status = %s")
+ params.append(status)
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
+ rows = fetch_all(
+ f"""
+ SELECT p.*,
+ (SELECT COUNT(*) FROM revenue_objectives o WHERE o.project_id = p.id AND o.status = 'open') AS open_objectives,
+ (SELECT COUNT(*) FROM agent_tasks t WHERE t.revenue_project_id = p.id AND t.status NOT IN ('completed','cancelled')) AS open_tasks
+ FROM revenue_projects p
+ {where}
+ ORDER BY p.sort_order ASC, p.name ASC
+ """,
+ tuple(params) if params else None,
+ )
+ return [_ser({**r, "row_style": _infer_row_style(r.get("name"), r.get("margin_month"), r.get("metadata"))}) for r in rows]
+
+
+def get_project(project_id: int) -> dict[str, Any] | None:
+ p = _ser(fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,)))
+ if not p:
+ return None
+ p["objectives"] = [_ser(r) for r in fetch_all(
+ "SELECT * FROM revenue_objectives WHERE project_id = %s ORDER BY sort_order, id",
+ (project_id,),
+ )]
+ p["tasks"] = [_ser(r) for r in fetch_all(
+ """
+ SELECT * FROM agent_tasks WHERE revenue_project_id = %s
+ ORDER BY created_at DESC LIMIT 50
+ """,
+ (project_id,),
+ )]
+ p["history"] = [_ser(r) for r in fetch_all(
+ """
+ SELECT * FROM revenue_change_log
+ WHERE (entity_type = 'project' AND entity_id = %s)
+ OR (entity_type = 'objective' AND entity_id IN (
+ SELECT id FROM revenue_objectives WHERE project_id = %s))
+ ORDER BY created_at DESC LIMIT 30
+ """,
+ (project_id, project_id),
+ )]
+ p["row_style"] = _infer_row_style(p.get("name"), p.get("margin_month"), p.get("metadata"))
+ return p
+
+
+def set_project_row_style(project_id: int, row_style: str) -> None:
+ old = fetch_one("SELECT metadata FROM revenue_projects WHERE id = %s", (project_id,))
+ meta = dict(old.get("metadata") or {}) if old else {}
+ meta["row_style"] = row_style
+ execute(
+ "UPDATE revenue_projects SET metadata = %s::jsonb, updated_at = NOW() WHERE id = %s",
+ (json.dumps(meta), project_id),
+ )
+
+
+def update_project(project_id: int, data: dict[str, Any], changed_by: str = "ceo") -> dict[str, Any] | None:
+ old = fetch_one("SELECT * FROM revenue_projects WHERE id = %s", (project_id,))
+ if not old:
+ return None
+ fields = {
+ "name": data.get("name"),
+ "category": data.get("category"),
+ "margin_month": data.get("margin_month"),
+ "margin_year": data.get("margin_year"),
+ "target_revenue": data.get("target_revenue"),
+ "next_steps": data.get("next_steps"),
+ "status": data.get("status"),
+ "priority": data.get("priority"),
+ }
+ sets, params = [], []
+ for k, v in fields.items():
+ if k in data:
+ sets.append(f"{k} = %s")
+ params.append(v)
+ if str(old.get(k)) != str(v):
+ _log_change("project", project_id, k, old.get(k), v, changed_by)
+ if not sets:
+ return get_project(project_id)
+ params.append(project_id)
+ execute(f"UPDATE revenue_projects SET {', '.join(sets)}, updated_at = NOW() WHERE id = %s", tuple(params))
+ return get_project(project_id)
+
+
+def _infer_row_style(name: str, margin_month: Any, metadata: Any) -> str:
+ if isinstance(metadata, dict) and metadata.get("row_style"):
+ return str(metadata["row_style"])
+ n = (name or "").lower()
+ if "foodlinkk food marketing" in n or "total earnings" in n or "loonkosten aissa" in n:
+ return "yellow"
+ if "foodservice" in n or n.strip() == "dirk":
+ return "orange"
+ if margin_month is not None and margin_month > 0:
+ return "green"
+ if any(x in n for x in ("plus", "vomar", "hoogvliet", "deka", "spar", "doner palace", "zakat")):
+ return "red"
+ return "white"
+
+
+def create_objective(project_id: int, title: str, description: str | None = None, priority: str = "normal") -> dict[str, Any]:
+ row = fetch_one(
+ """
+ INSERT INTO revenue_objectives (project_id, title, description, priority)
+ VALUES (%s, %s, %s, %s)
+ RETURNING *
+ """,
+ (project_id, title.strip(), description, priority),
+ )
+ _log_change("objective", row["id"], "created", None, title, "ceo")
+ return _ser(row) or {}
+
+
+def update_objective(objective_id: int, data: dict[str, Any]) -> dict[str, Any] | None:
+ old = fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,))
+ if not old:
+ return None
+ for k in ("title", "description", "status", "priority", "due_date"):
+ if k in data:
+ execute(
+ f"UPDATE revenue_objectives SET {k} = %s, updated_at = NOW() WHERE id = %s",
+ (data[k], objective_id),
+ )
+ _log_change("objective", objective_id, k, old.get(k), data[k])
+ return _ser(fetch_one("SELECT * FROM revenue_objectives WHERE id = %s", (objective_id,)))
+
+
+def dashboard_stats() -> dict[str, Any]:
+ goals = get_active_goals()
+ totals = fetch_one(
+ """
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'active') AS active_projects,
+ COALESCE(SUM(margin_year) FILTER (WHERE status = 'active'), 0) AS total_margin_year,
+ COALESCE(SUM(margin_month) FILTER (WHERE status = 'active'), 0) AS total_margin_month,
+ COUNT(*) FILTER (WHERE category = 'deal' AND status = 'active') AS active_deals
+ FROM revenue_projects
+ """
+ ) or {}
+ open_obj = fetch_one("SELECT COUNT(*) AS n FROM revenue_objectives WHERE status = 'open'") or {"n": 0}
+ open_tasks = fetch_one(
+ "SELECT COUNT(*) AS n FROM agent_tasks WHERE status NOT IN ('completed','cancelled') AND revenue_project_id IS NOT NULL"
+ ) or {"n": 0}
+ prev = fetch_one(
+ "SELECT * FROM revenue_snapshots WHERE snapshot_date < CURRENT_DATE ORDER BY snapshot_date DESC LIMIT 1"
+ )
+ return {
+ "goals": goals,
+ "active_projects": int(totals.get("active_projects") or 0),
+ "active_deals": int(totals.get("active_deals") or 0),
+ "total_margin_year": float(totals.get("total_margin_year") or 0),
+ "total_margin_month": float(totals.get("total_margin_month") or 0),
+ "open_objectives": int(open_obj.get("n") or 0),
+ "open_agent_tasks": int(open_tasks.get("n") or 0),
+ "previous_snapshot": _ser(prev),
+ }
+
+
+def take_snapshot() -> dict[str, Any]:
+ stats = dashboard_stats()
+ row = fetch_one(
+ """
+ INSERT INTO revenue_snapshots
+ (snapshot_date, total_margin_year, total_margin_month, active_projects, open_objectives, open_agent_tasks, payload)
+ VALUES (CURRENT_DATE, %s, %s, %s, %s, %s, %s::jsonb)
+ ON CONFLICT (snapshot_date) DO UPDATE SET
+ total_margin_year = EXCLUDED.total_margin_year,
+ total_margin_month = EXCLUDED.total_margin_month,
+ active_projects = EXCLUDED.active_projects,
+ open_objectives = EXCLUDED.open_objectives,
+ open_agent_tasks = EXCLUDED.open_agent_tasks,
+ payload = EXCLUDED.payload,
+ created_at = NOW()
+ RETURNING *
+ """,
+ (
+ stats["total_margin_year"],
+ stats["total_margin_month"],
+ stats["active_projects"],
+ stats["open_objectives"],
+ stats["open_agent_tasks"],
+ json.dumps({"goals_id": (stats.get("goals") or {}).get("id")}),
+ ),
+ )
+ return _ser(row) or {}
+
+
+def list_snapshots(limit: int = 90) -> list[dict[str, Any]]:
+ rows = fetch_all(
+ "SELECT * FROM revenue_snapshots ORDER BY snapshot_date DESC LIMIT %s",
+ (max(1, min(limit, 365)),),
+ )
+ return [_ser(r) for r in rows]
+
+
+def assign_agent_task(
+ project_id: int,
+ agent_name: str,
+ title: str,
+ description: str | None = None,
+ objective_id: int | None = None,
+ priority: str = "normal",
+ delegate_herman: bool = True,
+) -> dict[str, Any]:
+ project = fetch_one("SELECT name FROM revenue_projects WHERE id = %s", (project_id,))
+ if not project:
+ raise ValueError("Project not found")
+ agent = agent_name.strip().lower()
+ desc = description or ""
+ row = fetch_one(
+ """
+ INSERT INTO agent_tasks
+ (agent_name, title, description, status, priority, assigned_by, revenue_project_id, revenue_objective_id, source)
+ VALUES (%s, %s, %s, 'pending', %s, 'ceo', %s, %s, 'revenue_cockpit')
+ RETURNING *
+ """,
+ (agent, title.strip(), desc, priority, project_id, objective_id),
+ )
+ task = _ser(row) or {}
+ body = f"Revenue Cockpit · {project['name']}: {title}"
+ if desc:
+ body += f"\n{desc}"
+ try:
+ execute(
+ """
+ INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata, related_table, related_id)
+ VALUES (%s, 'revenue_cockpit', 'task_assigned', %s, %s, 'pending', 'revenue_cockpit', %s::jsonb, 'agent_tasks', %s)
+ """,
+ (
+ agent,
+ title.strip(),
+ body,
+ json.dumps({"project_id": project_id, "objective_id": objective_id, "task_id": task.get("id")}),
+ task.get("id"),
+ ),
+ )
+ except Exception:
+ pass
+ if delegate_herman and agent != "herman":
+ try:
+ agent_integration.create_handoff(
+ "ceo",
+ agent,
+ handoff_type="task",
+ payload={"task_id": task.get("id"), "project_id": project_id, "title": title},
+ status="pending",
+ )
+ except Exception:
+ pass
+ _log_change("project", project_id, "agent_task", None, title, "ceo")
+ return task
+
+
+def list_agent_tasks(limit: int = 50) -> list[dict[str, Any]]:
+ rows = fetch_all(
+ """
+ SELECT t.*, p.name AS project_name
+ FROM agent_tasks t
+ LEFT JOIN revenue_projects p ON p.id = t.revenue_project_id
+ WHERE t.revenue_project_id IS NOT NULL
+ ORDER BY t.created_at DESC
+ LIMIT %s
+ """,
+ (max(1, min(limit, 200)),),
+ )
+ return [_ser(r) for r in rows]
+
+
+async def delegate_via_herman(project_id: int, message: str) -> dict[str, Any]:
+ from app.services import herman as herman_service
+
+ project = get_project(project_id)
+ if not project:
+ raise ValueError("Project not found")
+ prompt = f"[Revenue Cockpit · {project['name']}] {message}"
+ result = await herman_service.chat(prompt)
+ return {"ok": True, "result": result, "project_id": project_id}
diff --git a/cockpit/app/services/voice_export_actions.py b/cockpit/app/services/voice_export_actions.py
new file mode 100644
index 0000000..f2fe608
--- /dev/null
+++ b/cockpit/app/services/voice_export_actions.py
@@ -0,0 +1,594 @@
+"""Voice/browser/Telegram command router: export intel, webbuilder/agy, bevestiging + UI."""
+from __future__ import annotations
+
+import re
+import time
+import uuid
+from typing import Any
+
+import httpx
+
+from app.config import settings
+from app.services import webbuilder_agent
+
+SESSION_TTL_SEC = 3600
+_pending: dict[str, dict[str, Any]] = {}
+
+VOICE_CHANNELS = frozenset({"voice", "browser", "telegram"})
+
+LOCATION_ALIASES: dict[str, dict[str, str]] = {
+ "dubai": {"country": "AE", "q": "Dubai", "label": "Dubai (VAE)"},
+ "abu dhabi": {"country": "AE", "q": "Abu Dhabi", "label": "Abu Dhabi (VAE)"},
+ "sharjah": {"country": "AE", "q": "Sharjah", "label": "Sharjah (VAE)"},
+ "vae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
+ "uae": {"country": "AE", "q": "", "label": "Verenigde Arabische Emiraten"},
+ "emiraten": {"country": "AE", "q": "", "label": "VAE"},
+ "saudi": {"country": "SA", "q": "", "label": "Saoedi-Arabië"},
+ "riyadh": {"country": "SA", "q": "Riyadh", "label": "Riyadh (SA)"},
+ "jeddah": {"country": "SA", "q": "Jeddah", "label": "Jeddah (SA)"},
+ "qatar": {"country": "QA", "q": "", "label": "Qatar"},
+ "doha": {"country": "QA", "q": "Doha", "label": "Doha (Qatar)"},
+ "kuwait": {"country": "KW", "q": "", "label": "Koeweit"},
+ "nederland": {"country": "NL", "q": "", "label": "Nederland"},
+ "amsterdam": {"country": "NL", "q": "Amsterdam", "label": "Amsterdam"},
+ "rotterdam": {"country": "NL", "q": "Rotterdam", "label": "Rotterdam"},
+ "belgië": {"country": "BE", "q": "", "label": "België"},
+ "belgie": {"country": "BE", "q": "", "label": "België"},
+ "antwerpen": {"country": "BE", "q": "Antwerp", "label": "Antwerpen"},
+ "duitsland": {"country": "DE", "q": "", "label": "Duitsland"},
+ "berlijn": {"country": "DE", "q": "Berlin", "label": "Berlijn"},
+ "frankfurt": {"country": "DE", "q": "Frankfurt", "label": "Frankfurt"},
+ "turkije": {"country": "TR", "q": "", "label": "Turkije"},
+ "istanbul": {"country": "TR", "q": "Istanbul", "label": "Istanbul"},
+ "marokko": {"country": "MA", "q": "", "label": "Marokko"},
+ "casablanca": {"country": "MA", "q": "Casablanca", "label": "Casablanca"},
+}
+
+ENTITY_ALIASES: dict[str, str] = {
+ "distri": "distributor,wholesaler,importer,logistics",
+ "distributeur": "distributor,wholesaler,importer,logistics",
+ "distributeurs": "distributor,wholesaler,importer,logistics",
+ "groothandel": "wholesaler,distributor",
+ "groothandels": "wholesaler,distributor",
+ "importeur": "importer",
+ "importeurs": "importer",
+ "logistiek": "logistics",
+ "restaurant": "restaurant",
+ "restaurants": "restaurant",
+ "cateraar": "caterer",
+ "cateraars": "caterer",
+ "slager": "butcher",
+ "slagers": "butcher",
+ "döner": "doner",
+ "doner": "doner",
+}
+
+SEARCH_TRIGGERS = (
+ "zoek", "opzoeken", "vind", "zoeken", "toon", "laat zien", "lijst",
+ "geef me", "haal op", "export intel", "wereldexport",
+)
+
+YES_RE = re.compile(
+ r"^(ja|jawel|jep|yep|ok|oke|oké|okay|klopt|bevestig|doe maar|graag|precies|goed|akkoord|start|uitvoeren|doorgaan)\b",
+ re.I,
+)
+NO_RE = re.compile(r"^(nee|neen|stop|annuleer|niet|cancel|laat maar|wacht)\b", re.I)
+
+
+def _cleanup_sessions() -> None:
+ now = time.time()
+ dead = [k for k, v in _pending.items() if now - float(v.get("_ts", 0)) > SESSION_TTL_SEC]
+ for k in dead:
+ _pending.pop(k, None)
+
+
+def is_confirmation_yes(text: str) -> bool:
+ t = (text or "").strip()
+ return bool(t and YES_RE.search(t))
+
+
+def is_confirmation_no(text: str) -> bool:
+ t = (text or "").strip()
+ return bool(t and NO_RE.search(t))
+
+
+def wants_export_search(text: str) -> bool:
+ t = (text or "").lower()
+ if not any(k in t for k in SEARCH_TRIGGERS):
+ return False
+ has_loc = any(alias in t for alias in LOCATION_ALIASES)
+ has_ent = any(alias in t for alias in ENTITY_ALIASES)
+ return has_loc or has_ent
+
+
+def _detect_location(text: str) -> dict[str, str] | None:
+ t = text.lower()
+ best: tuple[int, dict[str, str]] | None = None
+ for alias, loc in LOCATION_ALIASES.items():
+ if alias in t:
+ score = len(alias)
+ if best is None or score > best[0]:
+ best = (score, loc)
+ return best[1] if best else None
+
+
+def _detect_entity_types(text: str) -> str:
+ t = text.lower()
+ found: list[str] = []
+ for alias, types in ENTITY_ALIASES.items():
+ if alias in t:
+ for et in types.split(","):
+ if et not in found:
+ found.append(et)
+ if not found and any(w in t for w in ("distri", "distributeur", "groothandel", "b2b", "leverancier")):
+ return "distributor,wholesaler,importer,logistics"
+ return ",".join(found) if found else "distributor,wholesaler,importer,logistics"
+
+
+def _entity_label(entity_types: str) -> str:
+ labels = {
+ "distributor": "distributeurs",
+ "wholesaler": "groothandels",
+ "importer": "importeurs",
+ "logistics": "logistiek",
+ "restaurant": "restaurants",
+ "caterer": "cateraars",
+ "butcher": "slagers",
+ "doner": "dönerzaken",
+ }
+ parts = [labels.get(x.strip(), x.strip()) for x in entity_types.split(",") if x.strip()]
+ return ", ".join(parts) if parts else "bedrijven"
+
+
+def parse_export_search(text: str) -> dict[str, Any]:
+ loc = _detect_location(text)
+ entity_types = _detect_entity_types(text)
+ missing: list[str] = []
+ if not loc:
+ missing.append("locatie")
+ params: dict[str, Any] = {
+ "country": (loc or {}).get("country", ""),
+ "q": (loc or {}).get("q", ""),
+ "entity_types": entity_types,
+ "limit": 50,
+ }
+ label_loc = (loc or {}).get("label", "")
+ return {
+ "params": params,
+ "location_label": label_loc,
+ "entity_label": _entity_label(entity_types),
+ "missing": missing,
+ "incomplete": bool(missing),
+ }
+
+
+def store_pending(session_id: str, action: dict[str, Any]) -> str:
+ _cleanup_sessions()
+ action_id = str(uuid.uuid4())[:12]
+ action = dict(action)
+ action["id"] = action_id
+ action["_ts"] = time.time()
+ _pending[session_id] = action
+ return action_id
+
+
+def get_pending(session_id: str) -> dict[str, Any] | None:
+ _cleanup_sessions()
+ row = _pending.get(session_id)
+ if not row:
+ return None
+ if time.time() - float(row.get("_ts", 0)) > SESSION_TTL_SEC:
+ _pending.pop(session_id, None)
+ return None
+ return row
+
+
+def clear_pending(session_id: str) -> None:
+ _pending.pop(session_id, None)
+
+
+def build_confirmation_question(parsed: dict[str, Any]) -> str:
+ loc = parsed.get("location_label") or "de geselecteerde regio"
+ ent = parsed.get("entity_label") or "bedrijven"
+ return (
+ f"Ik ga **{ent}** in **{loc}** voor je opzoeken in Export Intel.\n\n"
+ "Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren, "
+ "of geef aan wat ik moet aanpassen (bijv. alleen distributeurs, of een andere stad)."
+ )
+
+
+def build_clarification_question(parsed: dict[str, Any]) -> str:
+ missing = parsed.get("missing") or []
+ if "locatie" in missing:
+ return (
+ "In welke **stad of welk land** wil je zoeken? "
+ "Bijvoorbeeld: Dubai, VAE, Nederland, Frankfurt…"
+ )
+ return "Kun je iets specifieker zijn over wat je zoekt en waar?"
+
+
+async def fetch_export_entities(params: dict[str, Any]) -> dict[str, Any]:
+ query = {k: v for k, v in params.items() if v not in (None, "")}
+ url = f"{settings.TOOLS_API_URL.rstrip('/')}/export-intel/entities"
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ resp = await client.get(url, params=query)
+ resp.raise_for_status()
+ return resp.json()
+
+
+def _open_url(params: dict[str, Any]) -> str:
+ qs = []
+ if params.get("country"):
+ qs.append(f"country={params['country']}")
+ if params.get("q"):
+ qs.append(f"q={params['q']}")
+ qs.append("tab=distributors")
+ return "/export-intel?" + "&".join(qs)
+
+
+async def execute_search_action(action: dict[str, Any]) -> dict[str, Any]:
+ params = action.get("params") or {}
+ data = await fetch_export_entities(params)
+ items = data.get("items") or []
+ total = int(data.get("total") or len(items))
+ loc = action.get("location_label") or params.get("q") or params.get("country") or "markt"
+ ent = action.get("entity_label") or "bedrijven"
+ title = f"{ent.title()} — {loc}"
+ if not items:
+ reply = (
+ f"Ik heb gezocht maar vond **geen** {ent} in {loc}. "
+ "Wil je dat ik een sync start of een bredere regio probeer?"
+ )
+ else:
+ reply = (
+ f"Gevonden: **{total}** {ent} in {loc}. "
+ f"Ik toon de eerste {min(len(items), 50)} in het resultatenvenster."
+ )
+ return {
+ "agent": "sourcing",
+ "agent_label": "Export Intel → Herman",
+ "reply": reply,
+ "delegated_agents": ["sourcing", "export_intel"],
+ "routing_reason": f"Export Intel zoekopdracht: {title}",
+ "agent_steps": [
+ {"agent": "export_intel", "status": "done", "message": f"{total} resultaten"},
+ {"agent": "sourcing", "status": "delegated", "message": "Marktdata opgehaald"},
+ ],
+ "needs_confirmation": False,
+ "ui_actions": [
+ {
+ "type": "show_export_results",
+ "title": title,
+ "entities": items[:50],
+ "total": total,
+ "params": params,
+ "open_url": _open_url(params),
+ }
+ ],
+ }
+
+
+def _pending_summary(action: dict[str, Any]) -> dict[str, Any]:
+ atype = action.get("type", "")
+ base = {"id": action.get("id"), "type": atype}
+ if atype == "export_intel_search":
+ base["location_label"] = action.get("location_label")
+ base["entity_label"] = action.get("entity_label")
+ base["params"] = action.get("params")
+ elif atype == "webbuilder_build":
+ base["project"] = action.get("project")
+ base["entity_label"] = f"website {action.get('project', '')}"
+ base["location_label"] = "Agy · Antigravity"
+ return base
+
+
+def build_webbuilder_confirmation(project: str, raw: str) -> str:
+ snippet = (raw or "")[:240]
+ return (
+ f"Ik stuur **Agy (Antigravity)** op Hermes aan om een website te bouwen.\n\n"
+ f"**Project:** {project}\n"
+ f"**Opdracht:** {snippet}{'…' if len(raw or '') > 240 else ''}\n\n"
+ "Klopt dat? Zeg **ja** om te starten, **nee** om te annuleren."
+ )
+
+
+async def execute_webbuilder_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
+ raw = action.get("raw_message") or ""
+ project = action.get("project") or webbuilder_agent.extract_project_name(raw)
+ try:
+ outcome = await webbuilder_agent.generate_from_message(raw, channel=channel, wait=False)
+ except Exception as exc:
+ return {
+ "agent": "webbuilder",
+ "agent_label": "Web Builder",
+ "reply": f"Agy kon niet starten op Hermes: {exc}\n\nControleer VM107 webbuilder-api (:8798).",
+ "delegated_agents": ["webbuilder"],
+ "needs_confirmation": False,
+ }
+
+ project = outcome.get("project") or project
+ preview = outcome.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE
+ nas_path = outcome.get("nas_path") or ""
+ reply = (
+ f"Agy is gestart voor **{project}**.\n"
+ f"Preview (na build): {preview}\n"
+ f"NAS: {nas_path}\n"
+ "Volg live: Agents → Terminals → Web Builder."
+ )
+ return {
+ "agent": "webbuilder",
+ "agent_label": "Agy / Web Builder → Herman",
+ "reply": reply,
+ "delegated_agents": ["webbuilder"],
+ "routing_reason": "Website via Antigravity CLI (agy) op Hermes VM107",
+ "agent_steps": [
+ {"agent": "webbuilder", "status": "running", "message": f"agy bouwt {project}"},
+ {"agent": "herman", "status": "delegated", "message": "Voice → build gestart"},
+ ],
+ "webbuilder_project": project,
+ "webbuilder_preview_url": preview,
+ "needs_confirmation": False,
+ "ui_actions": [
+ {
+ "type": "open_webbuilder_build",
+ "title": f"Website build — {project}",
+ "project": project,
+ "preview_url": preview,
+ "nas_path": nas_path,
+ "agents_url": "/agents",
+ "status_hint": "Build duurt enkele minuten — preview opent na afloop",
+ }
+ ],
+ }
+
+
+async def execute_pending_action(action: dict[str, Any], channel: str = "voice") -> dict[str, Any]:
+ atype = action.get("type")
+ if atype == "webbuilder_build":
+ return await execute_webbuilder_action(action, channel=channel)
+ return await execute_search_action(action)
+
+
+def _confirmation_reminder(pending: dict[str, Any]) -> str:
+ if pending.get("type") == "webbuilder_build":
+ return build_webbuilder_confirmation(pending.get("project", "website"), pending.get("raw_message", ""))
+ return build_confirmation_question(pending)
+
+
+async def handle_voice_command(
+ message: str,
+ session_id: str | None = None,
+ confirm_action_id: str | None = None,
+ channel: str = "voice",
+) -> dict[str, Any] | None:
+ """Unified voice/browser router: bevestiging + export + webbuilder/agy."""
+ sid = (session_id or "").strip() or "default"
+ text = (message or "").strip()
+ if not text:
+ return None
+
+ pending = get_pending(sid)
+
+ if confirm_action_id and pending and pending.get("id") == confirm_action_id:
+ clear_pending(sid)
+ return await execute_pending_action(pending, channel=channel)
+
+ if pending:
+ if is_confirmation_yes(text):
+ clear_pending(sid)
+ return await execute_pending_action(pending, channel=channel)
+ if is_confirmation_no(text):
+ clear_pending(sid)
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
+ "needs_confirmation": False,
+ }
+ if pending.get("awaiting") == "location" and pending.get("type") == "export_intel_search":
+ loc = _detect_location(text)
+ if loc:
+ merged = dict(pending)
+ merged["params"] = dict(pending.get("params") or {})
+ merged["params"]["country"] = loc["country"]
+ merged["params"]["q"] = loc.get("q", "")
+ merged["location_label"] = loc["label"]
+ merged.pop("awaiting", None)
+ merged.pop("incomplete", None)
+ merged.pop("missing", None)
+ action_id = store_pending(sid, merged)
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_confirmation_question(merged),
+ "needs_confirmation": True,
+ "pending_action": _pending_summary(merged),
+ }
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_clarification_question({"missing": ["locatie"]}),
+ "needs_confirmation": True,
+ "clarification": True,
+ }
+ if webbuilder_agent.wants_website(text):
+ project = webbuilder_agent.extract_project_name(text)
+ action_id = store_pending(sid, {
+ "type": "webbuilder_build",
+ "project": project,
+ "raw_message": text,
+ })
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_webbuilder_confirmation(project, text),
+ "needs_confirmation": True,
+ "pending_action": _pending_summary(get_pending(sid) or {}),
+ }
+ if wants_export_search(text):
+ parsed = parse_export_search(text)
+ if not parsed.get("incomplete"):
+ action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_confirmation_question(parsed),
+ "needs_confirmation": True,
+ "pending_action": _pending_summary(get_pending(sid) or {}),
+ }
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": (
+ f"Ik wacht nog op bevestiging.\n\n{_confirmation_reminder(pending)}\n\n"
+ "Zeg **ja** of **nee**."
+ ),
+ "needs_confirmation": True,
+ "pending_action": _pending_summary(pending),
+ }
+
+ if webbuilder_agent.wants_website(text):
+ project = webbuilder_agent.extract_project_name(text)
+ store_pending(sid, {"type": "webbuilder_build", "project": project, "raw_message": text})
+ pa = get_pending(sid) or {}
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_webbuilder_confirmation(project, text),
+ "needs_confirmation": True,
+ "pending_action": _pending_summary(pa),
+ }
+
+ return await handle_export_intent(message, session_id=session_id, confirm_action_id=confirm_action_id)
+
+
+async def handle_export_intent(
+ message: str,
+ session_id: str | None = None,
+ confirm_action_id: str | None = None,
+) -> dict[str, Any] | None:
+ """Return Herman-shaped dict when export flow applies, else None."""
+ sid = (session_id or "").strip() or "default"
+ text = (message or "").strip()
+ if not text:
+ return None
+
+ pending = get_pending(sid)
+
+ if confirm_action_id and pending and pending.get("id") == confirm_action_id:
+ clear_pending(sid)
+ return await execute_pending_action(pending)
+
+ if pending:
+ if is_confirmation_yes(text):
+ clear_pending(sid)
+ return await execute_pending_action(pending)
+ if is_confirmation_no(text):
+ clear_pending(sid)
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": "Oké, geannuleerd. Waar kan ik je verder mee helpen?",
+ "needs_confirmation": False,
+ }
+ if pending.get("awaiting") == "location":
+ loc = _detect_location(text)
+ if loc:
+ merged = dict(pending)
+ merged["params"] = dict(pending.get("params") or {})
+ merged["params"]["country"] = loc["country"]
+ merged["params"]["q"] = loc.get("q", "")
+ merged["location_label"] = loc["label"]
+ merged.pop("awaiting", None)
+ merged.pop("incomplete", None)
+ merged.pop("missing", None)
+ action_id = store_pending(sid, merged)
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_confirmation_question(merged),
+ "needs_confirmation": True,
+ "pending_action": {
+ "id": action_id,
+ "type": "export_intel_search",
+ "params": merged.get("params"),
+ "location_label": merged.get("location_label"),
+ "entity_label": merged.get("entity_label"),
+ },
+ }
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_clarification_question({"missing": ["locatie"]}),
+ "needs_confirmation": True,
+ "clarification": True,
+ }
+ if wants_export_search(text):
+ parsed = parse_export_search(text)
+ if parsed.get("incomplete"):
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_clarification_question(parsed),
+ "needs_confirmation": True,
+ "clarification": True,
+ }
+ action_id = store_pending(sid, parsed)
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_confirmation_question(parsed),
+ "needs_confirmation": True,
+ "pending_action": {
+ "id": action_id,
+ "type": "export_intel_search",
+ "params": parsed.get("params"),
+ "location_label": parsed.get("location_label"),
+ "entity_label": parsed.get("entity_label"),
+ },
+ }
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": (
+ f"Ik wacht nog op bevestiging: {build_confirmation_question(pending)}\n\n"
+ "Zeg **ja** of **nee**, of stel een nieuwe zoekopdracht."
+ ),
+ "needs_confirmation": True,
+ "pending_action": {
+ "id": pending.get("id"),
+ "type": pending.get("type", "export_intel_search"),
+ "params": pending.get("params"),
+ "location_label": pending.get("location_label"),
+ "entity_label": pending.get("entity_label"),
+ },
+ }
+
+ if not wants_export_search(text):
+ return None
+
+ parsed = parse_export_search(text)
+ if parsed.get("incomplete"):
+ store_pending(sid, {**parsed, "type": "export_intel_search", "awaiting": "location"})
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_clarification_question(parsed),
+ "needs_confirmation": True,
+ "clarification": True,
+ }
+
+ action_id = store_pending(sid, {**parsed, "type": "export_intel_search"})
+ return {
+ "agent": "herman",
+ "agent_label": "Herman",
+ "reply": build_confirmation_question(parsed),
+ "needs_confirmation": True,
+ "pending_action": {
+ "id": action_id,
+ "type": "export_intel_search",
+ "params": parsed.get("params"),
+ "location_label": parsed.get("location_label"),
+ "entity_label": parsed.get("entity_label"),
+ },
+ }
diff --git a/cockpit/app/services/webbuilder_agent.py b/cockpit/app/services/webbuilder_agent.py
new file mode 100644
index 0000000..e26b734
--- /dev/null
+++ b/cockpit/app/services/webbuilder_agent.py
@@ -0,0 +1,267 @@
+"""Web Builder agent — websites bouwen via Hermes/agy API op VM107."""
+from __future__ import annotations
+
+import os
+import re
+from typing import Any
+
+import httpx
+
+from app.config import settings
+from app.services.agent_events_log import log_agent_event
+
+HERMES_BUILD_URL = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798").rstrip("/")
+HERMES_PREVIEW_BASE = os.getenv("HERMES_PREVIEW_BASE", "http://10.4.7.27:8080").rstrip("/")
+
+WEBSITE_KEYWORDS = (
+ "maak website",
+ "maak een website",
+ "bouw website",
+ "bouw een website",
+ "genereer website",
+ "website voor",
+ "website bouwen",
+ "landingspagina",
+ "landing page",
+ "webshop",
+ "webpagina",
+ "nieuwe site",
+ "nieuwe website",
+ "/website",
+ "web builder",
+ "webbuilder",
+ "agy",
+ "antigravity",
+ "anti gravity",
+ "anti-gravity",
+ "site maken",
+ "maak een site",
+ "bouw een site",
+ "website laten maken",
+ "laat agy",
+ "via agy",
+)
+
+PROJECT_RE = re.compile(
+ r"(?:website|site|project)\s+(?:voor\s+|called\s+|genaamd\s+)?['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
+ re.I,
+)
+FOR_RE = re.compile(
+ r"\bvoor\s+['\"]?([a-z0-9][a-z0-9 _-]{1,48})['\"]?",
+ re.I,
+)
+
+
+def wants_website(raw: str) -> bool:
+ t = (raw or "").strip().lower()
+ return any(k in t for k in WEBSITE_KEYWORDS)
+
+
+def slugify(name: str) -> str:
+ s = (name or "").strip().lower()
+ s = re.sub(r"[^a-z0-9_-]+", "-", s)
+ s = re.sub(r"-+", "-", s).strip("-")
+ return s[:64] or "website"
+
+
+def extract_project_name(raw: str) -> str:
+ t = (raw or "").strip()
+ m = PROJECT_RE.search(t)
+ if m:
+ return slugify(m.group(1))
+ m = FOR_RE.search(t)
+ if m:
+ name = m.group(1)
+ name = re.split(r"\s+(?:met|with|incl|including)\b", name, maxsplit=1, flags=re.I)[0]
+ return slugify(name)
+ for token in t.split():
+ clean = slugify(token)
+ if len(clean) >= 3 and clean not in (
+ "maak", "bouw", "website", "site", "voor", "een", "the", "and", "met",
+ "simpele", "landing", "page", "pagina", "landingspagina",
+ ):
+ return clean
+ return slugify(t[:40]) or "website"
+
+
+def extract_build_prompt(raw: str, project: str) -> str:
+ body = (raw or "").strip()
+ lower = body.lower()
+ for k in WEBSITE_KEYWORDS:
+ if lower.startswith(k):
+ rest = body[len(k) :].strip(" :,-")
+ if rest:
+ return rest
+ return (
+ f"Bouw een complete, moderne, responsive website voor project '{project}'. "
+ f"Opdracht: {body}. "
+ "Gebruik index.html, style.css, script.js en een assets/ map. "
+ "Foodlinkk halal kant-en-klaar food branding waar passend."
+ )
+
+
+async def _hermes_get(path: str, timeout: float = 30.0) -> dict[str, Any]:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.get(f"{HERMES_BUILD_URL}{path}")
+ try:
+ data = resp.json()
+ except Exception:
+ data = {"ok": False, "detail": resp.text[:500]}
+ if resp.status_code >= 400:
+ data.setdefault("ok", False)
+ return data
+
+
+async def _hermes_post(path: str, payload: dict[str, Any], timeout: float = 60.0) -> dict[str, Any]:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.post(f"{HERMES_BUILD_URL}{path}", json=payload)
+ try:
+ data = resp.json()
+ except Exception:
+ data = {"ok": False, "detail": resp.text[:500]}
+ if resp.status_code >= 400:
+ data.setdefault("ok", False)
+ data.setdefault("detail", resp.text[:500])
+ return data
+
+
+async def list_projects() -> dict[str, Any]:
+ return await _hermes_get("/api/projects")
+
+
+async def get_build_status(project: str) -> dict[str, Any]:
+ slug = slugify(project)
+ return await _hermes_get(f"/api/build/{slug}/status")
+
+
+async def build_from_message(raw: str, *, channel: str = "herman") -> dict[str, Any]:
+ project = extract_project_name(raw)
+ prompt = extract_build_prompt(raw, project)
+
+ log_agent_event(
+ "webbuilder",
+ "website_build_start",
+ f"Start build: {project}",
+ prompt[:4000],
+ channel=channel,
+ metadata={"project": project, "prompt": prompt[:500]},
+ status="running",
+ )
+
+ data = await _hermes_post(
+ "/api/build",
+ {"project": project, "prompt": prompt},
+ timeout=45.0,
+ )
+ if not data.get("ok", True) and data.get("detail"):
+ log_agent_event(
+ "webbuilder",
+ "website_build_error",
+ f"Build mislukt: {project}",
+ str(data.get("detail", data))[:2000],
+ channel=channel,
+ metadata={"project": project},
+ status="error",
+ )
+ raise RuntimeError(str(data.get("detail") or data))
+
+ job_id = data.get("job_id") or project
+ preview = data.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
+ nas_path = data.get("nas_path") or f"//10.4.7.11/share/Websites/{project}/"
+
+ log_agent_event(
+ "webbuilder",
+ "website_build_running",
+ f"agy bezig: {project}",
+ f"Job {job_id}\nPreview: {preview}\nNAS: {nas_path}",
+ channel=channel,
+ metadata={
+ "project": project,
+ "job_id": job_id,
+ "preview_url": preview,
+ "nas_path": nas_path,
+ "correlation_id": job_id,
+ },
+ status="running",
+ )
+
+ return {
+ "project": project,
+ "job_id": job_id,
+ "preview_url": preview,
+ "nas_path": nas_path,
+ "prompt": prompt,
+ "hermes_status": data.get("status", "running"),
+ }
+
+
+async def poll_until_done(project: str, *, channel: str = "herman", timeout: float = 1800.0) -> dict[str, Any]:
+ """Poll Hermes build status until completed or failed."""
+ import asyncio
+
+ slug = slugify(project)
+ elapsed = 0.0
+ interval = 5.0
+ last_log = ""
+
+ while elapsed < timeout:
+ st = await get_build_status(slug)
+ status = str(st.get("status") or "unknown")
+ msg = str(st.get("message") or status)
+ if msg != last_log:
+ log_agent_event(
+ "webbuilder",
+ "website_build_progress",
+ msg[:255],
+ (st.get("log_tail") or "")[:4000],
+ channel=channel,
+ metadata={"project": slug, "status": status},
+ status="running" if status in ("running", "queued") else status,
+ )
+ last_log = msg
+
+ if status == "completed":
+ files = st.get("files") or []
+ preview = st.get("preview_url") or f"{HERMES_PREVIEW_BASE}/"
+ log_agent_event(
+ "webbuilder",
+ "website_build_done",
+ f"Website klaar: {slug}",
+ f"Bestanden: {', '.join(files[:8])}\nPreview: {preview}",
+ channel=channel,
+ metadata={
+ "project": slug,
+ "preview_url": preview,
+ "files": files,
+ "for_herman": True,
+ "source_agent": "webbuilder",
+ },
+ status="completed",
+ )
+ return {**st, "project": slug, "preview_url": preview}
+
+ if status == "failed":
+ detail = str(st.get("error") or st.get("message") or "Build mislukt")
+ log_agent_event(
+ "webbuilder",
+ "website_build_error",
+ f"Build mislukt: {slug}",
+ detail[:2000],
+ channel=channel,
+ metadata={"project": slug},
+ status="error",
+ )
+ raise RuntimeError(detail)
+
+ await asyncio.sleep(interval)
+ elapsed += interval
+
+ raise TimeoutError(f"Build timeout voor {slug} na {int(timeout)}s")
+
+
+async def generate_from_message(raw: str, *, channel: str = "herman", wait: bool = True) -> dict[str, Any]:
+ started = await build_from_message(raw, channel=channel)
+ if wait:
+ finished = await poll_until_done(started["project"], channel=channel)
+ started.update(finished)
+ return started
diff --git a/cockpit/base.html b/cockpit/base.html
new file mode 100644
index 0000000..ed38c9a
--- /dev/null
+++ b/cockpit/base.html
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+ {% block title %}{{ page_title }} · Cucina{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+ {% block extra_head %}{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+ Cucina · Foodlinkk
+
+
+ — · —
+
+
{% block content %}{% endblock %}
+
+
+
+
+
📱 Installeer Foodlinkk als app op je telefoon
+
+
+
+
+
+
+
+
+
+
+
+ {% block scripts %}{% endblock %}
+
+
diff --git a/cockpit/cto-dashboard.html b/cockpit/cto-dashboard.html
new file mode 100644
index 0000000..233730a
--- /dev/null
+++ b/cockpit/cto-dashboard.html
@@ -0,0 +1,195 @@
+{% extends "base.html" %}
+{% block extra_head %}
+
+
+
+{% endblock %}
+{% block content %}
+
+
+
+
CTO Dashboard Live
+
Platform · agents · infra · DevOps · goedkeuringen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Browser sessies (24u)
+
{{ kpis.browser_sessions_24h }}
+
+
+
+
+
+ Agent goedkeuringen
+
+
SysOps vraagt toestemming voor update-scan en Gitea backup.
+
+
+
+
+
+
+
+
+
+ Agent feed Live
+
+ {% for ev in agent_feed[:12] %}
+
+ {{ ev.created_at[11:16] if ev.created_at else '' }}
+ {{ ev.agent_name }}
+ {{ ev.title or ev.event_type }}
+
+ {% else %}
Nog geen events.
{% endfor %}
+
+
+
+
+ Browser sessies
+ {% if browser_sessions %}
+ {% for s in browser_sessions[:6] %}
+
+ {{ s.created_at[11:16] if s.created_at else '' }}
+ browser
+ {{ s.title or s.url or s.task }}
+
+ {% endfor %}
+ {% else %}
+ Geen recente browser-sessies.
+ {% endif %}
+ Browser agent →
+
+
+
+
+{% endblock %}
+{% block scripts %}
+
+{% endblock %}
diff --git a/cockpit/dashboard.html b/cockpit/dashboard.html
new file mode 100644
index 0000000..383222c
--- /dev/null
+++ b/cockpit/dashboard.html
@@ -0,0 +1,541 @@
+{% extends "base.html" %}
+{% block extra_head %}
+
+
+
+{% endblock %}
+{% block content %}
+
+
+
+
CEO Dashboard · Cucina Live
+
Business overzicht · retail · deals · briefing · marketing
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⋮⋮ Sleep · CEO KPI's
+ CEO KPI's — kies KPI's hieronder
+
+
+
+
+ ⋮⋮ Sleep · Executive
+ Alles op een rij
+
+
Executive samenvatting
live database
+
+
+
+
+
+
+ ⋮⋮ Sleep · Briefing
+ Herman briefing
+
+
+
+
📋 Herman activiteitenlog
+
+
+
+
+
+
+
+ ⋮⋮ Sleep · Retail
+ Retail operatie
+
+
+
+
Sales milestones
live DB
+
+
+
+
+
Markt highlights
M&A · retail
+
+
+
+
+
+ ⋮⋮ Sleep · Analytics
+ Data & analytics
+
+
+
Pipeline
live deals
+
+
+
+
+
NAS sentiment
neo-bars
+
+
+
+
NAS top woorden
corpus
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+{% block scripts %}
+
+
+
+
+{% endblock %}
diff --git a/cockpit/dashboard.py b/cockpit/dashboard.py
new file mode 100644
index 0000000..a9f9de7
--- /dev/null
+++ b/cockpit/dashboard.py
@@ -0,0 +1,216 @@
+from fastapi import APIRouter, Request
+from fastapi.responses import RedirectResponse
+from fastapi.templating import Jinja2Templates
+from pathlib import Path
+import json
+
+from app.db import fetch_all, fetch_one
+from app.services.briefing import collect_briefing_data, serialize_stats
+
+router = APIRouter(tags=["dashboard"])
+
+BASE_DIR = Path(__file__).resolve().parent.parent.parent
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+
+def _safe_count(table: str, where: str = "") -> int:
+ try:
+ clause = f" WHERE {where}" if where else ""
+ row = fetch_one(f"SELECT COUNT(*) AS c FROM {table}{clause}")
+ return int(row["c"]) if row else 0
+ except Exception:
+ return 0
+
+
+def _safe_sum(table: str, column: str, where: str = "") -> float:
+ try:
+ clause = f" WHERE {where}" if where else ""
+ row = fetch_one(f"SELECT COALESCE(SUM({column}), 0) AS total FROM {table}{clause}")
+ return float(row["total"]) if row else 0.0
+ except Exception:
+ return 0.0
+
+
+def _briefing_payload(briefing: dict | None) -> dict:
+ """Always use live DB stats; briefing text may be cached."""
+ payload: dict = {"content": None, "stats": serialize_stats(collect_briefing_data()), "created_at": None}
+ if not briefing:
+ return payload
+
+ payload["content"] = briefing.get("content")
+ payload["created_at"] = briefing.get("created_at")
+ return payload
+
+
+@router.get("/")
+async def dashboard(request: Request):
+ kpis = {
+ "deals_count": _safe_count("deals"),
+ "clients_count": _safe_count("clients"),
+ "pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
+ "pipeline_value": _safe_sum("deals", "value", "stage NOT IN ('won', 'lost')"),
+ "browser_sessions_24h": 0,
+ "monitor_sites": _safe_count("monitored_sites", "is_active = TRUE"),
+ "supermarkets_count": _safe_count("supermarkets"),
+ "clients_active": _safe_count("clients", "stage = 'active'"),
+ "crm_partnerships": _safe_count("supermarkets", "partnership_status = 'active'"),
+ }
+
+ briefing = None
+ try:
+ briefing = fetch_one(
+ "SELECT id, content, metadata, created_at FROM daily_briefings ORDER BY created_at DESC LIMIT 1"
+ )
+ if briefing and briefing.get("created_at"):
+ briefing["created_at"] = briefing["created_at"].isoformat()
+ except Exception:
+ briefing = None
+
+ agent_feed: list = []
+ try:
+ agent_feed = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, created_at
+ FROM agent_events ORDER BY created_at DESC LIMIT 25
+ """
+ )
+ for ev in agent_feed:
+ if ev.get("created_at"):
+ ev["created_at"] = ev["created_at"].isoformat()
+ except Exception:
+ agent_feed = []
+
+ approvals: list = []
+ try:
+ approvals = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, created_at
+ FROM agent_events WHERE status = 'needs_approval'
+ ORDER BY created_at ASC LIMIT 25
+ """
+ )
+ for ev in approvals:
+ if ev.get("created_at"):
+ ev["created_at"] = ev["created_at"].isoformat()
+ except Exception:
+ approvals = []
+
+ browser_sessions: list = []
+ try:
+ browser_sessions = fetch_all(
+ """
+ SELECT id, url, final_url, title, task, status, created_at,
+ LEFT(content_text, 300) AS preview
+ FROM browser_sessions
+ ORDER BY created_at DESC LIMIT 8
+ """
+ )
+ kpis["browser_sessions_24h"] = _safe_count(
+ "browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
+ )
+ for s in browser_sessions:
+ if s.get("created_at"):
+ s["created_at"] = s["created_at"].isoformat()
+ except Exception:
+ browser_sessions = []
+
+ monitor_sites: list = []
+ monitor_changes: list = []
+ try:
+ monitor_sites = fetch_all(
+ """
+ SELECT id, name, url, last_title, last_crawled, is_active
+ FROM monitored_sites WHERE is_active = TRUE ORDER BY last_crawled DESC NULLS LAST LIMIT 10
+ """
+ )
+ for s in monitor_sites:
+ if s.get("last_crawled"):
+ s["last_crawled"] = s["last_crawled"].isoformat()
+ monitor_changes = fetch_all(
+ """
+ SELECT pc.id, pc.changed_at, ms.name, ms.url
+ FROM page_changes pc
+ JOIN monitored_sites ms ON ms.id = pc.site_id
+ WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
+ ORDER BY pc.changed_at DESC LIMIT 10
+ """
+ )
+ for c in monitor_changes:
+ if c.get("changed_at"):
+ c["changed_at"] = c["changed_at"].isoformat()
+ except Exception:
+ pass
+
+ return templates.TemplateResponse(
+ "dashboard.html",
+ {
+ "request": request,
+ "page_title": "CEO Dashboard",
+ "kpis": kpis,
+ "briefing": briefing,
+ "briefing_payload": _briefing_payload(briefing),
+ "agent_feed": agent_feed,
+ "approvals": approvals,
+ "browser_sessions": browser_sessions,
+ "monitor_sites": monitor_sites,
+ "monitor_changes": monitor_changes,
+ },
+ )
+
+
+@router.get("/cto")
+async def cto_dashboard(request: Request):
+ kpis = {
+ "pending_approvals": _safe_count("agent_events", "status = 'needs_approval'"),
+ "browser_sessions_24h": 0,
+ }
+ try:
+ kpis["browser_sessions_24h"] = _safe_count(
+ "browser_sessions", "created_at >= NOW() - INTERVAL '24 hours'"
+ )
+ except Exception:
+ pass
+
+ agent_feed: list = []
+ try:
+ agent_feed = fetch_all(
+ """
+ SELECT id, agent_name, event_type, title, body, status, created_at
+ FROM agent_events ORDER BY created_at DESC LIMIT 25
+ """
+ )
+ for ev in agent_feed:
+ if ev.get("created_at"):
+ ev["created_at"] = ev["created_at"].isoformat()
+ except Exception:
+ agent_feed = []
+
+ browser_sessions: list = []
+ try:
+ browser_sessions = fetch_all(
+ """
+ SELECT id, url, final_url, title, task, status, created_at
+ FROM browser_sessions ORDER BY created_at DESC LIMIT 8
+ """
+ )
+ for s in browser_sessions:
+ if s.get("created_at"):
+ s["created_at"] = s["created_at"].isoformat()
+ except Exception:
+ browser_sessions = []
+
+ return templates.TemplateResponse(
+ "cto-dashboard.html",
+ {
+ "request": request,
+ "page_title": "CTO Dashboard",
+ "kpis": kpis,
+ "agent_feed": agent_feed,
+ "browser_sessions": browser_sessions,
+ },
+ )
+
+
+@router.get("/marketing-redirect")
+async def marketing_redirect():
+ return RedirectResponse(url="/marketing", status_code=302)
diff --git a/cockpit/doc-tabs.js b/cockpit/doc-tabs.js
new file mode 100644
index 0000000..d00245e
--- /dev/null
+++ b/cockpit/doc-tabs.js
@@ -0,0 +1,66 @@
+/* Documenten tabs — CSS radio primary, JS syncs Alpine */
+(function () {
+ var DEFAULT = 'overview';
+
+ function radioId(tab) {
+ return 'doctab-' + tab;
+ }
+
+ function getActiveTab() {
+ var checked = document.querySelector('.doc-tab-input:checked');
+ return (checked && checked.value) || window.__docActiveTab || DEFAULT;
+ }
+
+ function show(tab) {
+ if (!tab) tab = DEFAULT;
+ var radio = document.getElementById(radioId(tab));
+ if (radio) radio.checked = true;
+ document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
+ el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
+ });
+ window.__docActiveTab = tab;
+ }
+
+ function syncAlpine(tab) {
+ var hub = document.querySelector('.doc-hub');
+ if (!hub || !window.Alpine) return;
+ try {
+ var d = Alpine.$data(hub);
+ if (!d) return;
+ if (d.activeTab !== tab) d.activeTab = tab;
+ if (typeof d.onTabShown === 'function') d.onTabShown(tab);
+ } catch (e) {
+ console.warn('doc-tabs alpine', e);
+ }
+ }
+
+ function onTabChange() {
+ var tab = getActiveTab();
+ window.__docActiveTab = tab;
+ document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
+ el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
+ });
+ syncAlpine(tab);
+ }
+
+ function bind() {
+ document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
+ if (radio.dataset.bound) return;
+ radio.dataset.bound = '1';
+ radio.addEventListener('change', onTabChange);
+ });
+ }
+
+ window.DocTabs = { show: show, getActive: getActiveTab };
+
+ bind();
+ document.addEventListener('DOMContentLoaded', function () {
+ bind();
+ show(window.__docActiveTab || getActiveTab());
+ });
+ document.addEventListener('alpine:initialized', function () {
+ bind();
+ show(window.__docActiveTab || getActiveTab());
+ syncAlpine(getActiveTab());
+ });
+})();
diff --git a/cockpit/documents-v2.css b/cockpit/documents-v2.css
new file mode 100644
index 0000000..09a0c7c
--- /dev/null
+++ b/cockpit/documents-v2.css
@@ -0,0 +1,893 @@
+/* Documents & NAS Workbench v2 */
+[x-cloak] { display: none !important; }
+
+.doc-hub {
+ --doc-accent: #00e5ff;
+ --doc-gold: #ffd700;
+ --doc-purple: #a855f7;
+ --doc-border: rgba(0, 229, 255, 0.14);
+ --doc-text: #f1f5f9;
+ --doc-muted: #94a3b8;
+ --doc-dim: #64748b;
+}
+
+.doc-hub .page-header h1 {
+ font-size: 1.65rem;
+ background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+/* Tabs */
+.doc-nav {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.25rem;
+ padding: 0.35rem;
+ background: rgba(6, 10, 18, 0.85);
+ border: 1px solid var(--doc-border);
+ border-radius: 14px;
+}
+
+.doc-nav-btn {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.55rem 1.1rem;
+ border-radius: 10px;
+ font-size: 0.82rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--doc-muted);
+ transition: all 0.18s;
+ user-select: none;
+}
+
+.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
+
+.doc-nav-btn.active,
+#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
+#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
+#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
+#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
+#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"],
+#doctab-analytics360:checked ~ #doc-nav label[for="doctab-analytics360"] {
+ color: #e0f2fe;
+ background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
+ border-color: rgba(0, 229, 255, 0.35);
+ box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
+}
+
+/* Workbench shell */
+.doc-workbench {
+ border-radius: 16px;
+ border: 1px solid var(--doc-border);
+ background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
+ overflow: hidden;
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
+}
+
+/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
+.doc-tab-input {
+ position: fixed;
+ left: -9999px;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+#doc-tab-stage {
+ min-height: 50vh;
+}
+
+#doc-tab-stage [data-tab-panel] {
+ display: none !important;
+}
+
+#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
+#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
+#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
+#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
+#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
+#doctab-analytics360:checked ~ #doc-tab-stage [data-tab-panel="analytics360"],
+#doc-tab-stage [data-tab-panel].tab-active {
+ display: block !important;
+ min-height: 50vh;
+}
+
+.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
+ font-size: 0.65rem;
+ color: var(--doc-dim);
+ white-space: nowrap;
+}
+.dl-version-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-top: 0.35rem;
+}
+.doc-row-fallback { opacity: 0.85; }
+
+.share-breadcrumb {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.15rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.68rem;
+}
+.share-crumb {
+ background: transparent;
+ border: none;
+ color: #7dd3fc;
+ cursor: pointer;
+ padding: 0.1rem 0.25rem;
+ border-radius: 4px;
+}
+.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
+.share-crumb-sep { color: #475569; }
+.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
+.share-browser-list { max-height: 52vh; overflow-y: auto; }
+
+.doc-wb-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+ background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
+}
+
+.doc-wb-head h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ color: var(--doc-text);
+}
+
+.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
+
+.doc-kpi-strip {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
+
+.doc-kpi {
+ padding: 0.9rem 1.1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.06);
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
+.doc-kpi:last-child { border-right: none; }
+.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
+.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
+
+.doc-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ align-items: center;
+ padding: 0.85rem 1.1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
+
+/* Split layout */
+.doc-split {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0;
+ min-height: 280px;
+}
+
+@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
+
+.doc-split-col {
+ padding: 1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+.doc-split-col:last-child { border-right: none; }
+
+.doc-chart-wrap { height: 140px; position: relative; }
+
+/* Document table */
+.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
+
+.doc-table-wrap .data-table { font-size: 0.78rem; }
+.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
+.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
+.doc-table-wrap .data-table td strong { color: var(--doc-text); }
+.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
+
+.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
+
+/* Label chips */
+.doc-chip {
+ display: inline-block;
+ padding: 0.18rem 0.55rem;
+ margin: 0.12rem 0.12rem 0 0;
+ border-radius: 999px;
+ font-size: 0.65rem;
+ font-weight: 600;
+ background: rgba(0, 229, 255, 0.12);
+ color: #7dd3fc;
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ white-space: nowrap;
+}
+
+.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
+
+/* Photo grid */
+.photo-masonry {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.photo-tile {
+ border: 1px solid var(--doc-border);
+ border-radius: 14px;
+ overflow: hidden;
+ background: rgba(10, 15, 26, 0.95);
+ cursor: pointer;
+ transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
+}
+
+.photo-tile:hover {
+ transform: translateY(-2px);
+ border-color: rgba(0, 229, 255, 0.35);
+ box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
+}
+
+.photo-tile img {
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ background: #060a10;
+ display: block;
+}
+
+.photo-tile-body {
+ padding: 0.65rem 0.75rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.photo-tile-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
+.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
+
+.photo-tile-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.2rem;
+ min-height: 1.5rem;
+ align-items: flex-start;
+}
+
+.photo-tile-ocr {
+ font-size: 0.68rem;
+ line-height: 1.4;
+ color: var(--doc-muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ min-height: 2.8em;
+}
+
+.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
+
+/* Label filter chips row */
+.label-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ padding: 0 1rem 0.75rem;
+}
+
+.label-filter-chip {
+ padding: 0.28rem 0.65rem;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ cursor: pointer;
+ border: 1px solid rgba(100, 116, 139, 0.35);
+ background: transparent;
+ color: var(--doc-muted);
+}
+
+.label-filter-chip.active, .label-filter-chip:hover {
+ border-color: rgba(0, 229, 255, 0.4);
+ color: #e0f2fe;
+ background: rgba(0, 229, 255, 0.1);
+}
+
+/* ── Label modal (fixed overlap + dark text) ── */
+.label-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ background: rgba(0, 0, 0, 0.82);
+ backdrop-filter: blur(6px);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 1.25rem;
+}
+.label-modal-backdrop.open {
+ display: flex;
+}
+
+.label-modal {
+ width: min(780px, 100%);
+ max-height: 92vh;
+ overflow-y: auto;
+ background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ border-radius: 20px;
+ padding: 1.35rem;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
+ color: var(--doc-text);
+}
+
+.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
+.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
+
+.label-modal-preview {
+ display: grid;
+ grid-template-columns: 140px 1fr;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 0.85rem;
+ background: rgba(0, 0, 0, 0.35);
+ border-radius: 12px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+}
+
+@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
+
+.label-modal-preview img {
+ width: 100%;
+ border-radius: 10px;
+ object-fit: cover;
+ max-height: 160px;
+ border: 1px solid rgba(148, 163, 184, 0.15);
+}
+
+.modal-ocr-box {
+ font-size: 0.74rem;
+ line-height: 1.55;
+ color: #cbd5e1 !important;
+ background: rgba(15, 23, 42, 0.6);
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ border-radius: 8px;
+ padding: 0.65rem 0.75rem;
+ max-height: 140px;
+ overflow-y: auto;
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.modal-ocr-label {
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--doc-dim);
+ margin-bottom: 0.35rem;
+}
+
+.label-modal-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
+ gap: 0.5rem;
+}
+
+.label-pick {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.35rem;
+ padding: 0.75rem 0.5rem;
+ min-height: 92px;
+ border-radius: 12px;
+ border: 1px solid rgba(100, 116, 139, 0.35);
+ background: rgba(15, 23, 42, 0.7);
+ cursor: pointer;
+ transition: all 0.15s;
+ color: #e2e8f0;
+}
+
+.label-pick:hover {
+ border-color: rgba(0, 229, 255, 0.4);
+ background: rgba(0, 229, 255, 0.06);
+}
+
+.label-pick.selected {
+ border-color: var(--pick-color, #00e5ff);
+ background: rgba(0, 229, 255, 0.14);
+ box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
+}
+
+.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
+
+.label-pick-name {
+ font-size: 0.68rem;
+ font-weight: 600;
+ line-height: 1.3;
+ text-align: center;
+ color: #cbd5e1;
+ display: block;
+ width: 100%;
+ word-break: break-word;
+}
+
+.label-pick.selected .label-pick-name { color: #f8fafc; }
+
+.label-modal-extra {
+ margin-top: 1rem;
+ display: grid;
+ gap: 0.5rem;
+}
+
+.label-modal-extra label {
+ font-size: 0.72rem;
+ color: var(--doc-muted);
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.label-modal-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 1.1rem;
+ padding-top: 1rem;
+ border-top: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+/* Document label drawer */
+.doc-label-drawer {
+ margin: 1rem;
+ padding: 1rem;
+ border-radius: 14px;
+ border: 1px solid rgba(0, 229, 255, 0.2);
+ background: rgba(0, 0, 0, 0.25);
+}
+
+.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
+
+/* ── Docling workbench ── */
+.dl-workbench { min-height: 620px; }
+
+.dl-body {
+ display: grid;
+ grid-template-columns: 260px 300px 1fr;
+ min-height: 560px;
+}
+
+/* v3 split workbench: files | editor+preview */
+.dl-body-v3 {
+ display: grid;
+ grid-template-columns: minmax(260px, 300px) 1fr;
+ min-height: 580px;
+ border-top: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
+.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
+
+.dl-pipeline-panel {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ background: rgba(0, 0, 0, 0.18);
+}
+
+.dl-pipeline-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr auto;
+ gap: 1rem;
+ align-items: start;
+}
+
+@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
+
+.dl-workspace {
+ display: flex;
+ flex-direction: column;
+ min-height: 580px;
+ background: rgba(0, 0, 0, 0.08);
+ overflow: hidden;
+}
+
+.dl-workspace.empty { justify-content: center; align-items: center; }
+
+.dl-workspace-inner {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+}
+
+.dl-ws-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ flex-wrap: wrap;
+}
+
+.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
+.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
+.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
+.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
+
+.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
+
+.dl-workspace-panes {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ flex: 1;
+ min-height: 0;
+ gap: 0;
+}
+
+@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
+
+.dl-pane {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-right: 1px solid rgba(148, 163, 184, 0.06);
+}
+
+.dl-pane:last-child { border-right: none; }
+
+.dl-pane-label {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.45rem 0.85rem;
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--doc-dim);
+ border-bottom: 1px solid rgba(148, 163, 184, 0.06);
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.dl-export-tabs-inline { margin: 0; }
+
+.dl-editor-full {
+ flex: 1;
+ min-height: 420px;
+ max-height: none;
+ border: none;
+ border-radius: 0;
+ resize: none;
+}
+
+.dl-preview-viewport {
+ flex: 1;
+ min-height: 420px;
+ overflow: auto;
+ padding: 0.65rem;
+}
+
+.dl-preview-viewport .dl-preview-box {
+ min-height: 200px;
+ max-height: none;
+ height: 100%;
+}
+
+.dl-preview-img {
+ max-width: 100%;
+ max-height: 480px;
+ border-radius: 8px;
+ display: block;
+ margin: 0 auto;
+}
+
+.dl-preview-iframe {
+ width: 100%;
+ height: 480px;
+ border: none;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
+.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
+.dl-file-meta { flex: 1; min-width: 0; }
+.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
+.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
+.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
+
+.dl-body-v3 .dl-sidebar {
+ max-height: none;
+ height: 100%;
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+@media (max-width: 1200px) {
+ .dl-body-v3 { grid-template-columns: 1fr; }
+ .dl-body-v3 .dl-sidebar { max-height: 280px; }
+}
+
+@media (max-width: 1200px) {
+ .dl-body { grid-template-columns: 240px 1fr; }
+ .dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
+}
+
+.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
+ padding: 1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
+ overflow-y: auto;
+ max-height: 680px;
+}
+
+.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
+.dl-options { background: rgba(0, 0, 0, 0.12); }
+
+.dl-section-title {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--doc-dim);
+ margin: 0 0 0.5rem;
+}
+
+.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
+
+.dl-ext-chip {
+ padding: 0.22rem 0.5rem;
+ border-radius: 6px;
+ font-size: 0.65rem;
+ cursor: pointer;
+ border: 1px solid rgba(100, 116, 139, 0.3);
+ background: transparent;
+ color: var(--doc-muted);
+}
+
+.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
+
+.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
+
+.dl-file-item {
+ padding: 0.5rem 0.6rem;
+ border-radius: 10px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: background 0.12s;
+}
+
+.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
+
+.dl-file-item.selected {
+ border-color: rgba(0, 229, 255, 0.35);
+ background: rgba(0, 229, 255, 0.1);
+}
+
+.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.dl-file-badge {
+ display: inline-block;
+ margin-top: 0.25rem;
+ font-size: 0.58rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ background: rgba(74, 222, 128, 0.15);
+ color: #4ade80;
+}
+
+.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
+.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
+
+.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
+
+.dl-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.45rem 0.6rem;
+ border-radius: 8px;
+ border: 1px solid rgba(100, 116, 139, 0.2);
+ font-size: 0.76rem;
+ color: var(--doc-muted);
+ cursor: pointer;
+ transition: all 0.12s;
+}
+
+.dl-toggle:has(input:checked) {
+ border-color: rgba(0, 229, 255, 0.35);
+ background: rgba(0, 229, 255, 0.08);
+ color: #e0f2fe;
+}
+
+.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
+
+.dl-preview-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.65rem;
+}
+
+.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
+
+.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
+
+.dl-tab {
+ padding: 0.32rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid rgba(100, 116, 139, 0.3);
+ background: transparent;
+ color: var(--doc-muted);
+}
+
+.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
+
+.dl-preview-box {
+ min-height: 320px;
+ max-height: 480px;
+ overflow: auto;
+ font-size: 0.74rem;
+ line-height: 1.5;
+ background: #050810;
+ border: 1px solid rgba(0, 229, 255, 0.1);
+ border-radius: 10px;
+ padding: 0.85rem;
+ color: #cbd5e1;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.dl-preview-box.rendered { white-space: normal; }
+.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
+.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
+.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
+.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
+
+.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
+
+.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
+
+.dl-stat {
+ padding: 0.55rem 0.65rem;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
+.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
+
+.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
+
+.dl-history-item {
+ padding: 0.45rem 0.55rem;
+ border-radius: 8px;
+ font-size: 0.68rem;
+ cursor: pointer;
+ border: 1px solid transparent;
+ color: var(--doc-muted);
+}
+
+.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
+
+.dl-progress {
+ height: 3px;
+ background: rgba(148, 163, 184, 0.15);
+ border-radius: 999px;
+ overflow: hidden;
+ margin-top: 0.5rem;
+}
+
+.dl-progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #00e5ff, #a855f7);
+ animation: dl-pulse 1.2s ease-in-out infinite;
+ width: 40%;
+}
+
+.dl-editor {
+ width: 100%;
+ min-height: 360px;
+ max-height: 520px;
+ font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-size: 0.76rem;
+ line-height: 1.55;
+ background: #050810;
+ border: 1px solid rgba(0, 229, 255, 0.2);
+ border-radius: 10px;
+ padding: 0.85rem;
+ color: #e2e8f0;
+ resize: vertical;
+}
+
+.ppt-workbench { min-height: 560px; }
+.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
+@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
+.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
+.ppt-editor { padding: 1rem; }
+.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
+@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
+.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
+.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
+
+/* ── Document chat (RAG) ── */
+.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
+.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
+@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
+.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
+.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
+.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
+.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
+.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
+.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
+.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
+.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
+.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
+.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
+.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
+.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
+.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
+.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
+.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
+.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
+.doc-chat-input .form-input { flex: 1; }
+
+/* ── 360 Analytics ── */
+.analytics360-workbench { min-height: 560px; }
+.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
+@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
+.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
+.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
+@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
+.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
+.a360-links { max-height: 200px; overflow-y: auto; }
+.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); }
+.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(0,229,255,0.1); color: #7dd3fc; }
+.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
+.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
+
+@keyframes dl-pulse {
+ 0%, 100% { transform: translateX(-100%); width: 30%; }
+ 50% { transform: translateX(200%); width: 50%; }
+}
+
+.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
+.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
+.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
diff --git a/cockpit/documents-v2.js b/cockpit/documents-v2.js
new file mode 100644
index 0000000..9d4c99f
--- /dev/null
+++ b/cockpit/documents-v2.js
@@ -0,0 +1,1607 @@
+/* Documents hub v2 — NAS, labels, Docling */
+(function () {
+ const D = window.__DOC_DATA || {};
+ const topWords = D.topWords || [];
+ const summary = D.summary || {};
+ const allDocuments = D.documents || [];
+
+ let wordsChartInst = null;
+ let sentimentChartInst = null;
+
+ window.documentsDash = function documentsDash() {
+ return {
+ activeTab: 'overview',
+ filterQuery: '',
+ filterSentiment: '',
+ filterType: '',
+ filterLabel: '',
+ filteredDocs: allDocuments,
+ docTypes: [...new Set(allDocuments.map((d) => d.doc_type).filter(Boolean))],
+ labelVocabulary: [],
+ photoTaxonomy: [],
+ documentTaxonomy: [],
+ photoFilterLabel: '',
+ activeDoc: null,
+ docSelectedLabels: [],
+ docLabelsInput: '',
+ docNotesInput: '',
+ photos: [],
+ activePhoto: null,
+ photoModalOpen: false,
+ modalPhotoLabels: [],
+ activeOcr: '',
+ photoLabelsInput: '',
+ photoNotesInput: '',
+ activeItems: [],
+ detections: [],
+ liveRefresh: false,
+ liveTimer: null,
+ nasOcrBusy: false,
+ doclingStatus: 'Docling laden…',
+ doclingFiles: [],
+ doclingHistory: [],
+ doclingFilter: '',
+ doclingExtFilter: '',
+ doclingSelected: '',
+ doclingOpts: { ocr: true, tables: true, page_images: false, picture_images: true, force_full_page_ocr: false },
+ doclingFormats: ['markdown', 'html', 'json', 'text', 'doctags', 'yaml'],
+ doclingExportFormats: ['markdown', 'html', 'json', 'text', 'doctags', 'yaml'],
+ doclingBusy: false,
+ doclingResult: null,
+ doclingExports: {},
+ doclingTables: [],
+ doclingPictures: [],
+ doclingMeta: {},
+ doclingExportTab: 'markdown',
+ doclingPreviewSearch: '',
+ doclingFileStatus: {},
+ nasFileTotal: 0,
+ nasLoading: false,
+ doclingEditMode: true,
+ doclingEditContent: '',
+ doclingAutoConvert: true,
+ dlOptionsOpen: false,
+ doclingFileLoading: false,
+ doclingFileKind: '',
+ doclingSupportedSuffixes: [],
+ dlExtGroups: [
+ { ext: '', label: 'Alles', icon: '' },
+ { ext: '.pdf', label: 'PDF', icon: '📕' },
+ { ext: '.docx', label: 'Word', icon: '📝' },
+ { ext: '.doc', label: 'DOC', icon: '📝' },
+ { ext: '.xlsx', label: 'Excel', icon: '📊' },
+ { ext: '.xls', label: 'XLS', icon: '📊' },
+ { ext: '.pptx', label: 'PPT', icon: '📽️' },
+ { ext: '.ppt', label: 'PPT', icon: '📽️' },
+ { ext: '.txt', label: 'Tekst', icon: '📄' },
+ { ext: '.md', label: 'MD', icon: '📄' },
+ { ext: '.csv', label: 'CSV', icon: '📋' },
+ { ext: '.html', label: 'HTML', icon: '🌐' },
+ { ext: '.json', label: 'JSON', icon: '{}' },
+ { ext: '.png', label: 'IMG', icon: '🖼️' },
+ ],
+ pptTitle: 'Foodlinkk Presentatie',
+ pptSubtitle: 'Foodlinkk',
+ pptFilename: '',
+ pptSlides: [],
+ pptSourcePdf: '',
+ pptLastOutput: '',
+ pptBusy: false,
+ pptDownloadPath: '',
+ chatMessages: [],
+ chatInput: '',
+ chatBusy: false,
+ chatStatus: 'RAG laden…',
+ chatSearchQ: '',
+ chatSearchHits: [],
+ chatScopeFile: false,
+ chatScopeClient: true,
+ chatUseHerman: false,
+ chatReindexing: false,
+ llmProviders: [],
+ chatLlmProviderId: '',
+ nasDiagHint: '',
+ autoSyncBusy: false,
+ autoSyncLabel: 'Second brain: —',
+ autoSyncTimer: null,
+ linkClients: [],
+ linkClientId: '',
+ linkProjectId: '',
+ linkPathInput: '',
+ linkProjects: [],
+ client360: { stats: {}, links: [], sentiment_breakdown: [], monthly_trends: [], top_words: [], stores: [], deals: [] },
+ shareBrowsePath: '',
+ shareBrowseEntries: [],
+ shareBrowseLoading: false,
+ shareBrowseRecursive: true,
+ selectedSharePaths: [],
+ docVersions: [],
+ doclingFileMtime: '',
+
+ init() {
+ try {
+ document.querySelectorAll('.doc-row-fallback').forEach(function (el) { el.remove(); });
+ var tab = window.__docActiveTab || this.activeTab || 'overview';
+ this.activeTab = tab;
+ if (window.DocTabs && window.DocTabs.show) window.DocTabs.show(tab);
+ this.$watch('activeTab', function (v) {
+ if (v && window.DocTabs && window.DocTabs.getActive && window.DocTabs.getActive() !== v) {
+ window.DocTabs.show(v);
+ }
+ });
+ this.applyFilters();
+ this.renderCharts(window.__vizMode || 'neo-bars');
+ this.loadPhotos();
+ this.loadLabelVocabulary();
+ this.loadTaxonomy();
+ this.loadShareFiles(false);
+ this.browseShare('', false);
+ this.loadLinkClients();
+ this.loadLlmProviders();
+ this.refreshAutoSyncStatus();
+ document.addEventListener('foodlinkk:vizmode', (ev) => this.renderCharts(ev.detail.mode));
+ } catch (e) {
+ console.error('documentsDash init:', e);
+ Cockpit.toast('Documenten UI gedeeltelijk geladen — ververs de pagina (Ctrl+F5)', 'error');
+ }
+ },
+
+ switchTab(tab) {
+ this.activeTab = tab;
+ window.__docActiveTab = tab;
+ if (window.DocTabs && window.DocTabs.show) window.DocTabs.show(tab);
+ this.onTabShown(tab);
+ },
+
+ onTabShown(tab) {
+ const runners = {
+ labels: () => this.loadPhotos(),
+ docling: () => this.initDocling(),
+ powerpoint: () => this.initPowerpoint(),
+ chat: () => this.initDocChat(),
+ analytics360: () => this.initAnalytics360(),
+ };
+ const fn = runners[tab];
+ if (fn) {
+ try {
+ const out = fn();
+ if (out && typeof out.catch === 'function') out.catch(() => {});
+ } catch (err) {
+ console.error('switchTab', tab, err);
+ }
+ }
+ },
+
+ formatTs(iso) {
+ if (!iso) return '—';
+ try {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return String(iso).slice(0, 16);
+ return d.toLocaleString('nl-NL', {
+ day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ });
+ } catch (e) {
+ return String(iso).slice(0, 16);
+ }
+ },
+
+ async loadVersions(path) {
+ if (!path) { this.docVersions = []; return; }
+ try {
+ const data = await Cockpit.api('/docling/versions?path=' + encodeURIComponent(path));
+ this.docVersions = data.versions || [];
+ } catch (e) {
+ this.docVersions = [];
+ }
+ },
+
+ async loadVersion(versionId) {
+ if (!versionId || !this.doclingSelected) return;
+ try {
+ const data = await Cockpit.api('/docling/versions/' + versionId);
+ if (data.version && data.version.content != null) {
+ this.doclingEditContent = data.version.content;
+ Cockpit.toast('Versie geladen: ' + this.formatTs(data.version.created_at), 'info');
+ }
+ } catch (e) {
+ Cockpit.toast(e.message || 'Versie laden mislukt', 'error');
+ }
+ },
+
+ async recordVersion(label) {
+ if (!this.doclingSelected || !this.doclingEditContent) return;
+ try {
+ await Cockpit.api('/docling/versions', {
+ method: 'POST',
+ body: JSON.stringify({
+ storage_path: this.doclingSelected,
+ content: this.doclingEditContent,
+ source_format: 'markdown',
+ version_label: label || 'bewerking',
+ nas_mtime: this.doclingFileMtime || null,
+ }),
+ });
+ await this.loadVersions(this.doclingSelected);
+ } catch (e) { }
+ },
+
+ formatTs(iso) {
+ if (!iso) return '—';
+ try {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return String(iso).slice(0, 16);
+ return d.toLocaleString('nl-NL', {
+ day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ });
+ } catch (e) {
+ return String(iso).slice(0, 16);
+ }
+ },
+
+ async loadVersions(path) {
+ if (!path) { this.docVersions = []; return; }
+ try {
+ const data = await Cockpit.api('/docling/versions?path=' + encodeURIComponent(path));
+ this.docVersions = data.versions || [];
+ } catch (e) {
+ this.docVersions = [];
+ }
+ },
+
+ async loadVersion(versionId) {
+ if (!versionId || !this.doclingSelected) return;
+ try {
+ const data = await Cockpit.api('/docling/versions/' + versionId);
+ if (data.version && data.version.content != null) {
+ this.doclingEditContent = data.version.content;
+ Cockpit.toast('Versie geladen: ' + this.formatTs(data.version.created_at), 'info');
+ }
+ } catch (e) {
+ Cockpit.toast(e.message || 'Versie laden mislukt', 'error');
+ }
+ },
+
+ async recordVersion(label) {
+ if (!this.doclingSelected || !this.doclingEditContent) return;
+ try {
+ await Cockpit.api('/docling/versions', {
+ method: 'POST',
+ body: JSON.stringify({
+ storage_path: this.doclingSelected,
+ content: this.doclingEditContent,
+ source_format: 'markdown',
+ version_label: label || 'bewerking',
+ nas_mtime: this.doclingFileMtime || null,
+ }),
+ });
+ await this.loadVersions(this.doclingSelected);
+ } catch (e) {}
+ },
+
+ async loadLlmProviders() {
+ try {
+ const data = await Cockpit.api('/api/settings/llm');
+ this.llmProviders = data.providers || [];
+ const def = data.default || this.llmProviders.find((p) => p.is_default);
+ if (def && !this.chatLlmProviderId) this.chatLlmProviderId = def.id;
+ const banner = document.getElementById('doc-hub-status');
+ if (banner && def && def.provider_type === 'ollama') {
+ banner.style.display = 'block';
+ banner.textContent = 'Tip: Ollama is traag op CPU (~2 min). Voeg DeepSeek/Gemini toe via Instellingen → AI / LLM voor snelle chat & RAG.';
+ }
+ const cloud = this.llmProviders.find((p) => p.provider_type !== 'ollama' && p.api_key_set);
+ if (cloud && def && def.provider_type === 'ollama') {
+ this.chatLlmProviderId = cloud.id;
+ }
+ } catch (e) {}
+ },
+
+ onChatProviderChange() {
+ const p = this.llmProviders.find((row) => Number(row.id) === Number(this.chatLlmProviderId));
+ this.chatStatus = p ? (p.label + ' · ' + p.model) : 'Standaard LLM';
+ },
+
+ filteredPhotos() {
+ if (!this.photoFilterLabel) return this.photos;
+ return this.photos.filter((p) => (p.user_labels || []).includes(this.photoFilterLabel));
+ },
+
+ mergeShareItems(items) {
+ const byPath = new Map(allDocuments.map((d) => [d.storage_path, d]));
+ (items || []).forEach((f) => {
+ const path = f.path || f.storage_path;
+ if (!path) return;
+ if (byPath.has(path)) {
+ const ex = byPath.get(path);
+ if (f.modified_at) ex.modified_at = f.modified_at;
+ if (!ex.analyzed_at && f.modified_at) ex.modified_at = f.modified_at;
+ return;
+ }
+ const row = {
+ filename: f.filename || path.split('/').pop(),
+ storage_path: path,
+ doc_type: f.doc_type || 'general',
+ word_count: f.word_count || 0,
+ sentiment_label: f.sentiment_label || 'neutral',
+ sentiment_compound: f.sentiment_compound || 0,
+ analyzed_at: f.analyzed_at || '',
+ modified_at: f.modified_at || '',
+ user_labels: f.user_labels || [],
+ label_notes: f.label_notes || '',
+ from_share: !f.analyzed_at,
+ };
+ allDocuments.push(row);
+ byPath.set(path, row);
+ });
+ this.docTypes = [...new Set(allDocuments.map((d) => d.doc_type).filter(Boolean))];
+ this.applyFilters();
+ },
+
+ async loadShareFiles(showToast) {
+ try {
+ const r = await fetch('/api/admin/documents/share-files?limit=5000');
+ const data = await r.json();
+ if (data.ok !== false) {
+ this.mergeShareItems(data.items || []);
+ this.nasFileTotal = data.total || (data.items || []).length;
+ if (showToast) Cockpit.toast((this.nasFileTotal || 0) + ' bestanden op NAS', 'success');
+ } else if (showToast) {
+ Cockpit.toast(data.error || 'NAS laden mislukt', 'error');
+ }
+ } catch (e) {
+ if (showToast) Cockpit.toast('NAS bestanden laden mislukt', 'error');
+ }
+ },
+
+ async refreshShare(showToast) {
+ try {
+ if (showToast !== false) Cockpit.toast('NAS scan gestart (~30s)…', 'info');
+ const scanCtrl = new AbortController();
+ const scanTimer = setTimeout(() => scanCtrl.abort(), 90000);
+ try {
+ await fetch('/api/admin/documents/trigger-scan', { method: 'POST', signal: scanCtrl.signal });
+ } finally {
+ clearTimeout(scanTimer);
+ }
+ await this.loadShareFiles(showToast !== false);
+ } catch (e) {
+ if (showToast !== false) Cockpit.toast('Share scan mislukt — toon cache', 'error');
+ await this.loadShareFiles(false);
+ }
+ },
+
+ applyFilters() {
+ const q = this.filterQuery.toLowerCase();
+ this.filteredDocs = allDocuments.filter((d) => {
+ if (this.filterSentiment && d.sentiment_label !== this.filterSentiment) return false;
+ if (this.filterType && d.doc_type !== this.filterType) return false;
+ if (this.filterLabel && !(d.user_labels || []).includes(this.filterLabel)) return false;
+ if (q) {
+ const hay = ((d.filename || '') + ' ' + (d.storage_path || '') + ' ' + (d.user_labels || []).join(' ')).toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ return true;
+ });
+ },
+
+ async loadLabelVocabulary() {
+ try {
+ const r = await fetch('/api/admin/labels/vocabulary?limit=60');
+ const data = await r.json();
+ if (data.ok) this.labelVocabulary = data.labels || [];
+ } catch (e) {}
+ },
+
+ async loadTaxonomy() {
+ try {
+ const r = await fetch('/api/admin/labels/taxonomy');
+ const data = await r.json();
+ if (data.ok) {
+ this.photoTaxonomy = data.photo || [];
+ this.documentTaxonomy = data.document || [];
+ }
+ } catch (e) { }
+ },
+
+ formatTs(iso) {
+ if (!iso) return '—';
+ try {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return String(iso).slice(0, 16);
+ return d.toLocaleString('nl-NL', {
+ day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ });
+ } catch (e) {
+ return String(iso).slice(0, 16);
+ }
+ },
+
+ async loadVersions(path) {
+ if (!path) { this.docVersions = []; return; }
+ try {
+ const data = await Cockpit.api('/docling/versions?path=' + encodeURIComponent(path));
+ this.docVersions = data.versions || [];
+ } catch (e) {
+ this.docVersions = [];
+ }
+ },
+
+ async loadVersion(versionId) {
+ if (!versionId || !this.doclingSelected) return;
+ try {
+ const data = await Cockpit.api('/docling/versions/' + versionId);
+ if (data.version && data.version.content != null) {
+ this.doclingEditContent = data.version.content;
+ Cockpit.toast('Versie geladen: ' + this.formatTs(data.version.created_at), 'info');
+ }
+ } catch (e) {
+ Cockpit.toast(e.message || 'Versie laden mislukt', 'error');
+ }
+ },
+
+ async recordVersion(label) {
+ if (!this.doclingSelected || !this.doclingEditContent) return;
+ try {
+ await Cockpit.api('/docling/versions', {
+ method: 'POST',
+ body: JSON.stringify({
+ storage_path: this.doclingSelected,
+ content: this.doclingEditContent,
+ source_format: 'markdown',
+ version_label: label || 'bewerking',
+ nas_mtime: this.doclingFileMtime || null,
+ }),
+ });
+ await this.loadVersions(this.doclingSelected);
+ } catch (e) {}
+ },
+
+ openDocLabel(doc) {
+ this.activeDoc = doc;
+ this.docSelectedLabels = [...(doc.user_labels || [])];
+ this.docLabelsInput = '';
+ this.docNotesInput = doc.label_notes || '';
+ },
+
+ isShareSelected(path) {
+ return this.selectedSharePaths.includes(path);
+ },
+
+ toggleShareSelect(path) {
+ const idx = this.selectedSharePaths.indexOf(path);
+ if (idx >= 0) this.selectedSharePaths.splice(idx, 1);
+ else this.selectedSharePaths.push(path);
+ },
+
+ openInWorkbench(path) {
+ if (!path) return;
+ const folder = path.includes('/') ? path.replace(/\/[^/]+$/, '') : '';
+ const open = async () => {
+ if (folder !== this.shareBrowsePath) {
+ await this.browseShare(folder, false);
+ }
+ this.switchTab('docling');
+ await this.openDoclingFile(path);
+ };
+ open().catch((e) => Cockpit.toast(e.message || 'Openen mislukt', 'error'));
+ },
+
+ openSelectedInWorkbench() {
+ const paths = [...this.selectedSharePaths];
+ if (!paths.length) return;
+ this.openInWorkbench(paths[0]);
+ if (paths.length > 1) {
+ Cockpit.toast(paths.length + ' geselecteerd — eerste geopend in Workbench', 'info');
+ }
+ },
+
+ sharePathParts() {
+ return (this.shareBrowsePath || '').split('/').filter(Boolean);
+ },
+
+ sharePathPrefix(idx) {
+ return this.sharePathParts().slice(0, idx + 1).join('/');
+ },
+
+ shareBrowseFolders() {
+ return (this.shareBrowseEntries || []).filter((e) => e.type === 'folder');
+ },
+
+ async browseShare(path, showToast) {
+ const rel = (path || '').replace(/^\/+/, '').replace(/\/+$/, '');
+ this.shareBrowsePath = rel;
+ this.shareBrowseLoading = true;
+ try {
+ const r = await fetch(
+ '/api/admin/documents/share-browse?path=' + encodeURIComponent(rel)
+ + '&limit=3000&recursive=' + (this.shareBrowseRecursive ? 'true' : 'false'),
+ );
+ const data = await r.json();
+ if (data.ok !== false && Array.isArray(data.entries)) {
+ this.shareBrowseEntries = data.entries;
+ const files = data.entries.filter((e) => e.type === 'file');
+ files.forEach((f) => {
+ const ex = this.doclingFiles.find((x) => x.path === f.path);
+ if (!ex) {
+ this.doclingFiles.push({
+ path: f.path,
+ filename: f.filename || f.name,
+ size: f.size,
+ modified_at: f.modified_at,
+ });
+ }
+ });
+ if (showToast) Cockpit.toast((data.entries.length || 0) + ' items in ' + (rel || 'Share'), 'success');
+ } else {
+ await this.loadAllNasFiles(false);
+ this.buildShareEntriesFromFiles();
+ if (showToast) Cockpit.toast('Share map geladen (flat)', 'info');
+ }
+ } catch (e) {
+ await this.loadAllNasFiles(false);
+ this.buildShareEntriesFromFiles();
+ } finally {
+ this.shareBrowseLoading = false;
+ }
+ },
+
+ browseShareParent() {
+ const parts = this.sharePathParts();
+ parts.pop();
+ this.browseShare(parts.join('/'), false);
+ },
+
+ buildShareEntriesFromFiles() {
+ const prefix = this.shareBrowsePath ? this.shareBrowsePath + '/' : '';
+ const folders = new Set();
+ const files = [];
+ this.doclingFiles.forEach((f) => {
+ const path = f.path || '';
+ if (prefix && !path.startsWith(prefix)) return;
+ if (!prefix && path.includes('/')) {
+ folders.add(path.split('/')[0]);
+ return;
+ }
+ const rest = prefix ? path.slice(prefix.length) : path;
+ if (!rest) return;
+ const slash = rest.indexOf('/');
+ if (slash >= 0) folders.add(rest.slice(0, slash));
+ else files.push({ type: 'file', path, filename: f.filename, name: f.filename, size: f.size });
+ });
+ this.shareBrowseEntries = [
+ ...[...folders].sort().map((name) => ({
+ type: 'folder',
+ name,
+ path: prefix ? prefix.replace(/\/$/, '') + (prefix.endsWith('/') ? '' : '/') + name : name,
+ })),
+ ...files,
+ ];
+ },
+
+ toggleDocLabel(id) {
+ const idx = this.docSelectedLabels.indexOf(id);
+ if (idx >= 0) this.docSelectedLabels.splice(idx, 1);
+ else this.docSelectedLabels.push(id);
+ },
+
+ async saveDocLabels() {
+ if (!this.activeDoc) return;
+ const labels = [...this.docSelectedLabels];
+ this.docLabelsInput.split(',').map((s) => s.trim()).filter(Boolean).forEach((t) => {
+ if (!labels.includes(t)) labels.push(t);
+ });
+ try {
+ const r = await Cockpit.api('/documents/labels', {
+ method: 'PATCH',
+ body: JSON.stringify({
+ storage_path: this.activeDoc.storage_path,
+ labels,
+ notes: this.docNotesInput || null,
+ }),
+ });
+ const saved = r.document || {};
+ this.activeDoc.user_labels = saved.user_labels || labels;
+ this.activeDoc.label_notes = saved.label_notes || this.docNotesInput;
+ const idx = allDocuments.findIndex((d) => d.storage_path === this.activeDoc.storage_path);
+ if (idx >= 0) {
+ allDocuments[idx].user_labels = this.activeDoc.user_labels;
+ allDocuments[idx].label_notes = this.activeDoc.label_notes;
+ }
+ this.applyFilters();
+ await this.loadLabelVocabulary();
+ Cockpit.toast('Document gelabeld', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ toggleModalLabel(id) {
+ const idx = this.modalPhotoLabels.indexOf(id);
+ if (idx >= 0) this.modalPhotoLabels.splice(idx, 1);
+ else this.modalPhotoLabels.push(id);
+ },
+
+ async openPhotoModal(id) {
+ this.activePhoto = id;
+ this.photoModalOpen = true;
+ document.body.style.overflow = 'hidden';
+ this.modalPhotoLabels = [];
+ const p = this.photos.find((x) => x.id === id);
+ if (p && p.user_labels) this.modalPhotoLabels = [...p.user_labels];
+ this.photoLabelsInput = '';
+ this.photoNotesInput = p ? (p.label_notes || '') : '';
+ this.activeOcr = p ? (p.ocr_preview || '') : '';
+ try {
+ const r = await Cockpit.api('/photos/' + id + '/detections');
+ this.detections = r.detections || [];
+ this.activeItems = r.extracted_items || [];
+ this.activeOcr = r.ocr_text || this.activeOcr;
+ this.modalPhotoLabels = [...(r.user_labels || this.modalPhotoLabels)];
+ this.photoNotesInput = r.label_notes || this.photoNotesInput;
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ closePhotoModal() {
+ this.photoModalOpen = false;
+ document.body.style.overflow = '';
+ },
+
+ async saveModalPhotoLabels() {
+ if (!this.activePhoto) return;
+ const labels = [...this.modalPhotoLabels];
+ this.photoLabelsInput.split(',').map((s) => s.trim()).filter(Boolean).forEach((t) => {
+ if (!labels.includes(t)) labels.push(t);
+ });
+ if (!labels.length) return Cockpit.toast('Kies minimaal één label-type', 'error');
+ try {
+ const r = await Cockpit.api('/photos/' + this.activePhoto + '/labels', {
+ method: 'PATCH',
+ body: JSON.stringify({ labels, notes: this.photoNotesInput || null }),
+ });
+ const saved = r.photo || {};
+ const p = this.photos.find((x) => x.id === this.activePhoto);
+ if (p) {
+ p.user_labels = saved.user_labels || labels;
+ p.label_notes = saved.label_notes || this.photoNotesInput;
+ }
+ await this.loadLabelVocabulary();
+ this.closePhotoModal();
+ Cockpit.toast('Foto gelabeld', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ labelDisplayName(id) {
+ const t = this.photoTaxonomy.find((x) => x.id === id);
+ return t ? t.label : id;
+ },
+
+ async batchNasOcr(force) {
+ if (this.nasOcrBusy) return;
+ this.nasOcrBusy = true;
+ Cockpit.toast('NAS OCR gestart…', 'info');
+ try {
+ const url = '/api/admin/documents/nas-ocr' + (force ? '?force=true' : '');
+ const r = await fetch(url, { method: 'POST' });
+ const data = await r.json();
+ if (!r.ok || !data.ok) throw new Error(data.detail || data.error || 'OCR mislukt');
+ Cockpit.toast((data.images_extracted || 0) + ' afbeeldingen ge-OCR\'d', 'success');
+ await this.loadPhotos();
+ } catch (e) {
+ Cockpit.toast(e.message || 'NAS OCR mislukt', 'error');
+ } finally {
+ this.nasOcrBusy = false;
+ }
+ },
+
+ renderCharts(mode) {
+ mode = mode || window.__vizMode || 'neo-bars';
+ if (typeof Chart === 'undefined') return;
+ if (wordsChartInst) { wordsChartInst.destroy(); wordsChartInst = null; }
+ if (sentimentChartInst) { sentimentChartInst.destroy(); sentimentChartInst = null; }
+ const dash = this;
+ const wc = document.getElementById('wordsChart');
+ const sc = document.getElementById('sentimentChart');
+ if (!wc || !sc) return;
+ wordsChartInst = new Chart(wc, {
+ type: 'bar',
+ data: {
+ labels: topWords.slice(0, 10).map((w) => w.lemma),
+ datasets: [{
+ data: topWords.slice(0, 10).map((w) => w.total_count),
+ backgroundColor: 'rgba(0,229,255,0.75)',
+ borderWidth: 0,
+ borderRadius: 6,
+ }],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { display: false } },
+ onClick: (_, els) => {
+ if (els[0]) {
+ dash.filterQuery = topWords[els[0].index].lemma;
+ dash.applyFilters();
+ }
+ },
+ scales: {
+ y: { beginAtZero: true, ticks: { color: '#64748b', font: { size: 9 } } },
+ x: { ticks: { color: '#64748b', font: { size: 9 } } },
+ },
+ },
+ });
+ sentimentChartInst = new Chart(sc, {
+ type: 'doughnut',
+ data: {
+ labels: ['Positief', 'Neutraal', 'Negatief'],
+ datasets: [{
+ data: [summary.positive, summary.neutral, summary.negative],
+ backgroundColor: ['#4ade80', '#64748b', '#fb7185'],
+ borderWidth: 0,
+ }],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { labels: { color: '#94a3b8', font: { size: 9 } } } },
+ onClick: (_, els) => {
+ if (!els[0]) return;
+ dash.filterSentiment = ['positive', 'neutral', 'negative'][els[0].index];
+ dash.applyFilters();
+ },
+ },
+ });
+ },
+
+ async loadPhotos() {
+ try {
+ const r = await Cockpit.api('/photos?limit=80');
+ this.photos = r.photos || [];
+ } catch (e) {}
+ },
+
+ toggleLive() {
+ if (this.liveTimer) clearInterval(this.liveTimer);
+ if (this.liveRefresh) {
+ this.liveTimer = setInterval(() => {
+ this.loadPhotos();
+ this.refreshShare(false);
+ }, 15000);
+ }
+ },
+
+ async uploadPhoto(ev) {
+ const file = ev.target.files && ev.target.files[0];
+ if (!file) return;
+ const b64 = await new Promise((res, rej) => {
+ const r = new FileReader();
+ r.onload = () => res(r.result.split(',')[1]);
+ r.onerror = rej;
+ r.readAsDataURL(file);
+ });
+ try {
+ await Cockpit.api('/photos/analyze', {
+ method: 'POST',
+ body: JSON.stringify({ image_b64: b64, source: 'upload', filename: file.name }),
+ });
+ Cockpit.toast('Foto geanalyseerd', 'success');
+ await this.loadPhotos();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ ev.target.value = '';
+ },
+
+ /* ── Docling ── */
+ doclingFilteredFiles() {
+ const q = (this.doclingFilter || '').toLowerCase();
+ const fromBrowse = (this.shareBrowseEntries || [])
+ .filter((e) => e.type === 'file')
+ .map((e) => ({
+ path: e.path,
+ filename: e.filename || e.name,
+ size: e.size,
+ modified_at: e.modified_at,
+ }));
+ const pool = fromBrowse.length ? fromBrowse : this.doclingFiles.filter((f) => {
+ const path = f.path || '';
+ const prefix = this.shareBrowsePath ? this.shareBrowsePath.replace(/\/?$/, '/') : '';
+ if (prefix) {
+ if (!path.startsWith(prefix)) return false;
+ if (path.slice(prefix.length).includes('/')) return false;
+ } else if (path.includes('/')) {
+ return false;
+ }
+ return true;
+ });
+ return pool.filter((f) => {
+ if (this.doclingExtFilter) {
+ const ext = this.doclingExtFilter.startsWith('.') ? this.doclingExtFilter : '.' + this.doclingExtFilter;
+ const pathExt = this.fileExtension(f.path);
+ if (ext === '.png') {
+ if (!['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif'].includes(pathExt)) return false;
+ } else if (pathExt !== ext.toLowerCase()) return false;
+ }
+ if (!q) return true;
+ return ((f.path || '') + ' ' + (f.filename || '')).toLowerCase().includes(q);
+ });
+ },
+
+ fileExtension(path) {
+ const p = (path || '').toLowerCase();
+ const i = p.lastIndexOf('.');
+ return i >= 0 ? p.slice(i) : '';
+ },
+
+ fileIcon(path) {
+ const ext = this.fileExtension(path);
+ const map = {
+ '.pdf': '📕', '.docx': '📝', '.doc': '📝', '.xlsx': '📊', '.xls': '📊',
+ '.pptx': '📽️', '.ppt': '📽️', '.txt': '📄', '.md': '📄', '.csv': '📋',
+ '.html': '🌐', '.htm': '🌐', '.json': '{}', '.xml': '📰', '.yaml': '⚙️',
+ '.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.webp': '🖼️', '.gif': '🖼️',
+ };
+ return map[ext] || '📁';
+ },
+
+ fileKind(path) {
+ const ext = this.fileExtension(path);
+ if (this.isPlainTextExt(ext)) return 'text';
+ if (this.isImageExt(ext)) return 'image';
+ if (ext === '.pdf') return 'pdf';
+ if (['.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt'].includes(ext)) return 'office';
+ return 'other';
+ },
+
+ fileKindLabel(path) {
+ const k = this.fileKind(path);
+ return { text: 'Tekst', image: 'Afbeelding', pdf: 'PDF', office: 'Office', other: 'Bestand' }[k] || 'Bestand';
+ },
+
+ isPlainTextExt(ext) {
+ return ['.txt', '.md', '.csv', '.json', '.xml', '.html', '.htm', '.yaml', '.yml', '.log', '.rtf'].includes(ext);
+ },
+
+ isImageExt(ext) {
+ return ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif'].includes(ext);
+ },
+
+ isDoclingSupported(path) {
+ const ext = this.fileExtension(path);
+ if (!ext) return false;
+ const supported = this.doclingSupportedSuffixes.length
+ ? this.doclingSupportedSuffixes
+ : ['.pdf', '.docx', '.pptx', '.xlsx', '.html', '.htm', '.md', '.txt', '.png', '.jpg', '.jpeg', '.webp', '.tiff', '.tif', '.bmp', '.gif'];
+ return supported.includes(ext);
+ },
+
+ pptPdfFiles() {
+ return this.doclingFiles.filter((f) => (f.path || '').toLowerCase().endsWith('.pdf'));
+ },
+
+ pptPptxFiles() {
+ return this.doclingFiles.filter((f) => (f.path || '').toLowerCase().endsWith('.pptx'));
+ },
+
+ async loadAllNasFiles(showToast) {
+ this.nasLoading = true;
+ try {
+ if (showToast) Cockpit.toast('NAS bestanden laden…', 'info');
+ const r = await fetch('/api/admin/documents/share-files?limit=5000');
+ const data = await r.json();
+ const byPath = new Map();
+ allDocuments.forEach((d) => {
+ byPath.set(d.storage_path, {
+ path: d.storage_path,
+ filename: d.filename,
+ word_count: d.word_count,
+ sentiment_label: d.sentiment_label,
+ user_labels: d.user_labels || [],
+ });
+ });
+ (data.items || []).forEach((f) => {
+ const path = f.path || f.storage_path;
+ if (!path) return;
+ const ex = byPath.get(path) || {};
+ byPath.set(path, {
+ ...ex,
+ path,
+ filename: f.filename || path.split('/').pop(),
+ size: f.size,
+ modified_at: f.modified_at,
+ });
+ });
+ this.doclingFiles = Array.from(byPath.values()).sort((a, b) =>
+ (a.filename || '').localeCompare(b.filename || ''),
+ );
+ this.nasFileTotal = data.total || this.doclingFiles.length;
+ this.buildShareEntriesFromFiles();
+ if (showToast) Cockpit.toast(this.nasFileTotal + ' bestanden op NAS', 'success');
+ } catch (e) {
+ if (showToast) Cockpit.toast('NAS laden mislukt', 'error');
+ } finally {
+ this.nasLoading = false;
+ }
+ },
+
+ fileDoclingStatus(path) {
+ return this.doclingFileStatus[path] || '';
+ },
+
+ selectDoclingFile(path) {
+ this.openInWorkbench(path);
+ },
+
+ async openDoclingFile(path) {
+ if (!path || (this.doclingFileLoading && this.doclingSelected === path)) return;
+ this.doclingSelected = path;
+ const fileMeta = this.doclingFiles.find((f) => f.path === path) || {};
+ this.doclingFileMtime = fileMeta.modified_at || '';
+ this.doclingFileKind = this.fileKind(path);
+ this.doclingEditMode = true;
+ this.doclingFileLoading = true;
+ this.doclingResult = null;
+ this.doclingExports = {};
+ this.doclingTables = [];
+ this.doclingPictures = [];
+ this.doclingEditContent = '';
+ await this.loadVersions(path);
+
+ const ext = this.fileExtension(path);
+ if (this.doclingFileKind === 'pdf') this.doclingExportTab = 'original';
+ else if (this.doclingFileKind === 'image') this.doclingExportTab = 'original';
+ else this.doclingExportTab = 'rendered';
+
+ try {
+ const hasDraft = await this.loadWorkspace(path);
+ if (hasDraft) {
+ this.doclingFileLoading = false;
+ return;
+ }
+
+ if (this.isPlainTextExt(ext)) {
+ await this.loadNasRawText(path);
+ this.doclingFileLoading = false;
+ return;
+ }
+
+ if (this.doclingFileKind === 'image') {
+ this.doclingFileLoading = false;
+ if (this.doclingAutoConvert && this.isDoclingSupported(path)) {
+ await this.runDoclingConvert(true);
+ }
+ return;
+ }
+
+ if (this.doclingFileKind === 'pdf') {
+ const cached = await this.loadDoclingCached();
+ this.doclingFileLoading = false;
+ if (!cached && this.doclingAutoConvert) await this.runDoclingConvert(true);
+ return;
+ }
+
+ const cached = await this.loadDoclingCached();
+ if (cached) {
+ this.doclingFileLoading = false;
+ return;
+ }
+
+ if (this.doclingAutoConvert && this.isDoclingSupported(path)) {
+ this.doclingFileLoading = false;
+ await this.runDoclingConvert(true);
+ } else if (!this.isDoclingSupported(path)) {
+ this.doclingEditContent = '# ' + path.split('/').pop() + '\n\nDit bestandstype wordt niet ondersteund voor Docling-conversie.\nOpen het origineel via de knop rechtsboven.';
+ Cockpit.toast('Geen Docling-support voor ' + ext, 'info');
+ }
+ } catch (e) {
+ Cockpit.toast(e.message || 'Laden mislukt', 'error');
+ } finally {
+ this.doclingFileLoading = false;
+ }
+ },
+
+ async loadNasRawText(path) {
+ const data = await Cockpit.api('/docling/nas-read?path=' + encodeURIComponent(path));
+ this.doclingEditContent = data.content || '';
+ this.doclingExports = { markdown: this.doclingEditContent, text: this.doclingEditContent };
+ this.doclingResult = { status: 'raw' };
+ this.doclingExportTab = 'rendered';
+ },
+
+ async loadWorkspace(path) {
+ if (!path) return false;
+ try {
+ const data = await Cockpit.api('/docling/workspace?path=' + encodeURIComponent(path));
+ if (data.workspace && data.workspace.content) {
+ this.doclingEditContent = data.workspace.content;
+ return true;
+ }
+ } catch (e) {}
+ const md = this.doclingExports.markdown;
+ if (typeof md === 'string') this.doclingEditContent = md;
+ return false;
+ },
+
+ async saveWorkspace() {
+ if (!this.doclingSelected) return;
+ try {
+ await Cockpit.api('/docling/workspace', {
+ method: 'PUT',
+ body: JSON.stringify({
+ storage_path: this.doclingSelected,
+ content: this.doclingEditContent,
+ source_format: 'markdown',
+ }),
+ });
+ if (this.doclingExports) this.doclingExports.markdown = this.doclingEditContent;
+ await this.recordVersion('concept opgeslagen');
+ Cockpit.toast('Concept opgeslagen', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async saveToNas() {
+ if (!this.doclingSelected || !this.doclingEditContent) return;
+ try {
+ const data = await Cockpit.api('/docling/save-nas', {
+ method: 'POST',
+ body: JSON.stringify({
+ path: this.doclingSelected,
+ content: this.doclingEditContent,
+ subdir: 'Telegram/Exports',
+ }),
+ });
+ Cockpit.toast('Opgeslagen op NAS: ' + (data.path || data.filename), 'success');
+ await this.recordVersion('NAS export');
+ await this.loadAllNasFiles(false);
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ sendToPowerpoint() {
+ this.switchTab('powerpoint');
+ this.importMarkdownToSlides(this.doclingEditContent || this.doclingExports.markdown || '');
+ },
+
+ async initPowerpoint() {
+ await this.loadAllNasFiles(false);
+ if (!this.pptSlides.length) {
+ this.pptSlides = [{ title: 'Intro', bullets: [], bulletsText: '' }];
+ }
+ },
+
+ markdownToSlides(md) {
+ const slides = [];
+ let current = { title: '', bullets: [], bulletsText: '' };
+ (md || '').split('\n').forEach((line) => {
+ const t = line.trim();
+ if (t.startsWith('## ')) {
+ if (current.title || current.bullets.length) slides.push({ ...current });
+ current = { title: t.slice(3).trim(), bullets: [], bulletsText: '' };
+ } else if (t.startsWith('# ') && !current.title) {
+ current.title = t.slice(2).trim();
+ } else if (/^[-*]\s/.test(t)) {
+ current.bullets.push(t.replace(/^[-*]\s+/, ''));
+ current.bulletsText = current.bullets.join('\n');
+ } else if (t.length > 20 && !t.startsWith('|')) {
+ current.bullets.push(t);
+ current.bulletsText = current.bullets.join('\n');
+ }
+ });
+ if (current.title || current.bullets.length) slides.push(current);
+ return slides.length ? slides : [{ title: 'Inhoud', bullets: [(md || '').slice(0, 500)], bulletsText: (md || '').slice(0, 500) }];
+ },
+
+ importMarkdownToSlides(md) {
+ const text = md || this.doclingEditContent || this.doclingExports.markdown || '';
+ if (!text) return Cockpit.toast('Geen markdown — eerst Docling convert', 'info');
+ this.pptSlides = this.markdownToSlides(text);
+ const firstLine = (text.split('\n').find((l) => l.startsWith('# ')) || '').replace(/^#\s+/, '');
+ if (firstLine) this.pptTitle = firstLine.slice(0, 200);
+ Cockpit.toast(this.pptSlides.length + ' slides gegenereerd', 'success');
+ },
+
+ addPptSlide() {
+ this.pptSlides.push({ title: 'Nieuwe slide', bullets: [], bulletsText: '' });
+ },
+
+ removePptSlide(idx) {
+ this.pptSlides.splice(idx, 1);
+ },
+
+ syncSlideBullets(slide) {
+ slide.bullets = (slide.bulletsText || '').split('\n').map((s) => s.trim()).filter(Boolean);
+ },
+
+ async createPptx() {
+ if (!this.pptSlides.length) return;
+ this.pptSlides.forEach((s) => this.syncSlideBullets(s));
+ this.pptBusy = true;
+ try {
+ const data = await Cockpit.api('/pptx/create', {
+ method: 'POST',
+ body: JSON.stringify({
+ title: this.pptTitle || 'Presentatie',
+ subtitle: this.pptSubtitle || 'Foodlinkk',
+ filename: this.pptFilename || this.pptTitle,
+ slides: this.pptSlides.map((s) => ({ title: s.title, bullets: s.bullets })),
+ }),
+ });
+ this.pptLastOutput = data.relative_output || data.output || data.path || 'klaar';
+ Cockpit.toast('PowerPoint aangemaakt', 'success');
+ await this.loadAllNasFiles(false);
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.pptBusy = false;
+ }
+ },
+
+ async convertPdfToPptx() {
+ if (!this.pptSourcePdf) return;
+ this.pptBusy = true;
+ Cockpit.toast('PDF → PPTX conversie…', 'info');
+ try {
+ const data = await Cockpit.api('/pptx/pdf-to-pptx?path=' + encodeURIComponent(this.pptSourcePdf) + '&mode=smart');
+ this.pptLastOutput = data.relative_output || data.output || '';
+ Cockpit.toast('PPTX gegenereerd', 'success');
+ await this.loadAllNasFiles(false);
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.pptBusy = false;
+ }
+ },
+
+ async loadDoclingHistory() {
+ try {
+ const data = await Cockpit.api('/docling/history?limit=40');
+ this.doclingHistory = data.items || [];
+ const statusMap = {};
+ (this.doclingHistory || []).forEach((h) => {
+ if (!statusMap[h.storage_path]) statusMap[h.storage_path] = h.status;
+ });
+ this.doclingFileStatus = statusMap;
+ } catch (e) {}
+ },
+
+ async initDocling() {
+ if (!this.doclingFiles.length) await this.loadAllNasFiles(false);
+ else if (!this.shareBrowseEntries.length) await this.browseShare(this.shareBrowsePath || '', false);
+ else this.nasLoading = false;
+ try {
+ const caps = await Cockpit.api('/docling/capabilities');
+ if (caps.installed) {
+ this.doclingStatus = 'Docling actief · ' + (caps.supported_suffixes || []).length + ' formaten · klik = open + convert';
+ if (caps.export_formats) this.doclingExportFormats = caps.export_formats;
+ if (caps.supported_suffixes) this.doclingSupportedSuffixes = caps.supported_suffixes;
+ } else {
+ this.doclingStatus = 'Docling niet geïnstalleerd';
+ }
+ } catch (e) {
+ this.doclingStatus = 'Docling offline: ' + e.message;
+ }
+ await this.loadDoclingHistory();
+ },
+
+ doclingPayload() {
+ return {
+ path: this.doclingSelected,
+ ocr: this.doclingOpts.ocr,
+ tables: this.doclingOpts.tables,
+ page_images: this.doclingOpts.page_images,
+ picture_images: this.doclingOpts.picture_images,
+ force_full_page_ocr: this.doclingOpts.force_full_page_ocr,
+ formats: this.doclingFormats.length ? this.doclingFormats : ['markdown', 'json'],
+ };
+ },
+
+ applyDoclingResult(data) {
+ const src = data.result || data;
+ this.doclingResult = src;
+ this.doclingExports = src.exports || data.exports || {};
+ this.doclingTables = src.tables_data || data.tables || [];
+ this.doclingPictures = src.pictures || data.pictures || [];
+ this.doclingMeta = {
+ page_count: src.page_count || data.page_count || 0,
+ metadata: src.metadata || data.metadata || {},
+ status: src.status || 'done',
+ };
+ const keys = this.doclingAvailableExports();
+ this.doclingExportTab = keys.includes('markdown') ? 'rendered' : (keys[0] || 'rendered');
+ if (typeof this.doclingExports.markdown === 'string') {
+ this.doclingEditContent = this.doclingExports.markdown;
+ }
+ },
+
+ doclingAvailableExports() {
+ return Object.keys(this.doclingExports || {}).filter((k) => this.doclingExports[k] != null);
+ },
+
+ doclingPreviewText() {
+ let val = this.doclingExports[this.doclingExportTab];
+ if (val == null) return '';
+ if (typeof val === 'string') val = val;
+ else if (typeof val === 'object' && val.error) return 'Error: ' + val.error;
+ else val = JSON.stringify(val, null, 2);
+ const q = (this.doclingPreviewSearch || '').trim().toLowerCase();
+ if (!q || typeof val !== 'string') return val;
+ const lines = val.split('\n');
+ const hits = lines.filter((l) => l.toLowerCase().includes(q));
+ return hits.length ? hits.join('\n') : '(geen matches voor "' + q + '")';
+ },
+
+ doclingPreviewHtml() {
+ const val = this.doclingExports.html;
+ return typeof val === 'string' ? val : '';
+ },
+
+ doclingRenderedMarkdown() {
+ const md = this.doclingExports.markdown;
+ if (typeof md !== 'string') return '';
+ return md
+ .replace(/^### (.*)$/gm, '$1
')
+ .replace(/^## (.*)$/gm, '$1
')
+ .replace(/^# (.*)$/gm, '$1
')
+ .replace(/\*\*(.*?)\*\*/g, '$1')
+ .replace(/\n\n/g, '')
+ .replace(/^/, '
')
+ .replace(/$/, '
');
+ },
+
+ async copyDoclingExport() {
+ const text = this.doclingExportTab === 'html'
+ ? this.doclingPreviewHtml()
+ : this.doclingPreviewText();
+ try {
+ await navigator.clipboard.writeText(text);
+ Cockpit.toast('Gekopieerd', 'success');
+ } catch (e) {
+ Cockpit.toast('Kopiëren mislukt', 'error');
+ }
+ },
+
+ downloadDoclingExport() {
+ const tab = this.doclingExportTab;
+ let content = tab === 'html' ? this.doclingPreviewHtml() : this.doclingPreviewText();
+ if (!content) return Cockpit.toast('Geen export', 'info');
+ const ext = tab === 'json' ? 'json' : tab === 'html' ? 'html' : tab === 'yaml' ? 'yaml' : 'md';
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = (this.doclingSelected || 'export').split('/').pop().replace(/\.[^.]+$/, '') + '.' + ext;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ },
+
+ async runDoclingConvert(silent) {
+ if (!this.doclingSelected || this.doclingBusy) return;
+ this.doclingBusy = true;
+ if (!silent) Cockpit.toast('Docling converteert…', 'info');
+ try {
+ const data = await Cockpit.api('/docling/convert', {
+ method: 'POST',
+ body: JSON.stringify(this.doclingPayload()),
+ });
+ this.applyDoclingResult(data);
+ await this.loadDoclingHistory();
+ if (!silent) Cockpit.toast('Klaar · ' + (data.page_count || 0) + ' pagina\'s · ' + (this.doclingTables.length || 0) + ' tabellen', 'success');
+ } catch (e) {
+ if (!silent) Cockpit.toast(e.message, 'error');
+ else Cockpit.toast('Convert mislukt: ' + e.message, 'error');
+ } finally {
+ this.doclingBusy = false;
+ }
+ },
+
+ async runDoclingBatch() {
+ if (this.doclingBusy) return;
+ this.doclingBusy = true;
+ try {
+ const data = await Cockpit.api('/docling/batch', {
+ method: 'POST',
+ body: JSON.stringify({
+ limit: 10,
+ ext: this.doclingExtFilter || null,
+ formats: this.doclingFormats,
+ ...this.doclingOpts,
+ }),
+ });
+ await this.loadDoclingHistory();
+ Cockpit.toast((data.converted || 0) + ' / ' + (data.total || 0) + ' geconverteerd', 'success');
+ if (this.doclingSelected && data.results) {
+ const hit = (data.results || []).find((r) => r.storage_path === this.doclingSelected && r.ok);
+ if (hit) this.applyDoclingResult(hit);
+ }
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.doclingBusy = false;
+ }
+ },
+
+ async loadDoclingCached() {
+ if (!this.doclingSelected) return false;
+ try {
+ const data = await Cockpit.api('/docling/result?path=' + encodeURIComponent(this.doclingSelected));
+ if (data.ok && data.result) {
+ this.applyDoclingResult(data.result);
+ return true;
+ }
+ } catch (e) {}
+ return false;
+ },
+
+ openHistoryItem(item) {
+ if (!item || !item.storage_path) return;
+ this.openDoclingFile(item.storage_path);
+ },
+
+ stat360(key) {
+ const stats = this.client360 && this.client360.stats;
+ if (!stats || stats[key] == null) return 0;
+ return stats[key];
+ },
+
+ linkedClientName() {
+ const id = Number(this.linkClientId);
+ if (!id) return '';
+ const c = (this.linkClients || []).find((row) => Number(row.id) === id);
+ return 'Klant: ' + (c && c.name ? c.name : '—');
+ },
+
+ async initDocChat() {
+ await this.loadLlmProviders();
+ const p = this.llmProviders.find((row) => Number(row.id) === Number(this.chatLlmProviderId));
+ this.chatStatus = p ? (p.label + ' · ' + p.model) : 'LLM laden…';
+ await this.loadLinkClients();
+ try {
+ const d = await Cockpit.api('/documents/nas-diagnostics');
+ const vis = d.visible_files || 0;
+ this.chatStatus = vis + ' bestanden zichtbaar · Chroma RAG actief';
+ if (vis < 30) {
+ this.nasDiagHint = d.hint || 'Weinig bestanden zichtbaar op NAS — controleer Synology rechten voor map CUCINA/Foodlinkk.';
+ } else {
+ this.nasDiagHint = '';
+ }
+ } catch (e) {
+ this.chatStatus = 'Diagnostics offline';
+ }
+ },
+
+ async reindexNas() {
+ this.chatReindexing = true;
+ Cockpit.toast('NAS indexeren voor RAG…', 'info');
+ try {
+ await Cockpit.api('/documents/trigger-scan', { method: 'POST' });
+ Cockpit.toast('Index scan gestart — Herman kan zo meer documenten zien', 'success');
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.chatReindexing = false;
+ }
+ },
+
+ async runChatSearch() {
+ const q = (this.chatSearchQ || '').trim();
+ if (!q) return;
+ try {
+ const data = await Cockpit.api('/documents/search?q=' + encodeURIComponent(q) + '&limit=8');
+ this.chatSearchHits = data.results || [];
+ } catch (e) {
+ Cockpit.toast('Zoeken mislukt', 'error');
+ }
+ },
+
+ async sendDocChat() {
+ const msg = (this.chatInput || '').trim();
+ if (!msg || this.chatBusy) return;
+ this.chatBusy = true;
+ this.chatStatus = 'Bezig met denken… (kan ~1–2 min duren op CPU)';
+ this.chatMessages.push({ role: 'user', content: msg });
+ const savedInput = this.chatInput;
+ this.chatInput = '';
+ this.$nextTick(() => {
+ const log = document.getElementById('doc-chat-log');
+ if (log) log.scrollTop = log.scrollHeight;
+ });
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 110000);
+ try {
+ const history = this.chatMessages.slice(-8, -1).map((m) => ({ role: m.role, content: m.content }));
+ const payload = {
+ message: msg,
+ use_herman: !!this.chatUseHerman,
+ llm_provider_id: this.chatLlmProviderId ? Number(this.chatLlmProviderId) : null,
+ history,
+ path_prefix: this.chatScopeFile && this.doclingSelected ? this.doclingSelected : '',
+ client_id: this.chatScopeClient && this.linkClientId ? Number(this.linkClientId) : null,
+ project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
+ };
+ const data = await Cockpit.api('/documents/chat', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ signal: ctrl.signal,
+ });
+ if (!data.ok && !data.reply) throw new Error(data.detail || data.error || 'Chat mislukt');
+ this.chatMessages.push({
+ role: 'assistant',
+ content: data.reply || '(geen antwoord)',
+ agent: data.agent_label || 'Herman',
+ sources: data.rag_sources || [],
+ });
+ this.chatStatus = (data.rag_sources && data.rag_sources.length)
+ ? data.rag_sources.length + ' bronnen gebruikt'
+ : 'Antwoord ontvangen';
+ } catch (e) {
+ const errMsg = e.name === 'AbortError'
+ ? 'Timeout na 3 min — probeer een kortere vraag of zet Herman orchestrator uit'
+ : (e.message || e);
+ this.chatMessages.push({ role: 'assistant', content: 'Fout: ' + errMsg, agent: 'Systeem', sources: [] });
+ this.chatInput = savedInput;
+ this.chatStatus = 'Fout bij chat';
+ } finally {
+ clearTimeout(timer);
+ this.chatBusy = false;
+ this.$nextTick(() => {
+ const log = document.getElementById('doc-chat-log');
+ if (log) log.scrollTop = log.scrollHeight;
+ });
+ }
+ },
+
+ async loadLinkClients() {
+ try {
+ const data = await Cockpit.api('/clients');
+ this.linkClients = data.items || [];
+ } catch (e) {}
+ },
+
+ linkProjectsForClient() {
+ if (!this.linkClientId) return [];
+ return (this.client360.projects || []).length
+ ? this.client360.projects
+ : this.linkProjects.filter((p) => p.client_id === Number(this.linkClientId));
+ },
+
+ async initAnalytics360() {
+ await this.loadLinkClients();
+ if (this.doclingSelected) this.linkPathInput = this.doclingSelected;
+ if (this.linkClientId) await this.loadClient360();
+ await this.refreshAutoSyncStatus();
+ },
+
+ async loadClient360() {
+ if (!this.linkClientId) return;
+ try {
+ const data = await Cockpit.api('/clients/' + this.linkClientId + '/360');
+ if (!data.ok) throw new Error(data.error || '360 laden mislukt');
+ this.client360 = data;
+ this.linkProjects = data.projects || [];
+ } catch (e) {
+ Cockpit.toast('360: ' + (e.message || e), 'error');
+ }
+ },
+
+ async linkSelectedFile() {
+ const path = (this.linkPathInput || this.doclingSelected || '').trim();
+ if (!path || !this.linkClientId) return;
+ try {
+ await Cockpit.api('/documents/links', {
+ method: 'POST',
+ body: JSON.stringify({
+ storage_path: path,
+ client_id: Number(this.linkClientId),
+ project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
+ is_folder: false,
+ }),
+ });
+ Cockpit.toast('Bestand gekoppeld', 'success');
+ await this.loadClient360();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async linkSelectedFolder() {
+ let path = (this.linkPathInput || this.doclingSelected || '').trim();
+ if (!path || !this.linkClientId) return;
+ if (!path.endsWith('/')) path = path.replace(/\/[^/]+$/, '') || path;
+ try {
+ await Cockpit.api('/documents/links', {
+ method: 'POST',
+ body: JSON.stringify({
+ storage_path: path,
+ client_id: Number(this.linkClientId),
+ project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
+ is_folder: true,
+ }),
+ });
+ Cockpit.toast('Map gekoppeld: ' + path, 'success');
+ await this.loadClient360();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async unlinkDocument(linkId) {
+ try {
+ await Cockpit.api('/documents/links/' + linkId, { method: 'DELETE' });
+ await this.loadClient360();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ }
+ },
+
+ async runAutoSync(force) {
+ this.autoSyncBusy = true;
+ try {
+ const data = await Cockpit.api('/brain/auto-sync' + (force ? '?force=true' : ''), { method: 'POST' });
+ const brain = (data.steps || {}).brain_sync || {};
+ Cockpit.toast('Sync: ' + (brain.synced || 0) + ' docs → second brain', 'success');
+ await this.refreshAutoSyncStatus();
+ } catch (e) {
+ Cockpit.toast(e.message, 'error');
+ } finally {
+ this.autoSyncBusy = false;
+ }
+ },
+
+ async refreshAutoSyncStatus() {
+ try {
+ const data = await Cockpit.api('/brain/auto-sync/status');
+ const last = data.last;
+ if (last && last.created_at) {
+ this.autoSyncLabel = 'Laatste sync: ' + last.created_at.slice(0, 16) + ' (' + last.status + ')';
+ }
+ } catch (e) {}
+ },
+
+ startAutoSyncLoop() {
+ if (this.autoSyncTimer) clearInterval(this.autoSyncTimer);
+ this.autoSyncTimer = setInterval(() => this.runAutoSync(false), 10 * 60 * 1000);
+ },
+ };
+ };
+
+ window.wordSearch = function wordSearch() {
+ return {
+ query: '',
+ includeStopwords: false,
+ results: topWords.slice(0, 20),
+ loading: false,
+ async search() {
+ this.loading = true;
+ try {
+ const params = new URLSearchParams({ limit: '40', stopwords: this.includeStopwords ? 'true' : 'false' });
+ if (this.query.trim()) params.set('q', this.query.trim());
+ const res = await fetch('/api/admin/documents/words?' + params);
+ this.results = (await res.json()).items || [];
+ } catch (e) {
+ Cockpit.toast('Woord zoeken mislukt', 'error');
+ } finally {
+ this.loading = false;
+ }
+ },
+ init() {
+ this.search();
+ },
+ };
+ };
+
+ function registerDocumentsDash() {
+ if (window.Alpine && window.documentsDash) {
+ Alpine.data('documentsDash', window.documentsDash);
+ }
+ }
+ document.addEventListener('alpine:init', registerDocumentsDash);
+ if (window.Alpine) registerDocumentsDash();
+})();
diff --git a/cockpit/documents.html b/cockpit/documents.html
new file mode 100644
index 0000000..f42e146
--- /dev/null
+++ b/cockpit/documents.html
@@ -0,0 +1,674 @@
+{% extends "base.html" %}
+{% block title %}Documents · NAS Intelligence · Foodlinkk{% endblock %}
+{% block extra_head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Totaal woorden
+
{{ summary.total_words }}
+
+
+
+
Gem. sentiment
+
{{ summary.avg_sentiment }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NAS documenten
+
+
+
+
+
+
+
+
Label document —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Foto labeling studio
+
Klik een foto → kies label-type → opslaan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nog geen label
+
+
+
+
+
+
+
Geen foto's — upload, Telegram of NAS OCR.
+
+
+
+
+
+
+
+
Document Workbench
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Export
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Preview
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
Converteert… of geen preview beschikbaar.
+
+
+
+
+
+
+
+ 📂
+ Klik een bestand links om direct te bewerken.
+ PDF · Word · Excel · PowerPoint · tekst · afbeeldingen — auto-convert aan
+
+
+
+
+
+
+
+
+
+
+
PowerPoint Studio
+
Maak presentaties van NAS PDF's of bewerkte Docling-markdown
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Voeg slides toe of importeer uit Docling.
+
+
Laatste export:
+
+
+
+
+
+
+
+
+
Chat met je data
+
Herman + RAG over NAS-documenten ·
+
+
+
+
+
+ LLM API
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stel een vraag over je documenten.
+ bv. "Wat staat er in de Cucina catalogue?" of "Samenvat halal certificaten"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
360° Klant Intelligence
+
Koppel NAS → klant/project · trends · second brain ·
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gekoppelde paden
+
+
+
+
+
+
+
+
Nog geen koppelingen — selecteer bestand in Workbench
+
+
+
+
+
Sentiment & trends
+
+
+
+
+
+
+
+
+
+
Top woorden (klant corpus)
+
+
+
+
+
+
+
+
+
Retail & deals
+
+
+
+
+
+
+
+
+
+
Selecteer een klant om de 360° view te laden
+
+
+
+
+
+
+
+
+
+
Wat voor label is deze foto?
+
Foto # — selecteer één of meer types
+
+
+
+
+
+
![Preview]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+{% endblock %}
diff --git a/cockpit/en.json b/cockpit/en.json
new file mode 100644
index 0000000..94611e7
--- /dev/null
+++ b/cockpit/en.json
@@ -0,0 +1,128 @@
+{
+ "nav": {
+ "herman": "Herman",
+ "persona_ceo": "CEO · Business",
+ "persona_cto": "CTO · Tech",
+ "ceo_home": "CEO Overview",
+ "cto_home": "CTO Overview",
+ "business": "Business",
+ "platform": "Platform",
+ "devops": "DevOps",
+ "tools_api": "Tools API",
+ "grafana": "Grafana",
+ "crm": "CRM",
+ "clients": "Clients",
+ "deals": "Deals",
+ "products": "Products",
+ "suppliers": "Suppliers",
+ "intel": "Intel",
+ "beurs": "Market Live",
+ "retail": "Retail",
+ "browser": "Browser",
+ "documents": "Documents",
+ "projects": "Projects",
+ "hermes": "Hermes",
+ "telegram": "Telegram",
+ "agents": "Agents",
+ "marketing": "Marketing",
+ "chat": "Chat",
+ "studio": "Studio",
+ "packaging": "Packaging",
+ "reports": "Reports",
+ "system": "System",
+ "analytics": "Analytics",
+ "voice": "Voice",
+ "ops": "IT Ops",
+ "settings": "Settings",
+ "gitea": "Gitea"
+ },
+ "common": {
+ "save": "Save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "edit": "Edit",
+ "add": "Add",
+ "search": "Search",
+ "refresh": "Refresh",
+ "loading": "Loading…",
+ "none": "—",
+ "yes": "Yes",
+ "no": "No",
+ "confirm_delete": "Are you sure you want to delete this?",
+ "saved": "Saved",
+ "deleted": "Deleted",
+ "error": "Error",
+ "all": "All",
+ "actions": "Actions",
+ "language": "Language",
+ "dutch": "Nederlands",
+ "english": "English"
+ },
+ "clients": {
+ "title": "CRM · Clients",
+ "subtitle": "Manage clients · pipeline · link stores · enrich branch data",
+ "new": "New client",
+ "tab_kanban": "Kanban",
+ "tab_list": "List",
+ "tab_stores": "Supermarkets",
+ "tab_360": "360° Client"
+ },
+ "products": {
+ "title": "Product catalog",
+ "subtitle": "SKUs · margins · client links · launch planning",
+ "new": "New product",
+ "tab_grid": "Overview",
+ "tab_list": "List",
+ "tab_margin": "Margin calculator",
+ "total": "Total products",
+ "active": "Active",
+ "avg_margin": "Avg. margin",
+ "with_client": "With client",
+ "name": "Product name",
+ "status": "Status",
+ "margin_pct": "Margin %",
+ "moq": "MOQ",
+ "shelf_target": "Shelf / listing",
+ "launch_date": "Launch date",
+ "client": "Client",
+ "status_concept": "Concept",
+ "status_development": "Development",
+ "status_active": "Active",
+ "status_discontinued": "Discontinued",
+ "calc_cost": "Cost price",
+ "calc_sell": "Sell price",
+ "calc_profit": "Profit",
+ "calc_margin": "Margin",
+ "calc_run": "Calculate",
+ "no_products": "No products yet — add your first SKU.",
+ "drawer_deals": "Linked client"
+ },
+ "suppliers": {
+ "title": "Suppliers",
+ "subtitle": "Vendor registry · ratings · lead times · contacts",
+ "new": "New supplier",
+ "tab_grid": "Overview",
+ "tab_list": "List",
+ "total": "Total suppliers",
+ "avg_rating": "Avg. rating",
+ "countries": "Countries",
+ "avg_lead": "Avg. lead time",
+ "name": "Company name",
+ "country": "Country",
+ "category": "Category",
+ "rating": "Rating",
+ "moq": "MOQ",
+ "lead_time": "Lead time (days)",
+ "contact": "Contact",
+ "cat_meat": "Meat",
+ "cat_dairy": "Dairy",
+ "cat_spices": "Spices",
+ "cat_packaging": "Packaging",
+ "cat_logistics": "Logistics",
+ "cat_ingredients": "Ingredients",
+ "cat_other": "Other",
+ "no_suppliers": "No suppliers registered yet.",
+ "filter_country": "Filter country",
+ "filter_category": "Filter category"
+ }
+}
diff --git a/cockpit/manifest.json b/cockpit/manifest.json
new file mode 100644
index 0000000..91dc70d
--- /dev/null
+++ b/cockpit/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Cucina · Foodlinkk Command Center",
+ "short_name": "Foodlinkk",
+ "description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#0a0e14",
+ "theme_color": "#0a0e14",
+ "lang": "nl",
+ "icons": [
+ {
+ "src": "/static/icons/app-icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/static/icons/app-icon.svg",
+ "sizes": "512x512",
+ "type": "image/svg+xml",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/cockpit/nl.json b/cockpit/nl.json
new file mode 100644
index 0000000..cf7f550
--- /dev/null
+++ b/cockpit/nl.json
@@ -0,0 +1,128 @@
+{
+ "nav": {
+ "herman": "Herman",
+ "persona_ceo": "CEO · Business",
+ "persona_cto": "CTO · Tech",
+ "ceo_home": "CEO Overzicht",
+ "cto_home": "CTO Overzicht",
+ "business": "Business",
+ "platform": "Platform",
+ "devops": "DevOps",
+ "tools_api": "Tools API",
+ "grafana": "Grafana",
+ "crm": "CRM",
+ "clients": "Klanten",
+ "deals": "Deals",
+ "products": "Producten",
+ "suppliers": "Leveranciers",
+ "intel": "Intel",
+ "beurs": "Beurs Live",
+ "retail": "Retail",
+ "browser": "Browser",
+ "documents": "Documenten",
+ "projects": "Projecten",
+ "hermes": "Hermes",
+ "telegram": "Telegram",
+ "agents": "Agents",
+ "marketing": "Marketing",
+ "chat": "Chat",
+ "studio": "Studio",
+ "packaging": "Packaging",
+ "reports": "Rapporten",
+ "system": "Systeem",
+ "analytics": "Analytics",
+ "voice": "Voice",
+ "ops": "IT Ops",
+ "settings": "Instellingen",
+ "gitea": "Gitea"
+ },
+ "common": {
+ "save": "Opslaan",
+ "cancel": "Annuleren",
+ "delete": "Verwijderen",
+ "edit": "Bewerken",
+ "add": "Toevoegen",
+ "search": "Zoeken",
+ "refresh": "Verversen",
+ "loading": "Laden…",
+ "none": "—",
+ "yes": "Ja",
+ "no": "Nee",
+ "confirm_delete": "Weet je zeker dat je dit wilt verwijderen?",
+ "saved": "Opgeslagen",
+ "deleted": "Verwijderd",
+ "error": "Fout",
+ "all": "Alle",
+ "actions": "Acties",
+ "language": "Taal",
+ "dutch": "Nederlands",
+ "english": "English"
+ },
+ "clients": {
+ "title": "CRM · Klanten",
+ "subtitle": "Klanten beheren · pipeline · supermarkten koppelen · filiaaldata aanvullen",
+ "new": "Nieuwe klant",
+ "tab_kanban": "Kanban",
+ "tab_list": "Lijst",
+ "tab_stores": "Supermarkten",
+ "tab_360": "360° Klant"
+ },
+ "products": {
+ "title": "Productcatalogus",
+ "subtitle": "SKU's · marges · klantkoppeling · lanceringsplanning",
+ "new": "Nieuw product",
+ "tab_grid": "Overzicht",
+ "tab_list": "Lijst",
+ "tab_margin": "Marge calculator",
+ "total": "Totaal producten",
+ "active": "Actief",
+ "avg_margin": "Gem. marge",
+ "with_client": "Met klant",
+ "name": "Productnaam",
+ "status": "Status",
+ "margin_pct": "Marge %",
+ "moq": "MOQ",
+ "shelf_target": "Schap / listing",
+ "launch_date": "Launch datum",
+ "client": "Klant",
+ "status_concept": "Concept",
+ "status_development": "Ontwikkeling",
+ "status_active": "Actief",
+ "status_discontinued": "Stopgezet",
+ "calc_cost": "Inkoopprijs",
+ "calc_sell": "Verkoopprijs",
+ "calc_profit": "Winst",
+ "calc_margin": "Marge",
+ "calc_run": "Berekenen",
+ "no_products": "Nog geen producten — voeg je eerste SKU toe.",
+ "drawer_deals": "Gekoppelde klant"
+ },
+ "suppliers": {
+ "title": "Leveranciers",
+ "subtitle": "Vendor registry · ratings · levertijden · contacten",
+ "new": "Nieuwe leverancier",
+ "tab_grid": "Overzicht",
+ "tab_list": "Lijst",
+ "total": "Totaal leveranciers",
+ "avg_rating": "Gem. rating",
+ "countries": "Landen",
+ "avg_lead": "Gem. levertijd",
+ "name": "Bedrijfsnaam",
+ "country": "Land",
+ "category": "Categorie",
+ "rating": "Rating",
+ "moq": "MOQ",
+ "lead_time": "Levertijd (dagen)",
+ "contact": "Contact",
+ "cat_meat": "Vlees",
+ "cat_dairy": "Zuivel",
+ "cat_spices": "Kruiden & specerijen",
+ "cat_packaging": "Verpakking",
+ "cat_logistics": "Logistiek",
+ "cat_ingredients": "Ingrediënten",
+ "cat_other": "Overig",
+ "no_suppliers": "Nog geen leveranciers geregistreerd.",
+ "filter_country": "Filter land",
+ "filter_category": "Filter categorie"
+ }
+}
diff --git a/cockpit/requirements.txt b/cockpit/requirements.txt
index 83fc029..075b369 100644
--- a/cockpit/requirements.txt
+++ b/cockpit/requirements.txt
@@ -7,4 +7,5 @@ httpx==0.27.2
websockets==13.1
beautifulsoup4==4.12.3
textblob==0.18.0.post0
+openpyxl>=3.1.0
tweepy==4.15.0
diff --git a/cockpit/revenue-cockpit.css b/cockpit/revenue-cockpit.css
new file mode 100644
index 0000000..5813da5
--- /dev/null
+++ b/cockpit/revenue-cockpit.css
@@ -0,0 +1,501 @@
+/* Revenue Cockpit — Excel layout (licht) */
+.rc-excel-page {
+ --rc-green: #92d050;
+ --rc-red: #ff0000;
+ --rc-orange: #ffc000;
+ --rc-yellow: #ffff00;
+ --rc-blue: #bdd7ee;
+ --rc-border: #b4b4b4;
+ --rc-text: #000;
+ background: #e8e8e8;
+ color: var(--rc-text);
+ margin: -1rem -1.25rem 0;
+ padding: 0.75rem 1rem 2rem;
+ min-height: calc(100vh - 4rem);
+ font-family: Calibri, "Segoe UI", Arial, sans-serif;
+ font-size: 11pt;
+}
+
+.persona-ceo .main-topnav:has(.rc-excel-page),
+.main-topnav:has(.rc-excel-page) {
+ background: #e8e8e8;
+}
+
+.rc-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.65rem;
+ gap: 0.5rem;
+}
+.rc-toolbar-title {
+ font-weight: 700;
+ font-size: 0.85rem;
+ color: #333;
+}
+.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
+.rc-save-hint { font-size: 0.72rem; color: #666; }
+.rc-saved { color: #217346; }
+.rc-btn {
+ border: 1px solid #aaa;
+ background: #fff;
+ border-radius: 3px;
+ padding: 0.2rem 0.55rem;
+ font-size: 0.78rem;
+ cursor: pointer;
+ color: #222;
+}
+.rc-btn:hover { background: #f3f3f3; }
+.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
+.rc-btn-import { font-size: 0.72rem; }
+
+.rc-sheet-wrap {
+ overflow: auto;
+ background: #fff;
+ border: 1px solid #888;
+ box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
+}
+.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
+
+.rc-sheet {
+ width: 100%;
+ min-width: 980px;
+ border-collapse: collapse;
+ table-layout: fixed;
+ background: #fff;
+}
+
+.rc-col-logo { width: 72px; }
+.rc-col-deal { width: 16%; }
+.rc-col-num { width: 10%; }
+.rc-col-steps { width: auto; }
+
+.rc-row td {
+ border: 1px solid var(--rc-border);
+ vertical-align: top;
+ padding: 4px 6px;
+ line-height: 1.35;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #000;
+}
+
+/* Header goals — geel */
+.rc-row-goals td {
+ background: var(--rc-yellow);
+ font-weight: 600;
+ min-height: 48px;
+}
+.rc-cell-tagline {
+ font-size: 14pt;
+ font-style: italic;
+ text-align: center;
+}
+
+/* Kolomkoppen */
+.rc-row-head td {
+ background: #fff;
+ font-weight: 700;
+ border-bottom: 2px solid #666;
+}
+.rc-th { text-align: left; }
+.rc-num { text-align: right; }
+
+/* Row kleuren — exact Excel */
+.rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
+.rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
+.rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
+.rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
+.rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
+.rc-style-white td:not(.rc-logo-cell) { background: #fff; }
+
+.rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
+.rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
+
+.rc-cell-edit:focus {
+ outline: 2px solid #217346;
+ outline-offset: -2px;
+ background: #fff !important;
+ color: #000 !important;
+}
+.rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
+.rc-cell-deal { font-weight: 600; }
+.rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
+
+/* Logo kolom */
+.rc-logo-cell {
+ background: #fff !important;
+ text-align: center;
+ vertical-align: middle;
+ padding: 6px 4px;
+ width: 72px;
+}
+.rc-logo-cucina .rc-logo-ring {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 58px;
+ height: 58px;
+ border-radius: 50%;
+ background: #111;
+ color: #fff;
+ font-size: 6.5pt;
+ font-weight: 800;
+ line-height: 1.15;
+ letter-spacing: 0.02em;
+ text-align: center;
+}
+.rc-logo-foodlinkk {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 4px 0;
+}
+.rc-fl-brand {
+ font-size: 11pt;
+ font-weight: 900;
+ color: #111;
+ letter-spacing: -0.02em;
+}
+.rc-fl-brand::first-letter { color: #f5c400; }
+.rc-fl-sub {
+ font-size: 5.5pt;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: #333;
+ text-transform: uppercase;
+}
+
+/* Rij panel */
+.rc-row-panel {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ width: min(420px, calc(100vw - 2rem));
+ background: #fff;
+ border: 1px solid #888;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.18);
+ border-radius: 6px;
+ z-index: 100;
+ font-size: 0.82rem;
+}
+.rc-row-panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.55rem 0.75rem;
+ background: #f5f5f5;
+ border-bottom: 1px solid #ddd;
+}
+.rc-row-panel-body { padding: 0.65rem 0.75rem; }
+.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
+.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
+.rc-inline select, .rc-inline input {
+ flex: 1;
+ border: 1px solid #bbb;
+ padding: 0.3rem 0.45rem;
+ font-size: 0.78rem;
+}
+.rc-style-select {
+ border: 1px solid #bbb;
+ font-size: 0.72rem;
+ padding: 0.15rem 0.35rem;
+}
+
+.rc-footnote {
+ margin: 0.75rem 0 0;
+ font-size: 0.72rem;
+ color: #666;
+}
+
+@media (max-width: 768px) {
+ .rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
+ .rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
+}
+
+/* ── Cockpit: Excel-layout + charts (dark theme) ── */
+.rc-page {
+ --rc-green: #4ade80;
+ --rc-red: #f87171;
+ --rc-orange: #fb923c;
+ --rc-yellow: #facc15;
+ --rc-blue: #60a5fa;
+ --rc-green-fill: #22c55e;
+ --rc-red-fill: #ef4444;
+ --rc-orange-fill: #f59e0b;
+ --rc-yellow-fill: #eab308;
+ --rc-blue-fill: #3b82f6;
+ color: #e2e8f0;
+}
+.rc-page.rc-mode-sheet { background: transparent; }
+
+.rc-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.85rem;
+}
+.rc-header h1 { margin: 0; font-size: 1.25rem; }
+.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
+.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
+
+.rc-view-toggle {
+ display: flex;
+ border: 1px solid rgba(148,163,184,0.2);
+ border-radius: 8px;
+ overflow: hidden;
+}
+.rc-toggle-btn {
+ border: none;
+ background: transparent;
+ color: #94a3b8;
+ padding: 0.3rem 0.65rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
+
+.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
+
+.rc-summary-strip {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem 1.25rem;
+ margin-bottom: 0.75rem;
+ padding: 0.55rem 0.85rem;
+ background: rgba(15,20,25,0.6);
+ border: 1px solid rgba(148,163,184,0.1);
+ border-radius: 10px;
+}
+.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
+.rc-sum-label { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; }
+.rc-sum-item strong { font-size: 0.95rem; color: #34d399; font-variant-numeric: tabular-nums; }
+.rc-filter-inline {
+ flex: 1 1 140px;
+ min-width: 120px;
+ max-width: 220px;
+ padding: 0.35rem 0.6rem;
+ border-radius: 6px;
+ border: 1px solid rgba(148,163,184,0.18);
+ background: #0a0e12;
+ color: #e2e8f0;
+ font-size: 0.78rem;
+ margin-left: auto;
+}
+.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
+.rc-dot-btn {
+ width: 14px; height: 14px;
+ border-radius: 50%;
+ border: 2px solid rgba(148,163,184,0.3);
+ background: transparent;
+ cursor: pointer;
+ padding: 0;
+}
+.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
+.rc-dot-green { background: var(--rc-green); }
+.rc-dot-red { background: var(--rc-red); }
+.rc-dot-orange { background: var(--rc-orange); }
+.rc-dot-yellow { background: var(--rc-yellow); }
+.rc-dot-blue { background: var(--rc-blue); }
+.rc-dot-white { background: #e2e8f0; }
+
+.rc-visual-wrap {
+ overflow: auto;
+ border-radius: 10px;
+ border: 1px solid rgba(148,163,184,0.12);
+ box-shadow: 0 4px 24px rgba(0,0,0,0.25);
+ background: #0a0e12;
+}
+
+.rc-visual-table {
+ width: 100%;
+ min-width: 720px;
+ border-collapse: collapse;
+ table-layout: fixed;
+ background: #0a0e12;
+ font-family: inherit;
+ font-size: 0.82rem;
+ color: #e2e8f0;
+}
+
+.rc-col-chart-num { width: 11%; }
+.rc-col-donut { width: 64px; }
+
+.rc-visual-goals td {
+ background: rgba(234, 179, 8, 0.12);
+ color: #fde68a;
+ font-weight: 600;
+ font-size: 0.78rem;
+ padding: 6px 10px;
+ border: 1px solid rgba(148,163,184,0.1);
+ line-height: 1.3;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rc-vtagline { font-style: italic; text-align: center; color: #fbbf24; }
+
+.rc-visual-head td {
+ background: rgba(15,20,25,0.95);
+ color: #64748b;
+ font-weight: 600;
+ font-size: 0.68rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ padding: 6px 10px;
+ border: 1px solid rgba(148,163,184,0.1);
+ border-bottom: 1px solid rgba(148,163,184,0.2);
+}
+
+.rc-visual-row { cursor: pointer; transition: background 0.12s; }
+.rc-visual-row:hover td:not(.rc-logo-cell) { background: rgba(30,41,59,0.55) !important; }
+.rc-visual-row.rc-row-open td:not(.rc-logo-cell) { background: rgba(30,58,95,0.35) !important; }
+.rc-visual-row.rc-row-open { box-shadow: inset 0 0 0 1px rgba(56,189,248,0.35); }
+
+.rc-visual-row td,
+.rc-vcell {
+ border: 1px solid rgba(148,163,184,0.08);
+ padding: 6px 10px;
+ vertical-align: middle;
+ line-height: 1.35;
+ background: rgba(15,20,25,0.5);
+}
+
+.rc-visual-row.rc-style-green td:not(.rc-logo-cell) {
+ background: rgba(34, 197, 94, 0.1);
+ box-shadow: inset 3px 0 0 var(--rc-green-fill);
+}
+.rc-visual-row.rc-style-red td:not(.rc-logo-cell) {
+ background: rgba(239, 68, 68, 0.1);
+ box-shadow: inset 3px 0 0 var(--rc-red-fill);
+}
+.rc-visual-row.rc-style-orange td:not(.rc-logo-cell) {
+ background: rgba(245, 158, 11, 0.1);
+ box-shadow: inset 3px 0 0 var(--rc-orange-fill);
+}
+.rc-visual-row.rc-style-yellow td:not(.rc-logo-cell) {
+ background: rgba(234, 179, 8, 0.1);
+ box-shadow: inset 3px 0 0 var(--rc-yellow-fill);
+ font-weight: 600;
+ color: #fde68a;
+}
+.rc-visual-row.rc-style-blue td:not(.rc-logo-cell) {
+ background: rgba(59, 130, 246, 0.1);
+ box-shadow: inset 3px 0 0 var(--rc-blue-fill);
+}
+.rc-visual-row.rc-style-white td:not(.rc-logo-cell) {
+ background: rgba(15,20,25,0.5);
+ box-shadow: inset 3px 0 0 #475569;
+}
+
+.rc-brand-cucina .rc-vdeal { padding-left: 8px; }
+.rc-brand-foodlinkk .rc-vdeal { padding-left: 8px; }
+
+.rc-logo-mini { width: 36px; padding: 2px !important; text-align: center; background: transparent !important; }
+.rc-logo-xs .rc-logo-ring { width: 26px; height: 26px; font-size: 11px; font-weight: 900; }
+.rc-logo-xs.rc-logo-foodlinkk .rc-fl-brand { font-size: 14px; }
+
+.rc-vdeal { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #f1f5f9; }
+.rc-vnum { text-align: right; }
+.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.8rem; font-weight: 600; color: #34d399; }
+
+.rc-mini-bar {
+ height: 4px;
+ background: rgba(148,163,184,0.15);
+ border-radius: 2px;
+ margin-top: 4px;
+ overflow: hidden;
+}
+.rc-mini-bar-fill { height: 100%; border-radius: 2px; min-width: 2px; }
+.rc-mini-bar-year { background: #38bdf8 !important; }
+
+.rc-vdonut { text-align: center; padding: 4px !important; }
+.rc-donut {
+ width: 36px; height: 36px;
+ border-radius: 50%;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+.rc-donut-label { font-size: 0.58rem; font-weight: 700; color: #e2e8f0; z-index: 1; }
+.rc-donut-empty { color: #64748b; font-size: 0.75rem; }
+
+.rc-vsteps {
+ font-size: 0.75rem;
+ color: #94a3b8;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 0;
+}
+.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 4px; }
+
+.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
+.rc-expand-cell { padding: 0 !important; }
+.rc-expand-inner {
+ display: flex;
+ gap: 1rem;
+ padding: 0.85rem 1rem;
+ background: rgba(10,14,18,0.98);
+ color: #e2e8f0;
+ border-top: 1px solid rgba(56,189,248,0.15);
+}
+.rc-expand-chart { flex: 0 0 140px; }
+.rc-bar-chart {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 0.5rem;
+ height: 72px;
+ margin-bottom: 0.5rem;
+}
+.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
+.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
+.rc-bar-stack {
+ width: 28px;
+ min-height: 2px;
+ background: var(--rc-green-fill);
+ border-radius: 3px 3px 0 0;
+ transition: height 0.2s;
+}
+.rc-bar-year { background: #38bdf8; }
+.rc-expand-stats { font-size: 0.75rem; }
+.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
+.rc-expand-stats span { color: #64748b; }
+.rc-expand-stats strong { color: #34d399; }
+.rc-expand-body { flex: 1; min-width: 0; }
+.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
+.rc-expand-body p { margin: 0; font-size: 0.82rem; line-height: 1.5; white-space: pre-wrap; color: #cbd5e1; }
+.rc-expand-task { margin-top: 0.65rem; }
+.rc-expand-task .rc-inline select,
+.rc-expand-task .rc-inline input {
+ border: 1px solid rgba(148,163,184,0.25);
+ background: #0f1419;
+ color: #e2e8f0;
+ padding: 0.3rem 0.45rem;
+ font-size: 0.78rem;
+ border-radius: 4px;
+}
+
+.rc-page .rc-excel-page {
+ margin: 0;
+ padding: 0;
+ min-height: auto;
+ background: #e8e8e8;
+ color: #000;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+@media (max-width: 768px) {
+ .rc-summary-strip { gap: 0.5rem; }
+ .rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
+ .rc-expand-inner { flex-direction: column; }
+}
diff --git a/cockpit/revenue-cockpit.js b/cockpit/revenue-cockpit.js
new file mode 100644
index 0000000..6146c10
--- /dev/null
+++ b/cockpit/revenue-cockpit.js
@@ -0,0 +1,422 @@
+function revenueCockpit() {
+ return {
+ loading: false,
+ saving: false,
+ savedAt: '',
+ viewMode: 'cockpit',
+ goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
+ projects: [],
+ dbProjects: [],
+ sheetRows: [],
+ selectedId: null,
+ selectedKey: null,
+ expandedKey: null,
+ meta: {},
+ aggregates: {},
+ filterQ: '',
+ filterStyle: '',
+ filterCategory: '',
+ filterHasMargin: false,
+ agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
+ taskForm: { agent_name: 'research', title: '', description: '' },
+ styleFilters: [
+ { id: 'green', label: 'Live' },
+ { id: 'red', label: 'Inactief' },
+ { id: 'orange', label: 'Oranje' },
+ { id: 'yellow', label: 'Strategisch' },
+ { id: 'blue', label: 'Blauw' },
+ { id: 'white', label: 'Neutraal' },
+ ],
+ categoryFilters: [
+ { id: 'deal', label: 'Deal' },
+ { id: 'initiative', label: 'Initiatief' },
+ { id: 'strategic', label: 'Strategisch' },
+ ],
+
+ get selectedRow() {
+ return this.dbProjects.find((p) => p.id === this.selectedId) || null;
+ },
+
+ get selectedProject() {
+ if (!this.selectedKey) return null;
+ return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
+ },
+
+ async init() {
+ localStorage.setItem('foodlinkk-persona', 'ceo');
+ await this.loadLive();
+ await this.loadDbQuiet();
+ },
+
+ projectKey(p) {
+ return String(p.source_row || '') + '|' + (p.name || '');
+ },
+
+ fmtNum(n) {
+ if (n == null || n === '' || isNaN(n)) return '';
+ return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ },
+
+ fmtEuro(n) {
+ if (n == null || n === '' || isNaN(n)) return '—';
+ return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
+ },
+
+ fmtEuroCompact(n) {
+ if (n == null || n === '' || isNaN(n)) return '—';
+ const v = Number(n);
+ if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
+ if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
+ return '€' + Math.round(v);
+ },
+
+ fmtDate(iso) {
+ if (!iso) return '';
+ try {
+ return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
+ } catch (e) {
+ return iso;
+ }
+ },
+
+ categoryLabel(c) {
+ if (c === 'deal') return 'Deal';
+ if (c === 'strategic') return 'Strategisch';
+ return 'Initiatief';
+ },
+
+ parseNum(text) {
+ const t = String(text || '').trim();
+ if (!t || t === '—') return null;
+ const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
+ return isNaN(n) ? null : n;
+ },
+
+ filteredProjects() {
+ const q = (this.filterQ || '').trim().toLowerCase();
+ return this.projects.filter((p) => {
+ if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
+ if (this.filterCategory && p.category !== this.filterCategory) return false;
+ if (this.filterHasMargin && !p.margin_month) return false;
+ if (q) {
+ const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ return true;
+ });
+ },
+
+ clearFilters() {
+ this.filterQ = '';
+ this.filterStyle = '';
+ this.filterCategory = '';
+ this.filterHasMargin = false;
+ },
+
+ truncate(s, n) {
+ if (!s) return '';
+ const t = String(s).replace(/\s+/g, ' ').trim();
+ return t.length <= n ? t : t.slice(0, n).trim() + '…';
+ },
+
+ styleColor(style) {
+ const map = {
+ green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
+ yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
+ };
+ return map[style] || map.white;
+ },
+
+ foodlinkkIndexFrom(list) {
+ const idx = list.findIndex((p) =>
+ (p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
+ /foodlinkk food marketing/i.test(p.name)
+ );
+ return idx >= 0 ? idx : list.length;
+ },
+
+ visualRows() {
+ const items = this.filteredProjects();
+ const flIdx = this.foodlinkkIndexFrom(items);
+ return items.map((p, i) => {
+ let logoCell = null;
+ let brand = 'cucina';
+ if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
+ if (i === 0) logoCell = 'cucina';
+ else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
+ return { ...p, logoCell, brand };
+ });
+ },
+
+ maxMargin(field) {
+ const vals = this.filteredProjects().map((p) => p[field] || 0);
+ return Math.max(...vals, 1);
+ },
+
+ marginBarPct(p, field) {
+ if (!p[field]) return 0;
+ return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
+ },
+
+ sharePct(p) {
+ const total = this.aggregates.total_margin_month || 0;
+ if (!p.margin_month || !total) return 0;
+ return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
+ },
+
+ donutStyle(p) {
+ const pct = this.sharePct(p);
+ const color = this.styleColor(p.row_style);
+ return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, rgba(148,163,184,0.15) ' + pct + '% 100%)';
+ },
+
+ toggleExpand(p) {
+ const key = this.projectKey(p);
+ this.expandedKey = this.expandedKey === key ? null : key;
+ this.selectedKey = key;
+ this.taskForm.title = '';
+ this.taskForm.description = p.next_steps || '';
+ },
+
+ selectProject(p) {
+ this.toggleExpand(p);
+ },
+
+ mergeDbIds() {
+ const byName = {};
+ for (const d of this.dbProjects) {
+ const k = (d.name || '').trim().toLowerCase();
+ if (k) byName[k] = d.id;
+ }
+ this.projects = this.projects.map((p) => ({
+ ...p,
+ db_id: byName[(p.name || '').trim().toLowerCase()] || null,
+ }));
+ },
+
+ async loadLive() {
+ this.loading = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
+ if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
+ this.goals = r.goals || {};
+ this.projects = r.projects || [];
+ this.aggregates = {
+ ...(r.aggregates || {}),
+ project_count: r.project_count || this.projects.length,
+ };
+ this.meta = {
+ source_file: r.source_file,
+ sheet_name: r.sheet_name,
+ file_mtime: r.file_mtime,
+ parsed_at: r.parsed_at,
+ };
+ this.mergeDbIds();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
+ }
+ this.loading = false;
+ },
+
+ async loadDbQuiet() {
+ try {
+ const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
+ this.dbProjects = r.projects || [];
+ this.mergeDbIds();
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ foodlinkkIndex() {
+ const idx = this.dbProjects.findIndex((p) =>
+ (p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
+ /foodlinkk food marketing/i.test(p.name)
+ );
+ return idx >= 0 ? idx : this.dbProjects.length;
+ },
+
+ buildSheetRows() {
+ const flIdx = this.foodlinkkIndex();
+ this.sheetRows = this.dbProjects.map((p, i) => {
+ let logoCell = null;
+ let logoSpan = 1;
+ if (i === 0) {
+ logoCell = 'cucina';
+ logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
+ } else if (i === flIdx && flIdx < this.dbProjects.length) {
+ logoCell = 'foodlinkk';
+ logoSpan = this.dbProjects.length - flIdx;
+ }
+ return { ...p, logoCell, logoSpan };
+ });
+ },
+
+ async switchToSheet() {
+ this.viewMode = 'sheet';
+ await this.loadDb();
+ },
+
+ async loadDb() {
+ this.loading = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
+ this.goals = r.stats?.goals || this.goals;
+ this.dbProjects = r.projects || [];
+ this.buildSheetRows();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
+ }
+ this.loading = false;
+ },
+
+ editStart(ev) {
+ const el = ev.target;
+ if (el.classList.contains('rc-cell-num')) {
+ const raw = el.textContent.trim();
+ if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
+ }
+ requestAnimationFrame(() => {
+ const sel = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ });
+ },
+
+ markSaved() {
+ const now = new Date();
+ this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ },
+
+ async saveGoalsField(field, el) {
+ const val = el.innerText.trim();
+ if (this.goals[field] === val) return;
+ this.saving = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/goals', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ [field]: val }),
+ }).then((x) => x.json());
+ this.goals = r.goals || this.goals;
+ this.markSaved();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
+ }
+ this.saving = false;
+ },
+
+ async saveProjectField(row, field, el) {
+ let val;
+ if (field === 'margin_month' || field === 'margin_year') {
+ val = this.parseNum(el.innerText);
+ el.textContent = this.fmtNum(val);
+ } else {
+ val = el.innerText.trim();
+ }
+ if (String(row[field] ?? '') === String(val ?? '')) return;
+
+ const body = { [field]: val };
+ if (field === 'margin_month' && val != null) {
+ body.margin_year = val * 12;
+ }
+
+ this.saving = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }).then((x) => x.json());
+ const updated = r.project || {};
+ Object.assign(row, updated);
+ row.row_style = updated.row_style || row.row_style;
+ const pi = this.dbProjects.findIndex((p) => p.id === row.id);
+ if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
+ if (field === 'margin_month' && body.margin_year != null) {
+ const yearCell = el.nextElementSibling;
+ if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
+ row.margin_year = body.margin_year;
+ }
+ this.markSaved();
+ await this.loadLive();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
+ }
+ this.saving = false;
+ },
+
+ selectRow(row) {
+ this.selectedId = row.id;
+ this.taskForm.title = '';
+ this.taskForm.description = row.next_steps || '';
+ },
+
+ async setRowStyle(style) {
+ if (!this.selectedRow) return;
+ try {
+ await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ row_style: style }),
+ });
+ this.selectedRow.row_style = style;
+ const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
+ if (pi >= 0) this.dbProjects[pi].row_style = style;
+ this.buildSheetRows();
+ this.markSaved();
+ await this.loadLive();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
+ }
+ },
+
+ async assignTask() {
+ const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
+ const pid = row?.id || row?.db_id;
+ if (!pid || !this.taskForm.title.trim()) return;
+ try {
+ await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ agent_name: this.taskForm.agent_name,
+ title: this.taskForm.title.trim(),
+ description: this.taskForm.description,
+ delegate_herman: true,
+ }),
+ });
+ if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
+ this.taskForm.title = '';
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
+ }
+ },
+
+ async takeSnapshot() {
+ await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
+ if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
+ },
+
+ async importExcel() {
+ if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
+ this.loading = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/import-from-excel', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ replace: true }),
+ }).then((x) => x.json());
+ if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
+ await this.loadLive();
+ await this.loadDbQuiet();
+ if (this.viewMode === 'sheet') await this.loadDb();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
+ }
+ this.loading = false;
+ },
+ };
+}
diff --git a/cockpit/revenue_cockpit.html b/cockpit/revenue_cockpit.html
new file mode 100644
index 0000000..fb6611e
--- /dev/null
+++ b/cockpit/revenue_cockpit.html
@@ -0,0 +1,269 @@
+{% extends "base.html" %}
+{% block title %}Revenue Cockpit · Cucina{% endblock %}
+{% block extra_head %}
+
+{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+
+ Marge / mnd
+
+
+
+ Marge / jr
+
+
+
+ Deals
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+ |
+ Deal |
+ Marge mnd |
+ Marge jr |
+ Aandeel |
+ Next steps |
+
+
+
+
+ |
+
+ C
+
+
+ F
+
+ |
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+ —
+ |
+
+
+
+ ↵
+ |
+
+
+
+
+
+
+
+
+ Marge mnd
+ Marge jr
+ Portfolio
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+ |
+ Deal |
+ Marge Month |
+ Marge Year |
+ Next steps |
+
+
+
+
+ |
+ CUCINA HALAL FOOD
+ |
+
+
+
+
+ Foodlinkk
+ FOOD MARKETING AGENCY
+
+ |
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+{% block scripts %}
+
+{% endblock %}
diff --git a/cockpit/static/css/agents-mesh.css b/cockpit/static/css/agents-mesh.css
index ead1d41..7a58bb4 100644
--- a/cockpit/static/css/agents-mesh.css
+++ b/cockpit/static/css/agents-mesh.css
@@ -1,103 +1,517 @@
.mesh-wrap {
position: relative;
border-radius: 14px;
- border: 1px solid rgba(148, 163, 184, 0.16);
- background: radial-gradient(circle at 50% 40%, rgba(255, 215, 0, 0.08), rgba(0, 0, 0, 0.35));
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ background: linear-gradient(180deg, rgba(7, 11, 18, 0.95) 0%, rgba(15, 23, 42, 0.85) 100%);
overflow: hidden;
+ transition: box-shadow 0.25s ease, border-color 0.25s ease;
}
+.mesh-wrap-mode-mesh { border-color: rgba(168, 85, 247, 0.25); }
+.mesh-wrap-mode-org { border-color: rgba(255, 215, 0, 0.2); }
+.mesh-wrap-mode-pulse { border-color: rgba(34, 197, 94, 0.25); }
+.mesh-wrap-mode-clusters { border-color: rgba(255, 159, 67, 0.25); }
+.mesh-wrap-mode-pyramid { border-color: rgba(0, 229, 255, 0.2); }
+
.mesh-svg {
width: 100%;
- min-height: 520px;
+ min-height: 680px;
display: block;
}
+.mesh-tier-band {
+ fill: rgba(148, 163, 184, 0.03);
+ stroke: none;
+}
+
+.mesh-tier-0 { fill: rgba(0, 229, 255, 0.03); }
+.mesh-tier-1 { fill: rgba(168, 85, 247, 0.025); }
+.mesh-tier-2 { fill: rgba(34, 197, 94, 0.025); }
+
.mesh-edge {
- stroke: rgba(0, 229, 255, 0.42);
- stroke-width: 1.6;
fill: none;
+ stroke: rgba(100, 116, 139, 0.2);
stroke-linecap: round;
+ stroke-width: 1.2;
+ pointer-events: none;
+}
+
+.mesh-edge-report {
+ stroke: rgba(0, 229, 255, 0.45);
+ stroke-width: 1.5;
+}
+
+.mesh-edge-delegate {
+ stroke: rgba(255, 215, 0, 0.4);
+ stroke-width: 1.3;
+ stroke-dasharray: 5 6;
+}
+
+.mesh-edge-executive {
+ stroke: rgba(255, 159, 67, 0.28);
+ stroke-width: 1.1;
+}
+
+.mesh-edge-peer {
+ stroke: rgba(168, 85, 247, 0.12);
+ stroke-width: 0.8;
+}
+
+.mesh-edge-peer-live {
+ stroke: rgba(192, 132, 252, 0.55);
+ stroke-width: 1.4;
}
.mesh-pulse {
- stroke: rgba(0, 229, 255, 0.9);
- stroke-width: 3;
+ fill: none;
+ stroke-width: 2;
stroke-linecap: round;
- stroke-dasharray: 3 140;
- animation: mesh-pulse 2.4s linear infinite;
+ stroke-dasharray: 4 100;
+ animation: mesh-pulse 2.6s linear infinite;
+ pointer-events: none;
}
+.mesh-pulse-active.mesh-edge-report { stroke: rgba(0, 229, 255, 0.85); }
+.mesh-pulse-active.mesh-edge-delegate { stroke: rgba(255, 215, 0, 0.75); }
+.mesh-pulse-active.mesh-edge-peer-live { stroke: rgba(192, 132, 252, 0.9); }
+
@keyframes mesh-pulse {
from { stroke-dashoffset: 0; }
- to { stroke-dashoffset: -286; }
+ to { stroke-dashoffset: -208; }
}
.mesh-node { cursor: pointer; }
-.mesh-node text {
- font-size: 12px;
- fill: #e2e8f0;
- font-weight: 600;
+.mesh-node text.mesh-label {
+ font-size: 13px;
+ fill: #f1f5f9;
+ font-weight: 700;
text-anchor: middle;
+ pointer-events: none;
}
+.mesh-label-agent { font-size: 13px; }
+.mesh-label-herman { font-size: 14px; fill: #ffd700; }
+.mesh-label-exec { font-size: 13px; }
+.mesh-node text.mesh-sublabel {
+ font-size: 10px;
+ fill: #94a3b8;
+ text-anchor: middle;
+ pointer-events: none;
+}
+.mesh-sublabel-herman { fill: #cbd5e1; font-size: 10px; }
+.mesh-node text.mesh-context {
+ font-size: 9.5px;
+ fill: #64748b;
+ text-anchor: middle;
+ pointer-events: none;
+}
+.mesh-node text.mesh-domain {
+ font-size: 8.5px;
+ fill: #475569;
+ text-anchor: middle;
+ pointer-events: none;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.mesh-node text.mesh-emoji {
+ font-size: 14px;
+ text-anchor: middle;
+ pointer-events: none;
+}
+.mesh-herman-crown { font-size: 18px; pointer-events: none; }
-.mesh-node circle {
- fill: #0f172a;
- stroke: rgba(148, 163, 184, 0.65);
+.mesh-node circle.main {
+ fill: #0b1220;
+ stroke: rgba(148, 163, 184, 0.55);
stroke-width: 2;
}
-.mesh-node.healthy circle { stroke: #22c55e; }
-.mesh-node.warn circle { stroke: #f59e0b; }
-.mesh-node.idle circle { stroke: #38bdf8; }
-.mesh-node.offline circle { stroke: #64748b; }
+.mesh-node.healthy circle.main { stroke: #22c55e; }
+.mesh-node.warn circle.main { stroke: #f59e0b; }
+.mesh-node.idle circle.main { stroke: #475569; }
+.mesh-node.offline circle.main { stroke: #334155; opacity: 0.65; }
-.mesh-herman circle.main {
- fill: #111827;
- stroke: #ffd700;
- stroke-width: 4;
- filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6));
-}
-
-.mesh-exec circle.main {
- fill: #0f172a;
- stroke-width: 3;
-}
-
-.mesh-ceo circle.main { stroke: #ff9f43; filter: drop-shadow(0 0 10px rgba(255, 159, 67, 0.5)); }
-.mesh-cto circle.main { stroke: #a855f7; filter: drop-shadow(0 0 10px rgba(168, 85, 247, 0.5)); }
-
-.mesh-exec circle.ring {
+.mesh-agent-live .mesh-pulse-wrap circle.pulse-ring {
fill: none;
- stroke: rgba(255, 255, 255, 0.15);
- stroke-width: 1.5;
- stroke-dasharray: 4 4;
-}
-
-.mesh-agent .mesh-status { fill: #64748b; }
-
-.mesh-herman circle.ring {
- fill: none;
- stroke: rgba(255, 215, 0, 0.35);
- stroke-width: 2;
- stroke-dasharray: 8 4;
- animation: mesh-herman-ring 7s linear infinite;
+ stroke: rgba(34, 197, 94, 0.45);
+ stroke-width: 1.8;
+ animation: mesh-agent-pulse 2.8s ease-in-out infinite;
+ transform-box: fill-box;
transform-origin: center;
}
-@keyframes mesh-herman-ring {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
+@keyframes mesh-agent-pulse {
+ 0%, 100% { opacity: 0.3; transform: scale(1); }
+ 50% { opacity: 0.75; transform: scale(1.12); }
}
+.mesh-herman circle.main {
+ fill: #0f172a;
+ stroke: #ffd700;
+ stroke-width: 3;
+ filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.35));
+}
+
+.mesh-herman.active .mesh-herman-ring circle.ring {
+ fill: none;
+ stroke: rgba(255, 215, 0, 0.4);
+ stroke-width: 2;
+ stroke-dasharray: 10 8;
+}
+
+.mesh-status-live circle.main { stroke-width: 2.5; }
+.mesh-status-ready circle.main { stroke: #64748b; }
+.mesh-status-warn circle.main { stroke: #f59e0b; }
+.mesh-status-idle circle.main { stroke: #334155; opacity: 0.7; }
+
+.mesh-exec circle.main { stroke-width: 2.5; }
+.mesh-ceo circle.main { stroke: #ff9f43; }
+.mesh-cto circle.main { stroke: #a855f7; }
+
+/* ——— Communicatie-modus kiezer ——— */
+.mesh-mode-section {
+ margin-bottom: 1rem;
+}
+
+.mesh-mode-section > label {
+ display: block;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #64748b;
+ margin-bottom: 0.55rem;
+ font-weight: 600;
+}
+
+.mesh-mode-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 0.55rem;
+}
+
+@media (max-width: 1100px) {
+ .mesh-mode-grid { grid-template-columns: repeat(3, 1fr); }
+}
+@media (max-width: 640px) {
+ .mesh-mode-grid { grid-template-columns: repeat(2, 1fr); }
+}
+
+.mesh-mode-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.2rem;
+ padding: 0.7rem 0.75rem 0.65rem;
+ border-radius: 12px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ background: linear-gradient(145deg, rgba(15, 23, 42, 0.7) 0%, rgba(7, 11, 18, 0.9) 100%);
+ cursor: pointer;
+ text-align: left;
+ transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
+ min-height: 5.5rem;
+ color: inherit;
+ font: inherit;
+ width: 100%;
+}
+
+.mesh-mode-card:hover {
+ border-color: rgba(148, 163, 184, 0.35);
+ transform: translateY(-1px);
+}
+
+.mesh-mode-card.is-active {
+ border-color: rgba(0, 229, 255, 0.55);
+ box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.15), 0 4px 20px rgba(0, 229, 255, 0.08);
+ background: linear-gradient(145deg, rgba(0, 229, 255, 0.06) 0%, rgba(7, 11, 18, 0.95) 100%);
+}
+
+.mesh-mode-card[data-mode="mesh"].is-active {
+ border-color: rgba(168, 85, 247, 0.55);
+ box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15), 0 4px 20px rgba(168, 85, 247, 0.08);
+}
+
+.mesh-mode-card[data-mode="org"].is-active {
+ border-color: rgba(255, 215, 0, 0.45);
+ box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.12), 0 4px 20px rgba(255, 215, 0, 0.06);
+}
+
+.mesh-mode-card[data-mode="pulse"].is-active {
+ border-color: rgba(34, 197, 94, 0.5);
+ box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12), 0 4px 20px rgba(34, 197, 94, 0.08);
+}
+
+.mesh-mode-card[data-mode="clusters"].is-active {
+ border-color: rgba(255, 159, 67, 0.5);
+ box-shadow: 0 0 0 1px rgba(255, 159, 67, 0.12), 0 4px 20px rgba(255, 159, 67, 0.08);
+}
+
+.mesh-mode-icon {
+ font-size: 1.15rem;
+ line-height: 1;
+ opacity: 0.85;
+}
+
+.mesh-mode-title {
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: #f1f5f9;
+ line-height: 1.2;
+}
+
+.mesh-mode-sub {
+ font-size: 0.68rem;
+ color: #64748b;
+ line-height: 1.2;
+}
+
+.mesh-mode-desc {
+ font-size: 0.65rem;
+ color: #94a3b8;
+ line-height: 1.35;
+ margin-top: 0.15rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.mesh-mode-check {
+ position: absolute;
+ top: 0.45rem;
+ right: 0.5rem;
+ width: 1rem;
+ height: 1rem;
+ border-radius: 50%;
+ border: 1.5px solid rgba(148, 163, 184, 0.35);
+ opacity: 0;
+ transition: opacity 0.15s, background 0.15s;
+}
+
+.mesh-mode-card.is-active .mesh-mode-check {
+ opacity: 1;
+ background: #00e5ff;
+ border-color: #00e5ff;
+ box-shadow: 0 0 8px rgba(0, 229, 255, 0.5);
+}
+
+.mesh-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+ justify-content: flex-end;
+ margin-bottom: 0.65rem;
+}
+
+.mesh-mode-badge {
+ margin-right: auto;
+ font-size: 0.78rem;
+ font-weight: 700;
+ color: #00e5ff;
+ padding: 0.25rem 0.65rem;
+ border-radius: 999px;
+ border: 1px solid rgba(0, 229, 255, 0.35);
+ background: rgba(0, 229, 255, 0.08);
+}
+
+.mesh-framework-head { margin-bottom: 0.5rem; }
+.mesh-framework-head h2 { margin: 0; }
+
.mesh-legend {
display: flex;
- gap: 0.8rem;
+ gap: 0.65rem 1rem;
flex-wrap: wrap;
- margin-top: 0.75rem;
+ margin-top: 0.65rem;
color: #94a3b8;
+ font-size: 0.78rem;
+}
+
+.mesh-legend span::before {
+ content: '';
+ display: inline-block;
+ width: 1rem;
+ height: 2px;
+ margin-right: 0.3rem;
+ vertical-align: middle;
+ border-radius: 2px;
+}
+
+.mesh-legend .leg-report::before { background: #00e5ff; }
+.mesh-legend .leg-delegate::before { background: #ffd700; }
+.mesh-legend .leg-peer::before { background: #a855f7; }
+.mesh-legend .leg-exec::before { background: #ff9f43; }
+
+.mesh-cluster-label {
+ font-size: 11px;
+ fill: #94a3b8;
+ font-weight: 600;
+ pointer-events: none;
+}
+
+.mesh-agent-cluster-growth circle.main { stroke: rgba(0, 229, 255, 0.7); }
+.mesh-agent-cluster-product circle.main { stroke: rgba(168, 85, 247, 0.7); }
+.mesh-agent-cluster-ops circle.main { stroke: rgba(34, 197, 94, 0.65); }
+
+.mesh-edge-straight { stroke-dasharray: none; }
+
+/* ——— Terminals grid ——— */
+.agents-terminals-intro {
+ margin: 0 0 1rem;
+ color: #94a3b8;
+ font-size: 0.88rem;
+}
+
+.agents-terminals-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 0.85rem;
+}
+
+.agent-term-window {
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ border-radius: 12px;
+ background: #060a10;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-height: 200px;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.agent-term-window.is-live {
+ border-color: rgba(34, 197, 94, 0.45);
+ box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12);
+}
+
+.agent-term-window.is-expanded {
+ grid-column: 1 / -1;
+ min-height: 320px;
+}
+
+.agent-term-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.55rem 0.75rem;
+ background: rgba(15, 23, 42, 0.85);
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
font-size: 0.82rem;
}
+.agent-term-head .term-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #475569;
+ flex-shrink: 0;
+}
+
+.agent-term-window.is-live .term-dot {
+ background: #22c55e;
+ box-shadow: 0 0 6px rgba(34, 197, 94, 0.7);
+ animation: term-dot-blink 1.8s ease-in-out infinite;
+}
+
+@keyframes term-dot-blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.45; }
+}
+
+.agent-term-head strong { color: #f1f5f9; flex: 1; }
+.agent-term-head small { color: #64748b; font-size: 0.72rem; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.agent-term-body {
+ flex: 1;
+ overflow-y: auto;
+ max-height: 180px;
+ padding: 0.5rem 0.65rem;
+ font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
+ font-size: 0.72rem;
+ line-height: 1.45;
+}
+
+.agent-term-window.is-expanded .agent-term-body {
+ max-height: 280px;
+}
+
+.term-line {
+ padding: 0.2rem 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.06);
+ display: flex;
+ gap: 0.5rem;
+}
+
+.term-ts { color: #64748b; flex-shrink: 0; min-width: 2.5rem; }
+.term-msg { color: #cbd5e1; word-break: break-word; }
+.term-line.line-handoff .term-msg { color: #c4b5fd; }
+.term-line.line-action .term-msg { color: #86efac; }
+.term-line.line-command .term-msg { color: #86efac; font-weight: 600; }
+.term-line.line-output .term-msg { color: #e2e8f0; }
+.term-line.line-error .term-msg { color: #fca5a5; }
+.term-line.term-idle .term-msg { color: #64748b; font-style: italic; }
+.term-line.term-idle code { color: #94a3b8; font-size: 0.95em; }
+
+.agent-term-foot {
+ border-top: 1px solid rgba(148, 163, 184, 0.12);
+ background: rgba(10, 14, 20, 0.95);
+ padding: 0.45rem 0.55rem;
+}
+
+.agent-term-cmd {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ width: 100%;
+}
+
+.term-prompt {
+ color: #22c55e;
+ font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
+ font-size: 0.72rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.agent-term-input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: transparent;
+ color: #f1f5f9;
+ font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
+ font-size: 0.72rem;
+ outline: none;
+ padding: 0.2rem 0;
+}
+
+.agent-term-input::placeholder { color: #475569; }
+.agent-term-input.is-busy { opacity: 0.55; }
+
+.agent-term-window.is-running .term-dot {
+ background: #f59e0b;
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.7);
+}
+
+.agent-term-window.is-expanded .agent-term-foot {
+ padding: 0.55rem 0.75rem;
+}
+
+.agent-term-window.is-expanded .agent-term-input {
+ font-size: 0.78rem;
+}
+
+.mesh-terminal-focus {
+ margin-top: 1rem;
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ border-radius: 12px;
+ background: #060a10;
+}
+
+.mesh-terminal-focus .agent-term-body {
+ max-height: 240px;
+}
+
.vtab-badge {
display: inline-flex;
min-width: 1.1rem;
diff --git a/cockpit/static/css/app-sidebar-neo.css b/cockpit/static/css/app-sidebar-neo.css
new file mode 100644
index 0000000..14c3f4d
--- /dev/null
+++ b/cockpit/static/css/app-sidebar-neo.css
@@ -0,0 +1,177 @@
+/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
+
+.app-shell {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ min-height: 100vh;
+}
+
+.app-sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ overflow-y: auto;
+ padding: 0.75rem 0.6rem;
+ background: #080c12;
+ border-right: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+.app-sidebar-brand {
+ padding: 0.25rem 0.5rem 0.75rem;
+ margin-bottom: 0.5rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+}
+.app-sidebar-brand .brand-title {
+ font-size: 0.58rem;
+ letter-spacing: 0.14em;
+ color: #64748b;
+ margin: 0;
+}
+.app-sidebar-brand .brand-name {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: #e2e8f0;
+ margin: 0.2rem 0 0;
+}
+
+.persona-switch.brand-switch {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.35rem;
+ padding: 0 0.35rem 0.65rem;
+ margin-bottom: 0.35rem;
+}
+.persona-btn {
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ background: rgba(15, 23, 42, 0.6);
+ color: #94a3b8;
+ border-radius: 8px;
+ padding: 0.45rem 0.25rem;
+ font-size: 0.58rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+.brand-btn-cucina.active { color: #fdba74; border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.1); }
+.brand-btn-foodlinkk.active { color: #7dd3fc; border-color: rgba(14, 165, 233, 0.4); background: rgba(14, 165, 233, 0.1); }
+
+.persona-nav { display: contents; }
+
+.app-sidebar-group {
+ margin-bottom: 0.65rem;
+}
+.app-sidebar-label {
+ font-size: 0.56rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: #64748b;
+ padding: 0.2rem 0.5rem 0.35rem;
+}
+
+/* Base pill — accent via modifier class */
+.app-sidebar a.nav-pill {
+ --nav-accent: #64748b;
+ display: flex !important;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ margin: 0 0 0.2rem;
+ padding: 0.5rem 0.55rem;
+ border-radius: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.1);
+ background: rgba(15, 23, 42, 0.5);
+ color: #cbd5e1 !important;
+ font-size: 0.78rem;
+ font-weight: 500;
+ text-decoration: none;
+ text-align: left;
+ white-space: nowrap;
+ transform: none !important;
+ animation: none !important;
+ box-shadow: none !important;
+ text-shadow: none !important;
+ overflow: visible;
+ isolation: auto;
+}
+.app-sidebar a.nav-pill::before,
+.app-sidebar a.nav-pill::after {
+ display: none !important;
+ content: none !important;
+}
+
+.app-sidebar a.nav-pill:hover {
+ background: rgba(30, 41, 59, 0.8);
+ border-color: rgba(148, 163, 184, 0.2);
+ color: #f1f5f9 !important;
+ transform: none !important;
+}
+
+.app-sidebar a.nav-pill.active {
+ background: rgba(15, 23, 42, 0.95);
+ border-color: var(--nav-accent);
+ border-left: 3px solid var(--nav-accent);
+ padding-left: calc(0.55rem - 2px);
+ color: #fff !important;
+ box-shadow: none !important;
+ animation: none !important;
+}
+
+.sidebar-nav-icon {
+ width: 26px;
+ height: 26px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ font-size: 0.85rem;
+ background: rgba(8, 12, 18, 0.9);
+ border: 1px solid rgba(148, 163, 184, 0.12);
+}
+.app-sidebar a.nav-pill.active .sidebar-nav-icon {
+ background: rgba(15, 23, 42, 0.95);
+ border-color: var(--nav-accent);
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
+}
+
+.sidebar-nav-label {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Per-item accent colors */
+.app-sidebar a.nav-pill-wereldexport { --nav-accent: #0ea5e9; }
+.app-sidebar a.nav-pill-clients { --nav-accent: #38bdf8; }
+.app-sidebar a.nav-pill-deals { --nav-accent: #4ade80; }
+.app-sidebar a.nav-pill-products { --nav-accent: #a855f7; }
+.app-sidebar a.nav-pill-suppliers { --nav-accent: #fb923c; }
+.app-sidebar a.nav-pill-chat { --nav-accent: #fcd34d; }
+.app-sidebar a.nav-pill-reports { --nav-accent: #6366f1; }
+.app-sidebar a.nav-pill-herman { --nav-accent: #fbbf24; }
+.app-sidebar a.nav-pill-retail { --nav-accent: #0066cc; }
+.app-sidebar a.nav-pill-marketing { --nav-accent: #ff6b9d; }
+.app-sidebar a.nav-pill-beurs { --nav-accent: #b8ff3c; }
+.app-sidebar a.nav-pill-documents { --nav-accent: #94a3b8; }
+.app-sidebar a.nav-pill-telegram { --nav-accent: #2aabee; }
+.app-sidebar a.nav-pill-analytics { --nav-accent: #8b5cf6; }
+.app-sidebar a.nav-pill-agents { --nav-accent: #00e5ff; }
+.app-sidebar a.nav-pill-browser { --nav-accent: #14b8a6; }
+.app-sidebar a.nav-pill-studio { --nav-accent: #ec4899; }
+.app-sidebar a.nav-pill-voice { --nav-accent: #f97316; }
+.app-sidebar a.nav-pill-ops { --nav-accent: #22d3ee; }
+.app-sidebar a.nav-pill-settings { --nav-accent: #e2e8f0; }
+
+.persona-badge {
+ display: inline-block;
+ font-size: 0.62rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ padding: 0.15rem 0.45rem;
+ border-radius: 6px;
+ margin-right: 0.45rem;
+}
+.brand-badge-foodlinkk { color: #7dd3fc; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.25); }
+.brand-badge-cucina { color: #fdba74; background: rgba(251, 146, 60, 0.12); border: 1px solid rgba(251, 146, 60, 0.25); }
diff --git a/cockpit/static/css/clients-crm.css b/cockpit/static/css/clients-crm.css
index c65dfc0..edc22b6 100644
--- a/cockpit/static/css/clients-crm.css
+++ b/cockpit/static/css/clients-crm.css
@@ -11,15 +11,60 @@
.clients-kpi strong { font-size: 1.35rem; color: #f1f5f9; }
.clients-tabs {
- display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
- margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1.25rem;
+}
+.clients-tab-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ padding: 0.35rem;
+ background: rgba(6, 10, 18, 0.88);
+ border: 1px solid rgba(56, 189, 248, 0.14);
+ border-radius: 14px;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.clients-tab {
- border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25);
- color: #cbd5e1; border-radius: 999px; padding: 0.35rem 0.85rem; font-size: 0.78rem; cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.55rem 1rem;
+ border-radius: 10px;
+ font-size: 0.82rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid transparent;
+ background: transparent;
+ color: #94a3b8;
+ transition: color 0.18s, background 0.18s, border-color 0.18s, box-shadow 0.18s;
+ white-space: nowrap;
+}
+.clients-tab-icon {
+ font-size: 0.9rem;
+ line-height: 1;
+ opacity: 0.85;
+}
+.clients-tab:hover {
+ color: #e2e8f0;
+ background: rgba(56, 189, 248, 0.07);
+}
+.clients-tab.active {
+ color: #e0f2fe;
+ background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(168, 85, 247, 0.1));
+ border-color: rgba(56, 189, 248, 0.38);
+ box-shadow: 0 0 18px rgba(56, 189, 248, 0.1);
+}
+.clients-tab.active .clients-tab-icon { opacity: 1; }
+.clients-tab-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-left: auto; }
+@media (max-width: 720px) {
+ .clients-tabs { flex-direction: column; align-items: stretch; }
+ .clients-tab-bar { width: 100%; }
+ .clients-tab-actions { margin-left: 0; width: 100%; }
}
-.clients-tab.active { border-color: #38bdf8; color: #38bdf8; box-shadow: 0 0 12px rgba(56,189,248,0.15); }
-.clients-tab-actions { margin-left: auto; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.clients-kanban { display: grid; grid-template-columns: repeat(5, minmax(200px, 1fr)); gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; }
@media (max-width: 1100px) { .clients-kanban { grid-template-columns: repeat(5, minmax(180px, 1fr)); } }
@@ -39,7 +84,30 @@
.clients-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 16px rgba(56,189,248,0.08); }
.clients-card strong { display: block; color: #f1f5f9; font-size: 0.88rem; margin-bottom: 0.2rem; }
.clients-card-meta { font-size: 0.72rem; color: #94a3b8; line-height: 1.45; }
-.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; }
+.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; margin-bottom: 0.45rem; }
+.clients-card-stage {
+ width: 100%;
+ margin-top: 0.15rem;
+ padding: 0.28rem 0.45rem;
+ font-size: 0.68rem;
+ font-weight: 600;
+ border-radius: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ background: rgba(0, 0, 0, 0.35);
+ color: #e2e8f0;
+ cursor: pointer;
+}
+.clients-card-stage:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.15); }
+.clients-inline-stage {
+ min-width: 7.5rem;
+ padding: 0.25rem 0.4rem;
+ font-size: 0.75rem;
+ border-radius: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ background: rgba(0, 0, 0, 0.35);
+ color: #e2e8f0;
+}
+.clients-inline-stage:focus { outline: none; border-color: #38bdf8; }
.clients-tag { font-size: 0.62rem; padding: 0.12rem 0.4rem; border-radius: 999px; background: rgba(56,189,248,0.12); color: #7dd3fc; }
.clients-tag.mrr { background: rgba(74,222,128,0.12); color: #4ade80; }
.stage-intake { border-left: 3px solid #94a3b8; }
@@ -76,6 +144,103 @@
display: grid; grid-template-columns: 1fr 1.2fr; gap: 1rem;
}
@media (max-width: 900px) { .store-search-panel { grid-template-columns: 1fr; } }
+
+.store-filters-card {
+ background: rgba(13, 18, 25, 0.75);
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 14px;
+ padding: 0.85rem 1rem;
+ margin-bottom: 1rem;
+}
+.store-filters-grid {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 0.55rem;
+}
+@media (max-width: 1100px) { .store-filters-grid { grid-template-columns: repeat(3, 1fr); } }
+@media (max-width: 640px) { .store-filters-grid { grid-template-columns: 1fr 1fr; } }
+.store-filter-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
+.store-filter-field .filter-label {
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: #94a3b8;
+}
+.store-filter-search { grid-column: span 2; }
+@media (max-width: 640px) { .store-filter-search { grid-column: span 2; } }
+.store-filters-extra {
+ margin-top: 0.65rem;
+ padding-top: 0.55rem;
+ border-top: 1px solid rgba(148, 163, 184, 0.1);
+}
+.store-filters-extra summary {
+ cursor: pointer;
+ font-size: 0.78rem;
+ color: #7dd3fc;
+ user-select: none;
+ margin-bottom: 0.45rem;
+}
+.store-filter-checks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.55rem 1rem;
+ font-size: 0.78rem;
+ color: #cbd5e1;
+}
+.store-filter-checks label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ cursor: pointer;
+}
+.store-filters-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+ padding-top: 0.65rem;
+ border-top: 1px solid rgba(148, 163, 184, 0.08);
+}
+.store-results-meta {
+ margin-left: auto;
+ font-size: 0.72rem;
+ color: #94a3b8;
+}
+.store-results-col { min-width: 0; }
+.store-table-wrap {
+ max-height: 420px;
+ overflow: auto;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.2);
+}
+.store-results-table { font-size: 0.78rem; margin: 0; }
+.store-results-table th {
+ position: sticky;
+ top: 0;
+ background: #0d1219;
+ z-index: 1;
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #94a3b8;
+}
+.store-table-row { cursor: pointer; }
+.store-table-row:hover td { background: rgba(56, 189, 248, 0.06); }
+.store-table-row.selected td {
+ background: rgba(56, 189, 248, 0.12);
+ box-shadow: inset 3px 0 0 #38bdf8;
+}
+.store-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ margin-top: 0.55rem;
+ font-size: 0.78rem;
+}
+
.store-list { max-height: 520px; overflow-y: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
.store-list-item {
padding: 0.65rem 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.08); cursor: pointer;
@@ -91,3 +256,51 @@
.store-checks label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: #cbd5e1; }
.clients-empty { text-align: center; padding: 2rem; color: #64748b; font-size: 0.85rem; }
+
+/* ── 360° Klant (CRM) ── */
+.crm-360-shell {
+ border-radius: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
+ overflow: hidden;
+ min-height: 560px;
+}
+.crm-360-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+ background: linear-gradient(90deg, rgba(56, 189, 248, 0.06), transparent 70%);
+}
+.crm-360-head h2 { margin: 0; font-size: 1.05rem; color: #f1f5f9; }
+.crm-360-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
+.crm-360-section {
+ margin: 0 0 0.5rem;
+ font-size: 0.68rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: #94a3b8;
+}
+.crm-360-stat {
+ padding: 0.65rem;
+ border-radius: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ background: rgba(0, 0, 0, 0.25);
+ text-align: center;
+}
+.crm-360-stat .n { font-size: 1.2rem; font-weight: 800; color: #f1f5f9; }
+.crm-360-stat .l { font-size: 0.6rem; color: #64748b; text-transform: uppercase; }
+.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
+@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
+.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
+.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
+@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
+.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
+.a360-links { max-height: 200px; overflow-y: auto; }
+.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); color: #cbd5e1; }
+.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(56,189,248,0.12); color: #7dd3fc; }
+.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
+.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
diff --git a/cockpit/static/css/documents-v2.css b/cockpit/static/css/documents-v2.css
new file mode 100644
index 0000000..27ad8e1
--- /dev/null
+++ b/cockpit/static/css/documents-v2.css
@@ -0,0 +1,877 @@
+/* Documents & NAS Workbench v2 */
+[x-cloak] { display: none !important; }
+
+.doc-hub {
+ --doc-accent: #00e5ff;
+ --doc-gold: #ffd700;
+ --doc-purple: #a855f7;
+ --doc-border: rgba(0, 229, 255, 0.14);
+ --doc-text: #f1f5f9;
+ --doc-muted: #94a3b8;
+ --doc-dim: #64748b;
+}
+
+.doc-hub .page-header h1 {
+ font-size: 1.65rem;
+ background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+/* Tabs */
+.doc-nav {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.25rem;
+ padding: 0.35rem;
+ background: rgba(6, 10, 18, 0.85);
+ border: 1px solid var(--doc-border);
+ border-radius: 14px;
+}
+
+.doc-nav-btn {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.55rem 1.1rem;
+ border-radius: 10px;
+ font-size: 0.82rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--doc-muted);
+ transition: all 0.18s;
+ user-select: none;
+}
+
+.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
+
+.doc-nav-btn.active,
+#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
+#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
+#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
+#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
+#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"] {
+ color: #e0f2fe;
+ background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
+ border-color: rgba(0, 229, 255, 0.35);
+ box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
+}
+
+/* Workbench shell */
+.doc-workbench {
+ border-radius: 16px;
+ border: 1px solid var(--doc-border);
+ background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
+ overflow: hidden;
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
+}
+
+/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
+.doc-tab-input {
+ position: fixed;
+ left: -9999px;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+#doc-tab-stage {
+ min-height: 50vh;
+}
+
+#doc-tab-stage [data-tab-panel] {
+ display: none !important;
+}
+
+#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
+#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
+#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
+#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
+#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
+#doc-tab-stage [data-tab-panel].tab-active {
+ display: block !important;
+ min-height: 50vh;
+}
+
+.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
+ font-size: 0.65rem;
+ color: var(--doc-dim);
+ white-space: nowrap;
+}
+.dl-version-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-top: 0.35rem;
+}
+.doc-row-fallback { opacity: 0.85; }
+
+.share-breadcrumb {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.15rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.68rem;
+}
+.share-crumb {
+ background: transparent;
+ border: none;
+ color: #7dd3fc;
+ cursor: pointer;
+ padding: 0.1rem 0.25rem;
+ border-radius: 4px;
+}
+.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
+.share-crumb-sep { color: #475569; }
+.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
+.share-browser-list { max-height: 52vh; overflow-y: auto; }
+
+.doc-wb-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+ background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
+}
+
+.doc-wb-head h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ color: var(--doc-text);
+}
+
+.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
+
+.doc-kpi-strip {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
+
+.doc-kpi {
+ padding: 0.9rem 1.1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.06);
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
+.doc-kpi:last-child { border-right: none; }
+.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
+.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
+
+.doc-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ align-items: center;
+ padding: 0.85rem 1.1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
+
+/* Split layout */
+.doc-split {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0;
+ min-height: 280px;
+}
+
+@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
+
+.doc-split-col {
+ padding: 1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+.doc-split-col:last-child { border-right: none; }
+
+.doc-chart-wrap { height: 140px; position: relative; }
+
+/* Document table */
+.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
+
+.doc-table-wrap .data-table { font-size: 0.78rem; }
+.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
+.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
+.doc-table-wrap .data-table td strong { color: var(--doc-text); }
+.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
+
+.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
+
+/* Label chips */
+.doc-chip {
+ display: inline-block;
+ padding: 0.18rem 0.55rem;
+ margin: 0.12rem 0.12rem 0 0;
+ border-radius: 999px;
+ font-size: 0.65rem;
+ font-weight: 600;
+ background: rgba(0, 229, 255, 0.12);
+ color: #7dd3fc;
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ white-space: nowrap;
+}
+
+.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
+
+/* Photo grid */
+.photo-masonry {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.photo-tile {
+ border: 1px solid var(--doc-border);
+ border-radius: 14px;
+ overflow: hidden;
+ background: rgba(10, 15, 26, 0.95);
+ cursor: pointer;
+ transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
+}
+
+.photo-tile:hover {
+ transform: translateY(-2px);
+ border-color: rgba(0, 229, 255, 0.35);
+ box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
+}
+
+.photo-tile img {
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ background: #060a10;
+ display: block;
+}
+
+.photo-tile-body {
+ padding: 0.65rem 0.75rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.photo-tile-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
+.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
+
+.photo-tile-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.2rem;
+ min-height: 1.5rem;
+ align-items: flex-start;
+}
+
+.photo-tile-ocr {
+ font-size: 0.68rem;
+ line-height: 1.4;
+ color: var(--doc-muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ min-height: 2.8em;
+}
+
+.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
+
+/* Label filter chips row */
+.label-filter-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ padding: 0 1rem 0.75rem;
+}
+
+.label-filter-chip {
+ padding: 0.28rem 0.65rem;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ cursor: pointer;
+ border: 1px solid rgba(100, 116, 139, 0.35);
+ background: transparent;
+ color: var(--doc-muted);
+}
+
+.label-filter-chip.active, .label-filter-chip:hover {
+ border-color: rgba(0, 229, 255, 0.4);
+ color: #e0f2fe;
+ background: rgba(0, 229, 255, 0.1);
+}
+
+/* ── Label modal (fixed overlap + dark text) ── */
+.label-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ background: rgba(0, 0, 0, 0.82);
+ backdrop-filter: blur(6px);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 1.25rem;
+}
+.label-modal-backdrop.open {
+ display: flex;
+}
+
+.label-modal {
+ width: min(780px, 100%);
+ max-height: 92vh;
+ overflow-y: auto;
+ background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ border-radius: 20px;
+ padding: 1.35rem;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
+ color: var(--doc-text);
+}
+
+.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
+.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
+
+.label-modal-preview {
+ display: grid;
+ grid-template-columns: 140px 1fr;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 0.85rem;
+ background: rgba(0, 0, 0, 0.35);
+ border-radius: 12px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+}
+
+@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
+
+.label-modal-preview img {
+ width: 100%;
+ border-radius: 10px;
+ object-fit: cover;
+ max-height: 160px;
+ border: 1px solid rgba(148, 163, 184, 0.15);
+}
+
+.modal-ocr-box {
+ font-size: 0.74rem;
+ line-height: 1.55;
+ color: #cbd5e1 !important;
+ background: rgba(15, 23, 42, 0.6);
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ border-radius: 8px;
+ padding: 0.65rem 0.75rem;
+ max-height: 140px;
+ overflow-y: auto;
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.modal-ocr-label {
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--doc-dim);
+ margin-bottom: 0.35rem;
+}
+
+.label-modal-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
+ gap: 0.5rem;
+}
+
+.label-pick {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.35rem;
+ padding: 0.75rem 0.5rem;
+ min-height: 92px;
+ border-radius: 12px;
+ border: 1px solid rgba(100, 116, 139, 0.35);
+ background: rgba(15, 23, 42, 0.7);
+ cursor: pointer;
+ transition: all 0.15s;
+ color: #e2e8f0;
+}
+
+.label-pick:hover {
+ border-color: rgba(0, 229, 255, 0.4);
+ background: rgba(0, 229, 255, 0.06);
+}
+
+.label-pick.selected {
+ border-color: var(--pick-color, #00e5ff);
+ background: rgba(0, 229, 255, 0.14);
+ box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
+}
+
+.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
+
+.label-pick-name {
+ font-size: 0.68rem;
+ font-weight: 600;
+ line-height: 1.3;
+ text-align: center;
+ color: #cbd5e1;
+ display: block;
+ width: 100%;
+ word-break: break-word;
+}
+
+.label-pick.selected .label-pick-name { color: #f8fafc; }
+
+.label-modal-extra {
+ margin-top: 1rem;
+ display: grid;
+ gap: 0.5rem;
+}
+
+.label-modal-extra label {
+ font-size: 0.72rem;
+ color: var(--doc-muted);
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.label-modal-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ margin-top: 1.1rem;
+ padding-top: 1rem;
+ border-top: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+/* Document label drawer */
+.doc-label-drawer {
+ margin: 1rem;
+ padding: 1rem;
+ border-radius: 14px;
+ border: 1px solid rgba(0, 229, 255, 0.2);
+ background: rgba(0, 0, 0, 0.25);
+}
+
+.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
+
+/* ── Docling workbench ── */
+.dl-workbench { min-height: 620px; }
+
+.dl-body {
+ display: grid;
+ grid-template-columns: 260px 300px 1fr;
+ min-height: 560px;
+}
+
+/* v3 split workbench: files | editor+preview */
+.dl-body-v3 {
+ display: grid;
+ grid-template-columns: minmax(260px, 300px) 1fr;
+ min-height: 580px;
+ border-top: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
+.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
+
+.dl-pipeline-panel {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ background: rgba(0, 0, 0, 0.18);
+}
+
+.dl-pipeline-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr auto;
+ gap: 1rem;
+ align-items: start;
+}
+
+@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
+
+.dl-workspace {
+ display: flex;
+ flex-direction: column;
+ min-height: 580px;
+ background: rgba(0, 0, 0, 0.08);
+ overflow: hidden;
+}
+
+.dl-workspace.empty { justify-content: center; align-items: center; }
+
+.dl-workspace-inner {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+}
+
+.dl-ws-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ flex-wrap: wrap;
+}
+
+.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
+.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
+.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
+.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
+
+.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
+
+.dl-workspace-panes {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ flex: 1;
+ min-height: 0;
+ gap: 0;
+}
+
+@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
+
+.dl-pane {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-right: 1px solid rgba(148, 163, 184, 0.06);
+}
+
+.dl-pane:last-child { border-right: none; }
+
+.dl-pane-label {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.45rem 0.85rem;
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--doc-dim);
+ border-bottom: 1px solid rgba(148, 163, 184, 0.06);
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.dl-export-tabs-inline { margin: 0; }
+
+.dl-editor-full {
+ flex: 1;
+ min-height: 420px;
+ max-height: none;
+ border: none;
+ border-radius: 0;
+ resize: none;
+}
+
+.dl-preview-viewport {
+ flex: 1;
+ min-height: 420px;
+ overflow: auto;
+ padding: 0.65rem;
+}
+
+.dl-preview-viewport .dl-preview-box {
+ min-height: 200px;
+ max-height: none;
+ height: 100%;
+}
+
+.dl-preview-img {
+ max-width: 100%;
+ max-height: 480px;
+ border-radius: 8px;
+ display: block;
+ margin: 0 auto;
+}
+
+.dl-preview-iframe {
+ width: 100%;
+ height: 480px;
+ border: none;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
+.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
+.dl-file-meta { flex: 1; min-width: 0; }
+.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
+.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
+.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
+
+.dl-body-v3 .dl-sidebar {
+ max-height: none;
+ height: 100%;
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
+}
+
+@media (max-width: 1200px) {
+ .dl-body-v3 { grid-template-columns: 1fr; }
+ .dl-body-v3 .dl-sidebar { max-height: 280px; }
+}
+
+@media (max-width: 1200px) {
+ .dl-body { grid-template-columns: 240px 1fr; }
+ .dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
+}
+
+.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
+ padding: 1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
+ overflow-y: auto;
+ max-height: 680px;
+}
+
+.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
+.dl-options { background: rgba(0, 0, 0, 0.12); }
+
+.dl-section-title {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--doc-dim);
+ margin: 0 0 0.5rem;
+}
+
+.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
+
+.dl-ext-chip {
+ padding: 0.22rem 0.5rem;
+ border-radius: 6px;
+ font-size: 0.65rem;
+ cursor: pointer;
+ border: 1px solid rgba(100, 116, 139, 0.3);
+ background: transparent;
+ color: var(--doc-muted);
+}
+
+.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
+
+.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
+
+.dl-file-item {
+ padding: 0.5rem 0.6rem;
+ border-radius: 10px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: background 0.12s;
+}
+
+.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
+
+.dl-file-item.selected {
+ border-color: rgba(0, 229, 255, 0.35);
+ background: rgba(0, 229, 255, 0.1);
+}
+
+.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.dl-file-badge {
+ display: inline-block;
+ margin-top: 0.25rem;
+ font-size: 0.58rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ background: rgba(74, 222, 128, 0.15);
+ color: #4ade80;
+}
+
+.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
+.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
+
+.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
+
+.dl-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.45rem 0.6rem;
+ border-radius: 8px;
+ border: 1px solid rgba(100, 116, 139, 0.2);
+ font-size: 0.76rem;
+ color: var(--doc-muted);
+ cursor: pointer;
+ transition: all 0.12s;
+}
+
+.dl-toggle:has(input:checked) {
+ border-color: rgba(0, 229, 255, 0.35);
+ background: rgba(0, 229, 255, 0.08);
+ color: #e0f2fe;
+}
+
+.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
+
+.dl-preview-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.65rem;
+}
+
+.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
+
+.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
+
+.dl-tab {
+ padding: 0.32rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid rgba(100, 116, 139, 0.3);
+ background: transparent;
+ color: var(--doc-muted);
+}
+
+.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
+
+.dl-preview-box {
+ min-height: 320px;
+ max-height: 480px;
+ overflow: auto;
+ font-size: 0.74rem;
+ line-height: 1.5;
+ background: #050810;
+ border: 1px solid rgba(0, 229, 255, 0.1);
+ border-radius: 10px;
+ padding: 0.85rem;
+ color: #cbd5e1;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.dl-preview-box.rendered { white-space: normal; }
+.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
+.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
+.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
+.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
+
+.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
+
+.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
+
+.dl-stat {
+ padding: 0.55rem 0.65rem;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ border: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
+.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
+
+.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
+
+.dl-history-item {
+ padding: 0.45rem 0.55rem;
+ border-radius: 8px;
+ font-size: 0.68rem;
+ cursor: pointer;
+ border: 1px solid transparent;
+ color: var(--doc-muted);
+}
+
+.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
+
+.dl-progress {
+ height: 3px;
+ background: rgba(148, 163, 184, 0.15);
+ border-radius: 999px;
+ overflow: hidden;
+ margin-top: 0.5rem;
+}
+
+.dl-progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #00e5ff, #a855f7);
+ animation: dl-pulse 1.2s ease-in-out infinite;
+ width: 40%;
+}
+
+.dl-editor {
+ width: 100%;
+ min-height: 360px;
+ max-height: 520px;
+ font-family: ui-monospace, 'Cascadia Code', monospace;
+ font-size: 0.76rem;
+ line-height: 1.55;
+ background: #050810;
+ border: 1px solid rgba(0, 229, 255, 0.2);
+ border-radius: 10px;
+ padding: 0.85rem;
+ color: #e2e8f0;
+ resize: vertical;
+}
+
+.ppt-workbench { min-height: 560px; }
+.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
+@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
+.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
+.ppt-editor { padding: 1rem; }
+.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
+@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
+.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
+.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
+
+/* ── Document chat (RAG) ── */
+.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
+.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
+@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
+.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
+.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
+.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
+.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
+.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
+.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
+.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
+.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
+.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
+.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
+.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
+.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
+.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
+.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
+.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
+.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
+.doc-chat-input .form-input { flex: 1; }
+
+@keyframes dl-pulse {
+ 0%, 100% { transform: translateX(-100%); width: 30%; }
+ 50% { transform: translateX(200%); width: 50%; }
+}
+
+.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
+.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
+.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
diff --git a/cockpit/static/css/documents-workbench.css b/cockpit/static/css/documents-workbench.css
new file mode 100644
index 0000000..22eab17
--- /dev/null
+++ b/cockpit/static/css/documents-workbench.css
@@ -0,0 +1,51 @@
+.doc-tabs { display:flex; gap:0.35rem; flex-wrap:wrap; margin-bottom:1rem; }
+.doc-tab {
+ padding:0.45rem 0.9rem; border-radius:999px; font-size:0.78rem; cursor:pointer;
+ border:1px solid rgba(0,229,255,0.15); background:rgba(13,18,25,0.8); color:#94a3b8;
+}
+.doc-tab.active { background:rgba(0,229,255,0.15); color:#e0f2fe; border-color:rgba(0,229,255,0.45); }
+
+.label-modal-backdrop {
+ position:fixed; inset:0; z-index:1200; background:rgba(0,0,0,0.72);
+ display:flex; align-items:center; justify-content:center; padding:1rem;
+}
+.label-modal {
+ width:min(720px, 100%); max-height:90vh; overflow:auto;
+ background:linear-gradient(160deg,#0d1219,#080c12); border:1px solid rgba(0,229,255,0.2);
+ border-radius:16px; padding:1.1rem; box-shadow:0 20px 60px rgba(0,0,0,0.5);
+}
+.label-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:0.75rem; margin-bottom:0.85rem; }
+.label-modal-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:0.45rem; }
+.label-pick {
+ display:flex; flex-direction:column; align-items:center; gap:0.25rem; padding:0.65rem 0.4rem;
+ border-radius:12px; border:1px solid rgba(100,116,139,0.35); background:rgba(15,23,42,0.6);
+ cursor:pointer; font-size:0.72rem; text-align:center; transition:all .15s;
+}
+.label-pick:hover { border-color:rgba(0,229,255,0.35); }
+.label-pick.selected { border-color:var(--pick-color, #00e5ff); background:rgba(0,229,255,0.12); box-shadow:0 0 0 1px var(--pick-color, #00e5ff); }
+.label-pick-emoji { font-size:1.35rem; }
+
+.docling-grid { display:grid; grid-template-columns:280px 1fr 1fr; gap:1rem; }
+@media (max-width:1100px) { .docling-grid { grid-template-columns:1fr; } }
+.docling-panel { border:1px solid rgba(0,229,255,0.12); border-radius:12px; padding:0.85rem; background:rgba(13,18,25,0.85); }
+.docling-file-list { max-height:420px; overflow:auto; }
+.docling-file-row {
+ padding:0.45rem 0.55rem; border-radius:8px; cursor:pointer; font-size:0.75rem;
+ border:1px solid transparent; margin-bottom:0.25rem;
+}
+.docling-file-row:hover { background:rgba(0,229,255,0.06); }
+.docling-file-row.selected { border-color:rgba(0,229,255,0.35); background:rgba(0,229,255,0.1); }
+.docling-opt-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.35rem 0.75rem; font-size:0.78rem; }
+.docling-export-tabs { display:flex; flex-wrap:wrap; gap:0.35rem; margin:0.65rem 0; }
+.docling-export-tab {
+ padding:0.3rem 0.65rem; border-radius:999px; font-size:0.72rem; cursor:pointer;
+ border:1px solid rgba(100,116,139,0.35); background:transparent; color:#94a3b8;
+}
+.docling-export-tab.active { border-color:#00e5ff; color:#e0f2fe; background:rgba(0,229,255,0.12); }
+.docling-preview {
+ max-height:360px; overflow:auto; font-size:0.74rem; line-height:1.45;
+ background:#060a10; border:1px solid rgba(0,229,255,0.1); border-radius:8px; padding:0.65rem;
+ white-space:pre-wrap; word-break:break-word;
+}
+.docling-preview.html-preview { white-space:normal; }
+.docling-status { font-size:0.72rem; color:#94a3b8; margin-top:0.35rem; }
diff --git a/cockpit/static/css/export-intel-tabs.css b/cockpit/static/css/export-intel-tabs.css
new file mode 100644
index 0000000..54d7220
--- /dev/null
+++ b/cockpit/static/css/export-intel-tabs.css
@@ -0,0 +1,56 @@
+/* Wereldexport — KPIs + layout lock */
+
+.wereldexport-workspace.vtabs-layout {
+ display: flex !important;
+ flex-direction: row !important;
+ align-items: flex-start !important;
+ gap: 1rem !important;
+}
+.wereldexport-workspace > .vtabs-nav {
+ flex: 0 0 220px !important;
+ width: 220px !important;
+ max-width: 220px !important;
+}
+.wereldexport-workspace > .vtabs-content {
+ flex: 1 1 0 !important;
+ min-width: 0 !important;
+}
+
+.ei-neo-kpi-row {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+@media (max-width: 1100px) { .ei-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
+@media (max-width: 600px) { .ei-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
+
+.ei-neo-kpi {
+ padding: 0.65rem 0.75rem;
+ border-radius: 10px;
+ background: rgba(10, 14, 20, 0.85);
+ border: 1px solid rgba(148, 163, 184, 0.12);
+}
+.ei-neo-kpi-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
+.ei-neo-ring {
+ width: 32px; height: 32px; border-radius: 50%;
+ background: conic-gradient(var(--ring-color, #0ea5e9) calc(var(--ring-pct, 70) * 1%), rgba(30, 41, 59, 0.8) 0);
+ display: flex; align-items: center; justify-content: center;
+}
+.ei-neo-ring-inner {
+ width: 26px; height: 26px; border-radius: 50%;
+ background: #0a0e14;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 0.85rem;
+}
+.ei-neo-kpi-label { font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
+.ei-neo-kpi-value { font-size: 1.35rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
+
+.ei-sync-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding: 0.5rem 0;
+ margin-bottom: 0.5rem;
+}
+.ei-sync-bar .btn { font-size: 0.72rem; }
diff --git a/cockpit/static/css/export-intel.css b/cockpit/static/css/export-intel.css
new file mode 100644
index 0000000..f8e10a3
--- /dev/null
+++ b/cockpit/static/css/export-intel.css
@@ -0,0 +1,368 @@
+/* Export Intel — page layout */
+.ei-page { --ei-accent: #0ea5e9; }
+.ei-header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
+.ei-header h1 { font-size: 1.5rem; margin: 0; }
+.ei-subtitle { color: #94a3b8; font-size: 0.85rem; margin: 0.25rem 0 0; }
+.ei-phase-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(14,165,233,0.15); color: #7dd3fc; border: 1px solid rgba(14,165,233,0.3); }
+.ei-kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.65rem; margin-bottom: 1rem; }
+.ei-kpi { padding: 0.65rem 0.75rem; border-radius: 12px; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
+.ei-kpi span { display: block; font-size: 0.7rem; color: #94a3b8; }
+.ei-kpi strong { font-size: 1.25rem; color: #e2e8f0; }
+.ei-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
+.ei-filters select, .ei-filters input { font-size: 0.8rem; padding: 0.4rem 0.6rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.2); background: #0f1419; color: #e2e8f0; }
+
+.ei-filter-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 0.65rem 1rem;
+ margin-bottom: 1rem;
+ padding: 0.75rem 1rem;
+ background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95));
+ border: 1px solid rgba(148,163,184,0.15);
+ border-radius: 12px;
+}
+.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
+.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; }
+.ei-filter-label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; font-weight: 600; }
+.ei-filter-label select,
+.ei-filter-label input {
+ font-size: 0.82rem;
+ padding: 0.45rem 0.65rem;
+ border-radius: 8px;
+ border: 1px solid rgba(148,163,184,0.22);
+ background: #0a0e12;
+ color: #e2e8f0;
+ min-width: 8rem;
+}
+.ei-filter-search-wrap input { width: 100%; min-width: 0; }
+.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
+.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
+.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
+.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
+.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
+@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
+.ei-map-wrap { border-radius: 14px; overflow: hidden; border: 1px solid rgba(14,165,233,0.2); min-height: 400px; background: #0a0e14; }
+.ei-map-wrap #ei-map { height: 100%; min-height: 400px; }
+
+/* Leaflet popups — rich card */
+.ei-map-wrap .leaflet-popup-content-wrapper,
+.ei-map-wrap .leaflet-popup-tip {
+ background: #0f1724;
+ color: #e2e8f0;
+ border: 1px solid rgba(56, 189, 248, 0.35);
+ box-shadow: 0 8px 32px rgba(0,0,0,0.55);
+}
+.ei-map-wrap .leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; }
+.ei-map-wrap .leaflet-popup-content { margin: 0; min-width: 260px; line-height: 1.35; }
+.ei-map-wrap .leaflet-popup-close-button {
+ color: #94a3b8 !important; font-size: 1.2rem; padding: 6px 8px 0 0;
+}
+.ei-map-wrap .leaflet-popup-close-button:hover { color: #fff !important; background: transparent !important; }
+
+.ei-popup-card { padding: 0.85rem 1rem 1rem; }
+.ei-popup-top { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
+.ei-popup-badge {
+ font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
+ color: var(--badge-color, #38bdf8);
+ background: rgba(56, 189, 248, 0.12);
+ padding: 0.2rem 0.5rem; border-radius: 6px;
+}
+.ei-popup-flag { font-size: 0.72rem; font-weight: 800; color: #64748b; }
+.ei-map-wrap .leaflet-popup-content .ei-popup-name {
+ display: block; margin: 0 0 0.6rem; font-size: 1rem; font-weight: 700;
+ color: #f8fafc !important; line-height: 1.3;
+}
+.ei-popup-grid { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; }
+.ei-popup-row { display: grid; grid-template-columns: 72px 1fr; gap: 0.35rem; font-size: 0.78rem; }
+.ei-popup-k { color: #64748b; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.03em; }
+.ei-popup-v { color: #cbd5e1; word-break: break-word; }
+.ei-popup-v a { color: #38bdf8; text-decoration: none; }
+.ei-popup-v a:hover { text-decoration: underline; }
+.ei-popup-actions { border-top: 1px solid rgba(148,163,184,0.15); padding-top: 0.65rem; }
+.ei-popup-btn {
+ width: 100%; border: none; border-radius: 8px; padding: 0.5rem 0.75rem;
+ font-size: 0.8rem; font-weight: 600; cursor: pointer;
+ background: linear-gradient(135deg, #0ea5e9, #0284c7); color: #fff;
+}
+.ei-popup-btn:hover { filter: brightness(1.08); }
+
+.ei-seg-toggle {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: center;
+}
+.ei-seg-btn {
+ font-size: 0.78rem;
+ padding: 0.35rem 0.65rem;
+ border-radius: 8px;
+ border: 1px solid rgba(148,163,184,0.25);
+ background: rgba(15,20,25,0.85);
+ color: #94a3b8;
+ cursor: pointer;
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
+}
+.ei-seg-btn:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.45); }
+.ei-seg-btn.active {
+ color: #f8fafc;
+ border-color: rgba(14,165,233,0.65);
+ background: rgba(14,165,233,0.18);
+ box-shadow: 0 0 0 1px rgba(14,165,233,0.2);
+}
+
+.ei-filter-group-maptypes { flex: 1 1 100%; max-width: 100%; }
+.ei-type-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: center;
+}
+.ei-type-chip {
+ font-size: 0.72rem;
+ padding: 0.3rem 0.55rem;
+ border-radius: 999px;
+ border: 1px solid rgba(148,163,184,0.22);
+ background: rgba(15,20,25,0.9);
+ color: #94a3b8;
+ cursor: pointer;
+ white-space: nowrap;
+}
+.ei-type-chip:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.4); }
+.ei-type-chip.active {
+ color: #f0fdf4;
+ border-color: rgba(34,197,94,0.55);
+ background: rgba(34,197,94,0.12);
+}
+
+.ei-filter-group-halal {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+}
+.ei-filter-check {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.78rem;
+ color: #cbd5e1;
+ cursor: pointer;
+}
+.ei-halal-min {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.75rem;
+}
+.ei-halal-min input[type=range] { width: 90px; }
+
+.ei-legend-row { display: flex; flex-wrap: wrap; gap: 0.65rem 1rem; }
+.ei-pin-hot .ei-pin-ring { box-shadow: 0 0 0 3px rgba(34,197,94,0.45); }
+
+.ei-halal-markets { margin-bottom: 0.85rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
+.ei-halal-markets h4 { margin: 0 0 0.45rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
+.ei-halal-market-row {
+ display: grid;
+ grid-template-columns: 36px 1fr auto;
+ gap: 0.35rem;
+ align-items: center;
+ font-size: 0.78rem;
+ padding: 0.25rem 0;
+}
+.ei-halal-market-row:hover { color: #38bdf8; }
+.ei-halal-score { color: #22c55e; font-size: 0.82rem; }
+.ei-halal-pill {
+ display: inline-block;
+ min-width: 2rem;
+ text-align: center;
+ padding: 0.1rem 0.35rem;
+ border-radius: 6px;
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+.ei-halal-pill.ei-halal-hot { background: rgba(34,197,94,0.2); color: #4ade80; }
+.ei-halal-pill.ei-halal-warm { background: rgba(234,179,8,0.2); color: #facc15; }
+.ei-halal-pill.ei-halal-mild { background: rgba(249,115,22,0.2); color: #fb923c; }
+
+.ei-list-focus {
+ margin-bottom: 0.85rem;
+ padding: 0.75rem;
+ border-radius: 12px;
+ border: 1px solid rgba(34,197,94,0.35);
+ background: rgba(34,197,94,0.06);
+}
+.ei-list-focus-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
+.ei-list-focus-title { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f8fafc; line-height: 1.3; }
+.ei-list-focus-meta { margin: 0 0 0.65rem; font-size: 0.75rem; color: #94a3b8; }
+.ei-list-focus-kv { display: grid; grid-template-columns: 72px 1fr; gap: 0.3rem; font-size: 0.78rem; margin-bottom: 0.3rem; }
+.ei-list-focus-kv span:first-child { color: #64748b; }
+.ei-list-focus-kv a { color: #38bdf8; word-break: break-all; }
+.ei-list-focus-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-top: 0.65rem; }
+.ei-halal-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.5rem 0; }
+.ei-halal-tag {
+ font-size: 0.68rem;
+ padding: 0.15rem 0.45rem;
+ border-radius: 6px;
+ background: rgba(14,165,233,0.12);
+ color: #7dd3fc;
+ border: 1px solid rgba(14,165,233,0.2);
+}
+.ei-row-active { background: rgba(14,165,233,0.12) !important; }
+.ei-row-name { font-weight: 600; color: #e2e8f0; }
+.ei-contact-cell { font-size: 0.72rem; color: #94a3b8; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ei-type-pill-sm { font-size: 0.65rem; padding: 0.1rem 0.35rem; }
+.ei-contact-row-compact { margin-bottom: 0.45rem; font-size: 0.78rem; }
+.ei-contact-source { display: block; font-size: 0.68rem; color: #64748b; }
+.ei-drawer-coords { font-size: 0.78rem; color: #94a3b8; }
+.ei-drawer-coords a { margin-left: 0.5rem; color: #38bdf8; }
+
+.ei-selection-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.65rem;
+ margin: 0.5rem 0 0.75rem;
+ padding: 0.55rem 0.75rem;
+ border-radius: 10px;
+ border: 1px solid rgba(14,165,233,0.25);
+ background: rgba(14,165,233,0.06);
+}
+.ei-selection-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
+.ei-selection-count { font-size: 0.78rem; font-weight: 700; color: #38bdf8; }
+.ei-selection-hint { font-size: 0.72rem; color: #64748b; }
+
+.ei-th-check, .ei-td-check { width: 28px; text-align: center; }
+.ei-th-fav, .ei-td-fav { width: 32px; text-align: center; }
+.ei-fav-btn {
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ color: #64748b;
+ padding: 0;
+ line-height: 1;
+}
+.ei-fav-btn.active, .ei-fav-btn:hover { color: #facc15; }
+.ei-row-selected { background: rgba(14,165,233,0.08) !important; }
+.ei-row-crm .ei-row-name { padding-right: 0.25rem; }
+.ei-crm-badge {
+ display: inline-block;
+ margin-left: 0.35rem;
+ font-size: 0.58rem;
+ font-weight: 800;
+ padding: 0.1rem 0.35rem;
+ border-radius: 4px;
+ background: rgba(34,197,94,0.2);
+ color: #4ade80;
+ vertical-align: middle;
+}
+.ei-filter-fav { color: #facc15 !important; margin-left: 0.35rem; }
+.ei-pipeline-hint { font-size: 0.82rem; color: #94a3b8; margin: 0 0 1rem; }
+
+.ei-modal-root { position: fixed; inset: 0; z-index: 1300; display: flex; align-items: center; justify-content: center; padding: 1rem; }
+.ei-modal-card {
+ position: relative;
+ z-index: 1302;
+ width: min(420px, 96vw);
+ padding: 1.25rem;
+ border-radius: 14px;
+ background: #0f1419;
+ border: 1px solid rgba(14,165,233,0.35);
+ box-shadow: 0 20px 50px rgba(0,0,0,0.5);
+}
+.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
+.ei-modal-card p { font-size: 0.85rem; color: #94a3b8; margin: 0 0 0.75rem; }
+.ei-modal-note { font-size: 0.75rem !important; color: #64748b !important; }
+.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
+
+/* Map pins */
+.ei-pin-wrap { background: transparent !important; border: none !important; }
+.ei-pin {
+ width: 30px; height: 30px; position: relative;
+ display: flex; align-items: center; justify-content: center;
+}
+.ei-pin-ring {
+ position: absolute; inset: 0; border-radius: 50%;
+ background: var(--pin-color);
+ border: 2px solid rgba(255,255,255,0.9);
+ box-shadow: 0 2px 10px rgba(0,0,0,0.45);
+}
+.ei-pin-icon { position: relative; z-index: 1; font-size: 14px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.3)); }
+
+/* Clusters */
+.ei-cluster-wrap { background: transparent !important; border: none !important; }
+.ei-cluster {
+ width: 44px; height: 44px; border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ background: linear-gradient(135deg, #0ea5e9, #0369a1);
+ border: 2px solid rgba(255,255,255,0.55);
+ box-shadow: 0 4px 16px rgba(14,165,233,0.45);
+ color: #fff; font-weight: 800; font-size: 0.85rem;
+}
+.ei-cluster-md { width: 50px; height: 50px; font-size: 0.9rem; background: linear-gradient(135deg, #8b5cf6, #6d28d9); box-shadow: 0 4px 16px rgba(139,92,246,0.45); }
+.ei-cluster-lg { width: 56px; height: 56px; font-size: 0.95rem; background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4px 16px rgba(245,158,11,0.45); }
+.ei-map-wrap .marker-cluster-small,
+.ei-map-wrap .marker-cluster-medium,
+.ei-map-wrap .marker-cluster-large { background: transparent !important; }
+.ei-map-wrap .marker-cluster-small div,
+.ei-map-wrap .marker-cluster-medium div,
+.ei-map-wrap .marker-cluster-large div { background: transparent !important; color: transparent !important; }
+
+.ei-map-legend {
+ display: flex; flex-wrap: wrap; gap: 0.5rem 1rem;
+ margin-bottom: 0.65rem; font-size: 0.72rem; color: #94a3b8;
+}
+.ei-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; }
+.ei-leg-pin { font-style: normal; }
+
+.ei-map-empty { margin-bottom: 0.65rem; }
+.ei-map-empty-inner {
+ padding: 0.85rem 1rem;
+ border-radius: 10px;
+ background: rgba(245, 158, 11, 0.08);
+ border: 1px solid rgba(245, 158, 11, 0.25);
+ font-size: 0.82rem;
+ color: #cbd5e1;
+}
+.ei-map-empty-inner strong { display: block; color: #fbbf24; margin-bottom: 0.35rem; }
+.ei-map-empty-inner p { margin: 0.25rem 0; }
+.ei-map-empty-actions { display: flex; gap: 0.5rem; margin-top: 0.65rem; flex-wrap: wrap; }
+
+.ei-panel { border-radius: 14px; padding: 0.75rem; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
+.ei-table-wrap { overflow-x: auto; max-height: 480px; }
+.ei-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
+.ei-table th, .ei-table td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.08); }
+.ei-table th { color: #94a3b8; font-weight: 600; position: sticky; top: 0; background: #0f1419; }
+.ei-table tr:hover td { background: rgba(14,165,233,0.05); }
+.ei-empty { text-align: center; padding: 2rem; color: #64748b; }
+.ei-empty-cta { margin-top: 0.75rem; }
+.ei-type-pill { font-size: 0.65rem; padding: 0.1rem 0.45rem; border-radius: 6px; background: rgba(148,163,184,0.15); }
+.ei-type-distributor { color: #fbbf24; }
+.ei-type-wholesaler { color: #eab308; }
+.ei-type-contract_caterer { color: #a78bfa; }
+.ei-confidence { font-size: 0.65rem; color: #64748b; }
+.ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1200; }
+.ei-drawer-root { position: relative; z-index: 1201; }
+.ei-drawer { position: fixed; top: 0; right: 0; width: min(480px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1202; overflow-y: auto; box-shadow: -8px 0 32px rgba(0,0,0,0.45); }
+.ei-drawer-rich { width: min(480px, 100vw); padding: 0; display: flex; flex-direction: column; }
+.ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
+.ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; }
+.ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; }
+.ei-drawer-badge { font-size: 0.68rem; padding: 0.2rem 0.55rem; border-radius: 6px; background: rgba(148,163,184,0.15); text-transform: capitalize; }
+.ei-drawer-flag { font-size: 0.75rem; font-weight: 800; color: #64748b; }
+.ei-drawer-title { margin: 0 0 0.35rem; font-size: 1.25rem; line-height: 1.25; color: #f8fafc; }
+.ei-drawer-meta { font-size: 0.78rem; color: #94a3b8; margin: 0 0 1rem; }
+.ei-drawer-meta strong { color: #e2e8f0; }
+.ei-drawer-section { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.1); }
+.ei-drawer-section h4 { margin: 0 0 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
+.ei-drawer-kv { display: grid; grid-template-columns: 88px 1fr; gap: 0.35rem; font-size: 0.82rem; margin-bottom: 0.35rem; }
+.ei-drawer-kv span:first-child { color: #64748b; }
+.ei-drawer-kv a { color: #38bdf8; word-break: break-all; }
+.ei-drawer-empty { font-size: 0.78rem; color: #64748b; margin: 0; }
+.ei-contact-row { padding: 0.5rem 0; border-bottom: 1px solid rgba(148,163,184,0.08); font-size: 0.82rem; }
+.ei-contact-row a { color: #38bdf8; }
+.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
+.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
+.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }
diff --git a/cockpit/static/css/herman-assistant.css b/cockpit/static/css/herman-assistant.css
new file mode 100644
index 0000000..93974c2
--- /dev/null
+++ b/cockpit/static/css/herman-assistant.css
@@ -0,0 +1,148 @@
+/* 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;
+}
+.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));
+ display: flex;
+ flex-direction: column;
+ border-radius: 16px;
+ border: 1px solid rgba(167, 139, 250, 0.35);
+ background: rgba(10, 14, 20, 0.97);
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
+ overflow: hidden;
+}
+
+.ha-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.65rem 0.85rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.12);
+ background: rgba(167, 139, 250, 0.08);
+}
+.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; }
+.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; }
+.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-confirm {
+ padding: 0.5rem 0.75rem;
+ background: rgba(251, 191, 36, 0.1);
+ border-bottom: 1px solid rgba(251, 191, 36, 0.25);
+ font-size: 0.75rem;
+ color: #fde68a;
+}
+.ha-confirm-actions { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
+
+.ha-log {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.65rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-height: 200px;
+ max-height: 320px;
+}
+.ha-bubble {
+ max-width: 92%;
+ padding: 0.5rem 0.7rem;
+ border-radius: 10px;
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+.ha-bubble-user {
+ align-self: flex-end;
+ background: rgba(14, 165, 233, 0.15);
+ border: 1px solid rgba(14, 165, 233, 0.28);
+}
+.ha-bubble-agent {
+ align-self: flex-start;
+ background: rgba(167, 139, 250, 0.12);
+ border: 1px solid rgba(167, 139, 250, 0.25);
+}
+.ha-bubble-meta { font-size: 0.62rem; color: #64748b; margin-bottom: 0.15rem; }
+.ha-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
+
+.ha-preview {
+ margin: 0 0.65rem;
+ padding: 0.4rem 0.55rem;
+ border-radius: 8px;
+ font-size: 0.72rem;
+ color: #94a3b8;
+ border: 1px dashed rgba(167, 139, 250, 0.3);
+ min-height: 1.5rem;
+}
+.ha-preview.is-interim { font-style: italic; }
+
+.ha-input-row {
+ display: flex;
+ gap: 0.35rem;
+ padding: 0.55rem 0.65rem;
+ border-top: 1px solid rgba(148, 163, 184, 0.12);
+ align-items: center;
+}
+.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
+.ha-mic {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ border: 2px solid rgba(167, 139, 250, 0.45);
+ background: rgba(15, 20, 25, 0.95);
+ cursor: pointer;
+ font-size: 1rem;
+ flex-shrink: 0;
+}
+.ha-mic.is-listening { border-color: #f87171; animation: ha-pulse 1s infinite; }
+.ha-mic.is-busy { border-color: #38bdf8; }
+@keyframes ha-pulse {
+ 0%, 100% { box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.15); }
+ 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-results-modal { z-index: 1600; }
+.ha-results-card { width: min(640px, 96vw); max-height: 80vh; }
+
+.ei-modal-root {
+ position: fixed; inset: 0; z-index: 1600;
+ display: flex; align-items: center; justify-content: center;
+ padding: 1rem; background: rgba(0, 0, 0, 0.55);
+}
+.ei-modal-card {
+ background: #0f1419; border-radius: 14px; padding: 1.25rem;
+ border: 1px solid rgba(14, 165, 233, 0.25);
+ max-width: 96vw; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
+}
+.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
+.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
+.voice-results-table th, .voice-results-table td { padding: 0.4rem 0.55rem; border-top: 1px solid rgba(148,163,184,0.1); font-size: 0.78rem; }
+.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; }
+}
diff --git a/cockpit/static/css/herman-dashboard.css b/cockpit/static/css/herman-dashboard.css
index ea71c9d..242f302 100644
--- a/cockpit/static/css/herman-dashboard.css
+++ b/cockpit/static/css/herman-dashboard.css
@@ -558,3 +558,97 @@
display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem;
}
.agent-soul-editor .agent-editor-header h2 { margin: 0; }
+
+/* Herman dashboard tabs */
+.herman-dash-tabs {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+ margin: 0 0 1rem;
+ padding: 0.35rem;
+ background: rgba(6, 10, 18, 0.88);
+ border: 1px solid rgba(0, 229, 255, 0.14);
+ border-radius: 14px;
+}
+.herman-dash-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.55rem 1rem;
+ border-radius: 10px;
+ font-size: 0.82rem;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid transparent;
+ background: transparent;
+ color: #94a3b8;
+ transition: all 0.18s;
+}
+.herman-dash-tab:hover { color: #e2e8f0; background: rgba(0, 229, 255, 0.06); }
+.herman-dash-tab.active {
+ color: #e0f2fe;
+ background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.1));
+ border-color: rgba(0, 229, 255, 0.35);
+ box-shadow: 0 0 18px rgba(0, 229, 255, 0.08);
+}
+.herman-dash-tab-badge {
+ font-size: 0.62rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 999px;
+ background: rgba(0, 229, 255, 0.15);
+ color: #7dd3fc;
+}
+.hm-rss-teaser {
+ font-size: 0.8rem;
+ color: #94a3b8;
+ margin: 0;
+}
+.hm-rss-teaser a { color: #38bdf8; }
+
+/* RSS hub tab */
+.herman-rss-hub { margin-top: 0.25rem; }
+.herman-rss-head {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+.herman-rss-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
+.herman-rss-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
+.herman-rss-filters {
+ display: grid;
+ grid-template-columns: 1fr 200px auto;
+ gap: 0.55rem;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+@media (max-width: 720px) { .herman-rss-filters { grid-template-columns: 1fr; } }
+.herman-rss-count { font-size: 0.72rem; color: #94a3b8; white-space: nowrap; }
+.herman-rss-grid {
+ display: grid;
+ grid-template-columns: 1.4fr 0.9fr;
+ gap: 1rem;
+ align-items: start;
+}
+@media (max-width: 1000px) { .herman-rss-grid { grid-template-columns: 1fr; } }
+.herman-rss-list {
+ max-height: 68vh;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.55rem;
+ padding-right: 0.25rem;
+}
+.herman-rss-card { margin: 0; }
+.herman-rss-side-item {
+ padding: 0.45rem 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ font-size: 0.78rem;
+}
+.herman-rss-side-item a { color: #e2e8f0; text-decoration: none; display: block; margin-bottom: 0.15rem; }
+.herman-rss-side-item a:hover { color: #7dd3fc; }
+.herman-rss-side-item small { color: #64748b; font-size: 0.65rem; }
+.herman-rss-col-side .hm-chart-panel { margin-bottom: 0.75rem; }
+.herman-rss-col-side h4 { margin: 0 0 0.5rem; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
diff --git a/cockpit/static/css/mobile.css b/cockpit/static/css/mobile.css
index aed9325..57ac174 100644
--- a/cockpit/static/css/mobile.css
+++ b/cockpit/static/css/mobile.css
@@ -68,16 +68,6 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
}
.hm-exec-grid { grid-template-columns: 1fr !important; }
.hm-neo-charts, .analytics-charts, .beurs-two-col { grid-template-columns: 1fr !important; }
- .vtabs-layout { grid-template-columns: 1fr !important; }
- .vtabs-nav {
- flex-direction: row !important;
- flex-wrap: nowrap !important;
- overflow-x: auto !important;
- -webkit-overflow-scrolling: touch;
- position: relative !important;
- }
- .vtabs-nav::before { display: none; }
- .vtab-btn { flex: 0 0 auto; white-space: nowrap; }
.retail-workspace, .retail-workspace.with-filters {
grid-template-columns: 1fr !important;
}
diff --git a/cockpit/static/css/parse-intel.css b/cockpit/static/css/parse-intel.css
new file mode 100644
index 0000000..19b0b4b
--- /dev/null
+++ b/cockpit/static/css/parse-intel.css
@@ -0,0 +1,442 @@
+/* Parse Intelligence Workbench */
+.pi-workbench {
+ --pi-accent: #00e5ff;
+ --pi-gold: #ffd700;
+ --pi-purple: #a855f7;
+ --pi-bg: rgba(6, 10, 18, 0.92);
+ border-radius: 16px;
+ border: 1px solid rgba(0, 229, 255, 0.12);
+ background: linear-gradient(165deg, rgba(8, 12, 22, 0.98) 0%, rgba(12, 18, 32, 0.95) 100%);
+ overflow: hidden;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
+}
+
+.pi-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.12);
+ background: linear-gradient(90deg, rgba(0, 229, 255, 0.06) 0%, transparent 60%);
+}
+
+.pi-header h2 {
+ margin: 0;
+ font-size: 1.15rem;
+ background: linear-gradient(90deg, #f1f5f9, #00e5ff);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+.pi-header-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
+
+.pi-kpi-strip {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+@media (max-width: 900px) {
+ .pi-kpi-strip { grid-template-columns: repeat(2, 1fr); }
+}
+
+.pi-kpi-cell {
+ padding: 0.85rem 1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.08);
+ transition: background 0.2s;
+}
+
+.pi-kpi-cell:hover { background: rgba(0, 229, 255, 0.04); }
+.pi-kpi-cell:last-child { border-right: none; }
+.pi-kpi-cell .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
+.pi-kpi-cell .val { font-size: 1.4rem; font-weight: 800; color: #f8fafc; line-height: 1.2; }
+.pi-kpi-cell .sub { font-size: 0.68rem; color: #475569; margin-top: 0.1rem; }
+
+.pi-body {
+ display: grid;
+ grid-template-columns: 260px 1fr 380px;
+ min-height: 520px;
+}
+
+@media (max-width: 1200px) {
+ .pi-body { grid-template-columns: 240px 1fr; }
+ .pi-detail { display: none; }
+ .pi-body.detail-open .pi-detail { display: flex; grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.12); }
+}
+
+.pi-filters {
+ padding: 1rem;
+ border-right: 1px solid rgba(148, 163, 184, 0.1);
+ background: rgba(0, 0, 0, 0.2);
+ overflow-y: auto;
+ max-height: 720px;
+}
+
+.pi-filters label {
+ display: block;
+ font-size: 0.68rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #64748b;
+ margin: 0.85rem 0 0.35rem;
+ font-weight: 700;
+}
+
+.pi-filters label:first-child { margin-top: 0; }
+
+.pi-search {
+ width: 100%;
+ padding: 0.55rem 0.75rem;
+ border-radius: 10px;
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ background: rgba(0, 0, 0, 0.35);
+ color: #f1f5f9;
+ font-size: 0.85rem;
+}
+
+.pi-search:focus {
+ outline: none;
+ border-color: rgba(0, 229, 255, 0.55);
+ box-shadow: 0 0 0 2px rgba(0, 229, 255, 0.12);
+}
+
+.pi-select, .pi-range {
+ width: 100%;
+ padding: 0.45rem 0.6rem;
+ border-radius: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ background: rgba(15, 23, 42, 0.8);
+ color: #e2e8f0;
+ font-size: 0.82rem;
+}
+
+.pi-toggle-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ color: #cbd5e1;
+ margin: 0.45rem 0;
+ cursor: pointer;
+}
+
+.pi-toggle-row input { accent-color: #00e5ff; }
+
+.pi-filter-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.65rem; }
+
+.pi-filter-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.72rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 999px;
+ background: rgba(0, 229, 255, 0.12);
+ border: 1px solid rgba(0, 229, 255, 0.35);
+ color: #7dd3fc;
+ cursor: pointer;
+}
+
+.pi-filter-chip button {
+ border: none;
+ background: none;
+ color: inherit;
+ cursor: pointer;
+ padding: 0;
+ line-height: 1;
+ font-size: 0.9rem;
+}
+
+.pi-main {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.pi-trends-bar {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ background: rgba(168, 85, 247, 0.04);
+}
+
+.pi-trends-bar .lbl {
+ font-size: 0.68rem;
+ color: #94a3b8;
+ margin-bottom: 0.4rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.pi-hype-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }
+
+.pi-hype-chip {
+ font-size: 0.76rem;
+ padding: 0.28rem 0.65rem;
+ border-radius: 999px;
+ border: 1px solid rgba(168, 85, 247, 0.35);
+ background: rgba(168, 85, 247, 0.1);
+ color: #e9d5ff;
+ cursor: pointer;
+ transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
+ user-select: none;
+}
+
+.pi-hype-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(168, 85, 247, 0.2); }
+.pi-hype-chip.active {
+ border-color: #00e5ff;
+ background: rgba(0, 229, 255, 0.15);
+ color: #a5f3fc;
+ box-shadow: 0 0 12px rgba(0, 229, 255, 0.25);
+}
+
+.pi-hype-chip.cross {
+ border-color: rgba(255, 215, 0, 0.45);
+ background: rgba(255, 215, 0, 0.08);
+ color: #fde68a;
+}
+
+.pi-hype-chip .sc { opacity: 0.6; font-size: 0.62rem; margin-left: 0.2rem; }
+
+.pi-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.55rem 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ background: rgba(0, 0, 0, 0.15);
+}
+
+.pi-toolbar .count {
+ font-size: 0.78rem;
+ color: #94a3b8;
+ margin-right: auto;
+}
+
+.pi-toolbar .count strong { color: #00e5ff; }
+
+.pi-sort-btn {
+ font-size: 0.72rem;
+ padding: 0.25rem 0.55rem;
+ border-radius: 6px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ background: transparent;
+ color: #94a3b8;
+ cursor: pointer;
+}
+
+.pi-sort-btn.active {
+ border-color: rgba(0, 229, 255, 0.45);
+ color: #00e5ff;
+ background: rgba(0, 229, 255, 0.08);
+}
+
+.pi-view-toggle { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); }
+
+.pi-view-toggle button {
+ font-size: 0.72rem;
+ padding: 0.3rem 0.65rem;
+ border: none;
+ background: transparent;
+ color: #64748b;
+ cursor: pointer;
+}
+
+.pi-view-toggle button.active { background: rgba(0, 229, 255, 0.12); color: #00e5ff; }
+
+.pi-table-wrap {
+ flex: 1;
+ overflow: auto;
+ max-height: 420px;
+}
+
+.pi-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8rem;
+}
+
+.pi-table th {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ text-align: left;
+ padding: 0.55rem 0.75rem;
+ background: rgba(10, 15, 25, 0.98);
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
+ color: #64748b;
+ font-size: 0.68rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+}
+
+.pi-table th:hover { color: #00e5ff; }
+
+.pi-table th .arrow { margin-left: 0.25rem; opacity: 0.7; }
+
+.pi-table td {
+ padding: 0.6rem 0.75rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.06);
+ vertical-align: middle;
+ color: #e2e8f0;
+}
+
+.pi-table tr { cursor: pointer; transition: background 0.12s; }
+
+.pi-table tbody tr:hover { background: rgba(0, 229, 255, 0.05); }
+
+.pi-table tbody tr.selected {
+ background: rgba(0, 229, 255, 0.1);
+ box-shadow: inset 3px 0 0 #00e5ff;
+}
+
+.pi-table .site-cell { color: #7dd3fc; font-weight: 600; font-size: 0.75rem; }
+.pi-table .title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pi-table .num { font-variant-numeric: tabular-nums; text-align: right; }
+
+.pi-badge {
+ display: inline-block;
+ font-size: 0.62rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ font-weight: 700;
+}
+
+.pi-badge.changed { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
+.pi-badge.hype-high { background: rgba(168, 85, 247, 0.25); color: #e9d5ff; }
+
+.pi-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 0.65rem;
+ padding: 0.75rem 1rem;
+ overflow-y: auto;
+ max-height: 420px;
+}
+
+.pi-card {
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 12px;
+ padding: 0.85rem;
+ background: rgba(7, 11, 18, 0.8);
+ cursor: pointer;
+ transition: border-color 0.15s, transform 0.12s;
+}
+
+.pi-card:hover { border-color: rgba(0, 229, 255, 0.35); transform: translateY(-1px); }
+.pi-card.selected { border-color: #00e5ff; box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.2); }
+
+.pi-detail {
+ display: flex;
+ flex-direction: column;
+ border-left: 1px solid rgba(148, 163, 184, 0.12);
+ background: rgba(0, 0, 0, 0.25);
+ min-width: 0;
+}
+
+.pi-detail-empty {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 2rem;
+ color: #475569;
+ font-size: 0.85rem;
+}
+
+.pi-detail-head {
+ padding: 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+.pi-detail-head h3 { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f1f5f9; line-height: 1.35; }
+
+.pi-detail-meta { font-size: 0.72rem; color: #64748b; }
+
+.pi-detail-tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+}
+
+.pi-detail-tabs button {
+ flex: 1;
+ padding: 0.5rem;
+ border: none;
+ background: transparent;
+ color: #64748b;
+ font-size: 0.75rem;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+}
+
+.pi-detail-tabs button.active {
+ color: #00e5ff;
+ border-bottom-color: #00e5ff;
+ background: rgba(0, 229, 255, 0.05);
+}
+
+.pi-detail-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.85rem 1rem;
+ max-height: 480px;
+}
+
+.pi-read-text {
+ white-space: pre-wrap;
+ font-size: 0.78rem;
+ line-height: 1.6;
+ color: #cbd5e1;
+}
+
+.pi-read-text mark {
+ background: rgba(255, 215, 0, 0.35);
+ color: #fef9c3;
+ padding: 0 0.15rem;
+ border-radius: 2px;
+}
+
+.pi-link-list { list-style: none; padding: 0; margin: 0; }
+
+.pi-link-list li {
+ padding: 0.35rem 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.06);
+ font-size: 0.78rem;
+}
+
+.pi-link-list a { color: #86efac; word-break: break-all; }
+
+.pi-heading-list { display: flex; flex-direction: column; gap: 0.35rem; }
+
+.pi-heading-item {
+ font-size: 0.78rem;
+ padding: 0.35rem 0.5rem;
+ border-radius: 6px;
+ background: rgba(0, 229, 255, 0.06);
+ border-left: 3px solid rgba(0, 229, 255, 0.4);
+ color: #e2e8f0;
+}
+
+.pi-loading {
+ padding: 2rem;
+ text-align: center;
+ color: #64748b;
+}
+
+.pi-empty {
+ padding: 2.5rem 1rem;
+ text-align: center;
+ color: #64748b;
+}
+
+.pi-empty strong { display: block; color: #94a3b8; margin-bottom: 0.35rem; }
diff --git a/cockpit/static/css/revenue-cockpit.css b/cockpit/static/css/revenue-cockpit.css
new file mode 100644
index 0000000..35184d6
--- /dev/null
+++ b/cockpit/static/css/revenue-cockpit.css
@@ -0,0 +1,493 @@
+/* Revenue Cockpit — Excel layout (licht) */
+.rc-excel-page {
+ --rc-green: #92d050;
+ --rc-red: #ff0000;
+ --rc-orange: #ffc000;
+ --rc-yellow: #ffff00;
+ --rc-blue: #bdd7ee;
+ --rc-border: #b4b4b4;
+ --rc-text: #000;
+ background: #e8e8e8;
+ color: var(--rc-text);
+ margin: -1rem -1.25rem 0;
+ padding: 0.75rem 1rem 2rem;
+ min-height: calc(100vh - 4rem);
+ font-family: Calibri, "Segoe UI", Arial, sans-serif;
+ font-size: 11pt;
+}
+
+.persona-ceo .main-topnav:has(.rc-excel-page),
+.main-topnav:has(.rc-excel-page) {
+ background: #e8e8e8;
+}
+
+.rc-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.65rem;
+ gap: 0.5rem;
+}
+.rc-toolbar-title {
+ font-weight: 700;
+ font-size: 0.85rem;
+ color: #333;
+}
+.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
+.rc-save-hint { font-size: 0.72rem; color: #666; }
+.rc-saved { color: #217346; }
+.rc-btn {
+ border: 1px solid #aaa;
+ background: #fff;
+ border-radius: 3px;
+ padding: 0.2rem 0.55rem;
+ font-size: 0.78rem;
+ cursor: pointer;
+ color: #222;
+}
+.rc-btn:hover { background: #f3f3f3; }
+.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
+.rc-btn-import { font-size: 0.72rem; }
+
+.rc-sheet-wrap {
+ overflow: auto;
+ background: #fff;
+ border: 1px solid #888;
+ box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
+}
+.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
+
+.rc-sheet {
+ width: 100%;
+ min-width: 980px;
+ border-collapse: collapse;
+ table-layout: fixed;
+ background: #fff;
+}
+
+.rc-col-logo { width: 72px; }
+.rc-col-deal { width: 16%; }
+.rc-col-num { width: 10%; }
+.rc-col-steps { width: auto; }
+
+/* Alleen bewerk-modus (echte Excel) — niet lekken naar cockpit */
+.rc-sheet .rc-row td {
+ border: 1px solid var(--rc-border);
+ vertical-align: top;
+ padding: 4px 6px;
+ line-height: 1.35;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #000;
+}
+
+.rc-sheet .rc-row-goals td {
+ background: var(--rc-yellow);
+ font-weight: 600;
+ min-height: 48px;
+}
+.rc-sheet .rc-cell-tagline {
+ font-size: 14pt;
+ font-style: italic;
+ text-align: center;
+}
+
+.rc-sheet .rc-row-head td {
+ background: #fff;
+ font-weight: 700;
+ border-bottom: 2px solid #666;
+}
+.rc-sheet .rc-th { text-align: left; }
+.rc-sheet .rc-num { text-align: right; }
+
+.rc-sheet .rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
+.rc-sheet .rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
+.rc-sheet .rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
+.rc-sheet .rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
+.rc-sheet .rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
+.rc-sheet .rc-style-white td:not(.rc-logo-cell) { background: #fff; }
+
+.rc-sheet .rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
+.rc-sheet .rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
+
+.rc-sheet .rc-cell-edit:focus {
+ outline: 2px solid #217346;
+ outline-offset: -2px;
+ background: #fff !important;
+ color: #000 !important;
+}
+.rc-sheet .rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
+.rc-sheet .rc-cell-deal { font-weight: 600; }
+.rc-sheet .rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
+
+.rc-sheet .rc-logo-cell {
+ background: #fff !important;
+ text-align: center;
+ vertical-align: middle;
+ padding: 6px 4px;
+ width: 72px;
+}
+.rc-logo-cucina .rc-logo-ring {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 58px;
+ height: 58px;
+ border-radius: 50%;
+ background: #111;
+ color: #fff;
+ font-size: 6.5pt;
+ font-weight: 800;
+ line-height: 1.15;
+ letter-spacing: 0.02em;
+ text-align: center;
+}
+.rc-logo-foodlinkk {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 4px 0;
+}
+.rc-fl-brand {
+ font-size: 11pt;
+ font-weight: 900;
+ color: #111;
+ letter-spacing: -0.02em;
+}
+.rc-fl-brand::first-letter { color: #f5c400; }
+.rc-fl-sub {
+ font-size: 5.5pt;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: #333;
+ text-transform: uppercase;
+}
+
+/* Rij panel */
+.rc-row-panel {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ width: min(420px, calc(100vw - 2rem));
+ background: #fff;
+ border: 1px solid #888;
+ box-shadow: 0 4px 20px rgba(0,0,0,0.18);
+ border-radius: 6px;
+ z-index: 100;
+ font-size: 0.82rem;
+}
+.rc-row-panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.55rem 0.75rem;
+ background: #f5f5f5;
+ border-bottom: 1px solid #ddd;
+}
+.rc-row-panel-body { padding: 0.65rem 0.75rem; }
+.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
+.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
+.rc-inline select, .rc-inline input {
+ flex: 1;
+ border: 1px solid #bbb;
+ padding: 0.3rem 0.45rem;
+ font-size: 0.78rem;
+}
+.rc-style-select {
+ border: 1px solid #bbb;
+ font-size: 0.72rem;
+ padding: 0.15rem 0.35rem;
+}
+
+.rc-footnote {
+ margin: 0.75rem 0 0;
+ font-size: 0.72rem;
+ color: #666;
+}
+
+@media (max-width: 768px) {
+ .rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
+ .rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
+}
+
+/* ── Cockpit: Excel-layout + charts (dark theme) ── */
+.rc-page {
+ --rc-green: #4ade80;
+ --rc-red: #f87171;
+ --rc-orange: #fb923c;
+ --rc-yellow: #facc15;
+ --rc-blue: #60a5fa;
+ --rc-green-fill: #22c55e;
+ --rc-red-fill: #ef4444;
+ --rc-orange-fill: #f59e0b;
+ --rc-yellow-fill: #eab308;
+ --rc-blue-fill: #3b82f6;
+ color: #e2e8f0;
+}
+.rc-page.rc-mode-sheet { background: transparent; }
+
+.rc-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.85rem;
+}
+.rc-header h1 { margin: 0; font-size: 1.25rem; }
+.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
+.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
+
+.rc-view-toggle {
+ display: flex;
+ border: 1px solid rgba(148,163,184,0.2);
+ border-radius: 8px;
+ overflow: hidden;
+}
+.rc-toggle-btn {
+ border: none;
+ background: transparent;
+ color: #94a3b8;
+ padding: 0.3rem 0.65rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
+
+.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
+
+.rc-summary-strip {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem 1.25rem;
+ margin-bottom: 0.75rem;
+ padding: 0.55rem 0.85rem;
+ background: rgba(15,20,25,0.6);
+ border: 1px solid rgba(148,163,184,0.1);
+ border-radius: 10px;
+}
+.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
+.rc-sum-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; }
+.rc-sum-item strong { font-size: 1.05rem; color: #4ade80; font-variant-numeric: tabular-nums; }
+.rc-filter-inline {
+ flex: 1 1 140px;
+ min-width: 120px;
+ max-width: 220px;
+ padding: 0.35rem 0.6rem;
+ border-radius: 6px;
+ border: 1px solid rgba(148,163,184,0.18);
+ background: #0a0e12;
+ color: #e2e8f0;
+ font-size: 0.78rem;
+ margin-left: auto;
+}
+.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
+.rc-dot-btn {
+ width: 14px; height: 14px;
+ border-radius: 50%;
+ border: 2px solid rgba(148,163,184,0.3);
+ background: transparent;
+ cursor: pointer;
+ padding: 0;
+}
+.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
+.rc-dot-green { background: var(--rc-green); }
+.rc-dot-red { background: var(--rc-red); }
+.rc-dot-orange { background: var(--rc-orange); }
+.rc-dot-yellow { background: var(--rc-yellow); }
+.rc-dot-blue { background: var(--rc-blue); }
+.rc-dot-white { background: #e2e8f0; }
+
+.rc-visual-wrap {
+ overflow: auto;
+ border-radius: 10px;
+ border: 1px solid rgba(148,163,184,0.12);
+ box-shadow: 0 4px 24px rgba(0,0,0,0.25);
+ background: #0a0e12;
+}
+
+.rc-visual-table {
+ width: 100%;
+ min-width: 680px;
+ border-collapse: separate;
+ border-spacing: 0;
+ table-layout: fixed;
+ background: #111820;
+ font-family: inherit;
+ font-size: 0.9rem;
+ color: #f1f5f9;
+}
+
+.rc-col-deal { width: 14%; }
+.rc-col-chart-num { width: 12%; }
+.rc-col-donut { width: 72px; }
+.rc-col-steps { width: auto; }
+
+.rc-visual-goals td {
+ background: rgba(234, 179, 8, 0.2);
+ color: #fef3c7;
+ font-weight: 600;
+ font-size: 0.85rem;
+ padding: 8px 12px;
+ border-bottom: 1px solid rgba(234, 179, 8, 0.25);
+ line-height: 1.4;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rc-vtagline { font-style: italic; text-align: center; color: #fde047; font-size: 0.95rem; }
+
+.rc-visual-head td {
+ background: #1a2332;
+ color: #cbd5e1;
+ font-weight: 700;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ padding: 8px 12px;
+ border-bottom: 2px solid rgba(148,163,184,0.2);
+ position: sticky;
+ top: 0;
+ z-index: 2;
+}
+
+.rc-visual-row { cursor: pointer; }
+.rc-visual-row:hover td { background: #1e2a3a !important; }
+.rc-visual-row.rc-row-open td { background: #1a3050 !important; }
+
+.rc-visual-row td,
+.rc-visual-goals td,
+.rc-visual-head td,
+.rc-vcell {
+ border-bottom: 1px solid rgba(148,163,184,0.1);
+ padding: 10px 12px;
+ vertical-align: middle;
+ line-height: 1.45;
+ background: #151c28;
+ color: #f1f5f9;
+}
+
+/* Status = gekleurde linkerrand, neutrale donkere rij */
+.rc-visual-row.rc-style-green td { box-shadow: inset 4px 0 0 #4ade80; }
+.rc-visual-row.rc-style-red td { box-shadow: inset 4px 0 0 #f87171; }
+.rc-visual-row.rc-style-orange td { box-shadow: inset 4px 0 0 #fb923c; }
+.rc-visual-row.rc-style-yellow td { box-shadow: inset 4px 0 0 #facc15; color: #fef9c3; }
+.rc-visual-row.rc-style-blue td { box-shadow: inset 4px 0 0 #60a5fa; }
+.rc-visual-row.rc-style-white td { box-shadow: inset 4px 0 0 #94a3b8; }
+
+.rc-vdeal { font-weight: 700; }
+.rc-deal-name { display: block; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.rc-brand-tag {
+ display: inline-block;
+ margin-top: 2px;
+ font-size: 0.6rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #94a3b8;
+ padding: 1px 5px;
+ border-radius: 3px;
+ background: rgba(148,163,184,0.15);
+}
+.rc-brand-tag-fl { color: #fbbf24; background: rgba(251,191,36,0.12); }
+
+.rc-vnum { text-align: right; }
+.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.92rem; font-weight: 700; color: #4ade80; }
+
+.rc-mini-bar {
+ height: 5px;
+ background: rgba(255,255,255,0.1);
+ border-radius: 3px;
+ margin-top: 5px;
+ overflow: hidden;
+}
+.rc-mini-bar-fill { height: 100%; border-radius: 3px; min-width: 3px; opacity: 0.95; }
+.rc-mini-bar-year { background: #38bdf8 !important; }
+
+.rc-vdonut { text-align: center; }
+.rc-donut {
+ width: 40px; height: 40px;
+ border-radius: 50%;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.rc-donut-label { font-size: 0.62rem; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
+.rc-donut-empty { color: #64748b; font-size: 0.85rem; }
+
+.rc-vsteps {
+ font-size: 0.84rem;
+ color: #cbd5e1;
+ line-height: 1.4;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 6px; }
+
+.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
+.rc-expand-cell { padding: 0 !important; }
+.rc-expand-inner {
+ display: flex;
+ gap: 1rem;
+ padding: 0.85rem 1rem;
+ background: rgba(10,14,18,0.98);
+ color: #e2e8f0;
+ border-top: 1px solid rgba(56,189,248,0.15);
+}
+.rc-expand-chart { flex: 0 0 140px; }
+.rc-bar-chart {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 0.5rem;
+ height: 72px;
+ margin-bottom: 0.5rem;
+}
+.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
+.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
+.rc-bar-stack {
+ width: 28px;
+ min-height: 2px;
+ background: var(--rc-green-fill);
+ border-radius: 3px 3px 0 0;
+ transition: height 0.2s;
+}
+.rc-bar-year { background: #38bdf8; }
+.rc-expand-stats { font-size: 0.75rem; }
+.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
+.rc-expand-stats span { color: #64748b; }
+.rc-expand-stats strong { color: #34d399; }
+.rc-expand-body { flex: 1; min-width: 0; }
+.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
+.rc-expand-body p { margin: 0; font-size: 0.9rem; line-height: 1.55; white-space: pre-wrap; color: #e2e8f0; }
+.rc-expand-task { margin-top: 0.65rem; }
+.rc-expand-task .rc-inline select,
+.rc-expand-task .rc-inline input {
+ border: 1px solid rgba(148,163,184,0.25);
+ background: #0f1419;
+ color: #e2e8f0;
+ padding: 0.3rem 0.45rem;
+ font-size: 0.78rem;
+ border-radius: 4px;
+}
+
+.rc-page .rc-excel-page {
+ margin: 0;
+ padding: 0;
+ min-height: auto;
+ background: #e8e8e8;
+ color: #000;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+@media (max-width: 768px) {
+ .rc-summary-strip { gap: 0.5rem; }
+ .rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
+ .rc-expand-inner { flex-direction: column; }
+}
diff --git a/cockpit/static/css/revenue-cockpit.js b/cockpit/static/css/revenue-cockpit.js
new file mode 100644
index 0000000..2ac1a5a
--- /dev/null
+++ b/cockpit/static/css/revenue-cockpit.js
@@ -0,0 +1,422 @@
+function revenueCockpit() {
+ return {
+ loading: false,
+ saving: false,
+ savedAt: '',
+ viewMode: 'cockpit',
+ goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
+ projects: [],
+ dbProjects: [],
+ sheetRows: [],
+ selectedId: null,
+ selectedKey: null,
+ expandedKey: null,
+ meta: {},
+ aggregates: {},
+ filterQ: '',
+ filterStyle: '',
+ filterCategory: '',
+ filterHasMargin: false,
+ agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
+ taskForm: { agent_name: 'research', title: '', description: '' },
+ styleFilters: [
+ { id: 'green', label: 'Live' },
+ { id: 'red', label: 'Inactief' },
+ { id: 'orange', label: 'Oranje' },
+ { id: 'yellow', label: 'Strategisch' },
+ { id: 'blue', label: 'Blauw' },
+ { id: 'white', label: 'Neutraal' },
+ ],
+ categoryFilters: [
+ { id: 'deal', label: 'Deal' },
+ { id: 'initiative', label: 'Initiatief' },
+ { id: 'strategic', label: 'Strategisch' },
+ ],
+
+ get selectedRow() {
+ return this.dbProjects.find((p) => p.id === this.selectedId) || null;
+ },
+
+ get selectedProject() {
+ if (!this.selectedKey) return null;
+ return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
+ },
+
+ async init() {
+ localStorage.setItem('foodlinkk-persona', 'ceo');
+ await this.loadLive();
+ await this.loadDbQuiet();
+ },
+
+ projectKey(p) {
+ return String(p.source_row || '') + '|' + (p.name || '');
+ },
+
+ fmtNum(n) {
+ if (n == null || n === '' || isNaN(n)) return '';
+ return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ },
+
+ fmtEuro(n) {
+ if (n == null || n === '' || isNaN(n)) return '—';
+ return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
+ },
+
+ fmtEuroCompact(n) {
+ if (n == null || n === '' || isNaN(n)) return '—';
+ const v = Number(n);
+ if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
+ if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
+ return '€' + Math.round(v);
+ },
+
+ fmtDate(iso) {
+ if (!iso) return '';
+ try {
+ return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
+ } catch (e) {
+ return iso;
+ }
+ },
+
+ categoryLabel(c) {
+ if (c === 'deal') return 'Deal';
+ if (c === 'strategic') return 'Strategisch';
+ return 'Initiatief';
+ },
+
+ parseNum(text) {
+ const t = String(text || '').trim();
+ if (!t || t === '—') return null;
+ const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
+ return isNaN(n) ? null : n;
+ },
+
+ filteredProjects() {
+ const q = (this.filterQ || '').trim().toLowerCase();
+ return this.projects.filter((p) => {
+ if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
+ if (this.filterCategory && p.category !== this.filterCategory) return false;
+ if (this.filterHasMargin && !p.margin_month) return false;
+ if (q) {
+ const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
+ if (!hay.includes(q)) return false;
+ }
+ return true;
+ });
+ },
+
+ clearFilters() {
+ this.filterQ = '';
+ this.filterStyle = '';
+ this.filterCategory = '';
+ this.filterHasMargin = false;
+ },
+
+ truncate(s, n) {
+ if (!s) return '';
+ const t = String(s).replace(/\s+/g, ' ').trim();
+ return t.length <= n ? t : t.slice(0, n).trim() + '…';
+ },
+
+ styleColor(style) {
+ const map = {
+ green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
+ yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
+ };
+ return map[style] || map.white;
+ },
+
+ foodlinkkIndexFrom(list) {
+ const idx = list.findIndex((p) =>
+ (p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
+ /foodlinkk food marketing/i.test(p.name)
+ );
+ return idx >= 0 ? idx : list.length;
+ },
+
+ visualRows() {
+ const items = this.filteredProjects();
+ const flIdx = this.foodlinkkIndexFrom(items);
+ return items.map((p, i) => {
+ let logoCell = null;
+ let brand = 'cucina';
+ if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
+ if (i === 0) logoCell = 'cucina';
+ else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
+ return { ...p, logoCell, brand };
+ });
+ },
+
+ maxMargin(field) {
+ const vals = this.filteredProjects().map((p) => p[field] || 0);
+ return Math.max(...vals, 1);
+ },
+
+ marginBarPct(p, field) {
+ if (!p[field]) return 0;
+ return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
+ },
+
+ sharePct(p) {
+ const total = this.aggregates.total_margin_month || 0;
+ if (!p.margin_month || !total) return 0;
+ return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
+ },
+
+ donutStyle(p) {
+ const pct = this.sharePct(p);
+ const color = this.styleColor(p.row_style);
+ return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
+ },
+
+ toggleExpand(p) {
+ const key = this.projectKey(p);
+ this.expandedKey = this.expandedKey === key ? null : key;
+ this.selectedKey = key;
+ this.taskForm.title = '';
+ this.taskForm.description = p.next_steps || '';
+ },
+
+ selectProject(p) {
+ this.toggleExpand(p);
+ },
+
+ mergeDbIds() {
+ const byName = {};
+ for (const d of this.dbProjects) {
+ const k = (d.name || '').trim().toLowerCase();
+ if (k) byName[k] = d.id;
+ }
+ this.projects = this.projects.map((p) => ({
+ ...p,
+ db_id: byName[(p.name || '').trim().toLowerCase()] || null,
+ }));
+ },
+
+ async loadLive() {
+ this.loading = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
+ if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
+ this.goals = r.goals || {};
+ this.projects = r.projects || [];
+ this.aggregates = {
+ ...(r.aggregates || {}),
+ project_count: r.project_count || this.projects.length,
+ };
+ this.meta = {
+ source_file: r.source_file,
+ sheet_name: r.sheet_name,
+ file_mtime: r.file_mtime,
+ parsed_at: r.parsed_at,
+ };
+ this.mergeDbIds();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
+ }
+ this.loading = false;
+ },
+
+ async loadDbQuiet() {
+ try {
+ const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
+ this.dbProjects = r.projects || [];
+ this.mergeDbIds();
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ foodlinkkIndex() {
+ const idx = this.dbProjects.findIndex((p) =>
+ (p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
+ /foodlinkk food marketing/i.test(p.name)
+ );
+ return idx >= 0 ? idx : this.dbProjects.length;
+ },
+
+ buildSheetRows() {
+ const flIdx = this.foodlinkkIndex();
+ this.sheetRows = this.dbProjects.map((p, i) => {
+ let logoCell = null;
+ let logoSpan = 1;
+ if (i === 0) {
+ logoCell = 'cucina';
+ logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
+ } else if (i === flIdx && flIdx < this.dbProjects.length) {
+ logoCell = 'foodlinkk';
+ logoSpan = this.dbProjects.length - flIdx;
+ }
+ return { ...p, logoCell, logoSpan };
+ });
+ },
+
+ async switchToSheet() {
+ this.viewMode = 'sheet';
+ await this.loadDb();
+ },
+
+ async loadDb() {
+ this.loading = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
+ this.goals = r.stats?.goals || this.goals;
+ this.dbProjects = r.projects || [];
+ this.buildSheetRows();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
+ }
+ this.loading = false;
+ },
+
+ editStart(ev) {
+ const el = ev.target;
+ if (el.classList.contains('rc-cell-num')) {
+ const raw = el.textContent.trim();
+ if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
+ }
+ requestAnimationFrame(() => {
+ const sel = window.getSelection();
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ });
+ },
+
+ markSaved() {
+ const now = new Date();
+ this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ },
+
+ async saveGoalsField(field, el) {
+ const val = el.innerText.trim();
+ if (this.goals[field] === val) return;
+ this.saving = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/goals', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ [field]: val }),
+ }).then((x) => x.json());
+ this.goals = r.goals || this.goals;
+ this.markSaved();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
+ }
+ this.saving = false;
+ },
+
+ async saveProjectField(row, field, el) {
+ let val;
+ if (field === 'margin_month' || field === 'margin_year') {
+ val = this.parseNum(el.innerText);
+ el.textContent = this.fmtNum(val);
+ } else {
+ val = el.innerText.trim();
+ }
+ if (String(row[field] ?? '') === String(val ?? '')) return;
+
+ const body = { [field]: val };
+ if (field === 'margin_month' && val != null) {
+ body.margin_year = val * 12;
+ }
+
+ this.saving = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }).then((x) => x.json());
+ const updated = r.project || {};
+ Object.assign(row, updated);
+ row.row_style = updated.row_style || row.row_style;
+ const pi = this.dbProjects.findIndex((p) => p.id === row.id);
+ if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
+ if (field === 'margin_month' && body.margin_year != null) {
+ const yearCell = el.nextElementSibling;
+ if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
+ row.margin_year = body.margin_year;
+ }
+ this.markSaved();
+ await this.loadLive();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
+ }
+ this.saving = false;
+ },
+
+ selectRow(row) {
+ this.selectedId = row.id;
+ this.taskForm.title = '';
+ this.taskForm.description = row.next_steps || '';
+ },
+
+ async setRowStyle(style) {
+ if (!this.selectedRow) return;
+ try {
+ await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ row_style: style }),
+ });
+ this.selectedRow.row_style = style;
+ const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
+ if (pi >= 0) this.dbProjects[pi].row_style = style;
+ this.buildSheetRows();
+ this.markSaved();
+ await this.loadLive();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
+ }
+ },
+
+ async assignTask() {
+ const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
+ const pid = row?.id || row?.db_id;
+ if (!pid || !this.taskForm.title.trim()) return;
+ try {
+ await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ agent_name: this.taskForm.agent_name,
+ title: this.taskForm.title.trim(),
+ description: this.taskForm.description,
+ delegate_herman: true,
+ }),
+ });
+ if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
+ this.taskForm.title = '';
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
+ }
+ },
+
+ async takeSnapshot() {
+ await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
+ if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
+ },
+
+ async importExcel() {
+ if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
+ this.loading = true;
+ try {
+ const r = await fetch('/api/revenue-cockpit/import-from-excel', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ replace: true }),
+ }).then((x) => x.json());
+ if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
+ await this.loadLive();
+ await this.loadDbQuiet();
+ if (this.viewMode === 'sheet') await this.loadDb();
+ } catch (e) {
+ if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
+ }
+ this.loading = false;
+ },
+ };
+}
diff --git a/cockpit/static/css/revenue_cockpit.html b/cockpit/static/css/revenue_cockpit.html
new file mode 100644
index 0000000..273b76a
--- /dev/null
+++ b/cockpit/static/css/revenue_cockpit.html
@@ -0,0 +1,262 @@
+{% extends "base.html" %}
+{% block title %}Revenue Cockpit · Cucina{% endblock %}
+{% block extra_head %}
+
+{% endblock %}
+{% block content %}
+
+
+
+
+
+
+
+
+
+ Marge / mnd
+
+
+
+ Marge / jr
+
+
+
+ Deals
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ | Deal |
+ Marge mnd |
+ Marge jr |
+ Aandeel |
+ Next steps |
+
+
+
+
+
+ |
+
+ Cucina
+ Foodlinkk
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+ —
+ |
+
+
+
+ ↵
+ |
+
+
+
+
+
+
+
+
+ Marge mnd
+ Marge jr
+ Portfolio
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+ |
+ Deal |
+ Marge Month |
+ Marge Year |
+ Next steps |
+
+
+
+
+ |
+ CUCINA HALAL FOOD
+ |
+
+
+
+
+ Foodlinkk
+ FOOD MARKETING AGENCY
+
+ |
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+{% block scripts %}
+
+{% endblock %}
diff --git a/cockpit/static/css/topnav-neo.css b/cockpit/static/css/topnav-neo.css
index 505ea38..269e81d 100644
--- a/cockpit/static/css/topnav-neo.css
+++ b/cockpit/static/css/topnav-neo.css
@@ -194,3 +194,16 @@
.nav-pill.active { animation: none; }
.nav-pill:hover { transform: none; }
}
+
+/* Sidebar: app-sidebar-neo.css only — no topnav glow/pulse */
+.app-sidebar .nav-pill,
+.app-sidebar .nav-pill:hover,
+.app-sidebar .nav-pill.active {
+ animation: none !important;
+ box-shadow: none !important;
+ text-shadow: none !important;
+}
+.app-sidebar .nav-pill::before,
+.app-sidebar .nav-pill::after {
+ display: none !important;
+}
diff --git a/cockpit/static/css/vertical-tabs.css b/cockpit/static/css/vertical-tabs.css
index d6044ce..ca6b326 100644
--- a/cockpit/static/css/vertical-tabs.css
+++ b/cockpit/static/css/vertical-tabs.css
@@ -1,117 +1,278 @@
-/* Vertical tab navigation with animated active indicator */
+/* Vertical tabs — clean module nav + page tabs */
.vtabs-layout {
- display: grid;
- grid-template-columns: 220px 1fr;
+ display: flex !important;
+ flex-direction: row !important;
+ align-items: flex-start !important;
gap: 1rem;
- align-items: start;
- min-height: 60vh;
+ width: 100%;
+ min-height: 40vh;
+ --vt-color: #0ea5e9;
+ --vt-panel-bg: rgba(10, 14, 20, 0.85);
}
+
+.vtabs-layout > .vtabs-nav {
+ flex: 0 0 220px;
+ width: 220px;
+ max-width: 220px;
+}
+
+.vtabs-layout > .vtabs-content {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+@media (max-width: 639px) {
+ .vtabs-layout { flex-direction: column !important; }
+ .vtabs-layout > .vtabs-nav { flex: 1 1 auto; width: 100%; max-width: none; }
+}
+
.vtabs-nav {
position: sticky;
- top: 1rem;
+ top: 0.5rem;
display: flex;
flex-direction: column;
- gap: 0.35rem;
- padding: 0.75rem;
- border-radius: 14px;
- background: rgba(10, 14, 20, 0.85);
+ gap: 0.1rem;
+ padding: 0.65rem;
+ border-radius: 12px;
+ background: rgba(10, 14, 20, 0.9);
border: 1px solid rgba(148, 163, 184, 0.12);
overflow: hidden;
}
+
.vtabs-nav::before {
content: '';
position: absolute;
left: 0;
top: var(--vt-indicator-top, 0);
width: 3px;
- height: var(--vt-indicator-h, 40px);
- background: linear-gradient(180deg, var(--vt-color, #00e5ff), transparent);
- border-radius: 0 4px 4px 0;
- transition: top 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), height 0.25s ease;
- box-shadow: 0 0 12px var(--vt-color, #00e5ff);
+ height: var(--vt-indicator-h, 36px);
+ background: var(--vt-color);
+ border-radius: 0 3px 3px 0;
+ transition: top 0.25s ease, height 0.2s ease;
}
+
+.vtabs-nav-header {
+ padding: 0 0.35rem 0.5rem;
+ margin-bottom: 0.25rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+}
+.vtabs-nav-header strong {
+ font-size: 0.65rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: #94a3b8;
+}
+
.vtab-btn {
position: relative;
display: flex;
align-items: center;
- gap: 0.6rem;
+ gap: 0.55rem;
width: 100%;
- padding: 0.65rem 0.85rem;
- border: none;
- border-radius: 10px;
+ padding: 0.55rem 0.6rem;
+ border: 1px solid transparent;
+ border-radius: 8px;
background: transparent;
color: #94a3b8;
- font-size: 0.85rem;
- font-weight: 600;
+ font-size: 0.8rem;
+ font-weight: 500;
text-align: left;
cursor: pointer;
- transition: color 0.2s, background 0.25s, transform 0.2s;
text-decoration: none;
+ --tab-accent: var(--vt-color, #0ea5e9);
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
}
-.vtab-btn:hover {
- color: #e2e8f0;
- background: rgba(255, 255, 255, 0.04);
- transform: translateX(4px);
-}
-.vtab-btn.active {
- color: #fff;
- background: color-mix(in srgb, var(--vt-color, #00e5ff) 12%, transparent);
- text-shadow: 0 0 10px color-mix(in srgb, var(--vt-color, #00e5ff) 50%, transparent);
-}
-.vtab-icon { font-size: 1.1rem; flex-shrink: 0; }
-.vtabs-content { min-width: 0; animation: vtabFadeIn 0.35s ease; }
-@keyframes vtabFadeIn {
- from { opacity: 0; transform: translateY(8px); }
- to { opacity: 1; transform: translateY(0); }
+.vtab-btn::before { display: none; }
+
+.vtab-icon,
+.ei-vtab-icon-wrap {
+ width: 30px;
+ height: 30px;
+ flex-shrink: 0;
+ border-radius: 7px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.95rem;
+ background: rgba(15, 23, 42, 0.8);
+ border: 1px solid rgba(148, 163, 184, 0.1);
}
-/* App sidebar layout */
-.app-shell {
- display: grid;
- grid-template-columns: 200px 1fr;
- min-height: 100vh;
+.vtab-btn:hover {
+ background: rgba(30, 41, 59, 0.5);
+ color: #e2e8f0;
+ transform: none;
}
-.app-sidebar {
- position: sticky;
- top: 0;
- height: 100vh;
- overflow-y: auto;
- padding: 1rem 0.65rem;
- background: linear-gradient(180deg, rgba(8, 12, 18, 0.98), rgba(12, 18, 28, 0.95));
- border-right: 1px solid rgba(148, 163, 184, 0.1);
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
+
+.vtab-btn.active {
+ background: rgba(14, 165, 233, 0.08);
+ border-color: rgba(14, 165, 233, 0.2);
+ color: #f1f5f9;
+ text-shadow: none;
+ box-shadow: none;
}
-.app-sidebar-brand {
- padding: 0.5rem 0.75rem 1rem;
- border-bottom: 1px solid rgba(148, 163, 184, 0.08);
- margin-bottom: 0.5rem;
+.vtab-btn.active .vtab-icon,
+.vtab-btn.active .ei-vtab-icon-wrap {
+ border-color: var(--tab-accent);
+ background: rgba(14, 165, 233, 0.12);
+ box-shadow: none;
+ transform: none;
}
-.app-sidebar-brand .brand-title { font-size: 0.65rem; letter-spacing: 0.12em; color: #64748b; margin: 0; }
-.app-sidebar-brand .brand-name { font-size: 1rem; font-weight: 700; color: #e2e8f0; margin: 0.15rem 0 0; }
-.app-sidebar-group { margin-bottom: 0.75rem; }
-.app-sidebar-label {
- font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em;
- color: #64748b; padding: 0.25rem 0.75rem; margin-bottom: 0.25rem;
+
+.vtab-badge,
+.ei-vtab-badge {
+ margin-left: auto;
+ font-size: 0.65rem;
+ font-weight: 600;
+ padding: 0.1rem 0.4rem;
+ border-radius: 999px;
+ background: rgba(148, 163, 184, 0.15);
+ color: #cbd5e1;
+ border: none;
+ min-width: 1.25rem;
+ text-align: center;
}
-.app-sidebar .nav-pill {
- display: flex; width: 100%; justify-content: flex-start;
- margin-bottom: 0.2rem; border-radius: 10px;
+.vtab-btn.active .ei-vtab-badge,
+.vtab-btn.active .vtab-badge {
+ background: rgba(14, 165, 233, 0.2);
+ color: #7dd3fc;
animation: none;
}
-.app-sidebar .nav-pill.active { animation: nav-pill-pulse 2.5s ease-in-out infinite; }
+
+/* Rich tabs (Wereldexport) */
+.vtab-btn.vtab-btn-rich {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.55rem 0.6rem;
+}
+.ei-vtab-text { min-width: 0; }
+.ei-vtab-label {
+ display: block;
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: inherit;
+ line-height: 1.2;
+}
+.ei-vtab-sub {
+ display: block;
+ font-size: 0.62rem;
+ color: #64748b;
+ margin-top: 0.1rem;
+}
+.ei-vtab-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.15rem;
+}
+.ei-vtab-key {
+ font-size: 0.55rem;
+ color: #475569;
+ font-family: ui-monospace, monospace;
+}
+
+.vtabs-content { min-width: 0; }
+
+.vt-tab-panel,
+.ei-tab-panel {
+ border-radius: 12px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ background: var(--vt-panel-bg);
+ overflow: hidden;
+}
+.vt-tab-panel-head,
+.ei-tab-panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ background: rgba(15, 23, 42, 0.4);
+}
+.vt-tab-panel-head h2,
+.ei-tab-panel-head h2 {
+ margin: 0;
+ font-size: 0.92rem;
+ font-weight: 600;
+ color: #e2e8f0;
+}
+.vt-tab-panel-head p,
+.ei-tab-panel-head p {
+ margin: 0.1rem 0 0;
+ font-size: 0.72rem;
+ color: #64748b;
+}
+.vt-tab-panel-body,
+.ei-tab-panel-body { padding: 1rem; }
+
+/* Retail side-nav */
+.side-nav {
+ position: sticky;
+ top: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ padding: 0.65rem;
+ border-radius: 12px;
+ background: rgba(10, 14, 20, 0.9);
+ border: 1px solid rgba(148, 163, 184, 0.12);
+}
+.side-nav-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ width: 100%;
+ padding: 0.55rem 0.6rem;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ background: transparent;
+ color: #94a3b8;
+ font-size: 0.8rem;
+ font-weight: 500;
+ text-align: left;
+ cursor: pointer;
+ text-decoration: none;
+}
+.side-nav-btn:hover { background: rgba(30, 41, 59, 0.5); color: #e2e8f0; }
+.side-nav-btn.active {
+ background: rgba(0, 102, 204, 0.1);
+ border-color: rgba(0, 102, 204, 0.3);
+ color: #fff;
+}
+
.app-main { min-width: 0; }
.app-topbar {
- display: flex; justify-content: space-between; align-items: center;
- padding: 0.65rem 1.25rem; border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.65rem 1.25rem;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(8, 12, 18, 0.6);
}
.app-clock {
- font-family: ui-monospace, monospace; font-size: 0.85rem; color: #94a3b8;
+ font-family: ui-monospace, monospace;
+ font-size: 0.85rem;
+ color: #94a3b8;
}
.app-clock strong { color: #e2e8f0; }
.main-topnav { padding: 1rem 1.25rem; }
-/* Mobile layout → see mobile.css */
+@media (max-width: 639px) {
+ .vtabs-nav {
+ position: relative;
+ top: 0;
+ flex-direction: row;
+ overflow-x: auto;
+ flex-wrap: nowrap;
+ padding: 0.45rem;
+ }
+ .vtabs-nav::before { display: none; }
+ .vtabs-nav-header { display: none; }
+ .vtab-btn { flex: 0 0 auto; min-width: 64px; }
+ .vtab-btn.vtab-btn-rich .ei-vtab-text,
+ .vtab-btn.vtab-btn-rich .ei-vtab-key { display: none; }
+}
diff --git a/cockpit/static/css/voice-live.css b/cockpit/static/css/voice-live.css
new file mode 100644
index 0000000..f607cab
--- /dev/null
+++ b/cockpit/static/css/voice-live.css
@@ -0,0 +1,179 @@
+.voice-live-page { --vl-accent: #a78bfa; max-width: 1400px; }
+
+.voice-live-grid {
+ display: grid;
+ grid-template-columns: 280px 1fr 320px;
+ gap: 1rem;
+ min-height: 520px;
+}
+@media (max-width: 1100px) {
+ .voice-live-grid { grid-template-columns: 1fr; }
+}
+
+.voice-panel {
+ border-radius: 14px;
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ background: rgba(10, 14, 20, 0.9);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.voice-mic-zone { align-items: center; text-align: center; }
+.voice-mic-btn {
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+ border: 3px solid rgba(167, 139, 250, 0.45);
+ background: radial-gradient(circle at 30% 30%, rgba(167, 139, 250, 0.25), rgba(15, 20, 25, 0.95));
+ color: #f8fafc;
+ font-size: 2.5rem;
+ cursor: pointer;
+ transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
+ margin: 0.5rem auto 1rem;
+}
+.voice-mic-btn:hover { border-color: rgba(167, 139, 250, 0.8); transform: scale(1.03); }
+.voice-mic-btn.is-listening {
+ border-color: #f87171;
+ box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.2);
+ animation: vl-pulse 1.2s ease-in-out infinite;
+}
+.voice-mic-btn.is-busy {
+ border-color: #38bdf8;
+ box-shadow: 0 0 0 6px rgba(56, 189, 248, 0.15);
+}
+.voice-mic-btn.is-handsfree {
+ border-color: #34d399;
+ box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18);
+ animation: vl-pulse-hf 2s ease-in-out infinite;
+}
+@keyframes vl-pulse-hf {
+ 0%, 100% { box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12); }
+ 50% { box-shadow: 0 0 0 12px rgba(52, 211, 153, 0.22); }
+}
+@keyframes vl-pulse {
+ 0%, 100% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0.15); }
+ 50% { box-shadow: 0 0 0 14px rgba(248, 113, 113, 0.28); }
+}
+
+.voice-status {
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: #e2e8f0;
+ margin-bottom: 0.35rem;
+}
+.voice-status-sub { font-size: 0.72rem; color: #64748b; margin-bottom: 0.75rem; }
+
+.voice-live-preview {
+ width: 100%;
+ min-height: 3.5rem;
+ padding: 0.65rem 0.75rem;
+ border-radius: 10px;
+ background: rgba(15, 20, 25, 0.85);
+ border: 1px dashed rgba(167, 139, 250, 0.35);
+ font-size: 0.85rem;
+ color: #cbd5e1;
+ text-align: left;
+ line-height: 1.4;
+}
+.voice-live-preview.is-interim { color: #94a3b8; font-style: italic; }
+.voice-live-preview strong { color: #a78bfa; font-size: 0.68rem; text-transform: uppercase; display: block; margin-bottom: 0.25rem; }
+
+.voice-toggles { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 1rem; text-align: left; }
+.voice-toggles label { font-size: 0.78rem; color: #94a3b8; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
+
+.voice-chat-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
+.voice-chat-head h2 { margin: 0; font-size: 0.95rem; color: #f8fafc; }
+.voice-chat-log {
+ flex: 1;
+ overflow-y: auto;
+ max-height: 480px;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+ padding-right: 0.25rem;
+}
+.voice-bubble {
+ max-width: 92%;
+ padding: 0.65rem 0.85rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ line-height: 1.45;
+}
+.voice-bubble-user {
+ align-self: flex-end;
+ background: rgba(14, 165, 233, 0.15);
+ border: 1px solid rgba(14, 165, 233, 0.3);
+}
+.voice-bubble-agent {
+ align-self: flex-start;
+ background: rgba(167, 139, 250, 0.12);
+ border: 1px solid rgba(167, 139, 250, 0.28);
+}
+.voice-bubble-meta { font-size: 0.68rem; color: #64748b; margin-bottom: 0.25rem; }
+.voice-bubble-badge {
+ display: inline-block;
+ font-size: 0.65rem;
+ font-weight: 700;
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ background: rgba(167, 139, 250, 0.2);
+ color: #c4b5fd;
+ margin-bottom: 0.35rem;
+}
+.voice-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
+
+.voice-side-head { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; margin: 0 0 0.5rem; }
+.voice-pipeline { list-style: none; margin: 0 0 1rem; padding: 0; max-height: 220px; overflow-y: auto; }
+.voice-pipeline li {
+ display: grid;
+ grid-template-columns: 72px 1fr;
+ gap: 0.35rem;
+ font-size: 0.75rem;
+ padding: 0.35rem 0;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.08);
+}
+.voice-pipeline .vl-phase {
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ color: #64748b;
+ font-weight: 700;
+}
+.voice-pipeline .vl-label { color: #e2e8f0; font-weight: 600; }
+.voice-pipeline .vl-detail { grid-column: 2; color: #94a3b8; font-size: 0.72rem; }
+.voice-pipeline li.is-running .vl-label { color: #38bdf8; }
+.voice-pipeline li.is-error .vl-label { color: #f87171; }
+
+.voice-feed { max-height: 200px; overflow-y: auto; font-size: 0.72rem; }
+.voice-feed-item { padding: 0.35rem 0; border-bottom: 1px solid rgba(148, 163, 184, 0.08); color: #94a3b8; }
+.voice-feed-item strong { color: #c4b5fd; }
+.voice-feed-item .vl-ts { color: #64748b; margin-right: 0.35rem; }
+
+.voice-manual-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
+.voice-manual-row .form-input { flex: 1; font-size: 0.82rem; }
+
+.voice-confirm-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+ padding: 0.75rem 1rem;
+ border-radius: 12px;
+ background: rgba(251, 191, 36, 0.1);
+ border: 1px solid rgba(251, 191, 36, 0.35);
+}
+.voice-confirm-text { font-size: 0.82rem; color: #fde68a; }
+.voice-confirm-text strong { display: block; color: #fbbf24; font-size: 0.72rem; text-transform: uppercase; margin-bottom: 0.2rem; }
+.voice-confirm-actions { display: flex; gap: 0.5rem; }
+
+.voice-results-modal { z-index: 1400; }
+.voice-results-card { width: min(720px, 96vw); max-height: 85vh; display: flex; flex-direction: column; }
+.voice-results-table-wrap { overflow: auto; max-height: 50vh; margin: 0.5rem 0 1rem; border: 1px solid rgba(148,163,184,0.12); border-radius: 8px; }
+.voice-results-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
+.voice-results-table th { text-align: left; padding: 0.5rem 0.65rem; background: rgba(15,20,25,0.9); color: #64748b; font-size: 0.65rem; text-transform: uppercase; position: sticky; top: 0; }
+.voice-results-table td { padding: 0.45rem 0.65rem; border-top: 1px solid rgba(148,163,184,0.08); color: #cbd5e1; }
+.voice-results-table a { color: #38bdf8; text-decoration: none; }
+.voice-results-table a:hover { text-decoration: underline; }
diff --git a/cockpit/static/i18n/en.json b/cockpit/static/i18n/en.json
index d187741..08a66ef 100644
--- a/cockpit/static/i18n/en.json
+++ b/cockpit/static/i18n/en.json
@@ -1,6 +1,16 @@
{
"nav": {
"herman": "Herman",
+ "persona_ceo": "CEO · Business",
+ "persona_cto": "CTO · Tech",
+ "ceo_home": "CEO Overview",
+ "revenue_cockpit": "Revenue Cockpit",
+ "cto_home": "CTO Overview",
+ "business": "Business",
+ "platform": "Platform",
+ "devops": "DevOps",
+ "tools_api": "Tools API",
+ "grafana": "Grafana",
"crm": "CRM",
"clients": "Clients",
"deals": "Deals",
@@ -55,7 +65,8 @@
"new": "New client",
"tab_kanban": "Kanban",
"tab_list": "List",
- "tab_stores": "Supermarkets"
+ "tab_stores": "Supermarkets",
+ "tab_360": "360° Client"
},
"products": {
"title": "Product catalog",
diff --git a/cockpit/static/i18n/manifest.json b/cockpit/static/i18n/manifest.json
new file mode 100644
index 0000000..91dc70d
--- /dev/null
+++ b/cockpit/static/i18n/manifest.json
@@ -0,0 +1,26 @@
+{
+ "name": "Cucina · Foodlinkk Command Center",
+ "short_name": "Foodlinkk",
+ "description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#0a0e14",
+ "theme_color": "#0a0e14",
+ "lang": "nl",
+ "icons": [
+ {
+ "src": "/static/icons/app-icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/static/icons/app-icon.svg",
+ "sizes": "512x512",
+ "type": "image/svg+xml",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/cockpit/static/i18n/nl.json b/cockpit/static/i18n/nl.json
index 670c424..7167f8b 100644
--- a/cockpit/static/i18n/nl.json
+++ b/cockpit/static/i18n/nl.json
@@ -1,6 +1,16 @@
{
"nav": {
"herman": "Herman",
+ "persona_ceo": "CEO · Business",
+ "persona_cto": "CTO · Tech",
+ "ceo_home": "CEO Overzicht",
+ "revenue_cockpit": "Revenue Cockpit",
+ "cto_home": "CTO Overzicht",
+ "business": "Business",
+ "platform": "Platform",
+ "devops": "DevOps",
+ "tools_api": "Tools API",
+ "grafana": "Grafana",
"crm": "CRM",
"clients": "Klanten",
"deals": "Deals",
@@ -55,7 +65,8 @@
"new": "Nieuwe klant",
"tab_kanban": "Kanban",
"tab_list": "Lijst",
- "tab_stores": "Supermarkten"
+ "tab_stores": "Supermarkten",
+ "tab_360": "360° Klant"
},
"products": {
"title": "Productcatalogus",
diff --git a/cockpit/static/js/agent-avatars.js b/cockpit/static/js/agent-avatars.js
index 9d959d4..e89b5bc 100644
--- a/cockpit/static/js/agent-avatars.js
+++ b/cockpit/static/js/agent-avatars.js
@@ -17,6 +17,7 @@ window.AgentAvatars = (function () {
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
+ webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' },
};
function cfg(key, fallbackColor) {
diff --git a/cockpit/static/js/agents-mesh.js b/cockpit/static/js/agents-mesh.js
index d2f94a9..51f0bb1 100644
--- a/cockpit/static/js/agents-mesh.js
+++ b/cockpit/static/js/agents-mesh.js
@@ -1,9 +1,149 @@
(function () {
const NS = 'http://www.w3.org/2000/svg';
+ const TIER_ROWS = [4, 5, 7];
+ const MIN_NODE_GAP = 148;
+ const VIEW_W = 1200;
+ const VIEW_H = 860;
- function make(tag, attrs = {}) {
+ /** Korte rol-context per agent (NL) */
+ const AGENT_HINTS = {
+ marketing: { short: 'Campagnes', domain: 'Groei' },
+ retail: { short: 'Supermarkten', domain: 'Groei' },
+ bizdev: { short: 'Deals', domain: 'Groei' },
+ research: { short: 'Marktintel', domain: 'Groei' },
+ browser: { short: 'Web research', domain: 'Groei' },
+ webbuilder: { short: 'Websites', domain: 'Product' },
+ product: { short: 'Assortiment', domain: 'Product' },
+ packaging: { short: 'Verpakking', domain: 'Product' },
+ design: { short: 'Visuals', domain: 'Product' },
+ halal: { short: 'Compliance', domain: 'Product' },
+ sourcing: { short: 'Inkoop', domain: 'Product' },
+ finance: { short: 'Cijfers', domain: 'Operatie' },
+ hr: { short: 'Team', domain: 'Operatie' },
+ knowledge: { short: 'Kennisbank', domain: 'Operatie' },
+ email: { short: 'Inbox', domain: 'Operatie' },
+ sysops: { short: 'Infra', domain: 'Operatie' },
+ };
+
+ const CLUSTERS = {
+ growth: {
+ label: 'Groei',
+ icon: '📈',
+ color: 'rgba(0, 229, 255, 0.14)',
+ stroke: 'rgba(0, 229, 255, 0.35)',
+ agents: ['marketing', 'retail', 'bizdev', 'research', 'browser'],
+ },
+ product: {
+ label: 'Product',
+ icon: '📦',
+ color: 'rgba(168, 85, 247, 0.12)',
+ stroke: 'rgba(168, 85, 247, 0.35)',
+ agents: ['product', 'packaging', 'design', 'halal', 'sourcing', 'webbuilder'],
+ },
+ ops: {
+ label: 'Operatie',
+ icon: '⚙️',
+ color: 'rgba(34, 197, 94, 0.1)',
+ stroke: 'rgba(34, 197, 94, 0.32)',
+ agents: ['finance', 'hr', 'knowledge', 'email', 'sysops'],
+ },
+ };
+
+ /** @type {Record} */
+ const COMM_MODES = {
+ pyramid: {
+ id: 'pyramid',
+ label: 'Piramide',
+ subtitle: 'Herman als hub',
+ icon: '◆',
+ desc: 'Rapport ↑ en delegatie ↓. Alleen live handoffs tussen agents.',
+ layout: 'pyramid',
+ showExecutive: true,
+ showReport: true,
+ showDelegate: true,
+ showLivePeers: true,
+ showStaticPeers: false,
+ activeOnly: false,
+ edgeBend: 0.42,
+ },
+ mesh: {
+ id: 'mesh',
+ label: 'Vol netwerk',
+ subtitle: 'Alle partners',
+ icon: '⬡',
+ desc: 'Volledige samenwerkingsmatrix — wie met wie kan werken.',
+ layout: 'pyramid',
+ showExecutive: true,
+ showReport: true,
+ showDelegate: true,
+ showLivePeers: true,
+ showStaticPeers: true,
+ activeOnly: false,
+ staticPeerOpacity: 0.35,
+ edgeBend: 0.38,
+ },
+ org: {
+ id: 'org',
+ label: 'Organigram',
+ subtitle: 'Pure hiërarchie',
+ icon: '▤',
+ desc: 'CEO → Herman → agents. Geen peer-lijnen, overzichtelijk.',
+ layout: 'pyramid',
+ showExecutive: true,
+ showReport: true,
+ showDelegate: true,
+ showLivePeers: false,
+ showStaticPeers: false,
+ activeOnly: false,
+ edgeBend: 0.12,
+ straightExec: true,
+ },
+ pulse: {
+ id: 'pulse',
+ label: 'Live stream',
+ subtitle: 'Alleen actief',
+ icon: '◎',
+ desc: 'Geen stille lijnen — alleen wat nu beweegt (pulse).',
+ layout: 'pyramid',
+ showExecutive: true,
+ showReport: true,
+ showDelegate: true,
+ showLivePeers: true,
+ showStaticPeers: false,
+ activeOnly: true,
+ edgeBend: 0.45,
+ },
+ clusters: {
+ id: 'clusters',
+ label: 'Domeinen',
+ subtitle: 'Per afdeling',
+ icon: '◉',
+ desc: 'Groei · Product · Operatie — agents gegroepeerd rond Herman.',
+ layout: 'clusters',
+ showExecutive: true,
+ showReport: true,
+ showDelegate: true,
+ showLivePeers: true,
+ showStaticPeers: true,
+ activeOnly: false,
+ staticPeerOpacity: 0.2,
+ edgeBend: 0.5,
+ intraClusterOnly: false,
+ },
+ };
+
+ const STORAGE_KEY = 'foodlinkk_mesh_comm_mode';
+ let cachedData = null;
+ let pickerBound = false;
+ let onModeChangeCb = null;
+ let lastMountOpts = {};
+
+ function make(tag, attrs) {
+ attrs = attrs || {};
const el = document.createElementNS(NS, tag);
- Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
+ Object.entries(attrs).forEach(function (pair) {
+ el.setAttribute(pair[0], String(pair[1]));
+ });
return el;
}
@@ -11,9 +151,234 @@
while (el.firstChild) el.removeChild(el.firstChild);
}
- function linePath(x1, y1, x2, y2) {
- const midY = y1 + (y2 - y1) * 0.45;
- return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
+ function curvePath(x1, y1, x2, y2, bend) {
+ if (bend != null && bend < 0.18) {
+ return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2;
+ }
+ const midY = y1 + (y2 - y1) * (bend != null ? bend : 0.42);
+ return 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + midY + ', ' + x2 + ' ' + midY + ', ' + x2 + ' ' + y2;
+ }
+
+ function clusterOf(key) {
+ const k = (key || '').toLowerCase();
+ return (
+ Object.keys(CLUSTERS).find(function (cid) {
+ return CLUSTERS[cid].agents.indexOf(k) >= 0;
+ }) || null
+ );
+ }
+
+ function spreadTier(nodes, width, minGap) {
+ if (nodes.length <= 1) return;
+ nodes.sort(function (a, b) {
+ return a._x - b._x;
+ });
+ for (let i = 1; i < nodes.length; i++) {
+ const gap = nodes[i]._x - nodes[i - 1]._x;
+ if (gap < minGap) {
+ const shift = minGap - gap;
+ for (let j = i; j < nodes.length; j++) nodes[j]._x += shift;
+ }
+ }
+ const tierCx = (nodes[0]._x + nodes[nodes.length - 1]._x) / 2;
+ const offset = width / 2 - tierCx;
+ nodes.forEach(function (n) {
+ n._x += offset;
+ });
+ }
+
+ function deoverlapLayout(souls, width) {
+ const tiers = {};
+ souls.forEach(function (n) {
+ const y = n._y || 0;
+ if (!tiers[y]) tiers[y] = [];
+ tiers[y].push(n);
+ });
+ Object.keys(tiers).forEach(function (y) {
+ spreadTier(tiers[y], width, MIN_NODE_GAP);
+ });
+ }
+
+ function agentSubtitle(node) {
+ const hint = AGENT_HINTS[(node.agent_key || '').toLowerCase()];
+ const task = (node.current_task || node.last_event_title || '').trim();
+ if (task) return task.length > 32 ? task.slice(0, 30) + '…' : task;
+ if (hint) return hint.short;
+ return (node.role_title || '').split('·')[0].trim().slice(0, 24);
+ }
+
+ function agentStatus(node) {
+ if (node.is_active) return 'live';
+ const h = node.health || 'idle';
+ if (h === 'warn') return 'warn';
+ if (h === 'healthy') return 'ready';
+ return 'idle';
+ }
+
+ function addRotateAnim(group, dur) {
+ const anim = document.createElementNS(NS, 'animateTransform');
+ anim.setAttribute('attributeName', 'transform');
+ anim.setAttribute('type', 'rotate');
+ anim.setAttribute('from', '0 0 0');
+ anim.setAttribute('to', '360 0 0');
+ anim.setAttribute('dur', dur || '12s');
+ anim.setAttribute('repeatCount', 'indefinite');
+ group.appendChild(anim);
+ }
+
+ function layoutPyramid(souls, width, positions) {
+ const cx = width / 2;
+ const hermanY = positions.hermanY;
+ const execY = positions.execY;
+ const tierYs = positions.tierYs;
+ const minGap = MIN_NODE_GAP;
+ let idx = 0;
+ TIER_ROWS.forEach(function (count, tierIdx) {
+ const y = tierYs[tierIdx] || 580;
+ const slice = souls.slice(idx, idx + count);
+ idx += count;
+ const n = slice.length;
+ if (!n) return;
+ const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
+ const startX = cx - span / 2;
+ slice.forEach(function (node, i) {
+ node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
+ node._y = y;
+ node._tier = tierIdx;
+ node._cluster = clusterOf(node.agent_key);
+ });
+ });
+ while (idx < souls.length) {
+ const y = 660;
+ const rest = souls.slice(idx);
+ const n = rest.length;
+ const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
+ const startX = cx - span / 2;
+ rest.forEach(function (node, i) {
+ node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
+ node._y = y;
+ node._cluster = clusterOf(node.agent_key);
+ });
+ idx = souls.length;
+ }
+ deoverlapLayout(souls, width);
+ return { cx: cx, hermanY: hermanY, execY: execY };
+ }
+
+ function layoutClusters(souls, width, height) {
+ const cx = width / 2;
+ const hermanY = height * 0.42;
+ const execY = 58;
+ const clusterCenters = {
+ growth: { x: cx - 280, y: height * 0.72, r: 118 },
+ product: { x: cx, y: height * 0.78, r: 118 },
+ ops: { x: cx + 280, y: height * 0.72, r: 118 },
+ };
+
+ souls.forEach(function (node) {
+ const key = (node.agent_key || '').toLowerCase();
+ const cid = clusterOf(key) || 'product';
+ const center = clusterCenters[cid];
+ const members = CLUSTERS[cid].agents;
+ const idx = members.indexOf(key);
+ const n = members.length;
+ const angleStart = -Math.PI * 0.72;
+ const angleEnd = -Math.PI * 0.28;
+ const angle =
+ n <= 1 ? (angleStart + angleEnd) / 2 : angleStart + ((angleEnd - angleStart) * idx) / (n - 1);
+ const dist = center.r * 0.72;
+ node._x = center.x + Math.cos(angle) * dist;
+ node._y = center.y + Math.sin(angle) * dist * 0.55;
+ node._cluster = cid;
+ node._clusterCx = center.x;
+ node._clusterCy = center.y;
+ });
+
+ return { cx: cx, hermanY: hermanY, execY: execY, clusterCenters: clusterCenters };
+ }
+
+ function isSvgVisible(svg) {
+ if (!svg) return false;
+ const r = svg.getBoundingClientRect();
+ return r.width > 8 && r.height > 8;
+ }
+
+ function showMeshLoading(svg, text) {
+ clear(svg);
+ const g = make('g', { class: 'mesh-loading' });
+ const t = make('text', {
+ x: 600,
+ y: 360,
+ 'text-anchor': 'middle',
+ fill: '#94a3b8',
+ 'font-size': 14,
+ });
+ t.textContent = text || 'Netwerk laden…';
+ g.appendChild(t);
+ svg.appendChild(g);
+ }
+
+ function showMeshError(svg, text) {
+ clear(svg);
+ const t = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
+ t.textContent = text || 'Mesh kon niet geladen worden';
+ svg.appendChild(t);
+ }
+
+ function whenSvgVisible(svg, fn) {
+ if (!svg) return;
+ if (isSvgVisible(svg)) {
+ fn();
+ return;
+ }
+ let tries = 0;
+ const tick = function () {
+ tries += 1;
+ if (isSvgVisible(svg)) {
+ fn();
+ return;
+ }
+ if (tries < 60) {
+ requestAnimationFrame(tick);
+ } else {
+ fn();
+ }
+ };
+ requestAnimationFrame(tick);
+ }
+
+ function watchSvgResize(svg, renderFn) {
+ if (!svg || svg._meshResizeObs || typeof ResizeObserver === 'undefined') return;
+ const wrap = svg.closest('.mesh-wrap') || svg.parentElement;
+ if (!wrap) return;
+ let hadSize = isSvgVisible(svg);
+ svg._meshResizeObs = new ResizeObserver(function () {
+ const vis = isSvgVisible(svg);
+ if (vis && !hadSize) {
+ hadSize = true;
+ renderFn();
+ } else if (vis) {
+ hadSize = true;
+ }
+ });
+ svg._meshResizeObs.observe(wrap);
+ }
+
+ function renderMesh(svg, opts) {
+ if (!svg || !cachedData) return;
+ const drawOpts = Object.assign({}, lastMountOpts, opts || {}, {
+ commMode: (opts && opts.commMode) || getSavedMode(),
+ });
+ const doDraw = function () {
+ try {
+ drawMesh(svg, cachedData, drawOpts);
+ } catch (e) {
+ console.error('drawMesh failed', e);
+ showMeshError(svg, 'Diagram fout: ' + (e.message || 'onbekend'));
+ }
+ };
+ doDraw();
+ watchSvgResize(svg, doDraw);
}
async function loadMeshData() {
@@ -22,67 +387,209 @@
return r.json();
}
- function drawMesh(svg, data) {
+ async function ensureMeshData() {
+ if (cachedData) return cachedData;
+ cachedData = await loadMeshData();
+ return cachedData;
+ }
+
+ let pendingRender = false;
+
+ function scheduleRender(opts) {
+ const svg = document.getElementById('agents-mesh-canvas');
+ if (!svg) return;
+ if (opts) lastMountOpts = Object.assign({}, lastMountOpts, opts);
+ if (pendingRender) return;
+ pendingRender = true;
+ whenSvgVisible(svg, function () {
+ pendingRender = false;
+ renderMesh(svg, opts);
+ });
+ }
+
+ function nodePos(nodesByKey, key) {
+ const n = nodesByKey[key];
+ if (n && n._x != null) return { x: n._x, y: n._y };
+ return null;
+ }
+
+ function resolveMode(opts) {
+ const id = (opts && opts.commMode) || localStorage.getItem(STORAGE_KEY) || 'pyramid';
+ return COMM_MODES[id] || COMM_MODES.pyramid;
+ }
+
+ function drawMesh(svg, data, opts) {
+ opts = opts || {};
+ const mode = resolveMode(opts);
+ const onNodeClick = opts.onNodeClick;
+
clear(svg);
+ svg.setAttribute('data-mesh-mode', mode.id);
+
const vb = svg.viewBox.baseVal;
- const width = vb && vb.width ? vb.width : 1100;
- const height = vb && vb.height ? vb.height : 640;
+ const width = vb && vb.width ? vb.width : VIEW_W;
+ const height = vb && vb.height ? vb.height : VIEW_H;
+ if (!vb || vb.width !== VIEW_W || vb.height !== VIEW_H) {
+ svg.setAttribute('viewBox', '0 0 ' + VIEW_W + ' ' + VIEW_H);
+ }
- const cx = width / 2;
- const hermanY = 280;
- const execY = 75;
- const agentY = 520;
+ const positions = {
+ execY: 72,
+ hermanY: mode.layout === 'clusters' ? height * 0.38 : 228,
+ tierYs: [400, 540, 690],
+ };
- const souls = (data.nodes || []).filter((n) => {
+ const souls = (data.nodes || []).filter(function (n) {
const key = (n.agent_key || '').toLowerCase();
return key && key !== 'herman';
});
- const edgesBySource = {};
- (data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
+ const layoutMeta =
+ mode.layout === 'clusters'
+ ? layoutClusters(souls, width, height)
+ : layoutPyramid(souls, width, positions);
- const agentCount = Math.max(1, souls.length);
- const pad = 70;
- const span = width - pad * 2;
- souls.forEach((node, i) => {
- node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
- if (agentCount === 1) node._x = cx;
- node._y = agentY;
+ const cx = layoutMeta.cx;
+ const hermanY = layoutMeta.hermanY;
+ const execY = layoutMeta.execY;
+
+ const nodesByKey = {};
+ souls.forEach(function (n) {
+ nodesByKey[(n.agent_key || '').toLowerCase()] = n;
});
- const ceo = { x: cx - 180, y: execY, label: 'CEO', sub: 'Aissa · beslissingen' };
- const cto = { x: cx + 180, y: execY, label: 'CTO', sub: 'Platform · techniek' };
+ const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' };
+ const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' };
- const edgesLayer = make('g');
- const nodesLayer = make('g');
+ const bgLayer = make('g', { class: 'mesh-bg' });
+ const clusterLayer = make('g', { class: 'mesh-clusters' });
+ const edgesLayer = make('g', { class: 'mesh-edges' });
+ const nodesLayer = make('g', { class: 'mesh-nodes' });
+ svg.appendChild(bgLayer);
+ if (mode.layout === 'clusters') svg.appendChild(clusterLayer);
svg.appendChild(edgesLayer);
svg.appendChild(nodesLayer);
- function addEdge(x1, y1, x2, y2, pulse) {
- const d = linePath(x1, y1, x2, y2);
- edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
+ if (mode.layout === 'pyramid') {
+ positions.tierYs.forEach(function (y, i) {
+ bgLayer.appendChild(
+ make('rect', {
+ x: 40,
+ y: y - 42,
+ width: width - 80,
+ height: 84,
+ rx: 12,
+ class: 'mesh-tier-band mesh-tier-' + i,
+ })
+ );
+ });
+ }
+
+ if (mode.layout === 'clusters' && layoutMeta.clusterCenters) {
+ Object.keys(CLUSTERS).forEach(function (cid) {
+ const c = CLUSTERS[cid];
+ const center = layoutMeta.clusterCenters[cid];
+ if (!center) return;
+ const g = make('g', { class: 'mesh-cluster-blob mesh-cluster-' + cid });
+ g.appendChild(
+ make('ellipse', {
+ cx: center.x,
+ cy: center.y,
+ rx: center.r + 28,
+ ry: center.r * 0.62,
+ fill: c.color,
+ stroke: c.stroke,
+ 'stroke-width': 1.2,
+ })
+ );
+ const lbl = make('text', {
+ x: center.x,
+ y: center.y - center.r * 0.35,
+ class: 'mesh-cluster-label',
+ 'text-anchor': 'middle',
+ });
+ lbl.textContent = c.icon + ' ' + c.label;
+ g.appendChild(lbl);
+ clusterLayer.appendChild(g);
+ });
+ }
+
+ function addEdge(x1, y1, x2, y2, cls, pulse, extraAttrs) {
+ const d = curvePath(x1, y1, x2, y2, mode.edgeBend);
+ const attrs = { d: d, class: 'mesh-edge ' + cls };
+ if (extraAttrs) Object.assign(attrs, extraAttrs);
+ edgesLayer.appendChild(make('path', attrs));
if (pulse) {
- const p = make('path', { d, class: 'mesh-pulse' });
- p.style.animationDuration = pulse + 's';
+ const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls });
+ p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's';
edgesLayer.appendChild(p);
}
}
- souls.forEach((node) => {
- const weight = edgesBySource[node.agent_key]?.weight || 1;
- addEdge(node._x, node._y - 26, cx, hermanY + 48, Math.max(1.2, 3.2 - Math.min(2, weight / 10)));
+ if (mode.showExecutive) {
+ const execCls = mode.straightExec ? 'mesh-edge-executive mesh-edge-straight' : 'mesh-edge-executive';
+ addEdge(ceo.x, ceo.y + 30, cx, hermanY - 46, execCls, false);
+ addEdge(cto.x, cto.y + 30, cx, hermanY - 46, execCls, false);
+ }
+
+ (data.peer_edges || []).forEach(function (e) {
+ const live = e.type === 'live';
+ if (live && !mode.showLivePeers) return;
+ if (!live && !mode.showStaticPeers) return;
+ if (mode.activeOnly && !live) return;
+
+ const a = nodePos(nodesByKey, e.source);
+ const b = nodePos(nodesByKey, e.target);
+ if (!a || !b) return;
+
+ const srcNode = nodesByKey[e.source];
+ const tgtNode = nodesByKey[e.target];
+ const sameCluster =
+ srcNode &&
+ tgtNode &&
+ srcNode._cluster &&
+ srcNode._cluster === tgtNode._cluster;
+
+ if (mode.intraClusterOnly && !sameCluster) return;
+
+ const cls = live ? 'mesh-edge-peer-live' : 'mesh-edge-peer';
+ const pulse = live ? 2.2 : false;
+ const extra = {};
+ if (!live && mode.staticPeerOpacity != null) {
+ extra['stroke-opacity'] = mode.staticPeerOpacity;
+ }
+ if (sameCluster && mode.layout === 'clusters') {
+ extra['stroke-opacity'] = live ? 0.75 : mode.staticPeerOpacity || 0.25;
+ }
+
+ addEdge(a.x, a.y, b.x, b.y, cls, pulse, extra);
});
- addEdge(cx, hermanY - 48, ceo.x, ceo.y + 28, 2.2);
- addEdge(cx, hermanY - 48, cto.x, cto.y + 28, 2.4);
+ if (mode.showReport) {
+ (data.report_edges || []).forEach(function (e) {
+ if (mode.activeOnly && !e.active) return;
+ const a = nodePos(nodesByKey, e.source);
+ if (!a) return;
+ addEdge(a.x, a.y - 24, cx, hermanY + 42, 'mesh-edge-report', e.active ? 2.4 : false);
+ });
+ }
+
+ if (mode.showDelegate) {
+ (data.delegate_edges || []).forEach(function (e) {
+ if (mode.activeOnly && !e.active) return;
+ const b = nodePos(nodesByKey, e.target);
+ if (!b) return;
+ addEdge(cx, hermanY + 42, b.x, b.y - 24, 'mesh-edge-delegate', e.active ? 2.0 : false);
+ });
+ }
function drawExec(node, cls) {
- const g = make('g', { class: `mesh-node mesh-exec ${cls}` });
- g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 34, class: 'main' }));
- g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 42, class: 'ring' }));
- g.appendChild(make('text', { x: node.x, y: node.y - 2, 'font-size': 13 }));
- g.lastChild.textContent = node.label;
- const sub = make('text', { x: node.x, y: node.y + 14, 'font-size': 9, opacity: 0.85 });
+ const g = make('g', { class: 'mesh-node mesh-exec ' + cls, transform: 'translate(' + node.x + ',' + node.y + ')' });
+ g.appendChild(make('circle', { cx: 0, cy: 0, r: 30, class: 'main' }));
+ const t = make('text', { x: 0, y: -42, class: 'mesh-label mesh-label-exec' });
+ t.textContent = node.label;
+ g.appendChild(t);
+ const sub = make('text', { x: 0, y: -26, class: 'mesh-sublabel' });
sub.textContent = node.sub;
g.appendChild(sub);
nodesLayer.appendChild(g);
@@ -91,56 +598,222 @@
drawExec(ceo, 'mesh-ceo');
drawExec(cto, 'mesh-cto');
- const herman = make('g', { class: 'mesh-node mesh-herman' });
- herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
- herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
- const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
- crown.textContent = '👑';
- herman.appendChild(crown);
- const hLabel = make('text', { x: cx, y: hermanY + 18 });
- hLabel.textContent = 'Herman · Co-CEO';
- herman.appendChild(hLabel);
- const hSub = make('text', { x: cx, y: hermanY + 34, 'font-size': 10, opacity: 0.85 });
- hSub.textContent = 'Takenverdeler';
- herman.appendChild(hSub);
- nodesLayer.appendChild(herman);
+ const hermanActive =
+ (data.delegate_edges || []).some(function (e) {
+ return e.active;
+ }) ||
+ (data.report_edges || []).some(function (e) {
+ return e.active;
+ });
- souls.forEach((node) => {
+ const hermanG = make('g', {
+ class: 'mesh-node mesh-herman' + (hermanActive ? ' active' : ''),
+ transform: 'translate(' + cx + ',' + hermanY + ')',
+ });
+ if (hermanActive) {
+ const ringG = make('g', { class: 'mesh-herman-ring' });
+ ringG.appendChild(make('circle', { class: 'ring', cx: 0, cy: 0, r: 54 }));
+ addRotateAnim(ringG, '14s');
+ hermanG.appendChild(ringG);
+ }
+ hermanG.appendChild(make('circle', { class: 'main', cx: 0, cy: 0, r: 40 }));
+ const crown = make('text', { x: 0, y: 6, class: 'mesh-herman-crown', 'text-anchor': 'middle' });
+ crown.textContent = '👑';
+ hermanG.appendChild(crown);
+ const hLabel = make('text', { x: 0, y: 64, class: 'mesh-label mesh-label-herman' });
+ hLabel.textContent = 'Herman';
+ hermanG.appendChild(hLabel);
+ const hSub = make('text', { x: 0, y: 80, class: 'mesh-sublabel mesh-sublabel-herman' });
+ hSub.textContent = 'Co-CEO · takenverdeler';
+ hermanG.appendChild(hSub);
+ const hTask = make('text', { x: 0, y: 94, class: 'mesh-context' });
+ const hermanNode = (data.nodes || []).find(function (n) {
+ return (n.agent_key || '').toLowerCase() === 'herman';
+ });
+ hTask.textContent = hermanNode ? agentSubtitle(hermanNode) : 'Coördineert alle agents';
+ hermanG.appendChild(hTask);
+ nodesLayer.appendChild(hermanG);
+
+ souls.forEach(function (node, idx) {
const key = (node.agent_key || '').toLowerCase();
- const group = make('g', { class: `mesh-node mesh-agent ${node.health || 'idle'}` });
- group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 22 }));
- const shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
- const t = make('text', { x: node._x, y: node._y + 4 });
- t.textContent = shortName;
+ const live = !!node.is_active;
+ const status = agentStatus(node);
+ const hint = AGENT_HINTS[key];
+ const clusterCls = node._cluster ? ' mesh-agent-cluster-' + node._cluster : '';
+ const labelAbove = typeof node._tier === 'number' ? node._tier % 2 === 1 : idx % 2 === 1;
+ const group = make('g', {
+ class:
+ 'mesh-node mesh-agent mesh-status-' +
+ status +
+ ' ' +
+ (node.health || 'idle') +
+ (live ? ' mesh-agent-live' : '') +
+ clusterCls,
+ transform: 'translate(' + node._x + ',' + node._y + ')',
+ });
+ if (live) {
+ const pulseG = make('g', { class: 'mesh-pulse-wrap' });
+ pulseG.appendChild(make('circle', { cx: 0, cy: 0, r: 28, class: 'pulse-ring' }));
+ group.appendChild(pulseG);
+ }
+ group.appendChild(make('circle', { cx: 0, cy: 0, r: 22, class: 'main' }));
+ const emoji = make('text', { x: 0, y: 5, class: 'mesh-emoji', 'text-anchor': 'middle' });
+ emoji.textContent = node.avatar_emoji || (hint && hint.short ? hint.short.charAt(0) : '') || '●';
+ group.appendChild(emoji);
+ const displayName = (node.display_name || key || '?').split(' ')[0];
+ const nameY = labelAbove ? -30 : 36;
+ const ctxY = labelAbove ? -16 : 52;
+ const domY = labelAbove ? -2 : 66;
+ const t = make('text', { x: 0, y: nameY, class: 'mesh-label mesh-label-agent' });
+ t.textContent = displayName;
group.appendChild(t);
- const role = make('text', { x: node._x, y: node._y + 38, 'font-size': 9, opacity: 0.82 });
- role.textContent = (node.role_title || key).split('·')[0].trim().slice(0, 18);
- group.appendChild(role);
- const status = make('text', { x: node._x, y: node._y + 52, 'font-size': 8, class: 'mesh-status' });
- status.textContent = (node.health || 'idle').toUpperCase();
- group.appendChild(status);
- group.addEventListener('click', () => {
- if (window.Cockpit && Cockpit.toast) {
- Cockpit.toast(`${node.display_name || key}: ${node.health || 'idle'}`, 'info');
- }
+ const ctx = make('text', { x: 0, y: ctxY, class: 'mesh-context' });
+ ctx.textContent = agentSubtitle(node);
+ group.appendChild(ctx);
+ if (hint && mode.layout !== 'clusters') {
+ const dom = make('text', { x: 0, y: domY, class: 'mesh-domain' });
+ dom.textContent = hint.domain;
+ group.appendChild(dom);
+ }
+ const tip =
+ (node.role_title || '') +
+ (node.responsibilities ? '\n' + String(node.responsibilities).slice(0, 120) : '') +
+ (node.current_task ? '\nNu: ' + node.current_task : '');
+ group.setAttribute('title', tip.slice(0, 200));
+ group.addEventListener('click', function () {
+ if (onNodeClick) onNodeClick(node);
});
nodesLayer.appendChild(group);
});
}
- async function mount(targetId) {
- const svg = document.getElementById(targetId);
- if (!svg) return;
- try {
- const data = await loadMeshData();
- drawMesh(svg, data);
- } catch (e) {
- clear(svg);
- const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
- msg.textContent = 'Mesh kon niet geladen worden';
- svg.appendChild(msg);
+ function updatePickerUI(modeId) {
+ document.querySelectorAll('[data-mesh-mode]').forEach(function (btn) {
+ const active = btn.getAttribute('data-mesh-mode') === modeId;
+ btn.classList.toggle('is-active', active);
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ const wrap = document.querySelector('.mesh-wrap');
+ if (wrap) {
+ wrap.className = 'mesh-wrap mesh-wrap-mode-' + modeId;
+ wrap.setAttribute('data-mode', modeId);
+ }
+ const badge = document.getElementById('mesh-mode-badge');
+ if (badge && COMM_MODES[modeId]) {
+ badge.textContent = COMM_MODES[modeId].label;
}
}
- window.AgentsMesh = { mount };
+ function pickMode(modeId, opts, silent) {
+ const mode = COMM_MODES[modeId] ? modeId : 'pyramid';
+ const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: mode });
+ if (!silent) saveMode(mode);
+ updatePickerUI(mode);
+ if (onModeChangeCb) onModeChangeCb(mode);
+ const svg = document.getElementById('agents-mesh-canvas');
+ if (!svg) return mode;
+ showMeshLoading(svg, 'Stijl: ' + COMM_MODES[mode].label + '…');
+ ensureMeshData()
+ .then(function () {
+ whenSvgVisible(svg, function () {
+ renderMesh(svg, drawOpts);
+ });
+ })
+ .catch(function (e) {
+ showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
+ });
+ return mode;
+ }
+
+ function bindModePicker(opts) {
+ lastMountOpts = Object.assign({}, lastMountOpts, opts || {});
+ if (pickerBound) return;
+ pickerBound = true;
+ document.addEventListener('click', function (e) {
+ const btn = e.target.closest('[data-mesh-mode]');
+ if (!btn) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const id = btn.getAttribute('data-mesh-mode');
+ pickMode(id);
+ });
+ }
+
+ function setModeChangeCallback(fn) {
+ onModeChangeCb = fn;
+ }
+
+ async function mount(targetId, opts) {
+ opts = opts || {};
+ lastMountOpts = opts;
+ const svg = document.getElementById(targetId);
+ if (!svg) return null;
+ bindModePicker(opts);
+ const modeObj = resolveMode(opts);
+ updatePickerUI(modeObj.id);
+ showMeshLoading(svg, 'Netwerk laden…');
+ try {
+ await ensureMeshData();
+ const drawOpts = Object.assign({}, opts, { commMode: modeObj.id });
+ whenSvgVisible(svg, function () {
+ renderMesh(svg, drawOpts);
+ });
+ return cachedData;
+ } catch (e) {
+ showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
+ return null;
+ }
+ }
+
+ async function refresh(targetId, opts) {
+ cachedData = null;
+ return mount(targetId, opts);
+ }
+
+ function redraw(targetId, opts) {
+ const svg = document.getElementById(targetId);
+ if (!svg || !cachedData) return;
+ whenSvgVisible(svg, function () {
+ renderMesh(svg, opts || {});
+ });
+ }
+
+ function saveMode(modeId) {
+ if (COMM_MODES[modeId]) localStorage.setItem(STORAGE_KEY, modeId);
+ }
+
+ function getSavedMode() {
+ const id = localStorage.getItem(STORAGE_KEY) || 'pyramid';
+ return COMM_MODES[id] ? id : 'pyramid';
+ }
+
+ window.AgentsMesh = {
+ mount,
+ refresh,
+ redraw,
+ pickMode,
+ bindModePicker,
+ setModeChangeCallback,
+ loadMeshData,
+ drawMesh,
+ COMM_MODES,
+ CLUSTERS,
+ saveMode,
+ getSavedMode,
+ };
+
+ function bootPicker() {
+ bindModePicker({});
+ const saved = getSavedMode();
+ updatePickerUI(saved);
+ }
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', bootPicker);
+ } else {
+ bootPicker();
+ }
+
+ if (window.location.pathname.indexOf('/agents') >= 0) {
+ ensureMeshData().catch(function () {});
+ }
})();
diff --git a/cockpit/static/js/agents-terminals.js b/cockpit/static/js/agents-terminals.js
new file mode 100644
index 0000000..d983c12
--- /dev/null
+++ b/cockpit/static/js/agents-terminals.js
@@ -0,0 +1,244 @@
+(function () {
+ const buffers = {};
+ let liveEs = null;
+
+ function esc(s) {
+ const d = document.createElement('div');
+ d.textContent = s || '';
+ return d.innerHTML;
+ }
+
+ function nowIso() {
+ return new Date().toISOString();
+ }
+
+ function lineClass(type) {
+ if (!type) return 'line-action';
+ if (type === 'command') return 'line-command';
+ if (type === 'output') return 'line-output';
+ if (String(type).indexOf('handoff') >= 0) return 'line-handoff';
+ if (type === 'error') return 'line-error';
+ if (String(type).indexOf('monitor') >= 0) return 'line-handoff';
+ if (String(type).indexOf('browse') >= 0) return 'line-action';
+ return 'line-action';
+ }
+
+ function formatLine(entry) {
+ const t = (entry.at || '').substring(11, 19) || (entry.at || '').substring(11, 16) || '--:--';
+ const msg = entry.message || entry.title || '';
+ const detail = entry.detail ? ' — ' + String(entry.detail).slice(0, 200) : '';
+ return (
+ '' +
+ esc(t) +
+ '' +
+ esc(msg + detail) +
+ '
'
+ );
+ }
+
+ function render(key) {
+ const els = document.querySelectorAll('[data-term-key="' + key + '"]');
+ if (!els.length) return;
+ const lines = buffers[key] || [];
+ const html =
+ !lines.length
+ ? 'Typ help voor commando\'s · Enter om uit te voeren
'
+ : lines.slice(-80).map(formatLine).join('');
+ els.forEach(function (el) {
+ el.innerHTML = html;
+ el.scrollTop = el.scrollHeight;
+ const win = el.closest('.agent-term-window');
+ if (win && lines.length) win.classList.add('is-live');
+ });
+ }
+
+ function pushLine(key, entry, skipDup) {
+ if (!key || !entry) return;
+ key = String(key).toLowerCase();
+ if (!buffers[key]) buffers[key] = [];
+ if (skipDup && entry._id) {
+ const seen = buffers[key].some(function (l) {
+ return l._id === entry._id;
+ });
+ if (seen) return;
+ }
+ if (!entry.at) entry.at = nowIso();
+ buffers[key].push(entry);
+ if (buffers[key].length > 120) buffers[key].shift();
+ render(key);
+ }
+
+ function setBusy(key, busy) {
+ document.querySelectorAll('[data-term-input="' + key + '"]').forEach(function (input) {
+ input.disabled = !!busy;
+ input.classList.toggle('is-busy', !!busy);
+ });
+ document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (body) {
+ const win = body.closest('.agent-term-window');
+ if (win) win.classList.toggle('is-running', !!busy);
+ });
+ }
+
+ async function runCommand(key, cmd) {
+ key = String(key || '').toLowerCase();
+ cmd = (cmd || '').trim();
+ if (!cmd) return;
+ if (cmd.toLowerCase() === 'clear') {
+ buffers[key] = [];
+ render(key);
+ return;
+ }
+ pushLine(key, { type: 'command', message: '$ ' + cmd, at: nowIso() });
+ setBusy(key, true);
+ try {
+ const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/terminal/command', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ command: cmd }),
+ });
+ const data = await r.json().catch(function () {
+ return {};
+ });
+ if (!r.ok) {
+ const err =
+ (typeof data.detail === 'string' && data.detail) ||
+ (Array.isArray(data.detail) && data.detail[0]?.msg) ||
+ 'Commando mislukt';
+ pushLine(key, { type: 'error', message: err, at: nowIso() });
+ return;
+ }
+ (data.lines || []).forEach(function (line) {
+ pushLine(key, {
+ type: line.type || 'output',
+ message: line.message || '',
+ detail: line.detail || '',
+ at: nowIso(),
+ });
+ });
+ } catch (e) {
+ pushLine(key, { type: 'error', message: e.message || 'Netwerkfout', at: nowIso() });
+ } finally {
+ setBusy(key, false);
+ }
+ }
+
+ function bindInputs() {
+ document.querySelectorAll('[data-term-input]').forEach(function (input) {
+ if (input._termBound) return;
+ input._termBound = true;
+ const key = input.getAttribute('data-term-input');
+ input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ const val = input.value;
+ input.value = '';
+ runCommand(key, val);
+ }
+ });
+ input.addEventListener('click', function (e) {
+ e.stopPropagation();
+ });
+ });
+ }
+
+ async function loadHistory(key) {
+ try {
+ const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=30');
+ if (!r.ok) return;
+ const data = await r.json();
+ buffers[key] = (data.items || []).map(function (ev) {
+ const meta = ev.metadata || {};
+ let type = 'action';
+ const et = ev.event_type || '';
+ if (et.indexOf('handoff') >= 0) type = 'handoff_out';
+ if (meta.target_agent) type = 'handoff_out';
+ if (meta.source_agent) type = 'handoff_in';
+ if (et.indexOf('monitor') >= 0) type = 'monitor';
+ if (et.indexOf('browse') >= 0) type = 'browse';
+ if (et === 'terminal_in') type = 'command';
+ if (et === 'terminal_out' || et === 'terminal_note') type = 'output';
+ return {
+ _id: ev.id,
+ type: type,
+ message: ev.title || ev.event_type,
+ detail: ev.body,
+ at: ev.created_at,
+ };
+ });
+ render(key);
+ } catch (e) {}
+ }
+
+ function handleLivePayload(d) {
+ if (!d || d.type === 'connected' || d.type === 'error') return;
+ const key = (d.agent || '').toLowerCase();
+ if (!key) return;
+ pushLine(
+ key,
+ {
+ _id: d.id,
+ type: d.type,
+ message: d.message,
+ detail: d.detail,
+ at: d.at,
+ },
+ true
+ );
+ document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (el) {
+ const win = el.closest('.agent-term-window');
+ if (win) win.classList.add('is-live');
+ });
+ }
+
+ function startLiveStream() {
+ if (liveEs || typeof EventSource === 'undefined') return;
+ liveEs = new EventSource('/api/agents/live/stream');
+ liveEs.onmessage = function (ev) {
+ try {
+ handleLivePayload(JSON.parse(ev.data));
+ } catch (e) {}
+ };
+ liveEs.onerror = function () {
+ /* EventSource auto-reconnects */
+ };
+ }
+
+ function stopLiveStream() {
+ if (liveEs) {
+ try {
+ liveEs.close();
+ } catch (e) {}
+ liveEs = null;
+ }
+ }
+
+ async function mountAll() {
+ const nodes = document.querySelectorAll('[data-term-key]');
+ const keys = [];
+ nodes.forEach(function (n) {
+ const k = n.getAttribute('data-term-key');
+ if (k && keys.indexOf(k) < 0) keys.push(k);
+ });
+ await Promise.all(keys.map(loadHistory));
+ bindInputs();
+ startLiveStream();
+ }
+
+ function stopAll() {
+ stopLiveStream();
+ }
+
+ window.AgentsTerminals = {
+ mountAll,
+ stopAll,
+ pushLine,
+ render,
+ runCommand,
+ bindInputs,
+ startLiveStream,
+ stopLiveStream,
+ };
+})();
diff --git a/cockpit/static/js/alpine.min.js b/cockpit/static/js/alpine.min.js
new file mode 100644
index 0000000..2ca4827
--- /dev/null
+++ b/cockpit/static/js/alpine.min.js
@@ -0,0 +1,5 @@
+(()=>{var rt=!1,nt=!1,U=[],it=-1;function qt(e){Cn(e)}function Cn(e){U.includes(e)||U.push(e),Tn()}function Ee(e){let t=U.indexOf(e);t!==-1&&t>it&&U.splice(t,1)}function Tn(){!nt&&!rt&&(rt=!0,queueMicrotask(Rn))}function Rn(){rt=!1,nt=!0;for(let e=0;ee.effect(t,{scheduler:r=>{ot?qt(r):r()}}),st=e.raw}function at(e){D=e}function Gt(e){let t=()=>{};return[n=>{let i=D(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),L(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=D(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>L(i)}var Jt=[],Yt=[],Xt=[];function Zt(e){Xt.push(e)}function ee(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Yt.push(t))}function Ae(e){Jt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function ct(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Qt(e){if(e._x_cleanups)for(;e._x_cleanups.length;)e._x_cleanups.pop()()}var lt=new MutationObserver(pt),ut=!1;function le(){lt.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ut=!0}function ft(){Mn(),lt.disconnect(),ut=!1}var ce=[];function Mn(){let e=lt.takeRecords();ce.push(()=>e.length>0&&pt(e));let t=ce.length;queueMicrotask(()=>{if(ce.length===t)for(;ce.length>0;)ce.shift()()})}function _(e){if(!ut)return e();ft();let t=e();return le(),t}var dt=!1,Se=[];function er(){dt=!0}function tr(){dt=!1,pt(Se),Se=[]}function pt(e){if(dt){Se=Se.concat(e);return}let t=new Set,r=new Set,n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.add(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.add(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ct(s,o)}),n.forEach((o,s)=>{Jt.forEach(a=>a(s,o))});for(let o of r)t.has(o)||Yt.forEach(s=>s(o));t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.has(o)||o.isConnected&&(delete o._x_ignoreSelf,delete o._x_ignore,Xt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function Ce(e){return F(j(e))}function P(e,t,r){return e._x_dataStack=[t,...j(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function j(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?j(e.host):e.parentNode?j(e.parentNode):[]}function F(e){return new Proxy({objects:e},Nn)}var Nn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Dn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Dn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>Pn(n,i),s=>mt(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function Pn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function mt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),mt(e[t[0]],t.slice(1),r)}}var rr={};function y(e,t){rr[e]=t}function ue(e,t){return Object.entries(rr).forEach(([r,n])=>{let i=null;function o(){if(i)return i;{let[s,a]=_t(t);return i={interceptor:Re,...s},ee(t,a),i}}Object.defineProperty(e,`$${r}`,{get(){return n(t,o())},enumerable:!1})}),e}function nr(e,t,r,...n){try{return r(...n)}catch(i){te(i,e,t)}}function te(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function De(e){let t=Me;Me=!1;let r=e();return Me=t,r}function M(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return ir(...e)}var ir=gt;function or(e){ir=e}function gt(e,t){let r={};ue(r,e);let n=[r,...j(e)],i=typeof t=="function"?In(n,t):Ln(n,t,e);return nr.bind(null,e,t,i)}function In(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(F([n,...e]),i);Ne(r,o)}}var ht={};function kn(e,t){if(ht[e])return ht[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return te(s,t,e),Promise.resolve()}})();return ht[e]=o,o}function Ln(e,t,r){let n=kn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=F([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>te(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>te(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>te(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var bt="x-";function C(e=""){return bt+e}function sr(e){bt=e}var Pe={};function d(e,t){return Pe[e]=t,{before(r){if(!Pe[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=W.indexOf(r);W.splice(n>=0?n:W.indexOf("DEFAULT"),0,e)}}}function ar(e){return Object.keys(Pe).includes(e)}function de(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=wt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(ur((o,s)=>n[o]=s)).filter(dr).map(jn(n,r)).sort(Fn).map(o=>$n(e,o))}function wt(e){return Array.from(e).map(ur()).filter(t=>!dr(t))}var xt=!1,fe=new Map,cr=Symbol();function lr(e){xt=!0;let t=Symbol();cr=t,fe.set(t,[]);let r=()=>{for(;fe.get(t).length;)fe.get(t).shift()();fe.delete(t)},n=()=>{xt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Gt(e);return t.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:M.bind(M,e)},()=>t.forEach(a=>a())]}function $n(e,t){let r=()=>{},n=Pe[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),xt?fe.get(cr).push(n):n())};return s.runCleanups=o,s}var Ie=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),ke=e=>e;function ur(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=fr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var fr=[];function re(e){fr.push(e)}function dr({name:e}){return pr().test(e)}var pr=()=>new RegExp(`^${bt}([^:^.]+)\\b`);function jn(e,t){return({name:r,value:n})=>{let i=r.match(pr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var yt="DEFAULT",W=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",yt,"teleport"];function Fn(e,t){let r=W.indexOf(e.type)===-1?yt:e.type,n=W.indexOf(t.type)===-1?yt:t.type;return W.indexOf(r)-W.indexOf(n)}function G(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function T(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>T(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)T(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var mr=!1;function _r(){mr&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),mr=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `
-
+
{% endblock %}
@@ -25,12 +25,16 @@