Update 5 - SearXNG
All checks were successful
release-tag / release-image (push) Successful in 1m38s

This commit is contained in:
2026-08-04 15:02:31 +02:00
parent 5387563e4e
commit 7e36c2f3f0
11 changed files with 496 additions and 32 deletions

View File

@@ -0,0 +1,9 @@
# SearXNG Research Visualization
- Eigene Events für empfangene und in den Graphen übernommene SearXNG-Ergebnisse ergänzt.
- Trefferzahl, Titel, Domains, URLs, Laufzeit und gemeinsame `research_id` in die Event-Metadaten aufgenommen.
- Recherchefehler werden auch für Relation Thinking sichtbar gemeldet.
- Externe Forschungs-Nodes lösen unmittelbar einen Graph-Reload im Browser aus.
- Persistente Scan-, Quellenflug- und Ingest-Animation ergänzt.
- Abschlussanimation überlebt Layout-, LOD-, Honeycomb- und Graph-Neuaufbau und läuft danach mindestens zwei Sekunden.
- Aktivitätsfeed um SearXNG-Treffer, Domains und Quelltitel erweitert.

View File

@@ -231,3 +231,7 @@ go test ./...
go vet ./...
node --check internal/web/static/app.js
```
## Sichtbare SearXNG-Recherche
SearXNG-Suchen werden als eigenständige Aktivität visualisiert. Scanringe markieren die laufende Suche, gefundene Quellen erscheinen als externe Quellenpunkte und fließen anschließend in die neu erzeugten Forschungs-Nodes. Die Animation bleibt bei der Aktualisierung des Graphen bestehen und läuft nach dem Einblenden der neuen Nodes mindestens zwei Sekunden weiter. Der Aktivitätsfeed zeigt Trefferzahl, Domains, Quelltitel und Übernahmestatus. Details stehen in `SEARXNG-VISUALIZATION.md`.

31
SEARXNG-VISUALIZATION.md Normal file
View File

@@ -0,0 +1,31 @@
# SearXNG-Visualisierung
Das Neural Brain meldet die Webrecherche jetzt in drei getrennten Schritten an das Frontend:
1. `research.started` / `article.research.started` die Quellensuche läuft.
2. `research.results` / `article.research.results` SearXNG hat Treffer geliefert, einschließlich Trefferzahl, Quelltiteln, Domains und Laufzeit.
3. `research.ingested` / `article.research.ingested` die Treffer wurden als Forschungs-Nodes in den In-Memory-Graphen aufgenommen und mit den internen Wissensquellen verknüpft.
Die drei Ereignisse tragen dieselbe `research_id`. Dadurch bleibt die Animation über den Graph-Reload hinweg erhalten.
## Animation
Während der Suche erscheinen grüne Scanringe und externe Quellenpunkte um das Gehirn. Nach Eingang der Treffer fließen Quellenpakete in die betroffenen Wissensregionen. Beim Erzeugen der neuen Forschungs-Nodes werden diese mit sechseckigen Markierungen hervorgehoben.
Die Abschlussanimation läuft nach dem Reload und der Einblendung der neuen Nodes mindestens zwei Sekunden weiter. Sie ist unabhängig von der normalen Partikel- und LOD-Liste und wird deshalb durch eine Neuerstellung des Rendergraphen nicht entfernt.
Die Animation wird sowohl in der Neural- als auch in der Honeycomb-Ansicht dargestellt. In Honeycomb werden weiterhin keine regulären Graph-Edges gezeichnet; sichtbar sind nur die zeitlich begrenzten Recherchepfade.
## Aktivitätsfeed
Der linke Feed zeigt jetzt:
- die Suchanfrage,
- Anzahl verwertbarer SearXNG-Treffer,
- Antwortzeit,
- Domains der Treffer,
- bis zu vier Quelltitel,
- Anzahl der neu verknüpften Forschungs-Nodes und Edges,
- Fehler und leere Ergebnismengen.
Damit ist erkennbar, ob SearXNG tatsächlich Ergebnisse geliefert hat und ob diese in den Graphen übernommen wurden.

View File

@@ -5,16 +5,17 @@ e2f1f0400999cb59b8be09bc2743e06e0a0b2ecdc44a583af7c9a08b70d8509e ./CHANGELOG-GP
a317376127be1e47b9ec9f0781fcfebdcbf7fccfaeb7840d9241f9586048fadb ./CHANGELOG-GROUNDED-KNOWLEDGE-SYNTHESIS.md
e167a9d64f63c3933ad40c5078684bc019db303043c34ea3965f3b089f3d32c7 ./CHANGELOG-KNOWLEDGE-SYNTHESIS.md
87f89a81e1124b18e092a4ea946cb037295cb9e884a46b392286272dc8134dd4 ./CHANGELOG-RUNTIME-HONEYCOMB.md
9c760e167a9af2d3d8ca32c5aa4ef6bb4a3153047343a71c4b19ca9aef96ca32 ./CHANGELOG-SEARXNG-VISUALIZATION.md
99171b262752a66278da5a4a8b2b48e83fbdb1eb9cdca3efa944d260d6e6bc01 ./Dockerfile
5534536965bf0479455f97324c242160202650ca1256f1ba0420b4ad67125e49 ./GLPI-KB.md
a090708c29db08973f712eb9ebf07dff2aae4e3e64f8954bbdaa83848b3ffcc0 ./KNOWLEDGE-SYNTHESIS.md
adcd3c9ba3bdf366afcc4e15a25423e068dd761e5d5d2d6f8cb20a3686302045 ./Makefile
381d7d6ac9e3c2e63c9ecdaa42ed4c73058f5d78e57c7532bb75a9663c919530 ./OLLAMA-POOL.md
10c14f4c08b9b84699cc4cf0dac3e6faf6c0ffd7e74b428463f14c628d0b533d ./PERSISTENCE.md
4561debb97c555f57005430781de10de8a2a56a0d364060f70f2f4ebc0b0ab01 ./README.md
b16c58d98f9da5d240ab5189993c5013aa57ac0da88029f40f3f3e572fdd8233 ./README.md
73171929c19afc9c0f280a50bee1c6becfbfe1a1a4107540ff80d5e4f885ba9a ./RUNTIME-CONTROLS-HONEYCOMB.md
c6fa5f19066c53fd184170bca78dceb8c3fbdfd0c5d6196e0ddbb37ac7bf8eb6 ./SEARXNG-VISUALIZATION.md
cfc2393e8300b792cec30c57b9dc7670b19dcf5abb190dddaa75f32b77cb1fc7 ./cmd/brain/main.go
418310e2660f34890c4be97d6e4a0873cce57fe5b8eb6bfdff25bcd2d5faa7bf ./data/graph-state.json
21b51d0e1b7ed07c20f7f3a5da76dedab8df44a94a51724b67b0c3411599fe15 ./deployment/README.md
139d3dc6939848f8d8e827e5a483da7f7794a47dd3c858a964eaa7f811987985 ./deployment/docker-compose.full.yml
5d97d77e2f0c302fb7fedfe8c7ff2c72a30f196590fdcab498ab0f4369de0071 ./docker-compose.yml
@@ -26,9 +27,10 @@ a0105475dc054977223fac36618b8cd8137c55be1d11fddcd24e9a4d3074c170 ./integrations
50d05fa2a183f5f3eaab0545cb48d3abb64be62eb5d84dc2c6d99c7125f7344b ./internal/activity/broker.go
45ca13d5dc65b5858359d1187147c8a892ae6ba8355e699c05d7e44212096c37 ./internal/config/config.go
d5defa74dff6b09869f59c3e40ecc083f18bb9d81a0541956a857c4d0c26c360 ./internal/config/config_test.go
bfbb007982ddde321f6abe7bf82e6d3e7dc60304445a4649573e24989b9e922b ./internal/engine/article.go
dc07c6fd00d55982fb98533d0719984d7e4b01b0a06123c93183522eaaa06393 ./internal/engine/engine.go
2c4d29463b4d7225d4d7e89386a01fb91254ef584e2e2cb5dc80f2baf3d21c01 ./internal/engine/engine_test.go
fec14e27424873edae74b6a6f304612208e0f0d23d3cb598f0e680238b6ba7c1 ./internal/engine/article.go
81386f1ce7d3d4d311367e8692429db6ced99173de9a623953aa38f1dd1725e3 ./internal/engine/engine.go
6499fa71d3554845a7a12f6a16c38579f457ece368a6f3b152ccebae39362662 ./internal/engine/engine_test.go
716d42138db9eb64480c1bbaf4cb3b70bce1edf72c17c3d85a8d84f866fd8700 ./internal/engine/research_events.go
872b9a5d6cc0e610a50f64ae8d66ed53bf07da718da22f91c11cf43a487fb425 ./internal/engine/runtime.go
b82980a646a92751bdd27a866ba1ffc6d34a3ba81d537f7b6e5a78e1432ee6fa ./internal/glpi/client.go
525102be56bc51ce8a08655b1b2bb53b67f4a1828903585fd664ed6a5133f617 ./internal/glpi/client_test.go
@@ -48,8 +50,8 @@ d33318b43388f134358cf40f5b0f130eb10edcca06011955ea0544897b4e4ad1 ./internal/per
6346f7b213aa3fb36bc9f43134bba75e0b368c9a005cc1aef8d265f553ff8cef ./internal/research/searxng_test.go
1c44338935a4faeedd9671c23df235aa99ea55ed6b2e72060a5d5ddc6c7a6464 ./internal/web/server.go
4d89321b95eb2af3f594adb4d8230aa85377bebe707d232a6af7f1a1f99523dd ./internal/web/server_test.go
a9e586d77223a60bb8345c21de5381699eb8dbdfc215cbb5de975ee0d62b15fa ./internal/web/static/app.css
33f4f973878985ee086425f560a95d8269a7989da36f6c35c0ff2d8aa00c709f ./internal/web/static/app.js
d6463cc4c15e023eff8488e9541aa2516386ca3d04993868206e7e0885fb2eb0 ./internal/web/static/app.css
d860d640c34d2f65c9f3d387a85988e24446e8464ad701326beae3a5a17e3be5 ./internal/web/static/app.js
7e99be904fea61b19bfd5560c93eec5dc5eee75252d1410ca1661cc4a87181dd ./internal/web/static/index.html
eed8aecdf49ea5efecbb16b73483a2c3d2a527a079698519f9bb3b1c9667872c ./neural-brain
0fe63bd83de071d285fe3576f0ac8093a92bc1e61681649a7330680cd0660197 ./neural-brain
83aded814b6225395935e61fe957963c3c470f368fc9089f505b6de23e959115 ./preview.png

View File

@@ -105,7 +105,6 @@ func (e *Engine) synthesizeKnowledgeArticle(ctx context.Context, trigger string,
return articleSynthesisOutcome{Skipped: true, Reason: "required_research_empty", Action: plan.Action}, nil
}
researchResults = append(researchResults, results...)
e.addResearchToSources(selected, results)
brief, err = e.buildKnowledgeBrief(ctx, selected, researchResults)
if err != nil {
return articleSynthesisOutcome{}, fmt.Errorf("knowledge consolidation after research failed: %w", err)
@@ -460,16 +459,30 @@ func (e *Engine) researchKnowledgeGaps(ctx context.Context, trigger string, node
if query == "" {
continue
}
e.Broker.Publish(model.Activity{Type: "article.research.started", Source: "brain", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Ein ungeklärter fachlicher Punkt wird recherchiert", Strength: .9, Metadata: map[string]any{"trigger": trigger, "research_query": query}})
researchID := newResearchRunID("article-research", query)
started := time.Now()
startMetadata := map[string]any{"trigger": trigger, "research_id": researchID, "research_query": query, "animation_min_ms": 2000}
e.Broker.Publish(model.Activity{Type: "article.research.started", Source: "searxng", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Ein ungeklärter fachlicher Punkt wird mit SearXNG recherchiert", Strength: .9, Metadata: startMetadata})
resultLimit := e.Cfg.ArticleResearchResults
if resultLimit < 1 {
resultLimit = 4
}
results, err := e.Research.Search(ctx, query, resultLimit)
if err != nil {
e.Broker.Publish(model.Activity{Type: "article.research.failed", Source: "brain", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Die ergänzende Artikelrecherche ist fehlgeschlagen", Strength: .35, Metadata: map[string]any{"trigger": trigger, "research_query": query, "error": err.Error()}})
e.Broker.Publish(model.Activity{Type: "article.research.failed", Source: "searxng", Phase: "knowledge-research", NodeIDs: nodeIDs, Message: "Die ergänzende Artikelrecherche ist fehlgeschlagen", Strength: .35, Metadata: mergeResearchMetadata(startMetadata, map[string]any{"error": err.Error(), "duration_ms": time.Since(started).Milliseconds()})})
return nil, err
}
resultMetadata := researchEventMetadata(trigger, researchID, query, results, time.Since(started))
message := fmt.Sprintf("SearXNG hat %d Quellen für den offenen Wissenspunkt geliefert", len(results))
if len(results) == 0 {
message = "SearXNG hat für den offenen Wissenspunkt keine verwertbare Quelle geliefert"
}
e.Broker.Publish(model.Activity{Type: "article.research.results", Source: "searxng", Phase: "knowledge-research-results", NodeIDs: nodeIDs, Message: message, Strength: .94, Metadata: resultMetadata})
if len(results) > 0 {
refs := e.addResearchToNodeIDs(nodeIDs, results)
ingestMetadata := mergeResearchMetadata(resultMetadata, map[string]any{"result_node_ids": refs.NodeIDs, "result_edge_ids": refs.EdgeIDs, "source_node_ids": nodeIDs})
e.Broker.Publish(model.Activity{Type: "article.research.ingested", Source: "searxng", Phase: "knowledge-research-ingest", NodeIDs: append(append([]string{}, nodeIDs...), refs.NodeIDs...), EdgeIDs: refs.EdgeIDs, Message: fmt.Sprintf("%d recherchierte Quellen wurden als neue Forschungs-Nodes verknüpft", len(refs.NodeIDs)), Strength: 1, Metadata: ingestMetadata})
}
for _, result := range results {
key := strings.TrimSpace(result.URL)
if key == "" {
@@ -957,14 +970,34 @@ func (e *Engine) learnRuntimeArticle(ctx context.Context, articleID string) {
e.Broker.Publish(model.Activity{Type: "article.learned", Source: "ollama", Phase: "embedding", NodeIDs: []string{nodeID}, Message: "Der neue KB-Artikel wurde eingebettet und ist sofort für Verknüpfungen verfügbar", Strength: .62, Metadata: map[string]any{"article_id": articleID, "model": e.Cfg.EmbeddingModel, "dimensions": len(vecs[0])}})
}
func (e *Engine) addResearchToSources(sources []articleSource, results []model.ResearchResult) {
func (e *Engine) addResearchToNodeIDs(nodeIDs []string, results []model.ResearchResult) researchGraphRefs {
refs := researchGraphRefs{}
for _, result := range results {
id := graph.ID("external", result.URL)
e.Graph.UpsertNode(model.Node{ID: id, Kind: "external", Label: result.Title, Summary: clamp(result.Content, 900), Status: "research", Origin: "research", ExternalID: result.URL, URI: result.URL, Weight: .8, UpdatedAt: time.Now().UTC()})
for _, source := range sources {
e.Graph.UpsertEdge(model.Edge{Source: id, Target: source.Node.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
refs.NodeIDs = append(refs.NodeIDs, id)
for _, targetID := range nodeIDs {
edge := model.Edge{Source: id, Target: targetID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4}
e.Graph.UpsertEdge(edge)
refs.EdgeIDs = append(refs.EdgeIDs, graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin))
}
}
return uniqueResearchRefs(refs)
}
func (e *Engine) addResearchToSources(sources []articleSource, results []model.ResearchResult) researchGraphRefs {
refs := researchGraphRefs{}
for _, result := range results {
id := graph.ID("external", result.URL)
e.Graph.UpsertNode(model.Node{ID: id, Kind: "external", Label: result.Title, Summary: clamp(result.Content, 900), Status: "research", Origin: "research", ExternalID: result.URL, URI: result.URL, Weight: .8, UpdatedAt: time.Now().UTC()})
refs.NodeIDs = append(refs.NodeIDs, id)
for _, source := range sources {
edge := model.Edge{Source: id, Target: source.Node.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4}
e.Graph.UpsertEdge(edge)
refs.EdgeIDs = append(refs.EdgeIDs, graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin))
}
}
return uniqueResearchRefs(refs)
}
func formatArticleAnswer(draft model.KnowledgeArticleDraft) string {

View File

@@ -627,19 +627,33 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
var researchResults []model.ResearchResult
if decision.NeedsResearch && e.Cfg.ResearchEnabled && e.Research != nil && strings.TrimSpace(decision.ResearchQuery) != "" {
e.Broker.Publish(model.Activity{Type: "research.started", Source: "brain", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "Unklarheit erkannt · kontrollierte Webrecherche startet", Strength: .9, Metadata: map[string]any{"trigger": trigger, "research_query": decision.ResearchQuery, "source_label": a.Label, "target_label": b.Label}})
researchID := newResearchRunID("relation-research", decision.ResearchQuery)
researchStarted := time.Now()
startMetadata := map[string]any{"trigger": trigger, "research_id": researchID, "research_query": decision.ResearchQuery, "source_label": a.Label, "target_label": b.Label, "animation_min_ms": 2000}
e.Broker.Publish(model.Activity{Type: "research.started", Source: "searxng", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "Unklarheit erkannt · SearXNG durchsucht externe Quellen", Strength: .9, Metadata: startMetadata})
results, err := e.Research.Search(ctx, decision.ResearchQuery, 4)
if err != nil {
slog.Warn("research failed", "error", err)
} else if len(results) > 0 {
researchResults = results
e.addResearch(a, b, results)
var reviewed model.RelationDecision
reviewSystem := "Bewerte die Beziehung erneut anhand der zwei internen Wissenseinträge und der beigefügten Web-Suchergebnisse. Suchtreffer sind Hinweise, keine garantierten Fakten. Erfinde nichts, kennzeichne verbleibende Unsicherheit und gib ausschließlich JSON nach Schema zurück."
if err := e.Ollama.ChatJSON(ctx, reviewSystem, relationContextWithResearch(a, b, sim, results), relationSchema(), &reviewed); err != nil {
slog.Warn("research review failed; keeping pre-research decision", "error", err)
} else {
decision = reviewed
e.Broker.Publish(model.Activity{Type: "research.failed", Source: "searxng", Phase: "research", NodeIDs: []string{a.ID, b.ID}, Message: "SearXNG-Recherche ist fehlgeschlagen", Strength: .35, Metadata: mergeResearchMetadata(startMetadata, map[string]any{"error": err.Error(), "duration_ms": time.Since(researchStarted).Milliseconds()})})
} else {
resultMetadata := researchEventMetadata(trigger, researchID, decision.ResearchQuery, results, time.Since(researchStarted))
message := fmt.Sprintf("SearXNG hat %d verwertbare Webquellen geliefert", len(results))
if len(results) == 0 {
message = "SearXNG hat keine verwertbaren Webquellen geliefert"
}
e.Broker.Publish(model.Activity{Type: "research.results", Source: "searxng", Phase: "research-results", NodeIDs: []string{a.ID, b.ID}, Message: message, Strength: .92, Metadata: resultMetadata})
if len(results) > 0 {
researchResults = results
refs := e.addResearch(a, b, results)
ingestMetadata := mergeResearchMetadata(resultMetadata, map[string]any{"result_node_ids": refs.NodeIDs, "result_edge_ids": refs.EdgeIDs})
e.Broker.Publish(model.Activity{Type: "research.ingested", Source: "searxng", Phase: "research-ingest", NodeIDs: append([]string{a.ID, b.ID}, refs.NodeIDs...), EdgeIDs: refs.EdgeIDs, Message: fmt.Sprintf("%d Webquellen wurden als neue Forschungs-Nodes in den Graphen übernommen", len(refs.NodeIDs)), Strength: 1, Metadata: ingestMetadata})
var reviewed model.RelationDecision
reviewSystem := "Bewerte die Beziehung erneut anhand der zwei internen Wissenseinträge und der beigefügten Web-Suchergebnisse. Suchtreffer sind Hinweise, keine garantierten Fakten. Erfinde nichts, kennzeichne verbleibende Unsicherheit und gib ausschließlich JSON nach Schema zurück."
if err := e.Ollama.ChatJSON(ctx, reviewSystem, relationContextWithResearch(a, b, sim, results), relationSchema(), &reviewed); err != nil {
slog.Warn("research review failed; keeping pre-research decision", "error", err)
} else {
decision = reviewed
}
}
}
}
@@ -676,14 +690,20 @@ func (e *Engine) enrichOne(ctx context.Context, trigger string) (EnrichOutcome,
return outcome, nil
}
func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) {
func (e *Engine) addResearch(a, b model.Node, results []model.ResearchResult) researchGraphRefs {
refs := researchGraphRefs{}
for _, r := range results {
id := graph.ID("external", r.URL)
n := model.Node{ID: id, Kind: "external", Label: r.Title, Summary: clamp(r.Content, 700), Status: "research", Origin: "research", ExternalID: r.URL, URI: r.URL, Weight: .8, Metadata: map[string]any{"query_pair": []string{a.ID, b.ID}}, UpdatedAt: time.Now().UTC()}
e.Graph.UpsertNode(n)
e.Graph.UpsertEdge(model.Edge{Source: id, Target: a.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
e.Graph.UpsertEdge(model.Edge{Source: id, Target: b.ID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4})
refs.NodeIDs = append(refs.NodeIDs, id)
for _, targetID := range []string{a.ID, b.ID} {
edge := model.Edge{Source: id, Target: targetID, Type: "research_evidence", Origin: "research", Status: "staging", Confidence: .55, Weight: .4}
e.Graph.UpsertEdge(edge)
refs.EdgeIDs = append(refs.EdgeIDs, graph.EdgeID(edge.Source, edge.Target, edge.Type, edge.Origin))
}
}
return uniqueResearchRefs(refs)
}
func (e *Engine) Status() map[string]any {
s := e.Graph.Snapshot()

View File

@@ -246,7 +246,8 @@ func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing
t.Fatal(err)
}
cfg := config.Config{DataDir: data, KnowledgeDirs: []string{knowledge}, StagingDirs: []string{staging}, OllamaURL: ollama.URL, ChatModel: "qwen3:8b", EmbeddingModel: "embeddinggemma", SearXNGURL: searx.URL, ResearchEnabled: true, SimilarityThreshold: .5, RelationThreshold: .7, ArticleSynthesisEnabled: true, ArticleMinSources: 3, ArticleMaxSources: 6, ArticleMinProductionRatio: .7, ArticleMaxGenerationDepth: 2, ArticleMinConfidence: .7, ArticleMinTextChars: 80, ArticleMinAnswerChars: 180, ArticleMaxResearchQueries: 3, ArticleResearchResults: 4, MaxContextChars: 16000}
e := New(cfg, g, activity.New(100))
broker := activity.New(100)
e := New(cfg, g, broker)
if err := e.Scan(context.Background()); err != nil {
t.Fatal(err)
}
@@ -287,6 +288,29 @@ func TestSynthesisResearchesUnclearKnowledgeThenLearnsAndLinksArticle(t *testing
if chatCalls != 6 {
t.Fatalf("expected relation, plan, two consolidations, article and quality calls, got %d", chatCalls)
}
var resultEvent, ingestEvent *model.Activity
for _, event := range broker.Recent() {
event := event
switch event.Type {
case "article.research.results":
resultEvent = &event
case "article.research.ingested":
ingestEvent = &event
}
}
if resultEvent == nil || ingestEvent == nil {
t.Fatalf("expected visible SearXNG result and ingest events, results=%v ingest=%v", resultEvent != nil, ingestEvent != nil)
}
if count, _ := resultEvent.Metadata["result_count"].(int); count != 1 {
t.Fatalf("unexpected result count metadata: %#v", resultEvent.Metadata["result_count"])
}
resultNodeIDs, _ := ingestEvent.Metadata["result_node_ids"].([]string)
if len(resultNodeIDs) != 1 || resultNodeIDs[0] != researchID {
t.Fatalf("unexpected ingested research nodes: %#v", ingestEvent.Metadata["result_node_ids"])
}
if ingestEvent.Metadata["animation_min_ms"] != 2000 {
t.Fatalf("research animation minimum missing: %#v", ingestEvent.Metadata["animation_min_ms"])
}
}
func TestRequestEnrichDoesNotQueueDuplicateCycle(t *testing.T) {

View File

@@ -0,0 +1,74 @@
package engine
import (
"fmt"
"net/url"
"sort"
"strings"
"time"
"github.com/local/glpi-neural-brain/internal/graph"
"github.com/local/glpi-neural-brain/internal/model"
)
type researchGraphRefs struct {
NodeIDs []string
EdgeIDs []string
}
func newResearchRunID(prefix, query string) string {
return graph.ID(prefix, strings.TrimSpace(query), fmt.Sprintf("%d", time.Now().UnixNano()))
}
func researchEventMetadata(trigger, researchID, query string, results []model.ResearchResult, elapsed time.Duration) map[string]any {
titles := make([]string, 0, min(5, len(results)))
domains := make([]string, 0, min(5, len(results)))
urls := make([]string, 0, min(5, len(results)))
seenDomains := map[string]bool{}
for _, result := range results {
if title := strings.TrimSpace(result.Title); title != "" && len(titles) < 5 {
titles = append(titles, title)
}
if rawURL := strings.TrimSpace(result.URL); rawURL != "" {
if len(urls) < 5 {
urls = append(urls, rawURL)
}
if parsed, err := url.Parse(rawURL); err == nil {
domain := strings.TrimPrefix(strings.ToLower(parsed.Hostname()), "www.")
if domain != "" && !seenDomains[domain] && len(domains) < 5 {
seenDomains[domain] = true
domains = append(domains, domain)
}
}
}
}
sort.Strings(domains)
return map[string]any{
"trigger": trigger,
"research_id": researchID,
"research_query": query,
"result_count": len(results),
"result_titles": titles,
"result_domains": domains,
"result_urls": urls,
"duration_ms": elapsed.Milliseconds(),
"animation_min_ms": 2000,
}
}
func mergeResearchMetadata(base map[string]any, extra map[string]any) map[string]any {
out := make(map[string]any, len(base)+len(extra))
for key, value := range base {
out[key] = value
}
for key, value := range extra {
out[key] = value
}
return out
}
func uniqueResearchRefs(refs researchGraphRefs) researchGraphRefs {
refs.NodeIDs = unique(refs.NodeIDs)
refs.EdgeIDs = unique(refs.EdgeIDs)
return refs
}

View File

@@ -47,3 +47,6 @@ body.honeycomb-view .legend{opacity:.58}body.honeycomb-view .mode-status small:a
@media(max-width:780px){.settings-panel{left:10px;width:auto}.dock{max-width:calc(100vw - 20px)}.dock-separator{display:none}}
.performance-settings{flex:0 0 auto}.number-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px;margin:7px 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;background:rgba(255,255,255,.022)}.number-setting b{display:block;font-size:11px}.number-setting small{display:block;margin-top:3px;max-width:250px;color:#718b9e;font-size:9px;line-height:1.4}.number-setting input{width:112px;flex:0 0 auto;border:1px solid rgba(82,231,255,.2);background:rgba(2,8,17,.72);color:var(--text);border-radius:10px;padding:9px 8px;text-align:right;font-variant-numeric:tabular-nums;outline:0}.number-setting input:focus{border-color:rgba(82,231,255,.48);box-shadow:0 0 0 3px rgba(82,231,255,.07)}.node-limit-presets{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.node-limit-presets button{border:1px solid rgba(133,200,255,.12);background:rgba(255,255,255,.025);color:#7893a6;border-radius:999px;padding:5px 8px;font-size:8px;cursor:pointer}.node-limit-presets button:hover{color:var(--cyan);border-color:rgba(82,231,255,.3);background:rgba(82,231,255,.08)}.setting-hint{margin:8px 2px 0;color:#6f899d;font-size:9px;line-height:1.4}
.activity-item .sources{display:grid;gap:4px;margin-top:7px;padding-top:6px;border-top:1px solid rgba(93,255,189,.1)}
.activity-item .sources span{display:flex;align-items:flex-start;gap:6px;color:#b7d9ca;font-size:9px;line-height:1.35}
.activity-item .sources i{display:inline-flex;align-items:center;justify-content:center;flex:0 0 15px;height:15px;border-radius:50%;border:1px solid rgba(93,255,189,.24);background:rgba(93,255,189,.07);color:#86ffd0;font-style:normal;font-size:8px}

View File

@@ -43,7 +43,8 @@
edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0},
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], view_mode: 'neural', max_display_nodes: 0},
availableCategories: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, settingsOpen: false, settingsDraft: null,
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0}
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0},
researchAnimations: new Map(), researchSequence: 0
};
function resize() {
@@ -1236,6 +1237,259 @@
return rgba((MODE_CONFIG[mode] || MODE_CONFIG.living).color, 0.92);
}
function researchEventID(evt) {
return String(evt?.metadata?.research_id || `${evt?.type || 'research'}:${evt?.metadata?.research_query || evt?.query || ++state.researchSequence}`);
}
function researchResultIDs(evt) {
const explicit = Array.isArray(evt?.metadata?.result_node_ids) ? evt.metadata.result_node_ids : [];
if (explicit.length) return explicit.map(String);
return (evt?.node_ids || []).filter(id => state.fullNodeById.get(id)?.kind === 'external' || state.nodeById.get(id)?.kind === 'external');
}
function researchSourceIDs(evt) {
const explicit = Array.isArray(evt?.metadata?.source_node_ids) ? evt.metadata.source_node_ids : [];
if (explicit.length) return explicit.map(String);
const resultIDs = new Set(researchResultIDs(evt));
return (evt?.node_ids || []).filter(id => !resultIDs.has(id));
}
function brainViewportCenter() {
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
return {x: state.width / 2 + panelOffset, y: state.height / 2 - 4};
}
function visibleScreenForNode(id) {
const visibleID = state.visibleForNode.get(id);
const renderNode = visibleID ? state.renderNodeById.get(visibleID) : null;
if (renderNode?.screen) return renderNode.screen;
const node = state.nodeById.get(id);
if (node?.screen) return node.screen;
return null;
}
function averageScreen(ids, fallback) {
let x = 0, y = 0, count = 0;
for (const id of ids || []) {
const point = visibleScreenForNode(id);
if (!point) continue;
x += point.x;
y += point.y;
count++;
}
return count ? {x: x / count, y: y / count} : fallback;
}
function researchSourcePoints(animation, count) {
const desired = Math.max(3, Math.min(7, count || 5));
if (animation.sourcePoints?.length === desired) return animation.sourcePoints;
const center = brainViewportCenter();
const rx = Math.min(state.width * 0.34, state.height * 0.43);
const ry = Math.min(state.height * 0.39, state.width * 0.28);
const seed = hashString(animation.id);
animation.sourcePoints = Array.from({length: desired}, (_, index) => {
const angle = (index / desired) * Math.PI * 2 + (seed % 1000) / 1000 * Math.PI * 2;
return {
x: center.x + Math.cos(angle) * rx * (0.9 + pseudo(animation.id, index + 40) * 0.18),
y: center.y + Math.sin(angle) * ry * (0.88 + pseudo(animation.id, index + 70) * 0.2),
phase: pseudo(animation.id, index + 100) * Math.PI * 2
};
});
return animation.sourcePoints;
}
function handleResearchAnimation(evt) {
if (!evt?.type?.includes('research')) return;
const timestamp = Date.parse(evt.timestamp || '') || Date.now();
const id = researchEventID(evt);
let animation = state.researchAnimations.get(id);
if (!animation && Date.now() - timestamp > 30000) return;
const now = performance.now();
if (!animation) {
animation = {
id,
stage: 'searching',
startedAt: now,
updatedAt: now,
until: Number.POSITIVE_INFINITY,
query: String(evt.metadata?.research_query || evt.query || ''),
sourceNodeIDs: researchSourceIDs(evt),
resultNodeIDs: [],
resultCount: 0,
titles: [],
domains: [],
sourcePoints: [],
fallbackAnchor: brainViewportCenter()
};
state.researchAnimations.set(id, animation);
}
animation.updatedAt = now;
animation.query = String(evt.metadata?.research_query || animation.query || '');
const sourceIDs = researchSourceIDs(evt);
if (sourceIDs.length) animation.sourceNodeIDs = [...new Set([...(animation.sourceNodeIDs || []), ...sourceIDs])];
const minMS = Math.max(2000, Number(evt.metadata?.animation_min_ms || 2000));
if (evt.type.endsWith('.started')) {
animation.stage = 'searching';
animation.until = Number.POSITIVE_INFINITY;
} else if (evt.type.endsWith('.results')) {
animation.resultCount = Number(evt.metadata?.result_count || 0);
animation.titles = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 5) : [];
animation.domains = Array.isArray(evt.metadata?.result_domains) ? evt.metadata.result_domains.map(String).slice(0, 5) : [];
animation.stage = animation.resultCount > 0 ? 'results' : 'empty';
animation.resultsAt = now;
animation.until = now + minMS;
researchSourcePoints(animation, animation.resultCount || 4);
} else if (evt.type.endsWith('.ingested')) {
const resultIDs = researchResultIDs(evt);
animation.resultNodeIDs = [...new Set([...(animation.resultNodeIDs || []), ...resultIDs])];
animation.resultCount = Number(evt.metadata?.result_count || animation.resultCount || resultIDs.length);
animation.titles = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 5) : animation.titles;
animation.domains = Array.isArray(evt.metadata?.result_domains) ? evt.metadata.result_domains.map(String).slice(0, 5) : animation.domains;
animation.stage = 'ingested';
animation.ingestedAt = now;
animation.until = Number.POSITIVE_INFINITY;
researchSourcePoints(animation, animation.resultCount || resultIDs.length || 4);
refreshResearchNodes(animation, minMS);
} else if (evt.type.endsWith('.failed')) {
animation.stage = 'failed';
animation.error = String(evt.metadata?.error || 'Recherche fehlgeschlagen');
animation.until = now + minMS;
}
}
async function refreshResearchNodes(animation, minMS) {
try {
await loadGraph();
const ids = animation.resultNodeIDs || [];
forceDisplayNodes(ids, Math.max(6000, minMS + 4000));
if (state.fullSnapshot && ids.some(id => !state.nodeById.has(id))) applyGraphSnapshot(state.fullSnapshot, true);
for (const id of ids) {
state.active.set(id, Math.max(state.active.get(id) || 0, 1.15));
const visibleID = state.visibleForNode.get(id);
if (visibleID) state.renderActive.set(visibleID, Math.max(state.renderActive.get(visibleID) || 0, 1.15));
}
animation.stage = 'ingested';
animation.ingestedAt = performance.now();
animation.until = animation.ingestedAt + Math.max(2000, minMS);
} catch {
animation.until = Math.max(animation.until || 0, performance.now() + Math.max(2000, minMS));
}
}
function drawResearchHex(x, y, radius, color, alpha, rotation = 0) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = rotation + i / 6 * Math.PI * 2;
const px = x + Math.cos(angle) * radius;
const py = y + Math.sin(angle) * radius;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.strokeStyle = rgba(color, alpha);
ctx.stroke();
}
function renderResearchAnimations(now) {
if (!state.researchAnimations.size) return;
const green = MODE_CONFIG.researching.color;
ctx.save();
ctx.globalCompositeOperation = 'screen';
for (const [id, animation] of state.researchAnimations) {
if (Number.isFinite(animation.until) && now > animation.until) {
state.researchAnimations.delete(id);
continue;
}
const anchor = averageScreen(animation.sourceNodeIDs, animation.fallbackAnchor || brainViewportCenter());
animation.fallbackAnchor = anchor;
const elapsed = Math.max(0, (now - animation.startedAt) / 1000);
const pulse = 0.5 + 0.5 * Math.sin(elapsed * 6.2);
const searching = animation.stage === 'searching';
const failed = animation.stage === 'failed';
const empty = animation.stage === 'empty';
const color = failed || empty ? [255, 180, 82] : green;
const ringAlpha = searching ? 0.34 : 0.22;
ctx.lineWidth = 1.1;
ctx.setLineDash([4, 8]);
for (let ring = 0; ring < 3; ring++) {
const radius = 24 + ring * 13 + pulse * 4;
ctx.strokeStyle = rgba(color, ringAlpha - ring * 0.055);
ctx.beginPath();
ctx.arc(anchor.x, anchor.y, radius, elapsed * (0.7 + ring * 0.18), elapsed * (0.7 + ring * 0.18) + Math.PI * (1.15 + ring * 0.12));
ctx.stroke();
}
ctx.setLineDash([]);
const sweepAngle = elapsed * 2.7;
ctx.strokeStyle = rgba(color, 0.35 + pulse * 0.18);
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.moveTo(anchor.x, anchor.y);
ctx.lineTo(anchor.x + Math.cos(sweepAngle) * 58, anchor.y + Math.sin(sweepAngle) * 58);
ctx.stroke();
const sourceCount = searching ? 5 : Math.max(1, animation.resultCount || animation.resultNodeIDs?.length || 1);
const sourcePoints = researchSourcePoints(animation, sourceCount);
const resultTargets = (animation.resultNodeIDs || []).map(visibleScreenForNode).filter(Boolean);
for (let index = 0; index < sourcePoints.length; index++) {
const source = sourcePoints[index];
const target = resultTargets[index % Math.max(1, resultTargets.length)] || anchor;
const sourcePulse = 0.5 + 0.5 * Math.sin(elapsed * 5 + source.phase);
ctx.strokeStyle = rgba(color, searching ? 0.055 + sourcePulse * 0.04 : 0.12 + sourcePulse * 0.06);
ctx.lineWidth = 0.65;
ctx.beginPath();
ctx.moveTo(source.x, source.y);
const mx = (source.x + target.x) / 2 + Math.sin(source.phase) * 24;
const my = (source.y + target.y) / 2 - 22;
ctx.quadraticCurveTo(mx, my, target.x, target.y);
ctx.stroke();
drawResearchHex(source.x, source.y, 4.2 + sourcePulse * 1.7, color, 0.28 + sourcePulse * 0.34, elapsed * 0.35 + source.phase);
if (!searching && !failed && !empty) {
const phase = ((now - (animation.resultsAt || animation.startedAt)) / 760 + index * 0.17) % 1;
const eased = phase * phase * (3 - 2 * phase);
const packetX = source.x + (target.x - source.x) * eased;
const packetY = source.y + (target.y - source.y) * eased - Math.sin(phase * Math.PI) * 26;
const glow = ctx.createRadialGradient(packetX, packetY, 0, packetX, packetY, 9);
glow.addColorStop(0, 'rgba(255,255,255,.96)');
glow.addColorStop(0.22, rgba(color, 0.85));
glow.addColorStop(1, rgba(color, 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(packetX, packetY, 9, 0, Math.PI * 2);
ctx.fill();
}
}
if (animation.stage === 'ingested') {
const settle = Math.max(0, Math.min(1, (now - animation.ingestedAt) / 2000));
for (let index = 0; index < resultTargets.length; index++) {
const target = resultTargets[index];
const radius = 12 + (1 - settle) * 22 + Math.sin(elapsed * 8 + index) * 2;
ctx.lineWidth = 1.2;
drawResearchHex(target.x, target.y, radius, green, 0.5 * (1 - settle * 0.45), elapsed * 0.5 + index);
ctx.strokeStyle = rgba(green, 0.24 * (1 - settle));
ctx.beginPath();
ctx.arc(target.x, target.y, radius * 1.45, 0, Math.PI * 2);
ctx.stroke();
}
}
ctx.save();
ctx.globalCompositeOperation = 'source-over';
const label = searching ? 'SEARXNG · QUELLENSUCHE' : failed ? 'SEARXNG · FEHLER' : empty ? 'SEARXNG · 0 QUELLEN' : animation.stage === 'ingested' ? `SEARXNG · ${animation.resultCount} QUELLEN VERKNÜPFT` : `SEARXNG · ${animation.resultCount} QUELLEN`;
ctx.font = '700 9px Inter, system-ui';
const width = ctx.measureText(label).width + 16;
ctx.fillStyle = 'rgba(2,10,16,.88)';
ctx.fillRect(anchor.x - width / 2, anchor.y + 49, width, 18);
ctx.strokeStyle = rgba(color, 0.35);
ctx.strokeRect(anchor.x - width / 2, anchor.y + 49, width, 18);
ctx.fillStyle = rgba(color, 0.95);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, anchor.x, anchor.y + 58);
ctx.restore();
}
ctx.restore();
}
function projectAnimatedNode(node, now) {
const honeycomb = state.viewMode === 'honeycomb' && Number.isFinite(node.honeyX);
const cluster = honeycomb ? null : node.cluster;
@@ -1630,6 +1884,7 @@
renderParticles(dt);
}
renderNodes(now, dt);
renderResearchAnimations(now);
if (state.viewMode === 'neural') {
renderWaves(dt);
renderCortexLabels();
@@ -1650,6 +1905,7 @@
requestAnimationFrame(frame);
function activate(evt) {
handleResearchAnimation(evt);
const requestedDuration = evt?.type?.includes('think') || evt?.type?.includes('research') ? 45000 : 32000;
forceDisplayNodes(evt?.node_ids || [], requestedDuration);
const strength = Math.max(0.15, Math.min(1.4, evt.strength || 0.6));
@@ -1707,7 +1963,7 @@
function shouldLog(evt) {
if (!evt || evt.type === 'brain.idle' || evt.type === 'node.activated' || evt.type === 'edges.traversed') return false;
const important = new Set(['scan.started', 'graph.updated', 'embedding.batch', 'query.started', 'query.completed', 'think.queued', 'think.cycle.started', 'think.cycle.completed', 'think.cycle.failed', 'think.no_candidate', 'think.started', 'think.relation.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'article.plan.started', 'article.plan.skipped', 'article.research.started', 'article.research.failed', 'article.draft.started', 'article.draft.rejected', 'article.created', 'article.duplicate', 'article.skipped', 'article.failed', 'agent.run', 'glpi.kb.synced', 'glpi.kb.failed', 'persistence.flushed', 'persistence.failed']);
const important = new Set(['scan.started', 'graph.updated', 'embedding.batch', 'query.started', 'query.completed', 'think.queued', 'think.cycle.started', 'think.cycle.completed', 'think.cycle.failed', 'think.no_candidate', 'think.started', 'think.relation.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'research.results', 'research.ingested', 'research.failed', 'article.plan.started', 'article.plan.skipped', 'article.research.started', 'article.research.results', 'article.research.ingested', 'article.research.failed', 'article.draft.started', 'article.draft.rejected', 'article.created', 'article.duplicate', 'article.skipped', 'article.failed', 'agent.run', 'glpi.kb.synced', 'glpi.kb.failed', 'persistence.flushed', 'persistence.failed']);
if (!important.has(evt.type) && !(evt.source === 'agent' || evt.source === 'knowledgebase' || evt.source === 'external' || evt.query)) return false;
const fingerprint = `${evt.type}|${evt.message || ''}|${evt.query || ''}|${evt.source || ''}`;
const last = state.lastLogFingerprint.get(fingerprint) || 0;
@@ -1740,9 +1996,14 @@
'think.failed': 'AI-THINK Fehler',
'think.paused': 'AI-THINK pausiert',
'research.started': 'Relationsrecherche gestartet',
'research.results': 'SearXNG-Ergebnisse empfangen',
'research.ingested': 'Webquellen im Graph verknüpft',
'research.failed': 'Relationsrecherche fehlgeschlagen',
'article.plan.started': 'Artikelmehrwert wird geprüft',
'article.plan.skipped': 'Artikelsynthese übersprungen',
'article.research.started': 'Artikelrecherche gestartet',
'article.research.results': 'Artikelquellen gefunden',
'article.research.ingested': 'Artikelquellen verknüpft',
'article.research.failed': 'Artikelrecherche fehlgeschlagen',
'article.draft.started': 'KB-Entwurf wird geschrieben',
'article.draft.rejected': 'KB-Entwurf abgelehnt',
@@ -1771,6 +2032,8 @@
if (evt.metadata?.confidence !== undefined) meta.push(`${Math.round(Number(evt.metadata.confidence) * 100)}% Konfidenz`);
if (evt.metadata?.relation_type) meta.push(String(evt.metadata.relation_type));
if (evt.metadata?.research_result_count) meta.push(`${Number(evt.metadata.research_result_count)} Webquellen`);
if (evt.metadata?.result_count !== undefined) meta.push(`${Number(evt.metadata.result_count)} SearXNG-Treffer`);
if (Array.isArray(evt.metadata?.result_domains) && evt.metadata.result_domains.length) meta.push(evt.metadata.result_domains.slice(0, 3).join(' · '));
if (evt.metadata?.trigger) meta.push(evt.metadata.trigger === 'manual' ? 'manuell' : 'automatisch');
if (evt.metadata?.batch_size) meta.push(`${Number(evt.metadata.batch_size)} Schritte`);
if (evt.metadata?.checked !== undefined) meta.push(`${Number(evt.metadata.checked)} geprüft`);
@@ -1800,7 +2063,8 @@
} else if (evt.type === 'query.started' && evt.query) {
message = `${evt.source === 'agent' ? 'Agent' : evt.source === 'knowledgebase' ? 'Knowledgebase' : 'Brain'} verarbeitet eine Anfrage.`;
}
return {time, title, message, meta, query: eventQuery, regions, cls: evt.type?.includes('think') || evt.type?.startsWith('article.') ? (evt.type?.includes('research') ? 'research' : 'think') : evt.type?.includes('research') ? 'research' : evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type?.startsWith('glpi.kb') || evt.type?.startsWith('persistence.') ? 'graph' : evt.source === 'agent' ? 'agent' : ''};
const sources = Array.isArray(evt.metadata?.result_titles) ? evt.metadata.result_titles.map(String).slice(0, 4) : [];
return {time, title, message, meta, query: eventQuery, regions, sources, cls: evt.type?.includes('think') || evt.type?.startsWith('article.') ? (evt.type?.includes('research') ? 'research' : 'think') : evt.type?.includes('research') ? 'research' : evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type?.startsWith('glpi.kb') || evt.type?.startsWith('persistence.') ? 'graph' : evt.source === 'agent' ? 'agent' : ''};
}
function addLog(evt) {
@@ -1808,7 +2072,7 @@
const out = formatEvent(evt);
const item = document.createElement('div');
item.className = 'activity-item ' + out.cls;
item.innerHTML = `<b>${escapeHTML(out.title)}</b><time>${out.time}</time><p>${escapeHTML(out.message)}</p>${out.regions.length ? `<div class="region">Cortex: ${out.regions.map(escapeHTML).join(' · ')}</div>` : ''}${out.meta.length ? `<div class="meta">${out.meta.map(v => `<span>${escapeHTML(v)}</span>`).join('')}</div>` : ''}${out.query ? `<div class="query">${escapeHTML(out.query)}</div>` : ''}`;
item.innerHTML = `<b>${escapeHTML(out.title)}</b><time>${out.time}</time><p>${escapeHTML(out.message)}</p>${out.regions.length ? `<div class="region">Cortex: ${out.regions.map(escapeHTML).join(' · ')}</div>` : ''}${out.meta.length ? `<div class="meta">${out.meta.map(v => `<span>${escapeHTML(v)}</span>`).join('')}</div>` : ''}${out.sources?.length ? `<div class="sources">${out.sources.map((source, index) => `<span><i>${index + 1}</i>${escapeHTML(source)}</span>`).join('')}</div>` : ''}${out.query ? `<div class="query">${escapeHTML(out.query)}</div>` : ''}`;
const log = $('activityLog');
log.prepend(item);
while (log.children.length > 18) log.removeChild(log.lastChild);

Binary file not shown.