Files
glpi-neuroforge-mega/patches/v1.4.1-to-v1.4.2.diff
T
jbergner c7f1b006b5
release-tag / release-image (push) Successful in 9m13s
1.4.2
2026-08-26 22:19:16 +02:00

128 lines
25 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/README.md b/README.md
index d7c7452..d92c1b8 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# GLPI NeuroForge Mega v1.4.0
+> Release: **v1.4.2** · Bugfix für leeren NeuroForge-Wissensraum-Graph.
+
Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi.
## Unified Graph Explorer (v1.4.0)
diff --git a/RELEASE-NOTES-v1.4.2.md b/RELEASE-NOTES-v1.4.2.md
new file mode 100644
index 0000000..50fc79c
--- /dev/null
+++ b/RELEASE-NOTES-v1.4.2.md
@@ -0,0 +1,19 @@
+# GLPI NeuroForge Mega v1.4.2
+
+Bugfix-Release für den NeuroForge-WebUI-Tab **Wissensraum**.
+
+## Behoben
+
+- Leere Knowledge-Graphs werden von der Store/API-Schicht jetzt mit `nodes: []` und `edges: []` serialisiert statt mit `null`.
+- Das Wissensraum-WebUI normalisiert `nodes` und `edges` defensiv, sodass auch ältere oder teilweise Antworten mit `null` keinen `.length`-Fehler mehr auslösen.
+- Bei einem Graph-Ladefehler wird der Canvas auf einen definierten leeren Zustand zurückgesetzt.
+- Auch Event-, Source- und Goal-Listen im NeuroForge-WebUI behandeln unerwartete `null`-Listen defensiv als `[]`.
+- Regressionstest stellt sicher, dass ein leerer Knowledge-Graph nicht mehr als `null` serialisierbare Slices erzeugt.
+
+## Validierung
+
+- `go test ./...` für NeuroForge, Agent, Knowledgebase und Control Center: OK
+- `go vet ./...` für alle vier Go-Module: OK
+- NeuroForge Inline-JavaScript: `node --check` OK
+
+Keine Datenmigration und keine ENV-Änderung erforderlich. Ein Austausch/Neustart des NeuroForge-Images reicht.
diff --git a/mega-project.json b/mega-project.json
index 2b5d932..32d52e5 100644
--- a/mega-project.json
+++ b/mega-project.json
@@ -28,7 +28,7 @@
"schema": "Wiki/Schema.md",
"glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI"
},
- "version": "1.4.0",
+ "version": "1.4.2",
"controlled_learning": {
"raw_chat_auto_learning": false,
"validated_outcomes": [
diff --git a/platform/neuroforge/internal/httpapi/index.html b/platform/neuroforge/internal/httpapi/index.html
index 6b5a22c..95b9eb6 100644
--- a/platform/neuroforge/internal/httpapi/index.html
+++ b/platform/neuroforge/internal/httpapi/index.html
@@ -158,7 +158,7 @@ $$('[data-view]').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset
async function refreshAll(){if(!admin){$('sideStatus').textContent='Admin-Token setzen';return}try{let [s,k,g,src]=await Promise.all([req('/admin/api/status'),req('/admin/api/knowledge/summary'),req('/api/v1/goals'),req('/api/v1/sources?limit=8')]);$('sideDot').className='dot ok';$('sideStatus').textContent='bereit · rev '+s.revision;$('mMem').textContent=fmtN(s.memories);$('mSyn').textContent=fmtN(s.synapses);$('mSources').textContent=fmtN(k.sources||src.length);let active=(g||[]).filter(x=>x.status==='active');$('mGoals').textContent=active.length;$('mNextGoal').textContent=active.length?('nächster '+when(active.map(x=>x.next_cycle_at).filter(Boolean).sort()[0])):'keine aktiven Ziele';$('mTypes').textContent=Object.entries(k.by_type||{}).map(([a,b])=>a+' '+b).join(' · ');renderSystemState(s);renderEvents(k.recent_events||[],'dashEvents');renderDashboardGoals(active.slice(0,5));if(currentView==='ops')loadOps()}catch(e){$('sideDot').className='dot bad';$('sideStatus').textContent='Fehler';toast(e.message,true)}}
function renderSystemState(s){let o=s.observability||{},m=s.maintenance||{};$('systemState').innerHTML=[['Index',`${s.index_mode||o.index_mode||'–'} · HNSW ${fmtN(s.hnsw_nodes)} · PQ ${fmtN(s.disk_pq_items)}`],['Jobs',fmtN(s.pending_jobs)+' ausstehend'],['Cluster',s.cluster?.enabled?(s.cluster.role+' · term '+s.cluster.term):'deaktiviert'],['Letzter Autonomie-Lauf',m.last_autonomy_run?new Date(m.last_autonomy_run).toLocaleString():'–'],['Letzter Fehler',m.last_error||'keiner']].map(([a,b])=>`<div class="item"><div class="itemTitle"><b>${esc(a)}</b><span class="muted small">${esc(b)}</span></div></div>`).join('')}
-function renderEvents(xs,id){$(id).innerHTML=xs.length?xs.map(e=>`<div class="item"><div class="itemTitle"><b>${esc(e.type)}</b><span class="pill">${esc(e.actor||'system')}</span></div><p>${esc(e.summary||'')}</p><div class="meta"><span>${new Date(e.created_at).toLocaleString()}</span>${e.reason?`<span>${esc(e.reason)}</span>`:''}</div></div>`).join(''):'<p class="muted">Noch keine Lernereignisse.</p>'}
+function renderEvents(xs,id){xs=Array.isArray(xs)?xs:[];$(id).innerHTML=xs.length?xs.map(e=>`<div class="item"><div class="itemTitle"><b>${esc(e.type)}</b><span class="pill">${esc(e.actor||'system')}</span></div><p>${esc(e.summary||'')}</p><div class="meta"><span>${new Date(e.created_at).toLocaleString()}</span>${e.reason?`<span>${esc(e.reason)}</span>`:''}</div></div>`).join(''):'<p class="muted">Noch keine Lernereignisse.</p>'}
function renderDashboardGoals(xs){$('dashGoals').innerHTML=xs.length?xs.map(g=>`<div class="item"><div class="itemTitle"><b>${esc(g.title)}</b><span class="pill ${g.auto_run?'good':''}">${g.auto_run?'AUTO':'MANUELL'}</span></div><div class="progress" style="margin:9px 0"><span style="width:${Math.round((g.progress||0)*100)}%"></span></div><div class="meta"><span>${Math.round((g.progress||0)*100)}%</span><span>${g.research_enabled?'Research an':'Research aus'}</span><span>${g.next_cycle_at?when(g.next_cycle_at):'nicht geplant'}</span></div></div>`).join(''):'<p class="muted">Keine aktiven Ziele.</p>'}
// ---- Knowledge graph with client-side LOD ----
@@ -171,12 +171,12 @@ function lod(){return cam.z<.62?0:cam.z<1.45?1:2}function drawGraph(){let r=canv
function drawClusters(){let groups={};for(let p of graphPos.values()){let t=p.n.memory_type||'other';(groups[t]??=[]).push(p)}for(let[t,xs]of Object.entries(groups)){let x=xs.reduce((a,p)=>a+p.x,0)/xs.length,y=xs.reduce((a,p)=>a+p.y,0)/xs.length,s=worldToScreen({x,y}),rad=Math.min(58,20+Math.sqrt(xs.length)*6);ctx.fillStyle=(typeColors[t]||'#bd8cff')+'44';ctx.strokeStyle=typeColors[t]||'#bd8cff';ctx.lineWidth=2;ctx.beginPath();ctx.arc(s.x,s.y,rad,0,Math.PI*2);ctx.fill();ctx.stroke();ctx.fillStyle='#eef3fb';ctx.font='600 13px system-ui';ctx.textAlign='center';ctx.fillText(t,s.x,s.y-2);ctx.fillStyle='#a9b7cb';ctx.font='11px system-ui';ctx.fillText(xs.length+' nodes',s.x,s.y+15)}ctx.textAlign='left'}
function fitGraph(){if(!graphPos.size)return;let xs=[...graphPos.values()].map(p=>p.x),ys=[...graphPos.values()].map(p=>p.y),w=Math.max(...xs)-Math.min(...xs)+140,h=Math.max(...ys)-Math.min(...ys)+140,r=canvas.getBoundingClientRect();cam.z=Math.max(.25,Math.min(1.15,Math.min(r.width/w,r.height/h)));cam.x=cam.y=0;drawGraph()}
canvas.addEventListener('wheel',e=>{e.preventDefault();let before=screenToWorld(e.offsetX,e.offsetY),factor=Math.exp(-e.deltaY*.0012);cam.z=Math.max(.2,Math.min(5,cam.z*factor));let after=worldToScreen(before);cam.x+=e.offsetX-after.x;cam.y+=e.offsetY-after.y;drawGraph()},{passive:false});canvas.addEventListener('pointerdown',e=>{pointer.down=true;pointer.x=e.clientX;pointer.y=e.clientY;pointer.moved=false;canvas.setPointerCapture(e.pointerId)});canvas.addEventListener('pointermove',e=>{if(!pointer.down)return;let dx=e.clientX-pointer.x,dy=e.clientY-pointer.y;if(Math.hypot(dx,dy)>2)pointer.moved=true;cam.x+=dx;cam.y+=dy;pointer.x=e.clientX;pointer.y=e.clientY;drawGraph()});canvas.addEventListener('pointerup',e=>{pointer.down=false;if(pointer.moved)return;let rect=canvas.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top;if(lod()===0){cam.z=.8;drawGraph();return}let best=null,bd=15;for(let p of graphPos.values()){let s=worldToScreen(p),d=Math.hypot(s.x-x,s.y-y);if(d<bd){best=p;bd=d}}if(best)openMemory(best.n.id)});$('zoomInBtn').addEventListener('click',()=>{cam.z=Math.min(5,cam.z*1.3);drawGraph()});$('zoomOutBtn').addEventListener('click',()=>{cam.z=Math.max(.2,cam.z/1.3);drawGraph()});$('fitBtn').addEventListener('click',fitGraph);
-async function loadKnowledgeGraph(center=''){try{let d=await req('/admin/api/knowledge/graph?depth=3&max_nodes=600'+(center?'&center='+encodeURIComponent(center):''));graphData=d;$('graphCount').textContent=fmtN(d.nodes.length)+' Knoten · '+fmtN(d.edges.length)+' Kanten';layoutGraph()}catch(e){toast(e.message,true)}}$('graphReloadBtn').addEventListener('click',()=>loadKnowledgeGraph(selectedMemory));
+async function loadKnowledgeGraph(center=''){try{let d=await req('/admin/api/knowledge/graph?depth=3&max_nodes=600'+(center?'&center='+encodeURIComponent(center):''));graphData={...(d||{}),nodes:Array.isArray(d?.nodes)?d.nodes:[],edges:Array.isArray(d?.edges)?d.edges:[]};$('graphCount').textContent=fmtN(graphData.nodes.length)+' Knoten · '+fmtN(graphData.edges.length)+' Kanten';layoutGraph()}catch(e){graphData={nodes:[],edges:[]};graphLayoutReady=false;$('graphCount').textContent='0 Knoten · 0 Kanten';drawGraph();toast(e.message,true)}}$('graphReloadBtn').addEventListener('click',()=>loadKnowledgeGraph(selectedMemory));
$('knowledgeSearchBtn').addEventListener('click',searchKnowledge);$('knowledgeQuery').addEventListener('keydown',e=>{if(e.key==='Enter')searchKnowledge()});async function searchKnowledge(){let q=$('knowledgeQuery').value.trim();if(!q)return;try{let d=await req('/admin/api/knowledge/search',{method:'POST',body:JSON.stringify({text:q,k:16})});$('knowledgeHits').innerHTML=(d.hits||[]).map(h=>`<div class="item click" data-mid="${esc(h.memory.id)}"><div class="itemTitle"><b>${esc(h.memory.text.slice(0,120))}</b><span class="pill">${Number(h.score||0).toFixed(3)}</span></div><div class="meta"><span>${esc(h.candidate_source||'')}</span><span>sim ${Number(h.similarity||0).toFixed(3)}</span><span>base ${Number(h.base_score||0).toFixed(3)}</span><span>graph +${Number(h.graph_boost||0).toFixed(3)}</span></div></div>`).join('')||'<p class="muted">Keine Treffer.</p>';$$('#knowledgeHits [data-mid]').forEach(x=>x.addEventListener('click',()=>openMemory(x.dataset.mid)));if(d.hits?.[0]){selectedMemory=d.hits[0].memory.id;await loadKnowledgeGraph(selectedMemory);await openMemory(selectedMemory,false)}}catch(e){toast(e.message,true)}}
async function openMemory(id,recenter=true){selectedMemory=id;try{let d=await req('/admin/api/knowledge/memory/'+encodeURIComponent(id)),m=d.memory||{},p=m.provenance||{};$('knowledgeInspector').innerHTML=`<div class="meta"><span class="pill">${esc(m.memory_type)}</span><span class="pill">${esc(m.kind)}</span><span class="pill ${m.status==='active'?'good':m.status==='conflicted'?'warn':''}">${esc(m.status||'active')}</span></div><h3>${esc(m.truth_key||p.source_title||m.id)}</h3><div style="white-space:pre-wrap;line-height:1.5;font-size:13px">${esc(m.text)}</div><h3>Qualität</h3><div class="kv"><div>Confidence</div><div>${Number(m.confidence||0).toFixed(3)}</div><div>Salience</div><div>${Number(m.salience||0).toFixed(3)}</div><div>Reward</div><div>${Number(m.reward||0).toFixed(3)}</div><div>Evidenzquellen</div><div>${fmtN(m.evidence_count||m.evidence_source_ids?.length||0)}</div><div>Aufrufe</div><div>${fmtN(m.access_count)}</div></div><h3>Provenance</h3><div class="kv"><div>Quelle</div><div>${esc(p.source||'legacy/unknown')}</div><div>Source-ID</div><div class="mono">${esc(p.source_id||'–')}</div><div>Titel</div><div>${esc(p.source_title||'–')}</div><div>URI</div><div>${p.source_uri?`<a href="${esc(safeHref(p.source_uri))}" target="_blank" rel="noopener" style="color:var(--accent)">${esc(p.source_uri)}</a>`:'–'}</div><div>Chunk</div><div>${p.chunk_index||'–'} / ${p.chunk_count||'–'}</div><div>Embedding</div><div>${esc([p.embedding_model,p.embedding_node_id].filter(Boolean).join(' · ')||'–')}</div><div>Generierung</div><div>${esc([p.generation_model,p.generation_node_id].filter(Boolean).join(' · ')||'–')}</div></div><h3>Stärkste Synapsen</h3>${(d.edges||[]).slice(0,12).map(e=>`<div class="item click" data-mid="${esc(e.a===id?e.b:e.a)}"><div class="itemTitle"><span class="mono small">${esc(e.a===id?e.b:e.a)}</span><span class="pill">w ${Number(e.weight).toFixed(2)}</span></div></div>`).join('')||'<p class="muted">Keine Synapsen.</p>'}<h3>Timeline</h3>${(d.events||[]).map(e=>`<div class="item"><b>${esc(e.type)}</b><p>${esc(e.summary)}</p><div class="meta">${new Date(e.created_at).toLocaleString()}</div></div>`).join('')||'<p class="muted">Keine Events.</p>'}`;$$('#knowledgeInspector [data-mid]').forEach(x=>x.addEventListener('click',()=>openMemory(x.dataset.mid)));drawGraph();if(recenter)loadKnowledgeGraph(id)}catch(e){toast(e.message,true)}}
// ---- Sources & ingestion ----
-async function loadSources(){try{let xs=await req('/api/v1/sources?limit=100');$('sourcesList').innerHTML=xs.length?xs.map(s=>`<div class="item"><div class="itemTitle"><div class="row"><span class="sourceIcon">${s.type==='document'?'▤':s.type==='web'?'⌁':'T'}</span><div><b>${esc(s.title)}</b><div class="meta"><span>${esc(s.type)}</span><span>${s.chunk_count||0} chunks</span><span>${fmtBytes(s.bytes)}</span></div></div></div><span class="pill ${s.status==='ready'?'good':s.status==='error'?'bad':'warn'}">${esc(s.status)}</span></div>${s.uri?`<p class="mono small">${esc(s.uri)}</p>`:''}${s.error?`<p class="error small">${esc(s.error)}</p>`:''}</div>`).join(''):'<p class="muted">Noch keine externen Quellen.</p>';$('mSources').textContent=fmtN(xs.length)}catch(e){toast(e.message,true)}}$('sourcesReloadBtn').addEventListener('click',loadSources);
+async function loadSources(){try{let xs=await req('/api/v1/sources?limit=100');xs=Array.isArray(xs)?xs:[];$('sourcesList').innerHTML=xs.length?xs.map(s=>`<div class="item"><div class="itemTitle"><div class="row"><span class="sourceIcon">${s.type==='document'?'▤':s.type==='web'?'⌁':'T'}</span><div><b>${esc(s.title)}</b><div class="meta"><span>${esc(s.type)}</span><span>${s.chunk_count||0} chunks</span><span>${fmtBytes(s.bytes)}</span></div></div></div><span class="pill ${s.status==='ready'?'good':s.status==='error'?'bad':'warn'}">${esc(s.status)}</span></div>${s.uri?`<p class="mono small">${esc(s.uri)}</p>`:''}${s.error?`<p class="error small">${esc(s.error)}</p>`:''}</div>`).join(''):'<p class="muted">Noch keine externen Quellen.</p>';$('mSources').textContent=fmtN(xs.length)}catch(e){toast(e.message,true)}}$('sourcesReloadBtn').addEventListener('click',loadSources);
const dz=$('dropZone'),fi=$('docFile');['dragenter','dragover'].forEach(n=>dz.addEventListener(n,e=>{e.preventDefault();dz.classList.add('drag')}));['dragleave','drop'].forEach(n=>dz.addEventListener(n,e=>{e.preventDefault();dz.classList.remove('drag')}));dz.addEventListener('drop',e=>{if(e.dataTransfer.files.length)fi.files=e.dataTransfer.files});$('uploadBtn').addEventListener('click',async()=>{let f=fi.files[0];if(!f)return toast('Bitte eine Datei auswählen.',true);let fd=new FormData();fd.append('file',f);fd.append('title',$('docTitle').value);fd.append('tags',$('docTags').value);fd.append('trust',$('docTrust').value);try{let r=await fetch('/api/v1/ingest/document',{method:'POST',headers:tokenHeaders(false),body:fd}),txt=await r.text(),d;try{d=JSON.parse(txt)}catch{d=txt}if(!r.ok)throw new Error(d.error||d);toast(`Dokument gelernt: ${d.chunks} Chunks, ${d.duplicates} Duplikate`);fi.value='';loadSources();refreshAll()}catch(e){toast(e.message,true)}});
$('textLearnBtn').addEventListener('click',async()=>{try{let d=await req('/api/v1/ingest/text',{method:'POST',body:JSON.stringify({title:$('textTitle').value||'Manual text',text:$('textInput').value,source_uri:$('textURI').value,trust:Number($('textTrust').value||1),source_type:'text',memory_type:'semantic'})});toast(`Text gelernt: ${d.chunks} Chunks`);$('textInput').value='';loadSources();refreshAll()}catch(e){toast(e.message,true)}});
@@ -211,7 +211,7 @@ async function loadResearchHistory(){if(!researchWatch.goalId)return;try{let xs=
async function pollGoalResearch(){if(!researchWatch.goalId||currentView!=='goals')return;try{let q=`?after=${researchWatch.after}`+(researchWatch.runId?`&run_id=${encodeURIComponent(researchWatch.runId)}`:'');let d=await req('/api/v1/goals/'+encodeURIComponent(researchWatch.goalId)+'/research/live'+q);if(!d.run){researchWatch.run=null;researchWatch.events=[];researchWatch.after=0;renderResearchLive();return}if(d.reset||researchWatch.runId!==d.run.id){researchWatch.runId=d.run.id;researchWatch.after=0;researchWatch.events=[]}researchWatch.run=d.run;if(d.events?.length){researchWatch.events.push(...d.events);if(researchWatch.events.length>500)researchWatch.events=researchWatch.events.slice(-500);researchWatch.after=Math.max(researchWatch.after,...d.events.map(e=>Number(e.seq||0)))}renderResearchLive();if(d.run.status!=='running')loadResearchHistory()}catch(e){$('liveRunMeta').textContent='Live-Research konnte nicht geladen werden: '+e.message;$('livePulse').className='livePulse error'}}
function openGoalResearch(id,title){researchWatch.goalId=id;researchWatch.goalTitle=title||id;researchWatch.runId='';researchWatch.after=0;researchWatch.events=[];researchWatch.run=null;$('goalResearchLive').classList.add('show');$('liveGoalTitle').textContent='Live Research · '+researchWatch.goalTitle;renderResearchLive();loadResearchHistory();startResearchPolling();setTimeout(()=>$('goalResearchLive').scrollIntoView({behavior:'smooth',block:'start'}),20)}
$('liveCloseBtn').addEventListener('click',()=>{$('goalResearchLive').classList.remove('show');stopResearchPolling()});$('liveRefreshBtn').addEventListener('click',()=>{researchWatch.after=0;researchWatch.events=[];researchWatch.runId='';pollGoalResearch();loadResearchHistory()});
-async function loadGoals(){try{let xs=await req('/api/v1/goals');$('goalsList').innerHTML=xs.length?xs.map(g=>{let active=g.status==='active',paused=g.status==='paused';return `<div class="goal ${paused?'goalPaused':''}"><div class="goalHead"><div><h3>${esc(g.title)}</h3><div class="meta"><span>Priorität ${g.priority}</span><span>${g.research_enabled?'⌕ Research':'ohne Research'}</span><span>${g.auto_run?'AUTO':'MANUELL'}</span></div></div><span class="pill ${active?'good':paused?'warn':''}">${paused?'PAUSIERT':esc(g.status)}</span></div><p class="muted small">${esc(g.description||'')}</p><div class="progress"><span style="width:${Math.round((g.progress||0)*100)}%"></span></div><div class="meta"><span>${Math.round((g.progress||0)*100)}%</span><span>Intervall ${g.interval_minutes||'–'} min</span><span>${paused?'Scheduler angehalten':('Nächster: '+(g.next_cycle_at?when(g.next_cycle_at):'–'))}</span></div>${g.next_action?`<p><b>Nächste Aktion:</b> ${esc(g.next_action)}</p>`:''}${g.last_error?`<p class="error small">${esc(g.last_error)}</p>`:''}<div class="goalActions">${active?`<button class="btn secondary" data-goal-action="cycle" data-goal-id="${esc(g.id)}" data-goal-title="${esc(g.title)}">Jetzt lernen</button><button class="btn ghost" data-goal-action="pause" data-goal-id="${esc(g.id)}">Pausieren</button>`:''}${paused?`<button class="btn secondary" data-goal-action="resume" data-goal-id="${esc(g.id)}">Fortsetzen</button>`:''}${g.research_enabled?`<button class="btn ghost" data-research-live="${esc(g.id)}" data-goal-title="${esc(g.title)}">Live Research</button>`:''}<button class="btn ghost" data-focus="${esc(g.id)}">Graph</button><button class="btn danger" data-goal-action="delete" data-goal-id="${esc(g.id)}" data-goal-title="${esc(g.title)}">Löschen</button></div></div>`}).join(''):'<p class="muted">Noch keine Ziele.</p>';$$('[data-goal-action]').forEach(b=>b.addEventListener('click',()=>goalAction(b)));$$('[data-research-live]').forEach(b=>b.addEventListener('click',()=>openGoalResearch(b.dataset.researchLive,b.dataset.goalTitle)));$$('[data-focus]').forEach(b=>b.addEventListener('click',()=>{switchView('knowledge');$('knowledgeQuery').value='goal:'+b.dataset.focus;searchKnowledge()}))}catch(e){toast(e.message,true)}}
+async function loadGoals(){try{let xs=await req('/api/v1/goals');xs=Array.isArray(xs)?xs:[];$('goalsList').innerHTML=xs.length?xs.map(g=>{let active=g.status==='active',paused=g.status==='paused';return `<div class="goal ${paused?'goalPaused':''}"><div class="goalHead"><div><h3>${esc(g.title)}</h3><div class="meta"><span>Priorität ${g.priority}</span><span>${g.research_enabled?'⌕ Research':'ohne Research'}</span><span>${g.auto_run?'AUTO':'MANUELL'}</span></div></div><span class="pill ${active?'good':paused?'warn':''}">${paused?'PAUSIERT':esc(g.status)}</span></div><p class="muted small">${esc(g.description||'')}</p><div class="progress"><span style="width:${Math.round((g.progress||0)*100)}%"></span></div><div class="meta"><span>${Math.round((g.progress||0)*100)}%</span><span>Intervall ${g.interval_minutes||'–'} min</span><span>${paused?'Scheduler angehalten':('Nächster: '+(g.next_cycle_at?when(g.next_cycle_at):'–'))}</span></div>${g.next_action?`<p><b>Nächste Aktion:</b> ${esc(g.next_action)}</p>`:''}${g.last_error?`<p class="error small">${esc(g.last_error)}</p>`:''}<div class="goalActions">${active?`<button class="btn secondary" data-goal-action="cycle" data-goal-id="${esc(g.id)}" data-goal-title="${esc(g.title)}">Jetzt lernen</button><button class="btn ghost" data-goal-action="pause" data-goal-id="${esc(g.id)}">Pausieren</button>`:''}${paused?`<button class="btn secondary" data-goal-action="resume" data-goal-id="${esc(g.id)}">Fortsetzen</button>`:''}${g.research_enabled?`<button class="btn ghost" data-research-live="${esc(g.id)}" data-goal-title="${esc(g.title)}">Live Research</button>`:''}<button class="btn ghost" data-focus="${esc(g.id)}">Graph</button><button class="btn danger" data-goal-action="delete" data-goal-id="${esc(g.id)}" data-goal-title="${esc(g.title)}">Löschen</button></div></div>`}).join(''):'<p class="muted">Noch keine Ziele.</p>';$$('[data-goal-action]').forEach(b=>b.addEventListener('click',()=>goalAction(b)));$$('[data-research-live]').forEach(b=>b.addEventListener('click',()=>openGoalResearch(b.dataset.researchLive,b.dataset.goalTitle)));$$('[data-focus]').forEach(b=>b.addEventListener('click',()=>{switchView('knowledge');$('knowledgeQuery').value='goal:'+b.dataset.focus;searchKnowledge()}))}catch(e){toast(e.message,true)}}
async function goalAction(b){let id=b.dataset.goalId,action=b.dataset.goalAction;if(action==='delete'&&!confirm(`Ziel „${b.dataset.goalTitle||id}“ wirklich löschen? Bereits gelerntes Wissen bleibt erhalten.`))return;if(action==='cycle')openGoalResearch(id,b.dataset.goalTitle||id);let old=b.textContent;try{b.disabled=true;b.textContent=action==='cycle'?'läuft …':action==='delete'?'lösche …':'speichere …';let path='/api/v1/goals/'+encodeURIComponent(id)+(action==='cycle'?'/cycle':action==='pause'?'/pause':action==='resume'?'/resume':'');let d=await req(path,{method:action==='delete'?'DELETE':'POST',body:action==='delete'?undefined:'{}'});if(action==='cycle')toast(`Zyklus fertig · Evaluation ${Number(d.evaluation).toFixed(2)} · ${d.sources_ingested||0} Quellen`);else if(action==='pause')toast('Ziel pausiert. Der Scheduler verarbeitet es nicht weiter.');else if(action==='resume')toast('Ziel wieder aktiv. Nächster Lauf wurde eingeplant.');else{toast('Ziel gelöscht. Bereits gelerntes Wissen bleibt erhalten.');if(researchWatch.goalId===id){$('goalResearchLive').classList.remove('show');stopResearchPolling();researchWatch.goalId=''}}await loadGoals();refreshAll()}catch(e){toast(e.message,true)}finally{b.disabled=false;b.textContent=old}}
$('goalCreateBtn').addEventListener('click',async()=>{let title=$('goalTitle').value.trim();if(!title)return toast('Titel fehlt.',true);try{let g=await req('/api/v1/goals',{method:'POST',body:JSON.stringify({title,description:$('goalDesc').value,target:$('goalTarget').value,priority:Number($('goalPriority').value||50),interval_minutes:Number($('goalInterval').value||10),status:'active'})});toast(`Ziel ${g.title} angelegt – erster Lauf ist eingeplant.`);$('goalTitle').value='';$('goalDesc').value='';loadGoals();refreshAll()}catch(e){toast(e.message,true)}});$('runAutonomyBtn').addEventListener('click',async()=>{try{let d=await req('/admin/api/autonomy',{method:'POST',body:'{}'});toast(`${d.cycles?.length||0} fällige Zyklen ausgeführt.`);loadGoals()}catch(e){toast(e.message,true)}});
diff --git a/platform/neuroforge/internal/store/knowledge.go b/platform/neuroforge/internal/store/knowledge.go
index cd4d2d9..817175a 100644
--- a/platform/neuroforge/internal/store/knowledge.go
+++ b/platform/neuroforge/internal/store/knowledge.go
@@ -338,7 +338,7 @@ func (s *Store) KnowledgeGraph(center string, depth, maxNodes int) KnowledgeGrap
}
frontier = next
}
- out := KnowledgeGraph{Center: center}
+ out := KnowledgeGraph{Center: center, Nodes: make([]MemoryPreview, 0), Edges: make([]KnowledgeEdge, 0)}
for id := range selected {
if m, ok := s.fullMemoryForReadLocked(id); ok {
out.Nodes = append(out.Nodes, memoryPreview(m))
diff --git a/platform/neuroforge/internal/store/knowledge_test.go b/platform/neuroforge/internal/store/knowledge_test.go
index fff10d6..2ce942e 100644
--- a/platform/neuroforge/internal/store/knowledge_test.go
+++ b/platform/neuroforge/internal/store/knowledge_test.go
@@ -57,3 +57,22 @@ func TestKnowledgeEventPersistsAcrossRestart(t *testing.T) {
t.Fatalf("events=%+v", evs)
}
}
+
+func TestKnowledgeGraphEmptyUsesEmptySlices(t *testing.T) {
+ s, err := New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+
+ g := s.KnowledgeGraph("", 3, 600)
+ if g.Nodes == nil {
+ t.Fatal("nodes must be [] rather than null in JSON")
+ }
+ if g.Edges == nil {
+ t.Fatal("edges must be [] rather than null in JSON")
+ }
+ if len(g.Nodes) != 0 || len(g.Edges) != 0 {
+ t.Fatalf("unexpected graph contents: nodes=%d edges=%d", len(g.Nodes), len(g.Edges))
+ }
+}