BugFix
release-tag / release-image (push) Successful in 2m24s

This commit is contained in:
2026-08-05 11:58:25 +02:00
parent 31d3efb8a2
commit b3bd3d5ffd
25 changed files with 679 additions and 719 deletions
+98 -152
View File
@@ -51,15 +51,15 @@
lodOpenUntil: new Map(), lodHotUntil: new Map(), lodDirty: true, lodLastBuild: 0, lodNextExpiry: 0, lodZoomBand: 2,
renderNodes: [], renderEdges: [], renderIdleEdges: [], renderNodeById: new Map(), renderEdgeById: new Map(), visibleForNode: new Map(),
edgeRenderMap: new Map(), renderActive: new Map(), renderEdgeActive: new Map(), renderStats: {nodes: 0, edges: 0, hiddenNodes: 0, hiddenEdges: 0},
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {learning_enabled: true, thinking_enabled: true, learning_categories: [], display_categories: [], thinking_categories: [], learning_sources: [], display_sources: [], thinking_sources: [], effective_learning: {categories: [], sources: [], matches_none: false}, effective_display: {categories: [], sources: [], matches_none: false}, effective_thinking: {categories: [], sources: [], matches_none: false}, admin_learning: {categories: [], sources: []}, admin_display: {categories: [], sources: []}, admin_thinking: {categories: [], sources: []}, view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
availableCategories: [], availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
fullSnapshot: null, fullNodeById: new Map(), runtimeSettings: {source_filter_version: 1, learning_enabled: true, thinking_enabled: true, learning_sources: [], display_sources: [], thinking_sources: [], glpi_kb_source: '', view_mode: 'neural', max_display_nodes: 0, low_power_mode: false},
availableSources: [], viewMode: 'neural', honeycombNodes: [], honeycombSpacing: 0, honeySlotByID: new Map(), honeyPointPool: [], honeyFreeSlots: [],
constellationNodes: [], constellationLinks: [], settingsOpen: false, settingsDraft: null,
forcedDisplayUntil: new Map(), nextDisplayLimitExpiry: 0, graphVersion: null, displaySignature: '', displayLimitStats: {limit: 0, eligible: 0, shown: 0},
researchAnimations: new Map(), researchSequence: 0,
lowPowerMode: false, performanceProfile: PERFORMANCE_CONFIG.normal, lastPaint: 0, fpsWindowStarted: performance.now(), fpsFrames: 0, fps: 0,
backgroundCanvas: document.createElement('canvas'), backgroundKey: '', cameraFrame: null, projectedSortAt: 0,
retiringRenderNodes: [], retiringRenderEdges: [], retiringRenderNodeById: new Map(), viewGhostNodes: [],
topologyNodeIDs: new Set(), topologyEdgeIDs: new Set(), graphLoadPromise: null, graphLoadQueued: false, graphLoadTimer: 0
topologyNodeIDs: new Set(), topologyEdgeIDs: new Set(), graphLoadPromise: null, graphLoadQueued: false, graphLoadTimer: 0, sourceOptionsTimer: 0
};
function currentPerformanceProfile() {
@@ -141,46 +141,30 @@
return data;
}
function normalizedFilterValues(values) {
return [...new Set((values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean))];
function normalizedSourceValues(values) {
return [...new Set((values || []).map(value => String(value).trim()).filter(Boolean))];
}
// Source filtering intentionally uses only the explicit metadata.source value
// copied from a KB file (or assigned to learned web evidence). No origin,
// category, URI or display-label fallback is used.
function nodeSource(node) {
const explicit = node?.metadata?.source;
if (explicit !== undefined && explicit !== null) {
const value = String(explicit).trim();
if (value && value.toLowerCase() !== '<nil>' && value.toLowerCase() !== 'null') return value;
}
if (node?.kind === 'source' && String(node.label || '').trim()) return String(node.label).trim();
if (node?.kind === 'external') {
for (const raw of [node.uri, node.external_id]) {
try {
const host = new URL(String(raw || '')).hostname.trim().toLowerCase();
if (host) return host;
} catch {}
}
}
return String(node?.origin || '').trim();
if (explicit === undefined || explicit === null) return '';
const value = String(explicit).trim();
if (!value || value.toLowerCase() === '<nil>' || value.toLowerCase() === 'null') return '';
return value;
}
function filterDimensionMatches(values, filters, emptyToken) {
const wanted = new Set(normalizedFilterValues(filters));
if (!wanted.size || wanted.has('*')) return true;
const clean = (values || []).map(value => String(value).trim().toLowerCase()).filter(Boolean);
if (!clean.length) return wanted.has(emptyToken);
return clean.some(value => wanted.has(value));
function sourceFilterMatches(node, sources) {
const wanted = normalizedSourceValues(sources);
if (!wanted.length) return true;
const source = nodeSource(node);
return wanted.some(value => source === value);
}
function nodeFilterMatches(node, filter) {
if (filter?.matches_none) return false;
return filterDimensionMatches(node.categories || [], filter?.categories || [], '__uncategorized__') &&
filterDimensionMatches(nodeSource(node) ? [nodeSource(node)] : [], filter?.sources || [], '__unsourced__');
}
function effectiveRuntimeFilter(key, settings = state.runtimeSettings) {
const effective = settings?.[`effective_${key}`];
if (effective) return effective;
return {categories: settings?.[`${key}_categories`] || [], sources: settings?.[`${key}_sources`] || [], matches_none: false};
function runtimeSources(key, settings = state.runtimeSettings) {
return normalizedSourceValues(settings?.[`${key}_sources`] || []);
}
function activeForcedDisplayIDs(now = Date.now()) {
@@ -265,19 +249,18 @@
}
function filteredSnapshot(snapshot) {
const filter = effectiveRuntimeFilter('display');
const restricted = Boolean(filter?.matches_none || (filter?.categories || []).length || (filter?.sources || []).length);
const sources = runtimeSources('display');
let filtered = snapshot;
if (restricted) {
if (sources.length) {
const visible = new Set();
const noteKinds = new Set(['knowledge', 'ai-think', 'external']);
const taxonomyKinds = new Set(['category', 'source', 'concept']);
const nodeMap = new Map(snapshot.nodes.map(node => [node.id, node]));
for (const node of snapshot.nodes) {
if (noteKinds.has(node.kind) && nodeFilterMatches(node, filter)) visible.add(node.id);
if (noteKinds.has(node.kind) && sourceFilterMatches(node, sources)) visible.add(node.id);
}
// Add only the taxonomy directly attached to an already matching note.
// Never pull another knowledge/external note through a semantic edge.
// Taxonomy is visual context only. Another knowledge-bearing node is
// never pulled through an edge when its exact source does not match.
for (const edge of snapshot.edges) {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
@@ -294,8 +277,7 @@
}
function displaySignatureFor(settings = state.runtimeSettings) {
const filter = effectiveRuntimeFilter('display', settings);
return {categories: filter.categories || [], sources: filter.sources || [], matchesNone: Boolean(filter.matches_none), limit: Number(settings.max_display_nodes || 0)};
return {sources: runtimeSources('display', settings), limit: Number(settings.max_display_nodes || 0)};
}
function currentDisplaySignature() {
@@ -303,16 +285,28 @@
return JSON.stringify({...displaySignatureFor(), forced});
}
async function loadSourceOptions() {
const payload = await api('/api/sources');
state.availableSources = payload.sources || [];
renderSourceFilters($('sourceSearch')?.value || '');
return state.availableSources;
}
function scheduleSourceOptionsLoad(delay = 260) {
clearTimeout(state.sourceOptionsTimer);
state.sourceOptionsTimer = setTimeout(() => {
state.sourceOptionsTimer = 0;
loadSourceOptions().catch(() => {});
}, delay);
}
async function loadRuntimeConfiguration() {
try {
const [settings, categories, sources] = await Promise.all([api('/api/runtime-settings'), api('/api/categories'), api('/api/sources')]);
const [settings] = await Promise.all([api('/api/runtime-settings'), loadSourceOptions()]);
state.runtimeSettings = {...state.runtimeSettings, ...settings};
state.availableCategories = categories.categories || [];
state.availableSources = sources.sources || [];
state.viewMode = ['neural', 'honeycomb', 'constellation'].includes(state.runtimeSettings.view_mode) ? state.runtimeSettings.view_mode : 'neural';
applyPerformanceMode(Boolean(state.runtimeSettings.low_power_mode), true);
syncRuntimeControls();
renderCategoryFilters();
renderSourceFilters();
renderFilterScopeSummary();
} catch {
@@ -347,7 +341,7 @@
if (!hint) return;
const limit = Math.max(0, Math.trunc(Number(value) || 0));
if (!limit) {
hint.textContent = 'Unbegrenzt · Kategorie-Filter und LOD bestimmen die Renderlast.';
hint.textContent = 'Unbegrenzt · Quellenfilter und LOD bestimmen die Renderlast.';
return;
}
const stats = state.displayLimitStats || {};
@@ -355,113 +349,72 @@
hint.textContent = `Maximal ${limit.toLocaleString('de-DE')} Nodes · aktuell ${Math.min(limit, eligible).toLocaleString('de-DE')} von ${eligible.toLocaleString('de-DE')} auswählbar.`;
}
function categoryLabel(name) {
return name === '__uncategorized__' ? 'Ohne Kategorie' : name;
}
function sourceLabel(name) {
return name === '__unsourced__' ? 'Ohne Quelle' : name;
return String(name || '');
}
function filterSet(key, dimension) {
const source = state.settingsDraft || state.runtimeSettings;
return new Set((source[`${key}_${dimension}`] || []).map(value => String(value).toLowerCase()));
}
function adminAllows(key, dimension, value) {
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
const restricted = Boolean(admin[`${dimension}_restricted`]);
if (!restricted) return true;
const allowed = new Set(normalizedFilterValues(admin[dimension] || []));
return allowed.has(String(value).trim().toLowerCase());
}
function renderFilterOptions({dimension, search = '', options, targets, labeler, dataAttribute}) {
const query = String(search || '').trim().toLowerCase();
for (const [key, target] of Object.entries(targets)) {
if (!target) continue;
const selected = filterSet(key, dimension);
target.innerHTML = '';
const visibleOptions = options.filter(option => !query || labeler(option.name).toLowerCase().includes(query));
for (const option of visibleOptions) {
const allowed = adminAllows(key, dimension, option.name);
const label = document.createElement('label');
label.className = `category-option${allowed ? '' : ' disabled'}`;
const checked = selected.has(String(option.name).toLowerCase());
const disabled = allowed ? '' : ' disabled';
const notice = allowed ? '' : '<small>durch ENV ausgeschlossen</small>';
label.innerHTML = `<input type="checkbox" ${dataAttribute}="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}${disabled}><span>${escapeHTML(labeler(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${notice}</span>`;
target.appendChild(label);
}
if (!visibleOptions.length) target.innerHTML = `<span class="empty-filter">Keine passende ${dimension === 'categories' ? 'Kategorie' : 'Quelle'}</span>`;
}
}
function renderCategoryFilters(search = '') {
renderFilterOptions({
dimension: 'categories', search, options: state.availableCategories,
targets: {learning: $('learningCategoryList'), display: $('displayCategoryList'), thinking: $('thinkingCategoryList')},
labeler: categoryLabel, dataAttribute: 'data-category-filter'
});
function sourceSet(key) {
const settings = state.settingsDraft || state.runtimeSettings;
return new Set(normalizedSourceValues(settings[`${key}_sources`] || []));
}
function renderSourceFilters(search = '') {
renderFilterOptions({
dimension: 'sources', search, options: state.availableSources,
targets: {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')},
labeler: sourceLabel, dataAttribute: 'data-source-filter'
});
}
function intersectClientValues(adminValues, runtimeValues, restrictedFlag) {
const runtime = normalizedFilterValues(runtimeValues);
if (!restrictedFlag) return {values: runtime, restricted: runtime.length > 0, none: false};
const admin = normalizedFilterValues(adminValues);
if (!runtime.length) return {values: admin, restricted: true, none: false};
const allowed = new Set(admin);
const values = runtime.filter(value => allowed.has(value));
return {values, restricted: true, none: values.length === 0};
}
function previewEffectiveFilter(key) {
const query = String(search || '').trim().toLowerCase();
const settings = state.settingsDraft || state.runtimeSettings;
const admin = state.runtimeSettings?.[`admin_${key}`] || {};
const categories = intersectClientValues(admin.categories || [], settings?.[`${key}_categories`] || [], Boolean(admin.categories_restricted));
const sources = intersectClientValues(admin.sources || [], settings?.[`${key}_sources`] || [], Boolean(admin.sources_restricted));
return {categories: categories.values, sources: sources.values, matches_none: categories.none || sources.none};
const optionMap = new Map((state.availableSources || []).map(option => [String(option.name), {...option}]));
const configuredGLPI = String(state.runtimeSettings.glpi_kb_source || '').trim();
if (configuredGLPI && !optionMap.has(configuredGLPI)) optionMap.set(configuredGLPI, {name: configuredGLPI, count: 0});
for (const key of ['learning', 'display', 'thinking']) {
for (const source of runtimeSources(key, settings)) {
if (!optionMap.has(source)) optionMap.set(source, {name: source, count: 0});
}
}
const options = [...optionMap.values()].filter(option => !query || sourceLabel(option.name).toLowerCase().includes(query));
const targets = {learning: $('learningSourceList'), display: $('displaySourceList'), thinking: $('thinkingSourceList')};
for (const [key, target] of Object.entries(targets)) {
if (!target) continue;
const selected = sourceSet(key);
target.innerHTML = '';
for (const option of options) {
const label = document.createElement('label');
label.className = 'source-option';
const checked = selected.has(String(option.name));
const configured = option.name === state.runtimeSettings.glpi_kb_source && Number(option.count || 0) === 0 ? '<small>GLPI_KB_SOURCE</small>' : '';
label.innerHTML = `<input type="checkbox" data-source-filter="${key}" value="${escapeHTML(option.name)}" ${checked ? 'checked' : ''}><span>${escapeHTML(sourceLabel(option.name))}<em>${Number(option.count || 0).toLocaleString('de-DE')}</em>${configured}</span>`;
target.appendChild(label);
}
if (!options.length) target.innerHTML = '<span class="empty-filter">Keine passende source gefunden</span>';
}
}
function compactFilterValues(values, labeler) {
if (!values?.length) return 'alle';
const labels = values.slice(0, 3).map(labeler);
function compactSourceValues(values) {
if (!values?.length) return 'alle source-Werte';
const labels = values.slice(0, 3).map(sourceLabel);
return labels.join(', ') + (values.length > 3 ? ` +${values.length - 3}` : '');
}
function renderFilterScopeSummary() {
const target = $('filterScopeSummary');
if (!target) return;
const parts = [];
let hasEmpty = false;
for (const [key, label] of [['learning', 'Lernen'], ['display', 'Anzeige'], ['thinking', 'Thinking']]) {
const effective = previewEffectiveFilter(key);
hasEmpty ||= effective.matches_none;
parts.push(`${label}: ${effective.matches_none ? 'keine Übereinstimmung' : `${compactFilterValues(effective.categories, categoryLabel)} · ${compactFilterValues(effective.sources, sourceLabel)}`}`);
}
target.textContent = `Wirksam (ENV ∩ WebUI) — ${parts.join(' | ')}`;
target.classList.toggle('warn', hasEmpty);
target.classList.toggle('ok', !hasEmpty);
const settings = state.settingsDraft || state.runtimeSettings;
const parts = [
`Lernen: ${compactSourceValues(runtimeSources('learning', settings))}`,
`Anzeige: ${compactSourceValues(runtimeSources('display', settings))}`,
`Thinking: ${compactSourceValues(runtimeSources('thinking', settings))}`
];
target.textContent = `Exakter KB-source-Treffer — ${parts.join(' | ')}`;
target.classList.remove('warn');
target.classList.add('ok');
}
async function persistRuntimeSettings(settings = state.runtimeSettings) {
const normalized = {
source_filter_version: 1,
learning_enabled: Boolean(settings.learning_enabled),
thinking_enabled: Boolean(settings.thinking_enabled),
learning_categories: [...(settings.learning_categories || [])],
display_categories: [...(settings.display_categories || [])],
thinking_categories: [...(settings.thinking_categories || [])],
learning_sources: [...(settings.learning_sources || [])],
display_sources: [...(settings.display_sources || [])],
thinking_sources: [...(settings.thinking_sources || [])],
learning_sources: normalizedSourceValues(settings.learning_sources),
display_sources: normalizedSourceValues(settings.display_sources),
thinking_sources: normalizedSourceValues(settings.thinking_sources),
view_mode: ['neural', 'honeycomb', 'constellation'].includes(settings.view_mode) ? settings.view_mode : 'neural',
max_display_nodes: Math.max(0, Math.min(500000, Math.trunc(Number(settings.max_display_nodes) || 0))),
low_power_mode: Boolean(settings.low_power_mode)
@@ -488,7 +441,6 @@
$('settingsPanel')?.classList.remove('hidden');
$('settingsBackdrop')?.classList.remove('hidden');
syncRuntimeControls();
renderCategoryFilters($('categorySearch')?.value || '');
renderSourceFilters($('sourceSearch')?.value || '');
renderFilterScopeSummary();
}
@@ -2760,7 +2712,10 @@
}
}
addLog(evt);
if (evt.type === 'graph.updated') scheduleGraphLoad(180);
if (evt.type === 'graph.updated') {
scheduleGraphLoad(180);
scheduleSourceOptionsLoad(320);
}
if (evt.type?.startsWith('think.') || evt.type?.startsWith('article.') || evt.type?.startsWith('research.')) loadStatus();
}
@@ -2999,7 +2954,6 @@
$('openSettings').addEventListener('click', openSettingsPanel);
$('closeSettings').addEventListener('click', closeSettingsPanel);
$('settingsBackdrop').addEventListener('click', closeSettingsPanel);
$('categorySearch').addEventListener('input', e => renderCategoryFilters(e.currentTarget.value));
$('sourceSearch').addEventListener('input', e => renderSourceFilters(e.currentTarget.value));
$('settingsLearning').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.learning_enabled = e.currentTarget.checked; });
$('settingsThinking').addEventListener('change', e => { if (state.settingsDraft) state.settingsDraft.thinking_enabled = e.currentTarget.checked; });
@@ -3019,23 +2973,15 @@
$('settingsViewHoneycomb').addEventListener('click', () => selectSettingsView('honeycomb'));
$('settingsViewConstellation').addEventListener('click', () => selectSettingsView('constellation'));
$('settingsPanel').addEventListener('change', e => {
const input = e.target.closest('[data-category-filter], [data-source-filter]');
if (!input || !state.settingsDraft || input.disabled) return;
const dimension = input.dataset.categoryFilter !== undefined ? 'categories' : 'sources';
const key = input.dataset.categoryFilter ?? input.dataset.sourceFilter;
const property = `${key}_${dimension}`;
const selected = new Map((state.settingsDraft[property] || []).map(value => [String(value).toLowerCase(), value]));
const normalized = String(input.value).toLowerCase();
if (input.checked) selected.set(normalized, input.value); else selected.delete(normalized);
state.settingsDraft[property] = [...selected.values()];
const input = e.target.closest('[data-source-filter]');
if (!input || !state.settingsDraft) return;
const key = input.dataset.sourceFilter;
const property = `${key}_sources`;
const selected = new Set(normalizedSourceValues(state.settingsDraft[property] || []));
if (input.checked) selected.add(input.value); else selected.delete(input.value);
state.settingsDraft[property] = [...selected];
renderFilterScopeSummary();
});
document.querySelectorAll('[data-clear-filter]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
state.settingsDraft[`${button.dataset.clearFilter}_categories`] = [];
renderCategoryFilters($('categorySearch').value);
renderFilterScopeSummary();
}));
document.querySelectorAll('[data-clear-source-filter]').forEach(button => button.addEventListener('click', () => {
if (!state.settingsDraft) return;
state.settingsDraft[`${button.dataset.clearSourceFilter}_sources`] = [];