diff --git a/CHANGELOG-SEARXNG-VISUALIZATION.md b/CHANGELOG-SEARXNG-VISUALIZATION.md new file mode 100644 index 0000000..268be5e --- /dev/null +++ b/CHANGELOG-SEARXNG-VISUALIZATION.md @@ -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. diff --git a/README.md b/README.md index 757c495..42c818a 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/SEARXNG-VISUALIZATION.md b/SEARXNG-VISUALIZATION.md new file mode 100644 index 0000000..754ff18 --- /dev/null +++ b/SEARXNG-VISUALIZATION.md @@ -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. diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt index 193f4f1..850a5a8 100644 --- a/SHA256SUMS.txt +++ b/SHA256SUMS.txt @@ -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 diff --git a/internal/engine/article.go b/internal/engine/article.go index 8ee74a8..9ff41a5 100644 --- a/internal/engine/article.go +++ b/internal/engine/article.go @@ -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 { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 73acc6d..f4698b9 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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() diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index e81af5e..4f7d7a3 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -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) { diff --git a/internal/engine/research_events.go b/internal/engine/research_events.go new file mode 100644 index 0000000..46a3039 --- /dev/null +++ b/internal/engine/research_events.go @@ -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 +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index a1d4309..b782b27 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 9f07749..679998b 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -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 = `${escapeHTML(out.title)}
${escapeHTML(out.message)}
${out.regions.length ? `${escapeHTML(out.message)}
${out.regions.length ? `