Honeycomb und Control-Patch
This commit is contained in:
@@ -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
|
||||
|
||||
19
CHANGELOG-RUNTIME-HONEYCOMB.md
Normal file
19
CHANGELOG-RUNTIME-HONEYCOMB.md
Normal file
@@ -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.
|
||||
30
README.md
30
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 |
|
||||
|
||||
88
RUNTIME-CONTROLS-HONEYCOMB.md
Normal file
88
RUNTIME-CONTROLS-HONEYCOMB.md
Normal file
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
216
internal/engine/runtime.go
Normal file
216
internal/engine/runtime.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
81
internal/web/server_test.go
Normal file
81
internal/web/server_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 = `<input type="checkbox" data-category-filter="${key}" value="${escapeHTML(category.name)}" ${checked ? 'checked' : ''}><span>${escapeHTML(categoryLabel(category.name))}<em>${Number(category.count || 0).toLocaleString('de-DE')}</em></span>`;
|
||||
target.appendChild(label);
|
||||
}
|
||||
if (!categories.length) target.innerHTML = '<span class="empty-filter">Keine passende Kategorie</span>';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="metrics">
|
||||
<span><b id="nodeCount">0</b> Nodes</span>
|
||||
<span><b id="edgeCount">0</b> Edges</span>
|
||||
<span title="Aktuell gerenderte LOD-Knoten"><b id="renderCount">0</b> Render</span>
|
||||
<span title="Aktuell gerenderte Knoten"><b id="renderCount">0</b> Render</span>
|
||||
<span><b id="pulseCount">0</b> Impulse</span>
|
||||
<span class="state" id="systemState"><i></i> verbindet</span>
|
||||
</div>
|
||||
@@ -39,18 +39,25 @@
|
||||
<button id="clearLog" title="Aktivitätsprotokoll leeren">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-subtitle">Zeigt nur relevante Hirnaktivität, Anreicherungen, Recherchen und Suchläufe.</div>
|
||||
<div class="panel-subtitle">Zeigt relevante Hirnaktivität, Anreicherungen, Recherchen und Suchläufe.</div>
|
||||
<div id="autonomyStatus" class="autonomy-status" aria-live="polite"><i></i><span>AI-THINK-Status wird geladen …</span></div>
|
||||
<div id="activityLog" class="activity-log"></div>
|
||||
</aside>
|
||||
|
||||
<nav class="dock glass" aria-label="Ansichtssteuerung">
|
||||
<button id="viewNeural" class="active" title="Semantischer Wissensgraph mit Edges">NEURAL</button>
|
||||
<button id="viewHoneycomb" title="Gleichmäßig verteilte 3D-Wabenansicht ohne Edges">HONEYCOMB</button>
|
||||
<span class="dock-separator"></span>
|
||||
<button id="toggleLearning" class="active" title="Lernen, Reindex und Embeddings aktivieren oder pausieren">LEARNING</button>
|
||||
<button id="toggleThinking" class="active" title="Autonomes AI-THINK aktivieren oder pausieren">THINKING</button>
|
||||
<span class="dock-separator"></span>
|
||||
<button id="toggleLabels" class="active">Labels</button>
|
||||
<button id="toggleEdges" class="active">Synapsen</button>
|
||||
<button id="toggleRotate" class="active">Autopilot</button>
|
||||
<button id="toggleCortex" class="active">Cortex</button>
|
||||
<button id="toggleLOD" class="active" title="Hierarchische dynamische Verdichtung">LOD</button>
|
||||
<button id="resetView">Zentrieren</button>
|
||||
<button id="openSettings" title="Kategorie-Filter und Laufzeitsteuerung">FILTER</button>
|
||||
<button id="enrichNow" class="think-action" title="Einen autonomen AI-THINK-Zyklus sofort starten"><span>AI-THINK</span><small>STARTEN</small></button>
|
||||
</nav>
|
||||
|
||||
@@ -62,6 +69,53 @@
|
||||
<span><i class="external"></i>Recherche</span>
|
||||
</div>
|
||||
|
||||
<div id="settingsBackdrop" class="settings-backdrop hidden"></div>
|
||||
<aside id="settingsPanel" class="settings-panel glass hidden" aria-label="Brain-Einstellungen">
|
||||
<div class="settings-header">
|
||||
<div><strong>BRAIN CONTROL</strong><small>Laufzeitmodi und Kategorie-Filter</small></div>
|
||||
<button id="closeSettings" title="Schließen">×</button>
|
||||
</div>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Betriebsmodi</h2>
|
||||
<label class="switch-row" for="settingsLearning">
|
||||
<span><b>Learning</b><small>Ingest, Reindex, GLPI-Sync und Embeddings</small></span>
|
||||
<input id="settingsLearning" type="checkbox" checked>
|
||||
</label>
|
||||
<label class="switch-row" for="settingsThinking">
|
||||
<span><b>Thinking</b><small>AI-THINK, Verknüpfungsanalyse und Recherche</small></span>
|
||||
<input id="settingsThinking" type="checkbox" checked>
|
||||
</label>
|
||||
<div class="view-selector" aria-label="Standardansicht">
|
||||
<button id="settingsViewNeural" class="active" type="button">Neural</button>
|
||||
<button id="settingsViewHoneycomb" type="button">Honeycomb</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section category-settings">
|
||||
<div class="settings-section-title"><h2>Kategorie-Filter</h2><span>Leer = alle Kategorien</span></div>
|
||||
<label class="filter-search"><span>Kategorien durchsuchen</span><input id="categorySearch" type="search" placeholder="z. B. GLPI, Netzwerk, Ollama"></label>
|
||||
|
||||
<div class="filter-block">
|
||||
<div class="filter-heading"><div><b>Lernen</b><small>Nur diese Kategorien werden neu eingebettet.</small></div><button type="button" data-clear-filter="learning">Alle</button></div>
|
||||
<div id="learningCategoryList" class="category-list"></div>
|
||||
</div>
|
||||
<div class="filter-block">
|
||||
<div class="filter-heading"><div><b>Anzeige</b><small>Nur passende Notes und deren Taxonomie werden gerendert.</small></div><button type="button" data-clear-filter="display">Alle</button></div>
|
||||
<div id="displayCategoryList" class="category-list"></div>
|
||||
</div>
|
||||
<div class="filter-block">
|
||||
<div class="filter-heading"><div><b>Thinking</b><small>Nur diese Kategorien werden für neue AI-Edges geprüft.</small></div><button type="button" data-clear-filter="thinking">Alle</button></div>
|
||||
<div id="thinkingCategoryList" class="category-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-footer">
|
||||
<span id="settingsFeedback" aria-live="polite"></span>
|
||||
<button id="saveSettings" type="button">ÜBERNEHMEN</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div id="tooltip" class="tooltip hidden"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
|
||||
BIN
neural-brain
BIN
neural-brain
Binary file not shown.
Reference in New Issue
Block a user