Update 5 - SearXNG
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
+39 -6
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 {
+33 -13
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()
+25 -1
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) {
+74
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
}
+3
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}
+268 -4
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);