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

+ +
+ +
+
+
+

Platform status

+
Laden…
+
+
+ + Volledige IT Ops → +
+
+ +
+
+
Health
+
+
+
+
Online nodes
+
+
+
+
Services
+
+
+
+
Open goedkeuringen
+
+
+
+
Agent events (24u)
+
+
+
+
Browser sessies (24u)
+
{{ kpis.browser_sessions_24h }}
+
+
+ +
+
+

Agent goedkeuringen

+
+

SysOps vraagt toestemming voor update-scan en Gitea backup.

+
+
+
+ +
+

Maintenance

+ +
+ +
+

Alle maintenance →

+
+
+ +
+
+

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

+ +
+ +
+ + + +
+
+

Dagelijkse briefing

+
+
+
+ + + + CRM Clients + Reclame folders + Analytics +
+
+ +
+
+ + + + +
+
    + +
+
+ +
+
+ +
+ +
+
⋮⋮ 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

+
+
+

Samenvatting

+

+
+
+

📋 Herman activiteitenlog

+
    +
    +
    +
    +

    ⚡ Korte termijn

    +
      +
      +
      +

      🎯 Lange termijn

      +
        +
        +
        +
        +
        + +
        +
        ⋮⋮ Sleep · Retail
        +

        Retail operatie

        +
        +
        +

        Trending food retail

        Marketing Hub →
        +
        +
        +
        +
        +

        Sales milestones

        live DB
        +
        +
        +
        + +
        + +
        +
        ⋮⋮ Sleep · Analytics
        +

        Data & analytics

        +
        +
        +

        Pipeline

        live deals
        +
        +
        +
        +
        +

        NAS sentiment

        neo-bars
        +
        +
        +
        +
        +
        +
        +

        NAS top woorden

        corpus
        +
        +
        +
        +
        +
        + +
        +
        ⋮⋮ Sleep · Feed
        +
        +

        Projecten Alle projecten →

        +

        Laden…

        +
        +
        + +
        + + +
        +
        +
        +

        RSS Feeds

        +

        Alle live feeds op één plek — filter op categorie of zoek op titel

        +
        +
        + + Marketing Hub + ★ Bookmarks +
        +
        + +
        + + + +
        + +
        +
        +

        Live feed

        +
        + +

        Geen items — klik RSS ophalen of pas filters aan.

        +

        Feeds laden…

        +
        +
        + + +
        +
        +
        +
        +{% 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 %} +
        + + + + + + + + + + + + +
        + + +
        +
        +
        +
        Documenten
        +
        +
        +
        +
        Totaal woorden
        +
        {{ summary.total_words }}
        +
        +
        +
        Foto's OCR
        +
        +
        +
        +
        Gem. sentiment
        +
        {{ summary.avg_sentiment }}
        +
        +
        + +
        + + + + + + + + +
        + +
        +
        +
        Analytics
        +
        +
        +
        +
        +
        + NAS documenten + +
        + + + + + + {% for doc in documents[:80] %} + + + + + + + + + + {% endfor %} + + +
        BestandGewijzigdLabelsSentimentWoorden
        {{ doc.filename }}{{ doc.storage_path }}{{ doc.modified_at or doc.analyzed_at or '—' }}{% if doc.user_labels %}{% for lb in doc.user_labels %}{{ lb }}{% endfor %}{% else %}{% endif %}{{ doc.sentiment_label or '—' }}{{ doc.word_count or 0 }}
        +
        +
        + +
        +

        Label document —

        +
        + +
        +
        +
        +
        +
        +
        + + + +
        +
        +
        + + +
        +
        +
        +

        Foto labeling studio

        +

        Klik een foto → kies label-type → opslaan

        +
        +
        + + + +
        +
        + +
        + + +
        + +
        + +

        Geen foto's — upload, Telegram of NAS OCR.

        +
        +
        + + +
        +
        +
        +

        Document Workbench

        +

        +
        +
        +
        + + + + + + +
        +
        + +
        +
        +
        +

        Pipeline

        +
        + + + + + +
        +
        +
        +

        Export

        +
        + +
        +
        +
        +
        Pagina's
        +
        Tabellen
        +
        Figuren
        +
        Exports
        +
        +
        +
        + +
        + + + + +
        +
        +
        +
        + +
        + + +
        + + +
        +
        + + +
        +
        + + + + + + Open origineel +
        +
        + +
        + +
        + +
        +
        Editor
        + +
        + +
        +
        + 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 ·

        +
        +
        + + + +
        +
        + +
        + + + +
        + +
        +
        +
        Documenten
        +
        Koppelingen
        +
        Projecten
        +
        Winkels
        +
        + +
        +

        Gekoppelde paden

        + +
        + +
        +

        Sentiment & trends

        +
        + +
        + +
        + +
        +

        Top woorden (klant corpus)

        +
        + +
        +
        + +
        +

        Retail & deals

        + + +
        +
        + +

        Selecteer een klant om de 360° view te laden

        +
        + +
        + + +
        + +
        + + +{% 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 %} +
        + +
        +
        +

        Revenue Cockpit Excel Live

        +

        + + · +

        +
        +
        +
        + + +
        + + + +
        +
        + + +
        + +
        +
        + Marge / mnd + +
        +
        + Marge / jr + +
        +
        + Deals + +
        + +
        + + +
        +
        + +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        DealMarge mndMarge jrAandeelNext steps
        +
        +
        + + +
        +
        + Bewerken · DB +
        + Opslaan… + +
        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + +
        DealMarge MonthMarge YearNext steps
        +
        +
        +
        + + +
        +
        + +
        +
        +

        Klik in een cel om te bewerken · bron: Excel op NAS

        +
        +
        +{% 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 %} +
        + +
        +
        +

        Revenue Cockpit Excel Live

        +

        + + · +

        +
        +
        +
        + + +
        + + + +
        +
        + + +
        + +
        +
        + Marge / mnd + +
        +
        + Marge / jr + +
        +
        + Deals + +
        + +
        + + +
        +
        + +
        + + + + + + + + + + + + + + + + + + + + + + + + + + +
        DealMarge mndMarge jrAandeelNext steps
        +
        +
        + + +
        +
        + Bewerken · DB +
        + Opslaan… + +
        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + +
        DealMarge MonthMarge YearNext steps
        +
        +
        +
        + + +
        +
        + +
        +
        +

        Klik in een cel om te bewerken · bron: Excel op NAS

        +
        +
        +{% 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 @@
        + + +
        + Piramide + + +
        - +
        - Groen = healthy - Oranje = waarschuwing - Blauw = idle - Grijs = offline + ↑ Rapport + ↓ Delegatie + ↔ Handoff + ⇅ Executive +
        +
        +
        + + + +
        +
        +
        + + + +
        +
        +

        Agent terminals

        +

        Elke agent heeft een eigen terminal. Typ commando's en druk Enter — output verschijnt live. Start met help.

        +
        +
        @@ -196,7 +299,8 @@ {% block scripts %} - + + - + + + + + + + + {% block extra_head %}{% endblock %} + + - +
        @@ -81,7 +84,10 @@
        - Foodlinkk + + + Cucina · Foodlinkk + + +
        +
        +
        +
        +

        +

        resultaten

        +
        + + + + + +
        NaamTypeStad
        +
        +
        + Wereldexport + +
        +
        +
        +
        +
        +

        +

        · Agy / Antigravity op Hermes

        +

        Preview na afloop:

        +

        +
        + Agents terminal + Preview + +
        +
        +
        + + + + + + {% block scripts %}{% endblock %} diff --git a/cockpit/templates/browser.html b/cockpit/templates/browser.html index e96bcb6..a193b2e 100644 --- a/cockpit/templates/browser.html +++ b/cockpit/templates/browser.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block extra_head %} + + + + {% endblock %} + {% block content %} -
        - +
        -
        -
        -
        Documenten
        -
        -
        -
        -
        -
        Totaal woorden
        -
        {{ summary.total_words }}
        -
        -
        -
        -
        Foto's in DB
        -
        -
        -
        -
        -
        Gem. sentiment
        -
        {{ summary.avg_sentiment }}
        -
        -
        -
        + + + + + -
        - - - - - - -
        + -
        -
        -

        Top woorden

        klik = filter
        -
        -
        -
        -
        -
        -

        Sentiment

        klik = filter
        -
        -
        -
        -
        -
        + -
        -
        -

        Woord zoeken

        -
        - - - -
        -
        - - - - - - -
        LemmaTokenTotaalDocumenten
        -
        +
        -
        -

        Documenten op NAS

        - - - - - - - - -
        BestandPadTypeWoordenSentimentScoreGeanalyseerd
        Geen documenten voor dit filter.
        -
        - -
        -
        -

        Foto's — OCR & detectie

        -
        - - -
        -
        -

        Telegram-foto's worden automatisch opgeslagen. Klik een foto voor detectie-boxes (gewicht/prijs extractie).

        -
        - -

        Nog geen foto's — upload hier of stuur via Telegram.

        -
        -
        +
        +
        Totaal woorden
        +
        {{ summary.total_words }}
        +
        +
        +
        Foto's OCR
        +
        +
        +
        +
        Gem. sentiment
        +
        {{ summary.avg_sentiment }}
        +
        +
        -
        -

        Detectie overlay — foto #

        -
        -