diff --git a/.env.example b/.env.example index 4db6d52..1c0aa0e 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,15 @@ GLPI_KB_LIMIT=500 GLPI_KB_SYNC_INTERVAL=10m GLPI_KB_SOURCE=GLPI Knowledge Base +# Runtime controls. These defaults can later be changed live in the web UI. +# Empty category lists mean "all categories". Values are comma-separated. +BRAIN_LEARNING_ENABLED=true +BRAIN_THINKING_ENABLED=true +BRAIN_LEARNING_CATEGORIES= +BRAIN_DISPLAY_CATEGORIES= +BRAIN_THINKING_CATEGORIES= +BRAIN_DEFAULT_VIEW=neural + # Sequential enrichment BRAIN_AUTO_ENRICH=true BRAIN_SCAN_INTERVAL=20s diff --git a/CHANGELOG-RUNTIME-HONEYCOMB.md b/CHANGELOG-RUNTIME-HONEYCOMB.md new file mode 100644 index 0000000..c07b956 --- /dev/null +++ b/CHANGELOG-RUNTIME-HONEYCOMB.md @@ -0,0 +1,19 @@ +# Runtime Controls / Honeycomb + +## Neu + +- Learning und Thinking lassen sich im Web zur Laufzeit getrennt pausieren. +- Living-only-Zustand, wenn beide autonomen Modi deaktiviert sind. +- Persistente Laufzeiteinstellungen über die bestehende gebündelte Schreibqueue. +- Separate Kategorie-Filter für Lernen, Anzeige und Thinking. +- Kategorien-API mit Node-Anzahl und virtueller Kategorie „Ohne Kategorie“. +- Umschaltbare Honeycomb-Ansicht mit automatisch skaliertem 3D-Hirngitter. +- Honeycomb rendert nur Notes, keine Edges, Partikelpfade oder LOD-Gruppen. +- Abgefragte Notes leuchten in Honeycomb direkt an ihrer stabilen Wabenposition auf. + +## Backend-Schutz + +- Deaktiviertes Learning blockiert geplante und manuelle Scans sowie GLPI-KB-Syncs. +- Deaktiviertes Thinking blockiert automatische und manuelle AI-THINK-Zyklen. +- Thinking-Filter wird bei der Kandidatenwahl serverseitig angewendet. +- Learning-Filter wird bei echten und Fallback-Embeddings serverseitig angewendet. diff --git a/README.md b/README.md index c5dd6f7..360ea53 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Eigenständiger Go-Dienst für Agent, lokale Knowledgebase, GLPI-Knowledgebase u ## Kernfunktionen - Fullscreen-Canvas mit 3D-Hirnform, semantischen Cortex-Regionen und hierarchischem Level-of-Detail. +- Umschaltbare Honeycomb-Ansicht: gleichmäßig verteilte Notes in einer automatisch skalierten 3D-Gehirnwabe, ohne Edge-Rendering. +- Laufzeitsteuerung für Learning und Thinking sowie getrennte Kategorie-Filter für Lernen, Anzeige und AI-THINK. - Echtzeitaktivierung über Server-Sent Events: Nodes glühen, aggregierte Edges leuchten und Partikel folgen tatsächlichen Wissenspfaden. - Ingest lokaler produktiver Knowledge-JSONs sowie separater AI-THINK-Staging-Dateien. - Optionaler read-only Ingest sichtbarer Beiträge aus der GLPI-Knowledgebase. @@ -119,6 +121,31 @@ curl -X POST http://localhost:8090/api/flush Mehr Details: [`PERSISTENCE.md`](PERSISTENCE.md). +## Laufzeitsteuerung und Honeycomb + +Die untere Steuerleiste enthält direkte Schalter für **LEARNING**, **THINKING**, **NEURAL** und **HONEYCOMB**. Sind Learning und Thinking deaktiviert, bleibt das System im Living-Modus; eingehende Agent- oder KB-Anfragen können weiterhin die tatsächlich verwendeten Notes aktivieren. + +Über **FILTER** lassen sich drei unabhängige Kategorienlisten pflegen: + +- **Lernen**: neue Embeddings nur für passende Kategorien; +- **Anzeige**: Browser-Rendering nur für passende Notes; +- **Thinking**: neue AI-THINK-Kandidaten nur innerhalb der gewählten Kategorien. + +Leere Listen bedeuten „alle Kategorien“. Die Werte werden über die vorhandene Persistenzqueue in `runtime-settings.json` geschrieben. + +```env +BRAIN_LEARNING_ENABLED=true +BRAIN_THINKING_ENABLED=true +BRAIN_LEARNING_CATEGORIES= +BRAIN_DISPLAY_CATEGORIES= +BRAIN_THINKING_CATEGORIES= +BRAIN_DEFAULT_VIEW=neural +``` + +Honeycomb rendert nur `knowledge`, `ai-think` und `external`. Ein 3D-Gitter mit einheitlichem Punktabstand wird auf die Gehirngeometrie beschnitten und automatisch an die sichtbare Anzahl von Notes angepasst. Edges, LOD-Gruppen und Cortex-Flächen bleiben dort unsichtbar; bei einer Anfrage leuchten nur die referenzierten Notes. + +Mehr Details: [`RUNTIME-CONTROLS-HONEYCOMB.md`](RUNTIME-CONTROLS-HONEYCOMB.md). + ## Autonome Anreicherung Der Worker arbeitet bewusst sequenziell: @@ -150,6 +177,9 @@ curl -X POST 'http://localhost:8090/api/enrich?async=1' | `GET` | `/api/status` | Gesamtstatus inklusive Ollama-Pool, GLPI-KB und Persistenzqueue | | `GET` | `/api/graph` | vollständiger aktueller In-Memory-Graph | | `GET` | `/api/analysis` | strukturelle Graphanalyse | +| `GET` | `/api/runtime-settings` | aktuelle Learning-, Thinking-, Filter- und View-Einstellungen | +| `PUT` | `/api/runtime-settings` | Laufzeiteinstellungen ändern und gebündelt persistieren | +| `GET` | `/api/categories` | verfügbare Note-Kategorien mit Anzahl | | `GET` | `/api/stream` | SSE-Aktivitätsstrom | | `POST` | `/api/query` | programmatische Wissensanfrage; in der Fullscreen-UI verborgen | | `POST` | `/api/events` | optionale Agent-/KB-Telemetrie | diff --git a/RUNTIME-CONTROLS-HONEYCOMB.md b/RUNTIME-CONTROLS-HONEYCOMB.md new file mode 100644 index 0000000..ad9475d --- /dev/null +++ b/RUNTIME-CONTROLS-HONEYCOMB.md @@ -0,0 +1,88 @@ +# Runtime Controls und Honeycomb + +Die Fullscreen-Oberfläche kann die aktiven Hintergrundprozesse und die Darstellung ohne Neustart umschalten. Die Einstellungen gelten sofort im Arbeitsspeicher und werden über die gebündelte Persistenz in `BRAIN_DATA_DIR/runtime-settings.json` geschrieben. + +## Living-only + +Die Schalter **LEARNING** und **THINKING** befinden sich direkt in der unteren Steuerleiste. + +- **Learning aus**: geplante lokale Scans, Reindex, GLPI-KB-Synchronisation und neue Embeddings werden pausiert. Der bereits vorhandene In-Memory-Graph bleibt vollständig nutzbar. +- **Thinking aus**: automatische und manuelle AI-THINK-Zyklen, neue KI-Edges und die dazugehörige Recherche werden pausiert. +- Sind beide Schalter aus, bleibt die Visualisierung im **LIVING**-Modus. Eingehende Agent- oder Knowledgebase-Anfragen dürfen weiterhin die tatsächlich verwendeten Notes aufleuchten lassen; sie starten dadurch keinen autonomen Lern- oder Thinking-Zyklus. + +Ein bereits laufender Ollama-Aufruf wird nicht hart abgebrochen. Die Deaktivierung verhindert neue Schritte und neue Zyklen. + +## Honeycomb-Ansicht + +Mit **HONEYCOMB** wird die semantische Graphansicht durch eine gleichmäßig besetzte dreidimensionale Wabenstruktur ersetzt. + +- Gerendert werden nur Notes: `knowledge`, `ai-think` und `external`. +- Die Punkte liegen auf einem dicht gepackten 3D-Gitter mit einheitlichem Abstand. +- Das Gitter wird auf die mathematische 3D-Grenze der beiden Gehirnhälften beschnitten. +- Der Abstand wird per binärer Suche automatisch so gewählt, dass alle sichtbaren Notes in die Gehirnform passen. +- Edges, Partikelpfade, Cortex-Flächen und LOD-Supernodes werden in dieser Ansicht nicht gerendert. +- Wird eine Note durch Agent, Knowledgebase, Retrieval oder AI-THINK referenziert, leuchtet genau ihr Wabenpunkt auf. + +Die Honeycomb-Ansicht verändert weder den Graphen noch die gespeicherten Node-Positionen. Sie ist eine reine Renderprojektion und kann jederzeit zurück auf **NEURAL** geschaltet werden. + +## Kategorie-Filter + +Über **FILTER** öffnet sich das Control-Panel. Eine leere Liste bedeutet jeweils „alle Kategorien“. Mehrere ausgewählte Kategorien werden als ODER-Verknüpfung behandelt. + +### Lernen + +`learning_categories` begrenzt, welche neuen oder geänderten Notes Embeddings erhalten und damit für neue semantische Verarbeitung vorbereitet werden. Der Quell-Ingest bleibt read-only und darf die Datenbasis weiterhin vollständig erfassen; ausgeschlossen werden nur neue Embedding-Schritte. + +### Anzeige + +`display_categories` begrenzt die im Browser gerenderten Notes. Direkt verbundene Kategorie-, Quellen- und Taxonomie-Nodes werden in der Neural-Ansicht mitgeführt. In Honeycomb werden ausschließlich passende Notes gerendert. + +Der vollständige Graph bleibt serverseitig erhalten. Der Filter ist keine Lösch- oder Zugriffsregel. + +### Thinking + +`thinking_categories` begrenzt beide Nodes eines Kandidatenpaares für neue AI-THINK-Beziehungen. Bereits vorhandene Edges und AI-THINK-Beiträge bleiben erhalten. + +Die virtuelle Kategorie `__uncategorized__` steht im Web als **Ohne Kategorie** zur Verfügung. + +## Konfiguration + +Startwerte können über Umgebungsvariablen gesetzt werden: + +```env +BRAIN_LEARNING_ENABLED=true +BRAIN_THINKING_ENABLED=true +BRAIN_LEARNING_CATEGORIES= +BRAIN_DISPLAY_CATEGORIES= +BRAIN_THINKING_CATEGORIES= +BRAIN_DEFAULT_VIEW=neural +``` + +Zulässige Werte für `BRAIN_DEFAULT_VIEW` sind `neural` und `honeycomb`. + +Nach der ersten Änderung im Web haben die in `runtime-settings.json` gespeicherten Laufzeitwerte Vorrang vor den Startwerten. Zum Zurücksetzen kann die Datei bei gestopptem Dienst entfernt werden. + +## API + +```http +GET /api/runtime-settings +PUT /api/runtime-settings +GET /api/categories +``` + +Beispiel: + +```bash +curl -X PUT http://localhost:8090/api/runtime-settings \ + -H 'Content-Type: application/json' \ + -d '{ + "learning_enabled": false, + "thinking_enabled": false, + "learning_categories": [], + "display_categories": ["GLPI"], + "thinking_categories": ["GLPI", "Ollama"], + "view_mode": "honeycomb" + }' +``` + +Ist `BRAIN_API_KEY` gesetzt, benötigt der PUT-Aufruf `Authorization: Bearer …` oder `X-Brain-Key`. diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt index 81b59c7..75a6668 100644 --- a/SHA256SUMS.txt +++ b/SHA256SUMS.txt @@ -1,32 +1,35 @@ -532a6031326fcbb3b9862042ef8a17dbec46dee87a873b595984d5092c807392 ./.env.example +2b1c678560f1f0e9f7e4aa9f1c89baec738f0d2e948ad22e29b85581cc7d8294 ./.env.example 97f83ffd2da3c3a89184d8bb460493f67eca5ab22d45dc7ea846fc6ea25ea5b9 ./ARCHITECTURE.md 91f75b87bd95480fd40f191b551fe2d5f9f64e590e7c5442c19ee6166a362e6e ./CHANGELOG-GLPI-POOL-PERSISTENCE.md +87f89a81e1124b18e092a4ea946cb037295cb9e884a46b392286272dc8134dd4 ./CHANGELOG-RUNTIME-HONEYCOMB.md 99171b262752a66278da5a4a8b2b48e83fbdb1eb9cdca3efa944d260d6e6bc01 ./Dockerfile 5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 ./GLPI-KB.md adcd3c9ba3bdf366afcc4e15a25423e068dd761e5d5d2d6f8cb20a3686302045 ./Makefile 381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 ./OLLAMA-POOL.md 10c14f4c08b9b84699cc4cf0dac3e6faf6c0ffd7e74b428463f14c628d0b533d ./PERSISTENCE.md -9de8804000cc41c7b5683b196e433caa4fb3e2e23c8c1cd1e5950c127d95dcb8 ./README.md +a1628299224739d81e80707ce0505313ef5d05203963ee88b0c9ac13f82332ef ./README.md +f07a308db091bd42f5e9c2d3456b1ad5058882656b00f66340910e7e379e9736 ./RUNTIME-CONTROLS-HONEYCOMB.md cfc2393e8300b792cec30c57b9dc7670b19dcf5abb190dddaa75f32b77cb1fc7 ./cmd/brain/main.go 21b51d0e1b7ed07c20f7f3a5da76dedab8df44a94a51724b67b0c3411599fe15 ./deployment/README.md -652c8188f9b591a085afdf95d0b56744c8fc618be82d8e6d03341c813f693ea7 ./deployment/docker-compose.full.yml -4b0a58e828cd3d808b6385be5d17eb06a6851526f2914aebf75759b492cec158 ./docker-compose.yml +0f9c8802afd5ae65510d2cbe89058edb4a3b1fc514af903bb9f21694a028671e ./deployment/docker-compose.full.yml +10566f930e591293ec50067a26b3c11e89d5e5f6cc547ffe7304733f0f4c3357 ./docker-compose.yml 9126f8bca1144abfc77747063b2c8f31acf12839a75e060b98369e10a00061ca ./go.mod bf239391e61040b00d2f49d805b0d49a44bd3679f3c53849dfbc5e3b5a3fcc7a ./integrations/agent/README.md a0105475dc054977223fac36618b8cd8137c55be1d11fddcd24e9a4d3074c170 ./integrations/agent/glpi-ai-agent-neural-brain.patch 73ab7c49600cdaa4795e76c50e66ce919dec171b3a07f2d16ff6f45ce5f73365 ./integrations/knowledgebase/README.md 36666043ebf4139e13610e5fcd0b0c6f45e4e6a53fed33ec47d03a66878e7b08 ./integrations/knowledgebase/glpi-ai-knowledgebase-neural-brain.patch 50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go -c7f4c9702c5d4c0ab872001757a13564a065afa41ca2767475be42976bfb327f ./internal/config/config.go -6b6b9e92e1feee682a6663b1b617add06d53137de9305bdfc920c4536faeb866 ./internal/config/config_test.go -af76f57a74225b663bea7fbeb5ae4cf9d9bd8a71b292135e8a21c325923df76a ./internal/engine/engine.go -27c8ffa16cca14ab8a39298ecb11bc775cee6968d9d137a91ecc6a845064fd7e ./internal/engine/engine_test.go +d14cf93908ee9af0c75303db82e6ffeaf1fc1feaf7d35c525a9b6641a1b5f335 ./internal/config/config.go +678cc853f0905f9d9a301300872f94516814bea3cf92419f79026878532ec3e4 ./internal/config/config_test.go +0a1042eee574b1e72862da76c343b7a98c35bd87eceb09dd48abc63e64ae8bee ./internal/engine/engine.go +72fc0ea5056efbe3cec3d06f783743c0453fecb13ece9b3a47d6ebc2a921c629 ./internal/engine/engine_test.go +e507585b1606b9b54acd0a121ad7691a3c985ea9e36d9f3db282d4b1ec54211b ./internal/engine/runtime.go b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glpi/client.go 525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 ./internal/glpi/client_test.go -c41c845b3e342bf20a7453f16a34a8d1aa6c0f43424c2c86d6cf297ba4101381 ./internal/graph/store.go -5c32a4e4fca939165a2fa7c0f380fdf79cfab7cc65924e21fe7b9ac5e85a2b6d ./internal/graph/store_test.go +0a7fbd2d40cb70995651ef64dcc0d57ae7068aeefbd9457431f830cf799d51ce ./internal/graph/store.go +13c38603f7ad1dcc590117d06c0663ace3e7a5971b96b2a96e8a53d9bcae87ff ./internal/graph/store_test.go 4476351d388d11c6becd78b4c918fc8d47dfd70500d8b3e2ddf81f7ff61a2cea ./internal/ingest/agent.go -a64fde7d9be8841b363bc5cf5e41c81ade8e068335f7b04f41272e5bc3628d93 ./internal/ingest/glpikb.go +5e1417ed485d472b4effd05d3a5dd74a689578b77c4aac97de14c7fe97f53436 ./internal/ingest/glpikb.go 774e9155eb7d17a208d483625f9fb6b60b9f9d44a70843c9cc51e29cf147a2b7 ./internal/ingest/glpikb_test.go c0470d74bd3c3369cfefa6ba2343abcbdaa584a8898011704cba7d1cee620141 ./internal/ingest/knowledge.go ebd47d134e61badf5e1eb35bdf1f45a3e4c9f99dcbf0a022850409bebc587e56 ./internal/ingest/knowledge_test.go @@ -37,9 +40,10 @@ e61a9a426b96851b409ac357bdc53853324b5c5da7568ff6f89fe9cc09f83a73 ./internal/per d33318b43388f134358cf40f5b0f130eb10edcca06011955ea0544897b4e4ad1 ./internal/persist/coordinator_test.go 2eb686cfc9016b9b0cf15e5c342f693ec9b60c454bc4814c1c9583f6d5b5b806 ./internal/research/searxng.go 6346f7b213aa3fb36bc9f43134bba75e0b368c9a005cc1aef8d265f553ff8cef ./internal/research/searxng_test.go -1a8db923dcfa3416edd015b08c71cc48145f6bf7b68429239134d58895ba1af3 ./internal/web/server.go -644105a4921f0394bc0ab3cc4c8658053e456e593acf7573255bd867d6cde8dd ./internal/web/static/app.css -df87659f906533144dd407a488d2d2abbf5ea57d95a11fcec70aa4c51729a5d5 ./internal/web/static/app.js -02007a3143603cda56794e36613f9a3cfc3cdbaf66279af0b3e4b9b5e8f4cbc2 ./internal/web/static/index.html -39656b3c5e7f013545aa6a6b01c213f8f746a947f7158c3042b81a07429d5d2a ./neural-brain +1c44338935a4faeedd9671c23df235aa99ea55ed6b2e72060a5d5ddc6c7a6464 ./internal/web/server.go +0f06a3be1885b2a11d5d335cdc5f643b1f6ef51fa191e37a0f68e2ae2e6a2ade ./internal/web/server_test.go +c6202f88840edbf266e8eb5b50fed70e28c08695d8cdfb9b67f633e5fbba6eb8 ./internal/web/static/app.css +fe529752c7a975e55f3fc4f1458170c85ca18c24f5f862471c1c0f22838d474b ./internal/web/static/app.js +b8496ecc23847a9478b4ee12cf83f79fe08f5c353e892000888a84739606cc64 ./internal/web/static/index.html +d0fe0699532da9ec43b9e357bf2b9410a8b99ea56e17555df2af560fb83db4bf ./neural-brain 83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png diff --git a/deployment/docker-compose.full.yml b/deployment/docker-compose.full.yml index 11f30e6..4f79600 100644 --- a/deployment/docker-compose.full.yml +++ b/deployment/docker-compose.full.yml @@ -130,6 +130,12 @@ services: BRAIN_AUTO_ENRICH: ${BRAIN_AUTO_ENRICH:-true} BRAIN_SCAN_INTERVAL: ${BRAIN_SCAN_INTERVAL:-20s} BRAIN_PERSIST_INTERVAL: ${BRAIN_PERSIST_INTERVAL:-5m} + BRAIN_LEARNING_ENABLED: ${BRAIN_LEARNING_ENABLED:-true} + BRAIN_THINKING_ENABLED: ${BRAIN_THINKING_ENABLED:-true} + BRAIN_LEARNING_CATEGORIES: ${BRAIN_LEARNING_CATEGORIES:-} + BRAIN_DISPLAY_CATEGORIES: ${BRAIN_DISPLAY_CATEGORIES:-} + BRAIN_THINKING_CATEGORIES: ${BRAIN_THINKING_CATEGORIES:-} + BRAIN_DEFAULT_VIEW: ${BRAIN_DEFAULT_VIEW:-neural} BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s} BRAIN_RESEARCH_ENABLED: ${BRAIN_RESEARCH_ENABLED:-false} SEARXNG_URL: ${SEARXNG_URL:-} diff --git a/docker-compose.yml b/docker-compose.yml index 1dee321..ca958ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,12 @@ services: BRAIN_AUTO_ENRICH: ${BRAIN_AUTO_ENRICH:-true} BRAIN_SCAN_INTERVAL: ${BRAIN_SCAN_INTERVAL:-20s} BRAIN_PERSIST_INTERVAL: ${BRAIN_PERSIST_INTERVAL:-5m} + BRAIN_LEARNING_ENABLED: ${BRAIN_LEARNING_ENABLED:-true} + BRAIN_THINKING_ENABLED: ${BRAIN_THINKING_ENABLED:-true} + BRAIN_LEARNING_CATEGORIES: ${BRAIN_LEARNING_CATEGORIES:-} + BRAIN_DISPLAY_CATEGORIES: ${BRAIN_DISPLAY_CATEGORIES:-} + BRAIN_THINKING_CATEGORIES: ${BRAIN_THINKING_CATEGORIES:-} + BRAIN_DEFAULT_VIEW: ${BRAIN_DEFAULT_VIEW:-neural} BRAIN_ENRICH_INTERVAL: ${BRAIN_ENRICH_INTERVAL:-90s} BRAIN_ENRICH_BATCH_SIZE: ${BRAIN_ENRICH_BATCH_SIZE:-3} BRAIN_ENRICH_STEP_DELAY: ${BRAIN_ENRICH_STEP_DELAY:-3s} diff --git a/internal/config/config.go b/internal/config/config.go index e16254e..091da6a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,13 @@ type Config struct { AutoEnrich bool ResearchEnabled bool APIKey string + LearningEnabled bool + ThinkingEnabled bool + LearningCategories []string + DisplayCategories []string + ThinkingCategories []string + DefaultView string + RuntimeDefaultsConfigured bool GLPIKBEnabled bool GLPIURL string @@ -111,6 +118,13 @@ func Load() (Config, error) { AutoEnrich: boolean("BRAIN_AUTO_ENRICH", true), ResearchEnabled: boolean("BRAIN_RESEARCH_ENABLED", false), APIKey: strings.TrimSpace(os.Getenv("BRAIN_API_KEY")), + LearningEnabled: boolean("BRAIN_LEARNING_ENABLED", true), + ThinkingEnabled: boolean("BRAIN_THINKING_ENABLED", true), + LearningCategories: stringList("BRAIN_LEARNING_CATEGORIES"), + DisplayCategories: stringList("BRAIN_DISPLAY_CATEGORIES"), + ThinkingCategories: stringList("BRAIN_THINKING_CATEGORIES"), + DefaultView: strings.ToLower(env("BRAIN_DEFAULT_VIEW", "neural")), + RuntimeDefaultsConfigured: true, GLPIKBEnabled: boolean("GLPI_KB_ENABLED", false), GLPIURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GLPI_URL")), "/"), GLPIAPIVersion: env("GLPI_API_VERSION", "v2.3"), @@ -153,6 +167,9 @@ func Load() (Config, error) { if cfg.ResearchEnabled && cfg.SearXNGURL == "" { return Config{}, fmt.Errorf("BRAIN_RESEARCH_ENABLED requires SEARXNG_URL") } + if cfg.DefaultView != "neural" && cfg.DefaultView != "honeycomb" { + return Config{}, fmt.Errorf("BRAIN_DEFAULT_VIEW must be neural or honeycomb") + } if len(cfg.OllamaURLs) < 1 || len(cfg.OllamaURLs) > 64 { return Config{}, fmt.Errorf("OLLAMA_URLS must contain between 1 and 64 nodes") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c1aac75..bf89a57 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -37,3 +37,23 @@ func TestLoadRejectsMismatchedPoolMetadata(t *testing.T) { t.Fatal("expected pool metadata validation error") } } + +func TestLoadRuntimeControlDefaultsAndFilters(t *testing.T) { + t.Setenv("BRAIN_DATA_DIR", t.TempDir()) + t.Setenv("BRAIN_LEARNING_ENABLED", "false") + t.Setenv("BRAIN_THINKING_ENABLED", "false") + t.Setenv("BRAIN_LEARNING_CATEGORIES", "Netzwerk,GLPI KB") + t.Setenv("BRAIN_DISPLAY_CATEGORIES", "GLPI KB") + t.Setenv("BRAIN_THINKING_CATEGORIES", "Netzwerk") + t.Setenv("BRAIN_DEFAULT_VIEW", "honeycomb") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.LearningEnabled || cfg.ThinkingEnabled || cfg.DefaultView != "honeycomb" { + t.Fatalf("unexpected runtime defaults: %+v", cfg) + } + if len(cfg.LearningCategories) != 2 || len(cfg.DisplayCategories) != 1 || len(cfg.ThinkingCategories) != 1 { + t.Fatalf("unexpected category defaults: %+v", cfg) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index cd034fe..93df5c6 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -28,7 +28,11 @@ import ( "github.com/local/glpi-neural-brain/internal/research" ) -var ErrNoCandidate = errors.New("no enrichment candidate") +var ( + ErrNoCandidate = errors.New("no enrichment candidate") + ErrLearningDisabled = errors.New("learning is disabled") + ErrThinkingDisabled = errors.New("thinking is disabled") +) type EnrichOutcome struct { Result string @@ -63,9 +67,17 @@ type Engine struct { enrichCreated uint64 enrichRejected uint64 enrichRequests chan string + runtimeMu sync.RWMutex + runtime RuntimeSettings + runtimePath string } func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine { + if !cfg.RuntimeDefaultsConfigured { + cfg.LearningEnabled = true + cfg.ThinkingEnabled = true + cfg.DefaultView = "neural" + } if cfg.EnrichBatchSize < 1 { cfg.EnrichBatchSize = 1 } @@ -96,13 +108,14 @@ func New(cfg config.Config, g *graph.Store, b *activity.Broker) *Engine { RequireEmbeddingModel: cfg.OllamaRequireEmbeddingModel, }, cfg.ChatModel, cfg.EmbeddingModel) persistence := persist.New(g, b, cfg.PersistInterval) - e := &Engine{Cfg: cfg, Graph: g, Broker: b, Ollama: pool, Persistence: persistence, Scanner: &ingest.KnowledgeScanner{Graph: g, ProductionDirs: cfg.KnowledgeDirs, StagingDirs: cfg.StagingDirs}, enrichRequests: make(chan string, 1)} + e := &Engine{Cfg: cfg, Graph: g, Broker: b, Ollama: pool, Persistence: persistence, Scanner: &ingest.KnowledgeScanner{Graph: g, ProductionDirs: cfg.KnowledgeDirs, StagingDirs: cfg.StagingDirs}, enrichRequests: make(chan string, 1), runtimePath: filepath.Join(cfg.DataDir, "runtime-settings.json")} + e.loadRuntimeSettings() if cfg.SearXNGURL != "" { e.Research = research.New(cfg.SearXNGURL) } if cfg.GLPIKBEnabled { client := glpi.New(cfg.GLPIURL, cfg.GLPIAPIVersion, cfg.GLPIClientID, cfg.GLPIClientSecret, cfg.GLPIUsername, cfg.GLPIPassword, cfg.GLPITimeout) - e.GLPIKB = ingest.NewGLPIKBSyncer(ingest.GLPIKBConfig{Enabled: true, Path: cfg.GLPIKBPath, Filter: cfg.GLPIKBFilter, Limit: cfg.GLPIKBLimit, SyncInterval: cfg.GLPIKBSyncInterval, Source: cfg.GLPIKBSource, CachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json")}, client, g, b, persistence) + e.GLPIKB = ingest.NewGLPIKBSyncer(ingest.GLPIKBConfig{Enabled: true, Path: cfg.GLPIKBPath, Filter: cfg.GLPIKBFilter, Limit: cfg.GLPIKBLimit, SyncInterval: cfg.GLPIKBSyncInterval, Source: cfg.GLPIKBSource, CachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json"), ShouldSync: e.LearningEnabled}, client, g, b, persistence) } return e } @@ -113,8 +126,10 @@ func (e *Engine) Start(ctx context.Context) { e.GLPIKB.Start(ctx) } go func() { - if err := e.Scan(ctx); err != nil { - slog.Error("initial brain scan failed", "error", err) + if e.LearningEnabled() { + if err := e.Scan(ctx); err != nil && !errors.Is(err, ErrLearningDisabled) { + slog.Error("initial brain scan failed", "error", err) + } } ticker := time.NewTicker(e.Cfg.ScanInterval) defer ticker.Stop() @@ -123,7 +138,10 @@ func (e *Engine) Start(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if err := e.Scan(ctx); err != nil { + if !e.LearningEnabled() { + continue + } + if err := e.Scan(ctx); err != nil && !errors.Is(err, ErrLearningDisabled) { slog.Error("brain scan failed", "error", err) } } @@ -169,6 +187,13 @@ func (e *Engine) enrichmentWorker(ctx context.Context) { } func (e *Engine) RequestEnrich(trigger string) bool { + if !e.ThinkingEnabled() { + e.stateMu.Lock() + e.enrichResult = "disabled" + e.enrichError = ErrThinkingDisabled.Error() + e.stateMu.Unlock() + return false + } if strings.TrimSpace(trigger) == "" { trigger = "manual" } @@ -213,6 +238,10 @@ func (e *Engine) runEnrichmentCycle(ctx context.Context, trigger string) { result := "completed" var cycleErr error for step := 0; step < e.Cfg.EnrichBatchSize; step++ { + if !e.ThinkingEnabled() { + result = "disabled" + break + } outcome, err := e.enrichOne(ctx, trigger) if err != nil { cycleErr = err @@ -306,6 +335,9 @@ func (e *Engine) idle(ctx context.Context) { } } func (e *Engine) Scan(ctx context.Context) error { + if !e.LearningEnabled() { + return ErrLearningDisabled + } e.mu.Lock() defer e.mu.Unlock() beforeVersion := e.Graph.Version() @@ -313,7 +345,7 @@ func (e *Engine) Scan(ctx context.Context) error { if err != nil { return err } - pendingEmbeddings := len(e.Graph.NodesForEmbedding()) + pendingEmbeddings := len(e.Graph.NodesForEmbeddingFiltered(e.learningCategories())) if e.Graph.Version() != beforeVersion || pendingEmbeddings > 0 { e.Broker.Publish(model.Activity{Type: "scan.started", Source: "brain", Phase: "ingest", Message: "Neue oder geänderte Wissenselemente werden verarbeitet", Strength: .45, Metadata: map[string]any{"pending_embeddings": pendingEmbeddings}}) } @@ -346,7 +378,7 @@ func (e *Engine) Scan(ctx context.Context) error { return nil } func (e *Engine) ensureEmbeddings(ctx context.Context) error { - pending := e.Graph.NodesForEmbedding() + pending := e.Graph.NodesForEmbeddingFiltered(e.learningCategories()) if len(pending) == 0 { return nil } @@ -377,7 +409,7 @@ func (e *Engine) ensureEmbeddings(ctx context.Context) error { return nil } func (e *Engine) ensureFallbackEmbeddings() { - for _, n := range e.Graph.NodesForEmbedding() { + for _, n := range e.Graph.NodesForEmbeddingFiltered(e.learningCategories()) { e.Graph.SetVector(n.ID, hashEmbedding(embeddingText(n), 256)) } } @@ -490,11 +522,17 @@ func (e *Engine) fallbackAnswer(q string, hits []model.Hit) string { } func (e *Engine) EnrichOne(ctx context.Context) error { + if !e.ThinkingEnabled() { + return ErrThinkingDisabled + } _, err := e.enrichOne(ctx, "direct") return err } func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome, error) { + if !e.ThinkingEnabled() { + return EnrichOutcome{Result: "disabled"}, ErrThinkingDisabled + } e.mu.Lock() defer e.mu.Unlock() @@ -509,7 +547,7 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome, e.setOllamaOK(true) } - a, b, sim, ok, comparisons := e.Graph.NextPair(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors) + a, b, sim, ok, comparisons := e.Graph.NextPairFiltered(e.Cfg.SimilarityThreshold, e.Cfg.EnrichAnchors, e.thinkingCategories()) if !ok { e.stateMu.Lock() e.lastAttempt = time.Now().UTC() @@ -631,6 +669,7 @@ func (e *Engine) Status() map[string]any { "enrich_batch_size": e.Cfg.EnrichBatchSize, "enrich_anchors": e.Cfg.EnrichAnchors, "research_enabled": e.Cfg.ResearchEnabled, "chat_model": e.Cfg.ChatModel, "embedding_model": e.Cfg.EmbeddingModel, "ollama_pool": e.Ollama.PoolStatus(), "persistence": e.Persistence.Status(), + "runtime_settings": e.RuntimeSettings(), } if e.GLPIKB != nil { status["glpi_kb"] = e.GLPIKB.Status() @@ -646,6 +685,9 @@ func (e *Engine) Flush(ctx context.Context) error { } func (e *Engine) SyncGLPIKB(ctx context.Context) error { + if !e.LearningEnabled() { + return ErrLearningDisabled + } if e.GLPIKB == nil { return fmt.Errorf("GLPI knowledge-base integration is disabled") } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 22b12fb..261ebe7 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -116,3 +116,46 @@ func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) { t.Fatalf("unexpected enrich status: %#v", status["enrich_result"]) } } + +func TestRuntimeSettingsDisableThinkingAndPersist(t *testing.T) { + data := t.TempDir() + g, err := graph.Open(data) + if err != nil { + t.Fatal(err) + } + cfg := config.Config{DataDir: data, RuntimeDefaultsConfigured: true, LearningEnabled: true, ThinkingEnabled: true, DefaultView: "neural", PersistInterval: time.Minute} + e := New(cfg, g, activity.New(20)) + updated, err := e.SetRuntimeSettings(RuntimeSettings{ + LearningEnabled: false, + ThinkingEnabled: false, + LearningCategories: []string{"Netzwerk"}, + DisplayCategories: []string{"GLPI KB"}, + ThinkingCategories: []string{"Netzwerk"}, + ViewMode: "honeycomb", + }) + if err != nil { + t.Fatal(err) + } + if updated.LearningEnabled || updated.ThinkingEnabled || updated.ViewMode != "honeycomb" { + t.Fatalf("unexpected runtime settings: %+v", updated) + } + if e.RequestEnrich("manual") { + t.Fatal("disabled thinking must not queue AI-THINK") + } + if err := e.Scan(context.Background()); err != ErrLearningDisabled { + t.Fatalf("expected ErrLearningDisabled, got %v", err) + } + if err := e.Flush(context.Background()); err != nil { + t.Fatal(err) + } + + g2, err := graph.Open(data) + if err != nil { + t.Fatal(err) + } + e2 := New(cfg, g2, activity.New(20)) + loaded := e2.RuntimeSettings() + if loaded.LearningEnabled || loaded.ThinkingEnabled || loaded.ViewMode != "honeycomb" || len(loaded.DisplayCategories) != 1 { + t.Fatalf("runtime settings were not restored: %+v", loaded) + } +} diff --git a/internal/engine/runtime.go b/internal/engine/runtime.go new file mode 100644 index 0000000..1695138 --- /dev/null +++ b/internal/engine/runtime.go @@ -0,0 +1,216 @@ +package engine + +import ( + "encoding/json" + "os" + "sort" + "strings" + + "github.com/local/glpi-neural-brain/internal/model" +) + +const uncategorizedFilter = "__uncategorized__" + +type RuntimeSettings struct { + LearningEnabled bool `json:"learning_enabled"` + ThinkingEnabled bool `json:"thinking_enabled"` + LearningCategories []string `json:"learning_categories"` + DisplayCategories []string `json:"display_categories"` + ThinkingCategories []string `json:"thinking_categories"` + ViewMode string `json:"view_mode"` +} + +type CategoryInfo struct { + Name string `json:"name"` + Count int `json:"count"` +} + +func (e *Engine) defaultRuntimeSettings() RuntimeSettings { + return normalizeRuntimeSettings(RuntimeSettings{ + LearningEnabled: e.Cfg.LearningEnabled, + ThinkingEnabled: e.Cfg.ThinkingEnabled, + LearningCategories: append([]string(nil), e.Cfg.LearningCategories...), + DisplayCategories: append([]string(nil), e.Cfg.DisplayCategories...), + ThinkingCategories: append([]string(nil), e.Cfg.ThinkingCategories...), + ViewMode: e.Cfg.DefaultView, + }) +} + +func normalizeRuntimeSettings(in RuntimeSettings) RuntimeSettings { + in.LearningCategories = normalizeCategories(in.LearningCategories) + in.DisplayCategories = normalizeCategories(in.DisplayCategories) + in.ThinkingCategories = normalizeCategories(in.ThinkingCategories) + in.ViewMode = strings.ToLower(strings.TrimSpace(in.ViewMode)) + if in.ViewMode == "" { + in.ViewMode = "neural" + } + if in.ViewMode != "neural" && in.ViewMode != "honeycomb" { + in.ViewMode = "neural" + } + return in +} + +func normalizeCategories(values []string) []string { + seen := map[string]string{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + key := strings.ToLower(value) + if _, exists := seen[key]; !exists { + seen[key] = value + } + } + out := make([]string, 0, len(seen)) + for _, value := range seen { + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) }) + return out +} + +func (e *Engine) loadRuntimeSettings() { + settings := e.defaultRuntimeSettings() + if strings.TrimSpace(e.runtimePath) != "" { + if data, err := os.ReadFile(e.runtimePath); err == nil { + var stored RuntimeSettings + if json.Unmarshal(data, &stored) == nil { + settings = normalizeRuntimeSettings(stored) + } + } + } + e.runtimeMu.Lock() + e.runtime = settings + e.runtimeMu.Unlock() +} + +func (e *Engine) RuntimeSettings() RuntimeSettings { + e.runtimeMu.RLock() + settings := e.runtime + e.runtimeMu.RUnlock() + settings.LearningCategories = append([]string{}, settings.LearningCategories...) + settings.DisplayCategories = append([]string{}, settings.DisplayCategories...) + settings.ThinkingCategories = append([]string{}, settings.ThinkingCategories...) + return settings +} + +func (e *Engine) SetRuntimeSettings(settings RuntimeSettings) (RuntimeSettings, error) { + settings = normalizeRuntimeSettings(settings) + e.runtimeMu.Lock() + previous := e.runtime + e.runtime = settings + e.runtimeMu.Unlock() + + if !settings.ThinkingEnabled { + e.stateMu.Lock() + if !e.enrichRunning && e.enrichResult == "queued" { + e.enrichResult = "disabled" + } + e.stateMu.Unlock() + } + + if e.Persistence != nil && strings.TrimSpace(e.runtimePath) != "" { + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return previous, err + } + if _, err := e.Persistence.QueueFile(e.runtimePath, append(data, '\n'), 0o640); err != nil { + e.runtimeMu.Lock() + e.runtime = previous + e.runtimeMu.Unlock() + return previous, err + } + } + + e.Broker.Publish(model.Activity{ + Type: "runtime.settings.updated", + Source: "ui", + Phase: "control", + Message: "Lern-, Anzeige- und Thinking-Einstellungen wurden aktualisiert", + Strength: .32, + Metadata: map[string]any{ + "learning_enabled": settings.LearningEnabled, + "thinking_enabled": settings.ThinkingEnabled, + "learning_categories": len(settings.LearningCategories), + "display_categories": len(settings.DisplayCategories), + "thinking_categories": len(settings.ThinkingCategories), + "view_mode": settings.ViewMode, + }, + }) + return settings, nil +} + +func (e *Engine) LearningEnabled() bool { + e.runtimeMu.RLock() + enabled := e.runtime.LearningEnabled + e.runtimeMu.RUnlock() + return enabled +} + +func (e *Engine) ThinkingEnabled() bool { + e.runtimeMu.RLock() + enabled := e.runtime.ThinkingEnabled + e.runtimeMu.RUnlock() + return enabled +} + +func (e *Engine) learningCategories() []string { + e.runtimeMu.RLock() + out := append([]string(nil), e.runtime.LearningCategories...) + e.runtimeMu.RUnlock() + return out +} + +func (e *Engine) thinkingCategories() []string { + e.runtimeMu.RLock() + out := append([]string(nil), e.runtime.ThinkingCategories...) + e.runtimeMu.RUnlock() + return out +} + +func (e *Engine) Categories() []CategoryInfo { + snapshot := e.Graph.Snapshot() + counts := map[string]int{} + names := map[string]string{} + uncategorized := 0 + for _, node := range snapshot.Nodes { + if node.Kind != "knowledge" && node.Kind != "ai-think" && node.Kind != "external" { + continue + } + if len(node.Categories) == 0 { + uncategorized++ + continue + } + seen := map[string]bool{} + for _, category := range node.Categories { + category = strings.TrimSpace(category) + if category == "" { + continue + } + key := strings.ToLower(category) + if seen[key] { + continue + } + seen[key] = true + if _, ok := names[key]; !ok { + names[key] = category + } + counts[key]++ + } + } + out := make([]CategoryInfo, 0, len(counts)+1) + for key, count := range counts { + out = append(out, CategoryInfo{Name: names[key], Count: count}) + } + if uncategorized > 0 { + out = append(out, CategoryInfo{Name: uncategorizedFilter, Count: uncategorized}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count == out[j].Count { + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + } + return out[i].Count > out[j].Count + }) + return out +} diff --git a/internal/graph/store.go b/internal/graph/store.go index bfb9ba5..6843a62 100644 --- a/internal/graph/store.go +++ b/internal/graph/store.go @@ -161,6 +161,10 @@ func (s *Store) ClearVectorsByDimension(dim int) int { } func (s *Store) NodesForEmbedding() []model.Node { + return s.NodesForEmbeddingFiltered(nil) +} + +func (s *Store) NodesForEmbeddingFiltered(categories []string) []model.Node { s.mu.RLock() defer s.mu.RUnlock() out := []model.Node{} @@ -168,6 +172,9 @@ func (s *Store) NodesForEmbedding() []model.Node { if n.Kind != "knowledge" && n.Kind != "ai-think" && n.Kind != "external" { continue } + if !nodeMatchesCategories(n, categories) { + continue + } if _, ok := s.vectors[n.ID]; !ok { out = append(out, n) } @@ -332,6 +339,10 @@ func (s *Store) Similar(query []float64, limit int) []model.Hit { // selection responsive even for tens of thousands of knowledge nodes while the // rotating cursor eventually visits the complete corpus. func (s *Store) NextPair(min float64, anchorLimit int) (model.Node, model.Node, float64, bool, int) { + return s.NextPairFiltered(min, anchorLimit, nil) +} + +func (s *Store) NextPairFiltered(min float64, anchorLimit int, categories []string) (model.Node, model.Node, float64, bool, int) { s.mu.Lock() defer s.mu.Unlock() @@ -340,6 +351,9 @@ func (s *Store) NextPair(min float64, anchorLimit int) (model.Node, model.Node, if n.Kind != "knowledge" && n.Kind != "ai-think" { continue } + if !nodeMatchesCategories(n, categories) { + continue + } if v, ok := s.vectors[n.ID]; ok && len(v) > 0 { nodes = append(nodes, n) } @@ -439,6 +453,35 @@ func (s *Store) ConnectingEdges(ids []string) []string { } return out } +func nodeMatchesCategories(n model.Node, filters []string) bool { + if len(filters) == 0 { + return true + } + wanted := make(map[string]struct{}, len(filters)) + for _, filter := range filters { + filter = strings.ToLower(strings.TrimSpace(filter)) + if filter != "" { + wanted[filter] = struct{}{} + } + } + if len(wanted) == 0 { + return true + } + if _, ok := wanted["*"]; ok { + return true + } + if len(n.Categories) == 0 { + _, ok := wanted["__uncategorized__"] + return ok + } + for _, category := range n.Categories { + if _, ok := wanted[strings.ToLower(strings.TrimSpace(category))]; ok { + return true + } + } + return false +} + func edgeBetweenLocked(edges map[string]model.Edge, a, b string) bool { for _, e := range edges { if (e.Source == a && e.Target == b) || (e.Source == b && e.Target == a) { diff --git a/internal/graph/store_test.go b/internal/graph/store_test.go index 7cbf609..082b175 100644 --- a/internal/graph/store_test.go +++ b/internal/graph/store_test.go @@ -69,3 +69,34 @@ func TestNextPairUsesBoundedRotatingAnchors(t *testing.T) { t.Fatalf("rotating anchor did not reach the next semantic region, ok=%v comparisons=%d", ok, comparisons) } } + +func TestCategoryFiltersLimitEmbeddingAndThinkingCandidates(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + nodes := []model.Node{ + {ID: "net-a", Kind: "knowledge", Label: "Net A", Categories: []string{"Netzwerk"}, Origin: "test"}, + {ID: "net-b", Kind: "knowledge", Label: "Net B", Categories: []string{"Netzwerk"}, Origin: "test"}, + {ID: "app-a", Kind: "knowledge", Label: "App A", Categories: []string{"Applikation"}, Origin: "test"}, + {ID: "none", Kind: "knowledge", Label: "Ohne", Origin: "test"}, + } + for _, node := range nodes { + s.UpsertNode(node) + } + pending := s.NodesForEmbeddingFiltered([]string{"Netzwerk"}) + if len(pending) != 2 { + t.Fatalf("expected two network embeddings, got %d", len(pending)) + } + uncategorized := s.NodesForEmbeddingFiltered([]string{"__uncategorized__"}) + if len(uncategorized) != 1 || uncategorized[0].ID != "none" { + t.Fatalf("unexpected uncategorized nodes: %#v", uncategorized) + } + for _, node := range nodes { + s.SetVector(node.ID, []float64{1, .01}) + } + a, b, _, ok, _ := s.NextPairFiltered(.5, 8, []string{"Netzwerk"}) + if !ok || a.Categories[0] != "Netzwerk" || b.Categories[0] != "Netzwerk" { + t.Fatalf("thinking filter returned wrong pair: ok=%v a=%+v b=%+v", ok, a, b) + } +} diff --git a/internal/ingest/glpikb.go b/internal/ingest/glpikb.go index 3e60c6b..e17846f 100644 --- a/internal/ingest/glpikb.go +++ b/internal/ingest/glpikb.go @@ -29,6 +29,7 @@ type GLPIKBConfig struct { SyncInterval time.Duration Source string CachePath string + ShouldSync func() bool } type GLPIKBStatus struct { @@ -81,7 +82,9 @@ func (s *GLPIKBSyncer) Start(ctx context.Context) { } _ = s.LoadCache() go func() { - s.runSync(ctx, "startup") + if s.syncAllowed() { + s.runSync(ctx, "startup") + } ticker := time.NewTicker(s.cfg.SyncInterval) defer ticker.Stop() for { @@ -90,12 +93,18 @@ func (s *GLPIKBSyncer) Start(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - s.runSync(ctx, "interval") + if s.syncAllowed() { + s.runSync(ctx, "interval") + } } } }() } +func (s *GLPIKBSyncer) syncAllowed() bool { + return s.cfg.ShouldSync == nil || s.cfg.ShouldSync() +} + func (s *GLPIKBSyncer) runSync(ctx context.Context, trigger string) { timeout := s.cfg.SyncInterval / 2 if timeout < 30*time.Second { diff --git a/internal/web/server.go b/internal/web/server.go index d424aea..c8f9baf 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -31,6 +31,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/status", s.handleStatus) mux.HandleFunc("GET /api/graph", s.handleGraph) mux.HandleFunc("GET /api/analysis", s.handleAnalysis) + mux.HandleFunc("GET /api/runtime-settings", s.handleGetRuntimeSettings) + mux.HandleFunc("PUT /api/runtime-settings", s.handleSetRuntimeSettings) + mux.HandleFunc("GET /api/categories", s.handleCategories) mux.HandleFunc("GET /api/stream", s.Broker.ServeSSE) mux.HandleFunc("POST /api/query", s.handleQuery) mux.HandleFunc("POST /api/events", s.handleEvent) @@ -60,6 +63,33 @@ func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAnalysis(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.Graph.Analyze()) } + +func (s *Server) handleGetRuntimeSettings(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.Engine.RuntimeSettings()) +} + +func (s *Server) handleSetRuntimeSettings(w http.ResponseWriter, r *http.Request) { + if !s.authorized(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + var settings engine.RuntimeSettings + if err := decode(r, &settings); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + updated, err := s.Engine.SetRuntimeSettings(settings) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"categories": s.Engine.Categories()}) +} + func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) { if !s.authorized(r) { writeJSON(w, 401, map[string]string{"error": "unauthorized"}) @@ -145,6 +175,10 @@ func (s *Server) handleEnrich(w http.ResponseWriter, r *http.Request) { return } if r.URL.Query().Get("async") == "1" { + if !s.Engine.ThinkingEnabled() { + writeJSON(w, http.StatusConflict, map[string]any{"error": "AI-THINK is disabled by runtime settings", "status": s.Engine.Status()}) + return + } if !s.Engine.RequestEnrich("manual") { writeJSON(w, http.StatusConflict, map[string]any{"error": "AI-THINK is already running or queued", "status": s.Engine.Status()}) return diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..6779da5 --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/local/glpi-neural-brain/internal/activity" + "github.com/local/glpi-neural-brain/internal/config" + "github.com/local/glpi-neural-brain/internal/engine" + "github.com/local/glpi-neural-brain/internal/graph" + "github.com/local/glpi-neural-brain/internal/model" +) + +func TestRuntimeSettingsAndCategoriesAPI(t *testing.T) { + data := t.TempDir() + g, err := graph.Open(data) + if err != nil { + t.Fatal(err) + } + g.UpsertNode(model.Node{ID: "n1", Kind: "knowledge", Label: "GLPI", Origin: "test", Categories: []string{"GLPI"}}) + g.UpsertNode(model.Node{ID: "n2", Kind: "knowledge", Label: "Ollama", Origin: "test", Categories: []string{"Ollama"}}) + g.UpsertNode(model.Node{ID: "n3", Kind: "knowledge", Label: "Ohne Kategorie", Origin: "test"}) + + broker := activity.New(20) + eng := engine.New(config.Config{ + DataDir: data, + PersistInterval: time.Minute, + RuntimeDefaultsConfigured: true, + LearningEnabled: true, + ThinkingEnabled: true, + DefaultView: "neural", + }, g, broker) + h := (&Server{Engine: eng, Graph: g, Broker: broker}).Handler() + + body := `{"learning_enabled":false,"thinking_enabled":false,"learning_categories":["GLPI"],"display_categories":["Ollama"],"thinking_categories":["GLPI"],"view_mode":"honeycomb"}` + req := httptest.NewRequest(http.MethodPut, "/api/runtime-settings", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + h.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("PUT runtime settings returned %d: %s", res.Code, res.Body.String()) + } + var settings engine.RuntimeSettings + if err := json.NewDecoder(res.Body).Decode(&settings); err != nil { + t.Fatal(err) + } + if settings.LearningEnabled || settings.ThinkingEnabled || settings.ViewMode != "honeycomb" { + t.Fatalf("unexpected settings: %+v", settings) + } + + res = httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/api/categories", nil)) + if res.Code != http.StatusOK { + t.Fatalf("GET categories returned %d", res.Code) + } + var categories struct { + Categories []engine.CategoryInfo `json:"categories"` + } + if err := json.NewDecoder(res.Body).Decode(&categories); err != nil { + t.Fatal(err) + } + foundUncategorized := false + for _, category := range categories.Categories { + if category.Name == "__uncategorized__" && category.Count == 1 { + foundUncategorized = true + } + } + if !foundUncategorized { + t.Fatalf("uncategorized virtual category missing: %+v", categories.Categories) + } + + res = httptest.NewRecorder() + h.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/api/enrich?async=1", strings.NewReader(`{}`))) + if res.Code != http.StatusConflict { + t.Fatalf("disabled thinking should return 409, got %d: %s", res.Code, res.Body.String()) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index d847846..0c42eea 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -30,3 +30,18 @@ html,body{margin:0;width:100%;height:100%;overflow:hidden;background:radial-grad .autonomy-status{display:flex;align-items:center;gap:8px;margin:-1px 0 10px;padding:8px 9px;border:1px solid rgba(82,231,255,.12);border-radius:10px;background:rgba(82,231,255,.035);color:#8fa9ba;font-size:10px;line-height:1.35}.autonomy-status i{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:var(--cyan);box-shadow:0 0 10px var(--cyan)}.autonomy-status.running{border-color:rgba(255,180,82,.28);background:rgba(255,180,82,.06);color:#ffd9a7}.autonomy-status.running i{background:var(--amber);box-shadow:0 0 12px var(--amber);animation:pulse .72s infinite}.autonomy-status.offline{border-color:rgba(255,95,136,.22);background:rgba(255,95,136,.05);color:#ffb0c5}.autonomy-status.offline i{background:var(--red);box-shadow:0 0 10px var(--red)}.autonomy-status.waiting{color:#a8bdca}.autonomy-status.waiting i{background:var(--blue);box-shadow:0 0 10px var(--blue)} .dock .think-action{display:flex;align-items:center;gap:7px;border-color:rgba(255,180,82,.22);background:rgba(255,180,82,.07);color:var(--amber)}.dock .think-action small{font-size:8px;letter-spacing:.11em;opacity:.68}.dock .think-action:hover{background:rgba(255,180,82,.14);box-shadow:0 0 22px rgba(255,180,82,.08)}.dock .think-action.running{border-color:rgba(255,180,82,.48);background:rgba(255,180,82,.13);box-shadow:0 0 24px rgba(255,180,82,.11)}.dock .think-action:disabled{cursor:wait;opacity:.72} .metrics span[title]{cursor:help}.dock #toggleLOD.active{color:var(--green);border-color:rgba(93,255,189,.22);background:rgba(93,255,189,.065)} + +/* Laufzeitsteuerung, Kategorie-Filter und Honeycomb-Ansicht */ +.dock{max-width:calc(100vw - 390px);overflow-x:auto;scrollbar-width:none}.dock::-webkit-scrollbar{display:none}.dock-separator{width:1px;min-width:1px;background:var(--line);margin:5px 2px}.dock button:disabled{opacity:.32;cursor:not-allowed}.dock #toggleLearning.active{color:var(--blue);border-color:rgba(75,123,255,.25);background:rgba(75,123,255,.08)}.dock #toggleThinking.active{color:var(--amber);border-color:rgba(255,180,82,.25);background:rgba(255,180,82,.08)}.dock #viewHoneycomb.active,.dock #viewNeural.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)}.dock #openSettings{color:#a7bdcc} +body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:after{content:" · Honeycomb"} +.settings-backdrop{position:fixed;inset:0;z-index:20;background:rgba(0,3,9,.55);backdrop-filter:blur(3px)} +.settings-panel{position:fixed;z-index:21;right:18px;top:18px;bottom:18px;width:min(460px,calc(100vw - 36px));border-radius:20px;padding:16px;display:flex;flex-direction:column;overflow:hidden} +.settings-header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);padding:2px 2px 13px}.settings-header strong{display:block;font-size:12px;letter-spacing:.17em}.settings-header small{display:block;margin-top:4px;color:var(--muted);font-size:10px}.settings-header button{border:0;background:transparent;color:var(--muted);font-size:24px;cursor:pointer;padding:0 5px} +.settings-section{padding:14px 2px;border-bottom:1px solid var(--line)}.settings-section h2{font-size:10px;letter-spacing:.17em;text-transform:uppercase;color:#9eb5c6;margin:0 0 10px}.settings-section-title{display:flex;justify-content:space-between;align-items:center}.settings-section-title span{font-size:9px;color:#668094} +.switch-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:9px 10px;margin:7px 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;background:rgba(255,255,255,.022);cursor:pointer}.switch-row b{display:block;font-size:11px}.switch-row small{display:block;margin-top:3px;color:#718b9e;font-size:9px;line-height:1.35}.switch-row input{appearance:none;width:38px;height:21px;border-radius:999px;background:rgba(119,146,168,.25);position:relative;cursor:pointer;flex:0 0 auto;outline:1px solid rgba(255,255,255,.06)}.switch-row input:after{content:"";position:absolute;width:15px;height:15px;left:3px;top:3px;border-radius:50%;background:#8196a7;transition:transform .2s,background .2s,box-shadow .2s}.switch-row input:checked{background:rgba(82,231,255,.19)}.switch-row input:checked:after{transform:translateX(17px);background:var(--cyan);box-shadow:0 0 10px rgba(82,231,255,.7)} +.view-selector{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px}.view-selector button{border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.025);color:#738da1;border-radius:10px;padding:9px;font-size:10px;font-weight:700;letter-spacing:.1em;cursor:pointer}.view-selector button.active{color:var(--green);border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.07)} +.category-settings{flex:1;min-height:0;overflow:auto;padding-right:5px}.filter-search{display:block;margin-bottom:12px}.filter-search span{display:block;font-size:9px;color:#6d879a;margin-bottom:5px}.filter-search input{width:100%;border:1px solid rgba(133,200,255,.14);background:rgba(2,8,17,.6);color:var(--text);border-radius:10px;padding:9px 10px;outline:0}.filter-search input:focus{border-color:rgba(82,231,255,.38);box-shadow:0 0 0 3px rgba(82,231,255,.06)} +.filter-block{margin:12px 0 16px}.filter-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:7px}.filter-heading b{display:block;font-size:10px;color:#c8d9e4}.filter-heading small{display:block;margin-top:2px;color:#607b90;font-size:9px}.filter-heading button{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.04);color:#87a8bc;border-radius:8px;padding:5px 8px;font-size:8px;letter-spacing:.08em;cursor:pointer}.category-list{display:flex;flex-wrap:wrap;gap:5px;max-height:128px;overflow:auto;padding:1px}.category-option{position:relative}.category-option input{position:absolute;opacity:0;pointer-events:none}.category-option span{display:block;border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.022);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:9px;cursor:pointer;white-space:nowrap}.category-option input:checked+span{color:#dffaff;border-color:rgba(82,231,255,.32);background:rgba(82,231,255,.11);box-shadow:0 0 12px rgba(82,231,255,.05)}.category-option em{font-style:normal;opacity:.58;margin-left:4px} +.settings-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:13px}.settings-footer span{font-size:9px;color:#7f9aac;line-height:1.35}.settings-footer button{border:1px solid rgba(82,231,255,.3);background:rgba(82,231,255,.1);color:var(--cyan);border-radius:11px;padding:10px 13px;font-size:9px;font-weight:800;letter-spacing:.13em;cursor:pointer}.settings-footer button:disabled{opacity:.5;cursor:wait} +@media(max-width:1100px){.dock{max-width:calc(100vw - 40px)}.settings-panel{right:10px;top:10px;bottom:10px}} +@media(max-width:780px){.settings-panel{left:10px;width:auto}.dock{max-width:calc(100vw - 20px)}.dock-separator{display:none}} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index d0944d6..5a33a62 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -40,7 +40,10 @@ lodEnabled: true, lodLeaves: [], lodCoarse: [], lodGroupById: new Map(), lodLeafByNode: new Map(), lodCoarseByNode: new Map(), lodOpenUntil: new Map(), lodHotUntil: new Map(), lodDirty: true, lodLastBuild: 0, lodNextExpiry: 0, lodZoomBand: 2, renderNodes: [], renderEdges: [], renderIdleEdges: [], renderNodeById: new Map(), renderEdgeById: new Map(), visibleForNode: new Map(), - edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0} + edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0}, + fullSnapshot: null, runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], view_mode: 'neural'}, + availableCategories: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, settingsOpen: false, settingsDraft: null, + graphVersion: null, displaySignature: '' }; function resize() { @@ -63,13 +66,147 @@ return data; } + function categoryFilterMatches(node, categories) { + if (!categories || categories.length === 0) return true; + const wanted = new Set(categories.map(value => String(value).trim().toLowerCase()).filter(Boolean)); + if (wanted.has('*')) return true; + const nodeCategories = (node.categories || []).map(value => String(value).trim().toLowerCase()).filter(Boolean); + if (nodeCategories.length === 0) return wanted.has('__uncategorized__'); + return nodeCategories.some(category => wanted.has(category)); + } + + function filteredSnapshot(snapshot) { + const filters = state.runtimeSettings.display_categories || []; + if (!filters.length) return snapshot; + const visible = new Set(); + const noteKinds = new Set(['knowledge', 'ai-think', 'external']); + for (const node of snapshot.nodes) { + if (noteKinds.has(node.kind) && categoryFilterMatches(node, filters)) visible.add(node.id); + if (node.kind === 'category' && filters.some(value => String(value).toLowerCase() === String(node.label || '').toLowerCase())) visible.add(node.id); + } + for (const edge of snapshot.edges) { + if (visible.has(edge.source)) visible.add(edge.target); + if (visible.has(edge.target)) visible.add(edge.source); + } + return { + ...snapshot, + nodes: snapshot.nodes.filter(node => visible.has(node.id)), + edges: snapshot.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target)) + }; + } + + async function loadRuntimeConfiguration() { + try { + const [settings, categories] = await Promise.all([api('/api/runtime-settings'), api('/api/categories')]); + state.runtimeSettings = {...state.runtimeSettings, ...settings}; + state.availableCategories = categories.categories || []; + state.viewMode = state.runtimeSettings.view_mode === 'honeycomb' ? 'honeycomb' : 'neural'; + syncRuntimeControls(); + renderCategoryFilters(); + } catch { + syncRuntimeControls(); + } + } + + function syncRuntimeControls() { + const learning = Boolean(state.runtimeSettings.learning_enabled); + const thinking = Boolean(state.runtimeSettings.thinking_enabled); + const panelSettings = state.settingsOpen && state.settingsDraft ? state.settingsDraft : state.runtimeSettings; + const learningButton = $('toggleLearning'); + const thinkingButton = $('toggleThinking'); + if (learningButton) learningButton.classList.toggle('active', learning); + if (thinkingButton) thinkingButton.classList.toggle('active', thinking); + if ($('settingsLearning')) $('settingsLearning').checked = Boolean(panelSettings.learning_enabled); + if ($('settingsThinking')) $('settingsThinking').checked = Boolean(panelSettings.thinking_enabled); + if ($('settingsViewNeural')) $('settingsViewNeural').classList.toggle('active', panelSettings.view_mode !== 'honeycomb'); + if ($('settingsViewHoneycomb')) $('settingsViewHoneycomb').classList.toggle('active', panelSettings.view_mode === 'honeycomb'); + const enrichButton = $('enrichNow'); + if (enrichButton && !state.brainStatus?.enrich_running) enrichButton.disabled = !thinking; + if (!learning && !thinking) setVisualMode('living'); + updateViewButtons(); + } + + function categoryLabel(name) { + return name === '__uncategorized__' ? 'Ohne Kategorie' : name; + } + + function filterSet(key) { + const source = state.settingsDraft || state.runtimeSettings; + return new Set((source[`${key}_categories`] || []).map(value => String(value).toLowerCase())); + } + + function renderCategoryFilters(search = '') { + const query = String(search || '').trim().toLowerCase(); + const targets = {learning: $('learningCategoryList'), display: $('displayCategoryList'), thinking: $('thinkingCategoryList')}; + for (const [key, target] of Object.entries(targets)) { + if (!target) continue; + const selected = filterSet(key); + target.innerHTML = ''; + const categories = state.availableCategories.filter(category => !query || categoryLabel(category.name).toLowerCase().includes(query)); + for (const category of categories) { + const label = document.createElement('label'); + label.className = 'category-option'; + const checked = selected.has(String(category.name).toLowerCase()); + label.innerHTML = `${escapeHTML(categoryLabel(category.name))}${Number(category.count || 0).toLocaleString('de-DE')}`; + target.appendChild(label); + } + if (!categories.length) target.innerHTML = 'Keine passende Kategorie'; + } + } + + function collectCategoryFilter(key) { + return [...document.querySelectorAll(`[data-category-filter="${key}"]:checked`)].map(input => input.value); + } + + async function persistRuntimeSettings(settings = state.runtimeSettings) { + const normalized = { + learning_enabled: Boolean(settings.learning_enabled), + thinking_enabled: Boolean(settings.thinking_enabled), + learning_categories: [...(settings.learning_categories || [])], + display_categories: [...(settings.display_categories || [])], + thinking_categories: [...(settings.thinking_categories || [])], + view_mode: settings.view_mode === 'honeycomb' ? 'honeycomb' : 'neural' + }; + const previousDisplay = JSON.stringify(state.runtimeSettings.display_categories || []); + const updated = await api('/api/runtime-settings', {method: 'PUT', body: JSON.stringify(normalized)}); + state.runtimeSettings = {...normalized, ...updated}; + state.viewMode = state.runtimeSettings.view_mode; + syncRuntimeControls(); + applyViewMode(state.viewMode, false); + if (previousDisplay !== JSON.stringify(state.runtimeSettings.display_categories || [])) await loadGraph(); + await loadStatus(); + return state.runtimeSettings; + } + + function openSettingsPanel() { + state.settingsOpen = true; + state.settingsDraft = JSON.parse(JSON.stringify(state.runtimeSettings)); + $('settingsPanel')?.classList.remove('hidden'); + $('settingsBackdrop')?.classList.remove('hidden'); + syncRuntimeControls(); + renderCategoryFilters($('categorySearch')?.value || ''); + } + + function closeSettingsPanel() { + state.settingsOpen = false; + state.settingsDraft = null; + $('settingsPanel')?.classList.add('hidden'); + $('settingsBackdrop')?.classList.add('hidden'); + } + async function loadGraph() { try { const snap = await api('/api/graph'); + const displaySignature = JSON.stringify(state.runtimeSettings.display_categories || []); + if (state.fullSnapshot && state.graphVersion === snap.version && state.displaySignature === displaySignature) return; + state.graphVersion = snap.version; + state.displaySignature = displaySignature; + state.fullSnapshot = snap; + const filtered = filteredSnapshot(snap); const old = state.nodeById; const oldClusters = state.clusterByKey; - state.nodes = snap.nodes.map(n => ({...n, glow: old.get(n.id)?.glow || 0, screen: null, clusterKey: '', clusterColor: old.get(n.id)?.clusterColor || ''})); - state.edges = snap.edges; + state.nodes = filtered.nodes.map(n => ({...n, glow: old.get(n.id)?.glow || 0, screen: null, clusterKey: '', clusterColor: old.get(n.id)?.clusterColor || ''})); + state.edges = filtered.edges; state.nodeById = new Map(state.nodes.map(n => [n.id, n])); state.edgeById = new Map(state.edges.map(e => [e.id, e])); state.adjacency = new Map(); @@ -80,9 +217,16 @@ state.adjacency.get(e.target).push(e); } buildLayout(old, oldClusters); + for (const node of state.nodes) { + node.neuralX = node.x; + node.neuralY = node.y; + node.neuralZ = node.z; + } + buildHoneycombLayout(); buildLODHierarchy(); - rebuildRenderGraph(performance.now(), true); + applyViewMode(state.runtimeSettings.view_mode || state.viewMode, false); $('nodeCount').textContent = state.nodes.length.toLocaleString('de-DE'); + $('nodeCount').parentElement.title = `${state.nodes.length.toLocaleString('de-DE')} sichtbar · ${snap.nodes.length.toLocaleString('de-DE')} insgesamt`; $('edgeCount').textContent = state.edges.length.toLocaleString('de-DE'); } catch { setSystem('offline', false); @@ -98,6 +242,10 @@ try { const status = await api('/api/status'); state.brainStatus = status; + if (status.runtime_settings) { + state.runtimeSettings = {...state.runtimeSettings, ...status.runtime_settings}; + syncRuntimeControls(); + } renderAutonomyStatus(status); } catch { renderAutonomyStatus({ollama_ok: false, auto_enrich: false, enrich_error: 'Status nicht erreichbar'}); @@ -108,11 +256,17 @@ const panel = $('autonomyStatus'); const button = $('enrichNow'); if (!panel || !button) return; + const runtime = status.runtime_settings || state.runtimeSettings; + const thinkingEnabled = runtime.thinking_enabled !== false; + const learningEnabled = runtime.learning_enabled !== false; const queued = status.enrich_result === 'queued' && !status.enrich_running; const running = Boolean(status.enrich_running || queued); let text = ''; let cls = 'waiting'; - if (status.enrich_running) { + if (!thinkingEnabled) { + text = `Living-only · Thinking pausiert${learningEnabled ? '' : ' · Learning pausiert'}.`; + cls = 'waiting'; + } else if (status.enrich_running) { text = `AI-THINK läuft · ${status.enrich_trigger === 'manual' ? 'manuell' : 'automatisch'} · Batch ${status.enrich_batch_size || 1}`; cls = 'running'; } else if (queued) { @@ -140,10 +294,10 @@ panel.title = diagnostics.join(' · '); const span = panel.querySelector('span'); if (span) span.textContent = text; - button.disabled = running; + button.disabled = running || !thinkingEnabled; button.classList.toggle('running', running); const small = button.querySelector('small'); - if (small) small.textContent = status.enrich_running ? 'LÄUFT' : queued ? 'WARTET' : 'STARTEN'; + if (small) small.textContent = !thinkingEnabled ? 'PAUSIERT' : status.enrich_running ? 'LÄUFT' : queued ? 'WARTET' : 'STARTEN'; } function nextRunText(value) { @@ -256,6 +410,112 @@ return out; } + function honeycombPointCount(spacing, stopAt = Infinity, collect = false) { + const points = collect ? [] : null; + let count = 0; + const yStep = spacing * Math.sqrt(3) / 2; + const zStep = spacing * Math.sqrt(2 / 3); + const kMin = Math.floor(-0.72 / zStep) - 1; + const kMax = Math.ceil(0.72 / zStep) + 1; + const rMin = Math.floor(-0.9 / yStep) - 1; + const rMax = Math.ceil(0.9 / yStep) + 1; + for (let k = kMin; k <= kMax; k++) { + const layer = Math.abs(k) % 2; + const z = k * zStep; + const layerX = layer * spacing * 0.5; + const layerY = layer * yStep / 3; + for (let r = rMin; r <= rMax; r++) { + const y = r * yStep + layerY; + const rowX = (Math.abs(r) % 2) * spacing * 0.5; + const qMin = Math.floor(-1.02 / spacing) - 1; + const qMax = Math.ceil(1.02 / spacing) + 1; + for (let q = qMin; q <= qMax; q++) { + const x = q * spacing + rowX + layerX; + if (!insideBrain(x, y, z)) continue; + count++; + if (collect) points.push({x, y, z}); + if (count >= stopAt) return collect ? points : count; + } + } + } + return collect ? points : count; + } + + function buildHoneycombLayout() { + const noteKinds = new Set(['knowledge', 'ai-think', 'external']); + const notes = state.nodes.filter(node => noteKinds.has(node.kind)); + state.honeycombNodes = notes; + if (!notes.length) { + state.honeycombSpacing = 0; + return; + } + let low = 0.008; + let high = 0.32; + while (honeycombPointCount(low, notes.length) < notes.length && low > 0.0025) low *= 0.75; + for (let i = 0; i < 12; i++) { + const mid = (low + high) / 2; + const count = honeycombPointCount(mid, notes.length); + if (count >= notes.length) low = mid; else high = mid; + } + const spacing = Math.max(0.0025, low * 0.985); + let points = honeycombPointCount(spacing, Infinity, true); + if (points.length < notes.length) points = honeycombPointCount(Math.max(0.0025, spacing * 0.96), Infinity, true); + points.sort((a, b) => hashString(`${a.x.toFixed(5)}:${a.y.toFixed(5)}:${a.z.toFixed(5)}`) - hashString(`${b.x.toFixed(5)}:${b.y.toFixed(5)}:${b.z.toFixed(5)}`)); + notes.sort((a, b) => hashString(a.id) - hashString(b.id)); + const step = points.length / notes.length; + for (let i = 0; i < notes.length; i++) { + const point = points[Math.min(points.length - 1, Math.floor(i * step))]; + notes[i].honeyX = point.x; + notes[i].honeyY = point.y; + notes[i].honeyZ = point.z; + } + state.honeycombSpacing = spacing; + } + + function updateViewButtons() { + const neural = $('viewNeural'); + const honey = $('viewHoneycomb'); + if (neural) neural.classList.toggle('active', state.viewMode === 'neural'); + if (honey) honey.classList.toggle('active', state.viewMode === 'honeycomb'); + document.body.classList.toggle('honeycomb-view', state.viewMode === 'honeycomb'); + const edgeButton = $('toggleEdges'); + const cortexButton = $('toggleCortex'); + const lodButton = $('toggleLOD'); + for (const button of [edgeButton, cortexButton, lodButton]) { + if (button) button.disabled = state.viewMode === 'honeycomb'; + } + } + + function applyViewMode(mode, persist = true) { + mode = mode === 'honeycomb' ? 'honeycomb' : 'neural'; + state.viewMode = mode; + state.runtimeSettings.view_mode = mode; + state.hover = null; + state.selected = null; + state.particles.length = 0; + state.edgeRenderMap.clear(); + for (const node of state.nodes) { + node.transitionFrom = null; + node.transitionStart = 0; + } + if (mode === 'honeycomb') { + state.renderNodes = state.honeycombNodes; + state.renderEdges = []; + state.renderIdleEdges = []; + state.renderNodeById = new Map(state.honeycombNodes.map(node => [node.id, node])); + state.renderEdgeById = new Map(); + state.visibleForNode = new Map(state.honeycombNodes.map(node => [node.id, node.id])); + state.renderStats = {nodes: state.honeycombNodes.length, edges: 0, hiddenNodes: Math.max(0, state.nodes.length - state.honeycombNodes.length), hiddenEdges: state.edges.length}; + const renderCount = $('renderCount'); + if (renderCount) renderCount.textContent = state.honeycombNodes.length.toLocaleString('de-DE'); + } else { + state.lodDirty = true; + rebuildRenderGraph(performance.now(), true); + } + updateViewButtons(); + if (persist) persistRuntimeSettings().catch(() => {}); + } + function buildLayout(previousNodes = new Map(), previousClusters = new Map()) { const degree = new Map(state.nodes.map(n => [n.id, (state.adjacency.get(n.id) || []).length])); const labels = new Map(); @@ -731,6 +991,9 @@ } function setVisualMode(mode, evt = null, strength = 1) { + if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) mode = 'living'; + if (mode === 'learning' && !state.runtimeSettings.learning_enabled) mode = 'living'; + if ((mode === 'thinking' || mode === 'researching') && !state.runtimeSettings.thinking_enabled) mode = 'living'; const cfg = MODE_CONFIG[mode] || MODE_CONFIG.living; const now = performance.now(); state.mode = mode; @@ -760,9 +1023,12 @@ function focusCamera(node) { state.focusNodeID = node.id; state.focusClusterKey = node.clusterKey || ''; - const radial = Math.max(0.05, Math.hypot(node.x, node.z)); - state.cameraTargetYaw = nearestAngle(state.yaw, Math.atan2(node.x, node.z)); - state.cameraTargetPitch = Math.max(-0.62, Math.min(0.62, Math.atan2(node.y, radial))); + const x = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX) ? node.honeyX : node.x; + const y = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyY) ? node.honeyY : node.y; + const z = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyZ) ? node.honeyZ : node.z; + const radial = Math.max(0.05, Math.hypot(x, z)); + state.cameraTargetYaw = nearestAngle(state.yaw, Math.atan2(x, z)); + state.cameraTargetPitch = Math.max(-0.62, Math.min(0.62, Math.atan2(y, radial))); } function updateVisualState(now, dt) { @@ -792,6 +1058,7 @@ } function autonomousLivingPulse(now) { + if (state.viewMode === 'honeycomb') return; if (state.mode !== 'living' || now < state.nextAmbientAt || !state.clusters.length) return; const candidates = state.clusters.slice(0, Math.min(18, state.clusters.length)); const cluster = candidates[state.ambientCursor % candidates.length]; @@ -815,11 +1082,12 @@ } function eventMode(evt) { + if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) return 'living'; if (!evt || evt.type === 'brain.idle') return 'living'; - if (evt.type?.includes('research')) return 'researching'; - if (evt.type?.includes('think')) return 'thinking'; + if (evt.type?.includes('research')) return state.runtimeSettings.thinking_enabled ? 'researching' : 'living'; + if (evt.type?.includes('think')) return state.runtimeSettings.thinking_enabled ? 'thinking' : 'living'; if (evt.source === 'agent' || evt.source === 'knowledgebase' || evt.type?.includes('query')) return 'processing'; - if (evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type === 'embedding.batch') return 'learning'; + if (evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type === 'embedding.batch' || evt.type?.startsWith('glpi.kb')) return state.runtimeSettings.learning_enabled ? 'learning' : 'living'; return 'processing'; } @@ -828,8 +1096,11 @@ } function projectAnimatedNode(node, now) { - const cluster = node.cluster; - let x = node.x, y = node.y, z = node.z; + const honeycomb = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX); + const cluster = honeycomb ? null : node.cluster; + let x = honeycomb ? node.honeyX : node.x; + let y = honeycomb ? node.honeyY : node.y; + let z = honeycomb ? node.honeyZ : node.z; if (node.transitionFrom && node.transitionStart) { const t = Math.max(0, Math.min(1, (now - node.transitionStart) / 520)); const eased = t * t * (3 - 2 * t); @@ -911,6 +1182,7 @@ } function renderClusterClouds(now) { + if (state.viewMode === 'honeycomb') return; ctx.save(); ctx.globalCompositeOperation = 'screen'; for (const cluster of state.clusters) { @@ -944,6 +1216,7 @@ } function renderCortexLabels() { + if (state.viewMode === 'honeycomb') return; if (!state.cortexVisible || state.width < 850) return; const visible = state.clusters .filter(cluster => cluster.screen && (cluster.importance > 0.52 || cluster.key === state.focusClusterKey)) @@ -983,6 +1256,7 @@ } function renderEdges() { + if (state.viewMode === 'honeycomb') return; if (!state.edgesVisible) return; ctx.save(); ctx.globalCompositeOperation = 'screen'; @@ -1013,6 +1287,7 @@ } function renderParticles(dt) { + if (state.viewMode === 'honeycomb') return; ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (let i = state.particles.length - 1; i >= 0; i--) { @@ -1080,20 +1355,22 @@ ctx.save(); ctx.globalCompositeOperation = 'lighter'; for (const node of state.projected) { + const honeycomb = state.viewMode === 'honeycomb'; const isGroup = node.kind === 'supernode'; const act = state.renderActive.get(node.id) || (isGroup ? 0 : state.active.get(node.id) || 0); const hover = state.hover?.id === node.id; const selected = state.selected?.id === node.id; - const degree = Math.min(60, isGroup ? node.externalEdgeCount || 0 : (state.adjacency.get(node.id) || []).length); + const degree = honeycomb ? 0 : Math.min(60, isGroup ? node.externalEdgeCount || 0 : (state.adjacency.get(node.id) || []).length); const memberScale = isGroup ? Math.min(7.5, 1.1 + Math.log2((node.memberCount || 1) + 1) * 0.72) : 0; - const baseWeight = isGroup ? memberScale : Math.max(0.25, Math.min(2.5, (node.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05)); + const baseWeight = honeycomb ? 0.72 : isGroup ? memberScale : Math.max(0.25, Math.min(2.5, (node.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05)); const breathe = 0.5 + 0.5 * Math.sin(now * 0.0014 + node.x * 7 + node.y * 9); - const clusterEnergy = state.clusterActive.get(node.clusterKey) || 0; - const focused = node.clusterKey === state.focusClusterKey ? state.activityEnergy : 0; + const clusterEnergy = honeycomb ? 0 : state.clusterActive.get(node.clusterKey) || 0; + const focused = honeycomb ? 0 : node.clusterKey === state.focusClusterKey ? state.activityEnergy : 0; const r = (isGroup ? 2.4 + baseWeight : 1.05 + baseWeight * 0.7 + node.screen.p * 0.55) * (1 + act * 0.48 + clusterEnergy * 0.06 + focused * 0.025) + (hover || selected ? 1.6 : 0); const idleDim = state.mode === 'living' ? 0 : 0.025; - const alpha = Math.min(1, (isGroup ? 0.42 : 0.17) - idleDim + node.screen.p * 0.1 + Math.min(0.22, degree * 0.004) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0)); - if (act > 0.05 || hover || selected || node.kind === 'ai-think' || isGroup) { + const baseAlpha = honeycomb ? 0.23 : (isGroup ? 0.42 : 0.17); + const alpha = Math.min(1, baseAlpha - idleDim + node.screen.p * (honeycomb ? 0.035 : 0.1) + Math.min(0.22, degree * 0.004) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0)); + if (act > 0.05 || hover || selected || (!honeycomb && (node.kind === 'ai-think' || isGroup))) { const haloRadius = r * (isGroup ? 2.35 + act * 2.4 : 3 + act * 4); const halo = ctx.createRadialGradient(node.screen.x, node.screen.y, 0, node.screen.x, node.screen.y, haloRadius); halo.addColorStop(0, nodeColor(node, (isGroup ? 0.28 : 0.48) + act * 0.35)); @@ -1124,7 +1401,7 @@ ctx.fillText(node.memberCount > 999 ? `${Math.round(node.memberCount / 100) / 10}k` : String(node.memberCount), node.screen.x, node.screen.y + 0.5); ctx.restore(); } - } else if (node.kind === 'ai-think') { + } else if (!honeycomb && node.kind === 'ai-think') { ctx.strokeStyle = nodeColor(node, 0.55); ctx.lineWidth = 0.7; ctx.beginPath(); @@ -1141,7 +1418,7 @@ for (const node of state.projected) { const act = state.renderActive.get(node.id) || (node.kind === 'supernode' ? 0 : state.active.get(node.id) || 0); const isGroup = node.kind === 'supernode'; - if (!(act > 0.45 || state.hover?.id === node.id || state.selected?.id === node.id || (!isGroup && node.kind === 'ai-think' && node.screen.p > 1.02))) continue; + if (!(act > 0.45 || state.hover?.id === node.id || state.selected?.id === node.id || (state.viewMode !== 'honeycomb' && !isGroup && node.kind === 'ai-think' && node.screen.p > 1.02))) continue; const raw = isGroup ? `${node.label} · ${node.memberCount}` : node.label; const label = raw.length > 42 ? raw.slice(0, 40) + '…' : raw; const w = ctx.measureText(label).width + 12; @@ -1202,15 +1479,22 @@ const dt = Math.min(0.05, (now - state.last) / 1000); state.last = now; updateVisualState(now, dt); - updateLOD(now); + if (state.viewMode === 'neural') updateLOD(now); drawBackground(now); - renderActivityAura(now); - renderClusterClouds(now); - renderEdges(); - renderParticles(dt); + if (state.viewMode === 'neural') { + renderActivityAura(now); + renderClusterClouds(now); + renderEdges(); + renderParticles(dt); + } renderNodes(now, dt); - renderWaves(dt); - renderCortexLabels(); + if (state.viewMode === 'neural') { + renderWaves(dt); + renderCortexLabels(); + } else { + state.waves.length = 0; + state.bursts.length = 0; + } for (const [id, value] of state.edgeActive) { const next = value - dt * (state.mode === 'living' ? 0.34 : 0.5); if (next <= 0) state.edgeActive.delete(id); else state.edgeActive.set(id, next); @@ -1228,7 +1512,7 @@ const mode = eventMode(evt); const substep = evt.type === 'node.activated' || evt.type === 'edges.traversed'; if (evt.type !== 'brain.idle' && !substep) setVisualMode(mode, evt, strength); - if (evt.type !== 'brain.idle' && mode !== 'learning' && evt.node_ids?.length) { + if (state.viewMode === 'neural' && evt.type !== 'brain.idle' && mode !== 'learning' && evt.node_ids?.length) { const duration = mode === 'thinking' || mode === 'researching' ? 45000 : LOD_CONFIG.revealNodeMS; revealLODNodes(evt.node_ids, duration); rebuildRenderGraph(performance.now(), !substep); @@ -1245,28 +1529,32 @@ if (visibleID) state.renderActive.set(visibleID, Math.max(state.renderActive.get(visibleID) || 0, strength)); if (node?.clusterKey) state.clusterActive.set(node.clusterKey, Math.max(state.clusterActive.get(node.clusterKey) || 0, strength)); } - for (const id of evt.edge_ids || []) { - state.edgeActive.set(id, Math.max(state.edgeActive.get(id) || 0, strength)); - const renderID = state.edgeRenderMap.get(id); - if (renderID) state.renderEdgeActive.set(renderID, Math.max(state.renderEdgeActive.get(renderID) || 0, strength)); - const count = evt.type === 'brain.idle' ? 1 : Math.ceil(3 + strength * (mode === 'thinking' ? 7 : 5)); - for (let i = 0; i < count; i++) { - state.particles.push({edgeId: id, t: -i * 0.065, speed: 0.32 + Math.random() * (mode === 'thinking' ? 0.9 : 0.62), color: modeParticleColor(mode), size: mode === 'thinking' ? 1.12 : mode === 'researching' ? 0.95 : 0.86}); + if (state.viewMode === 'neural') { + for (const id of evt.edge_ids || []) { + state.edgeActive.set(id, Math.max(state.edgeActive.get(id) || 0, strength)); + const renderID = state.edgeRenderMap.get(id); + if (renderID) state.renderEdgeActive.set(renderID, Math.max(state.renderEdgeActive.get(renderID) || 0, strength)); + const count = evt.type === 'brain.idle' ? 1 : Math.ceil(3 + strength * (mode === 'thinking' ? 7 : 5)); + for (let i = 0; i < count; i++) { + state.particles.push({edgeId: id, t: -i * 0.065, speed: 0.32 + Math.random() * (mode === 'thinking' ? 0.9 : 0.62), color: modeParticleColor(mode), size: mode === 'thinking' ? 1.12 : mode === 'researching' ? 0.95 : 0.86}); + } } } if (evt.type !== 'brain.idle') { state.pulses++; $('pulseCount').textContent = state.pulses.toLocaleString('de-DE'); - const focus = evt.node_ids?.[0] ? state.nodeById.get(evt.node_ids[0]) : null; - const visibleID = focus ? state.visibleForNode.get(focus.id) : ''; - const visibleFocus = visibleID ? state.renderNodeById.get(visibleID) : null; - const x = visibleFocus?.screen?.x ?? focus?.screen?.x ?? state.width / 2; - const y = visibleFocus?.screen?.y ?? focus?.screen?.y ?? state.height / 2; - const waveCount = mode === 'thinking' ? 3 : mode === 'researching' ? 2 : 1; - for (let i = 0; i < waveCount; i++) { - state.waves.push({x, y, r: 18 + i * 17, life: 1 - i * 0.08, color: modeColor, speed: 118 + i * 35, width: mode === 'thinking' ? 1.8 : 1.25, alpha: mode === 'thinking' ? 0.3 : 0.24, dashed: mode === 'researching' && i === 1}); + if (state.viewMode === 'neural') { + const focus = evt.node_ids?.[0] ? state.nodeById.get(evt.node_ids[0]) : null; + const visibleID = focus ? state.visibleForNode.get(focus.id) : ''; + const visibleFocus = visibleID ? state.renderNodeById.get(visibleID) : null; + const x = visibleFocus?.screen?.x ?? focus?.screen?.x ?? state.width / 2; + const y = visibleFocus?.screen?.y ?? focus?.screen?.y ?? state.height / 2; + const waveCount = mode === 'thinking' ? 3 : mode === 'researching' ? 2 : 1; + for (let i = 0; i < waveCount; i++) { + state.waves.push({x, y, r: 18 + i * 17, life: 1 - i * 0.08, color: modeColor, speed: 118 + i * 35, width: mode === 'thinking' ? 1.8 : 1.25, alpha: mode === 'thinking' ? 0.3 : 0.24, dashed: mode === 'researching' && i === 1}); + } + if (mode === 'thinking' || mode === 'researching') state.bursts.push({x, y, life: 1, color: modeColor, rays: mode === 'thinking' ? 22 : 16, length: mode === 'thinking' ? 150 : 115, spin: pseudo(evt.id || evt.type, 9) * Math.PI}); } - if (mode === 'thinking' || mode === 'researching') state.bursts.push({x, y, life: 1, color: modeColor, rays: mode === 'thinking' ? 22 : 16, length: mode === 'thinking' ? 150 : 115, spin: pseudo(evt.id || evt.type, 9) * Math.PI}); } addLog(evt); if (evt.type === 'graph.updated') loadGraph(); @@ -1374,6 +1662,91 @@ stream.onerror = () => setSystem('verbindet', false); stream.onopen = () => setSystem('lebt', true); + $('viewNeural').addEventListener('click', () => applyViewMode('neural', true)); + $('viewHoneycomb').addEventListener('click', () => applyViewMode('honeycomb', true)); + + $('toggleLearning').addEventListener('click', async e => { + const button = e.currentTarget; + button.disabled = true; + state.runtimeSettings.learning_enabled = !state.runtimeSettings.learning_enabled; + try { + await persistRuntimeSettings(); + if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) setVisualMode('living'); + } catch (err) { + state.runtimeSettings.learning_enabled = !state.runtimeSettings.learning_enabled; + addLog({type: 'runtime.settings.failed', source: 'ui', phase: 'control', message: `Learning konnte nicht umgestellt werden: ${err.message}`, timestamp: new Date().toISOString()}); + } finally { + button.disabled = false; + syncRuntimeControls(); + } + }); + + $('toggleThinking').addEventListener('click', async e => { + const button = e.currentTarget; + button.disabled = true; + state.runtimeSettings.thinking_enabled = !state.runtimeSettings.thinking_enabled; + try { + await persistRuntimeSettings(); + if (!state.runtimeSettings.learning_enabled && !state.runtimeSettings.thinking_enabled) setVisualMode('living'); + } catch (err) { + state.runtimeSettings.thinking_enabled = !state.runtimeSettings.thinking_enabled; + addLog({type: 'runtime.settings.failed', source: 'ui', phase: 'control', message: `Thinking konnte nicht umgestellt werden: ${err.message}`, timestamp: new Date().toISOString()}); + } finally { + button.disabled = false; + syncRuntimeControls(); + } + }); + + $('openSettings').addEventListener('click', openSettingsPanel); + $('closeSettings').addEventListener('click', closeSettingsPanel); + $('settingsBackdrop').addEventListener('click', closeSettingsPanel); + $('categorySearch').addEventListener('input', e => renderCategoryFilters(e.currentTarget.value)); + $('settingsLearning').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.learning_enabled = e.currentTarget.checked; }); + $('settingsThinking').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.thinking_enabled = e.currentTarget.checked; }); + $('settingsViewNeural').addEventListener('click', () => { + if (state.settingsDraft) state.settingsDraft.view_mode = 'neural'; + $('settingsViewNeural').classList.add('active'); + $('settingsViewHoneycomb').classList.remove('active'); + }); + $('settingsViewHoneycomb').addEventListener('click', () => { + if (state.settingsDraft) state.settingsDraft.view_mode = 'honeycomb'; + $('settingsViewHoneycomb').classList.add('active'); + $('settingsViewNeural').classList.remove('active'); + }); + $('settingsPanel').addEventListener('change', e => { + const input = e.target.closest('[data-category-filter]'); + if (!input || !state.settingsDraft) return; + const key = input.dataset.categoryFilter; + const property = `${key}_categories`; + const selected = new Map((state.settingsDraft[property] || []).map(value => [String(value).toLowerCase(), value])); + const normalized = String(input.value).toLowerCase(); + if (input.checked) selected.set(normalized, input.value); else selected.delete(normalized); + state.settingsDraft[property] = [...selected.values()]; + }); + document.querySelectorAll('[data-clear-filter]').forEach(button => button.addEventListener('click', () => { + if (!state.settingsDraft) return; + state.settingsDraft[`${button.dataset.clearFilter}_categories`] = []; + renderCategoryFilters($('categorySearch').value); + })); + $('saveSettings').addEventListener('click', async e => { + if (!state.settingsDraft) return; + const button = e.currentTarget; + const feedback = $('settingsFeedback'); + button.disabled = true; + feedback.textContent = 'Einstellungen werden übernommen …'; + try { + state.settingsDraft.learning_enabled = $('settingsLearning').checked; + state.settingsDraft.thinking_enabled = $('settingsThinking').checked; + await persistRuntimeSettings(state.settingsDraft); + feedback.textContent = 'Aktiv · Speicherung erfolgt gebündelt mit dem nächsten Flush.'; + setTimeout(closeSettingsPanel, 550); + } catch (err) { + feedback.textContent = `Fehler: ${err.message}`; + } finally { + button.disabled = false; + } + }); + $('toggleLabels').addEventListener('click', e => { state.labels = !state.labels; e.currentTarget.classList.toggle('active', state.labels); @@ -1494,8 +1867,11 @@ return String(v ?? '').replace(/[&<>'"]/g, c => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[c])); } - loadGraph(); - loadStatus(); + (async () => { + await loadRuntimeConfiguration(); + await loadGraph(); + await loadStatus(); + })(); setInterval(loadGraph, 30000); setInterval(loadStatus, 3000); })(); diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 2b8d29b..eb16c67 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -25,7 +25,7 @@
0 Nodes 0 Edges - 0 Render + 0 Render 0 Impulse verbindet
@@ -39,18 +39,25 @@ -
Zeigt nur relevante Hirnaktivität, Anreicherungen, Recherchen und Suchläufe.
+
Zeigt relevante Hirnaktivität, Anreicherungen, Recherchen und Suchläufe.
AI-THINK-Status wird geladen …
@@ -62,6 +69,53 @@ Recherche + + + diff --git a/neural-brain b/neural-brain index 4cea4e6..8634285 100644 Binary files a/neural-brain and b/neural-brain differ