999 lines
45 KiB
JavaScript
999 lines
45 KiB
JavaScript
(() => {
|
|
'use strict';
|
|
|
|
const canvas = document.getElementById('brain');
|
|
const ctx = canvas.getContext('2d', {alpha: false});
|
|
const $ = (id) => document.getElementById(id);
|
|
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
|
const CORTEX_PALETTE = [
|
|
[82, 231, 255], [145, 126, 255], [255, 105, 178], [93, 255, 189],
|
|
[255, 180, 82], [75, 143, 255], [196, 117, 255], [255, 102, 92],
|
|
[135, 231, 105], [78, 207, 224], [235, 215, 98], [151, 205, 255]
|
|
];
|
|
const MODE_CONFIG = {
|
|
living: {label: 'LIVING', detail: 'ruhige Eigenaktivität', className: 'living', color: [82, 231, 255], duration: 0},
|
|
thinking: {label: 'AI-THINK', detail: 'verknüpft und bewertet', className: 'thinking', color: [255, 180, 82], duration: 10500},
|
|
researching: {label: 'RESEARCH', detail: 'klärt Unsicherheiten', className: 'researching', color: [93, 255, 189], duration: 11500},
|
|
processing: {label: 'PROCESSING', detail: 'Agent- und Wissenssuche', className: 'processing', color: [183, 117, 255], duration: 7800},
|
|
learning: {label: 'LEARNING', detail: 'Graph wird neu geordnet', className: 'learning', color: [75, 123, 255], duration: 6500}
|
|
};
|
|
|
|
const state = {
|
|
nodes: [], edges: [], clusters: [], clusterByKey: new Map(), nodeById: new Map(), edgeById: new Map(), adjacency: new Map(),
|
|
active: new Map(), edgeActive: new Map(), particles: [], waves: [], pulses: 0,
|
|
yaw: 0.18, pitch: -0.12, zoom: 1.02, autoRotate: true, labels: true, edgesVisible: true, cortexVisible: true,
|
|
dragging: false, moved: false, lastX: 0, lastY: 0, hover: null, selected: null, projected: [],
|
|
width: innerWidth, height: innerHeight, dpr: Math.min(devicePixelRatio || 1, 2), last: performance.now(),
|
|
lastLogFingerprint: new Map(), recentImportant: [], clusterActive: new Map(),
|
|
mode: 'living', modeUntil: 0, activityEnergy: 0, targetEnergy: 0, focusClusterKey: '', focusNodeID: '',
|
|
cameraTargetYaw: null, cameraTargetPitch: null, zoomTarget: 1.02, nextAmbientAt: performance.now() + 1800,
|
|
ambientCursor: 0, bursts: []
|
|
};
|
|
|
|
function resize() {
|
|
state.width = innerWidth;
|
|
state.height = innerHeight;
|
|
state.dpr = Math.min(devicePixelRatio || 1, 2);
|
|
canvas.width = Math.floor(state.width * state.dpr);
|
|
canvas.height = Math.floor(state.height * state.dpr);
|
|
canvas.style.width = state.width + 'px';
|
|
canvas.style.height = state.height + 'px';
|
|
ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
|
|
}
|
|
addEventListener('resize', resize);
|
|
resize();
|
|
|
|
async function api(path, options = {}) {
|
|
const res = await fetch(path, {headers: {'Content-Type': 'application/json', ...(options.headers || {})}, ...options});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
return data;
|
|
}
|
|
|
|
async function loadGraph() {
|
|
try {
|
|
const snap = await api('/api/graph');
|
|
const old = state.nodeById;
|
|
state.nodes = snap.nodes.map(n => ({...n, glow: old.get(n.id)?.glow || 0, screen: null, clusterKey: '', clusterColor: old.get(n.id)?.clusterColor || ''}));
|
|
state.edges = snap.edges;
|
|
state.nodeById = new Map(state.nodes.map(n => [n.id, n]));
|
|
state.edgeById = new Map(state.edges.map(e => [e.id, e]));
|
|
state.adjacency = new Map();
|
|
for (const e of state.edges) {
|
|
if (!state.adjacency.has(e.source)) state.adjacency.set(e.source, []);
|
|
if (!state.adjacency.has(e.target)) state.adjacency.set(e.target, []);
|
|
state.adjacency.get(e.source).push(e);
|
|
state.adjacency.get(e.target).push(e);
|
|
}
|
|
buildLayout();
|
|
$('nodeCount').textContent = state.nodes.length.toLocaleString('de-DE');
|
|
$('edgeCount').textContent = state.edges.length.toLocaleString('de-DE');
|
|
} catch {
|
|
setSystem('offline', false);
|
|
}
|
|
}
|
|
|
|
function setSystem(text, ok = true) {
|
|
$('systemState').lastChild.textContent = ' ' + text;
|
|
$('systemState').style.color = ok ? 'var(--green)' : 'var(--red)';
|
|
}
|
|
|
|
function hashString(value) {
|
|
let h = 2166136261;
|
|
const s = String(value || '');
|
|
for (let i = 0; i < s.length; i++) {
|
|
h ^= s.charCodeAt(i);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
return h >>> 0;
|
|
}
|
|
|
|
function fract(v) {
|
|
return v - Math.floor(v);
|
|
}
|
|
|
|
function pseudo(value, offset = 0) {
|
|
const h = hashString(value + ':' + offset);
|
|
return fract(Math.sin(h * 0.00000137 + offset * 12.345) * 43758.5453123);
|
|
}
|
|
|
|
function hsvToRgb(h, s, v) {
|
|
const c = v * s;
|
|
const hh = (h % 360) / 60;
|
|
const x = c * (1 - Math.abs(hh % 2 - 1));
|
|
let r = 0, g = 0, b = 0;
|
|
if (hh >= 0 && hh < 1) [r, g, b] = [c, x, 0];
|
|
else if (hh < 2) [r, g, b] = [x, c, 0];
|
|
else if (hh < 3) [r, g, b] = [0, c, x];
|
|
else if (hh < 4) [r, g, b] = [0, x, c];
|
|
else if (hh < 5) [r, g, b] = [x, 0, c];
|
|
else [r, g, b] = [c, 0, x];
|
|
const m = v - c;
|
|
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
|
}
|
|
|
|
function explicitClusterKey(node) {
|
|
const cats = (node.categories || []).filter(Boolean);
|
|
const preferred = cats.find(c => !/^(AI-THINK|AI-Staging|Staging|Produktiv)$/i.test(c));
|
|
if (preferred) return preferred;
|
|
if (node.kind === 'category' && node.label) return node.label;
|
|
if (node.kind === 'ai-think') return 'AI-THINK';
|
|
return '';
|
|
}
|
|
|
|
function categoryKey(node) {
|
|
const explicit = explicitClusterKey(node);
|
|
if (explicit) return explicit;
|
|
if (node.kind === 'external') return 'Recherche';
|
|
if (node.kind === 'source') return 'Quellen';
|
|
if (node.kind === 'concept') return 'Konzepte';
|
|
return `${node.kind || 'node'}:${node.status || 'live'}`;
|
|
}
|
|
|
|
function clusterColorFor(key) {
|
|
const hue = hashString(key) % 360;
|
|
return hsvToRgb(hue, 0.48, 1);
|
|
}
|
|
|
|
function nodeColor(node, alpha = 1) {
|
|
let c = node.clusterColor || [82, 231, 255];
|
|
if (node.kind === 'ai-think' || (node.categories || []).some(x => String(x).toUpperCase() === 'AI-THINK')) c = [255, 180, 82];
|
|
else if (node.status === 'staging') c = [183, 117, 255];
|
|
else if (node.kind === 'external') c = [93, 255, 189];
|
|
else if (node.kind === 'category') c = [75, 123, 255];
|
|
else if (node.kind === 'source') c = [255, 95, 136];
|
|
return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;
|
|
}
|
|
|
|
function clusterFill(cluster, alpha = 1) {
|
|
const c = cluster.color || [82, 231, 255];
|
|
return `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;
|
|
}
|
|
|
|
function insideBrain(x, y, z) {
|
|
const fissure = Math.abs(x) < 0.045 && y > -0.58 && y < 0.42;
|
|
if (fissure) return false;
|
|
const taperY = y + Math.abs(z) * 0.10 - Math.max(0, Math.abs(x) - 0.58) * 0.18;
|
|
const lx = (x + 0.35) / 0.58;
|
|
const rx = (x - 0.35) / 0.58;
|
|
const ny = taperY / 0.76;
|
|
const nz = z / 0.58;
|
|
const left = lx * lx + ny * ny + nz * nz <= 1;
|
|
const right = rx * rx + ny * ny + nz * nz <= 1;
|
|
const stemCut = y < -0.76 || y > 0.82;
|
|
return !stemCut && (left || right);
|
|
}
|
|
|
|
function clampBrain(point, sideHint) {
|
|
const out = {x: point.x, y: point.y, z: point.z};
|
|
const centerX = sideHint === 'left' ? -0.36 : sideHint === 'right' ? 0.36 : (out.x < 0 ? -0.36 : 0.36);
|
|
for (let i = 0; i < 18 && !insideBrain(out.x, out.y, out.z); i++) {
|
|
out.x = centerX + (out.x - centerX) * 0.88;
|
|
out.y *= 0.93;
|
|
out.z *= 0.93;
|
|
}
|
|
if (sideHint === 'left' && out.x > -0.06) out.x = -0.06 - Math.abs(out.x) * 0.1;
|
|
if (sideHint === 'right' && out.x < 0.06) out.x = 0.06 + Math.abs(out.x) * 0.1;
|
|
out.x = Math.max(-0.95, Math.min(0.95, out.x));
|
|
out.y = Math.max(-0.9, Math.min(0.9, out.y));
|
|
out.z = Math.max(-0.72, Math.min(0.72, out.z));
|
|
return out;
|
|
}
|
|
|
|
function buildLayout() {
|
|
const degree = new Map(state.nodes.map(n => [n.id, (state.adjacency.get(n.id) || []).length]));
|
|
const labels = new Map();
|
|
const locked = new Set();
|
|
for (const node of state.nodes) {
|
|
const explicit = explicitClusterKey(node);
|
|
if (explicit) {
|
|
labels.set(node.id, explicit);
|
|
locked.add(node.id);
|
|
}
|
|
}
|
|
for (let round = 0; round < 4; round++) {
|
|
const pending = [];
|
|
for (const node of state.nodes) {
|
|
if (locked.has(node.id)) continue;
|
|
const votes = new Map();
|
|
for (const edge of state.adjacency.get(node.id) || []) {
|
|
const otherID = edge.source === node.id ? edge.target : edge.source;
|
|
const label = labels.get(otherID);
|
|
if (!label) continue;
|
|
const trust = edge.origin === 'ai-inference' ? 0.72 : edge.type === 'categorized_as' ? 1.35 : 1;
|
|
const score = Math.max(0.05, edge.weight || 1) * trust;
|
|
votes.set(label, (votes.get(label) || 0) + score);
|
|
}
|
|
let best = '', bestScore = 0;
|
|
for (const [label, score] of votes) {
|
|
if (score > bestScore) { best = label; bestScore = score; }
|
|
}
|
|
if (best) pending.push([node.id, best]);
|
|
}
|
|
for (const [id, label] of pending) labels.set(id, label);
|
|
if (!pending.length) break;
|
|
}
|
|
|
|
const clusters = new Map();
|
|
for (const n of state.nodes) {
|
|
const key = labels.get(n.id) || categoryKey(n);
|
|
n.clusterKey = key;
|
|
let cluster = clusters.get(key);
|
|
if (!cluster) {
|
|
cluster = {key, label: key, nodes: [], size: 0, mass: 0, color: clusterColorFor(key), x: 0, y: 0, z: 0, side: 'left', radius: 0.12, links: new Map()};
|
|
clusters.set(key, cluster);
|
|
}
|
|
cluster.nodes.push(n);
|
|
cluster.size++;
|
|
cluster.mass += 1 + Math.min(10, Math.sqrt(degree.get(n.id) || 0));
|
|
}
|
|
|
|
for (const e of state.edges) {
|
|
const a = state.nodeById.get(e.source);
|
|
const b = state.nodeById.get(e.target);
|
|
if (!a || !b || a.clusterKey === b.clusterKey) continue;
|
|
const c1 = clusters.get(a.clusterKey);
|
|
const c2 = clusters.get(b.clusterKey);
|
|
c1.links.set(c2.key, (c1.links.get(c2.key) || 0) + (e.weight || 1));
|
|
c2.links.set(c1.key, (c2.links.get(c1.key) || 0) + (e.weight || 1));
|
|
}
|
|
|
|
const clusterList = Array.from(clusters.values()).sort((a, b) => b.mass - a.mass || a.key.localeCompare(b.key));
|
|
clusterList.forEach((cluster, index) => {
|
|
cluster.color = CORTEX_PALETTE[index % CORTEX_PALETTE.length];
|
|
cluster.importance = index < 12 ? 1 : Math.max(0.25, 1 - index / Math.max(1, clusterList.length));
|
|
cluster.screen = null;
|
|
});
|
|
const left = [], right = [];
|
|
let leftMass = 0, rightMass = 0;
|
|
for (const cluster of clusterList) {
|
|
const preferLeft = (hashString(cluster.key) % 2) === 0;
|
|
const chooseLeft = Math.abs(leftMass - rightMass) > cluster.mass * 0.35 ? leftMass <= rightMass : preferLeft;
|
|
cluster.side = chooseLeft ? 'left' : 'right';
|
|
if (chooseLeft) { left.push(cluster); leftMass += cluster.mass; }
|
|
else { right.push(cluster); rightMass += cluster.mass; }
|
|
cluster.radius = Math.max(0.08, Math.min(0.24, 0.08 + Math.sqrt(cluster.size) / 75));
|
|
}
|
|
|
|
seedHemis(left, -1);
|
|
seedHemis(right, 1);
|
|
relaxClusters(clusterList);
|
|
placeNodes(clusterList, degree);
|
|
state.clusters = clusterList;
|
|
state.clusterByKey = new Map(clusterList.map(cluster => [cluster.key, cluster]));
|
|
}
|
|
|
|
function seedHemis(list, sign) {
|
|
const total = Math.max(1, list.length);
|
|
for (let i = 0; i < list.length; i++) {
|
|
const cluster = list[i];
|
|
const t = (i + 0.6) / total;
|
|
const ring = Math.sqrt(t);
|
|
const angle = i * GOLDEN;
|
|
const x = sign * (0.18 + 0.26 * (0.25 + ring * 0.75));
|
|
const y = Math.cos(angle) * 0.56 * ring;
|
|
const z = Math.sin(angle) * 0.46 * ring;
|
|
Object.assign(cluster, clampBrain({x, y, z}, sign < 0 ? 'left' : 'right'));
|
|
}
|
|
}
|
|
|
|
function relaxClusters(clusterList) {
|
|
for (let iter = 0; iter < 70; iter++) {
|
|
const force = clusterList.map(() => ({x: 0, y: 0, z: 0}));
|
|
for (let i = 0; i < clusterList.length; i++) {
|
|
const a = clusterList[i];
|
|
for (let j = i + 1; j < clusterList.length; j++) {
|
|
const b = clusterList[j];
|
|
let dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
|
const dist = Math.max(0.001, Math.hypot(dx, dy, dz));
|
|
const ux = dx / dist, uy = dy / dist, uz = dz / dist;
|
|
const minDist = a.radius + b.radius + 0.06;
|
|
const repulsion = 0.0016 * Math.sqrt(a.mass * b.mass) / (dist * dist);
|
|
force[i].x -= ux * repulsion; force[i].y -= uy * repulsion; force[i].z -= uz * repulsion;
|
|
force[j].x += ux * repulsion; force[j].y += uy * repulsion; force[j].z += uz * repulsion;
|
|
if (dist < minDist) {
|
|
const push = (minDist - dist) * 0.022;
|
|
force[i].x -= ux * push; force[i].y -= uy * push; force[i].z -= uz * push;
|
|
force[j].x += ux * push; force[j].y += uy * push; force[j].z += uz * push;
|
|
}
|
|
const linkWeight = (a.links.get(b.key) || 0);
|
|
if (linkWeight > 0) {
|
|
const target = a.side === b.side ? 0.24 + Math.max(a.radius, b.radius) * 0.35 : 0.38 + Math.max(a.radius, b.radius) * 0.25;
|
|
const spring = (dist - target) * 0.0022 * Math.log1p(linkWeight);
|
|
force[i].x += ux * spring; force[i].y += uy * spring; force[i].z += uz * spring;
|
|
force[j].x -= ux * spring; force[j].y -= uy * spring; force[j].z -= uz * spring;
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < clusterList.length; i++) {
|
|
const cluster = clusterList[i];
|
|
const fx = force[i];
|
|
const centerX = cluster.side === 'left' ? -0.34 : 0.34;
|
|
fx.x += (centerX - cluster.x) * 0.012;
|
|
fx.y += (-0.02 - cluster.y) * 0.01;
|
|
fx.z += (0 - cluster.z) * 0.008;
|
|
cluster.x += Math.max(-0.03, Math.min(0.03, fx.x));
|
|
cluster.y += Math.max(-0.03, Math.min(0.03, fx.y));
|
|
cluster.z += Math.max(-0.03, Math.min(0.03, fx.z));
|
|
Object.assign(cluster, clampBrain(cluster, cluster.side));
|
|
}
|
|
}
|
|
}
|
|
|
|
function placeNodes(clusterList, degree) {
|
|
for (const cluster of clusterList) {
|
|
cluster.nodes.sort((a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0) || a.id.localeCompare(b.id));
|
|
const total = Math.max(1, cluster.nodes.length);
|
|
for (let i = 0; i < cluster.nodes.length; i++) {
|
|
const node = cluster.nodes[i];
|
|
node.clusterColor = cluster.color;
|
|
node.cluster = cluster;
|
|
const rank = i / total;
|
|
const spread = Math.pow(rank, 0.58);
|
|
const angleA = i * GOLDEN + pseudo(node.id, 1) * Math.PI * 2;
|
|
const angleB = Math.acos(1 - 2 * pseudo(node.id, 2));
|
|
const shell = cluster.radius * (0.18 + 0.92 * spread);
|
|
const density = 0.75 + pseudo(node.id, 3) * 0.5;
|
|
let ox = Math.cos(angleA) * Math.sin(angleB) * shell * density;
|
|
let oy = Math.sin(angleA) * Math.sin(angleB) * shell * density * 0.95;
|
|
let oz = Math.cos(angleB) * shell * (0.66 + pseudo(node.id, 4) * 0.24);
|
|
ox += (cluster.side === 'left' ? -1 : 1) * (0.015 + (1 - spread) * 0.018);
|
|
const pos = clampBrain({x: cluster.x + ox, y: cluster.y + oy, z: cluster.z + oz}, cluster.side);
|
|
node.x = pos.x;
|
|
node.y = pos.y;
|
|
node.z = pos.z;
|
|
}
|
|
}
|
|
}
|
|
|
|
function rotatePoint(n) {
|
|
const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw);
|
|
const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch);
|
|
const x1 = n.x * cy - n.z * sy;
|
|
const z1 = n.x * sy + n.z * cy;
|
|
const y1 = n.y * cp - z1 * sp;
|
|
const z2 = n.y * sp + z1 * cp;
|
|
return {x: x1, y: y1, z: z2};
|
|
}
|
|
|
|
function project(n) {
|
|
const r = rotatePoint(n);
|
|
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
|
|
const centerX = state.width / 2 + panelOffset;
|
|
const centerY = state.height / 2 - 4;
|
|
const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40));
|
|
const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom;
|
|
const perspective = 2.9 / (3.3 - r.z * 0.42);
|
|
return {x: centerX + r.x * scale * perspective, y: centerY + r.y * scale * perspective, z: r.z, p: perspective};
|
|
}
|
|
|
|
|
|
function rgba(color, alpha) {
|
|
return `rgba(${color[0]},${color[1]},${color[2]},${alpha})`;
|
|
}
|
|
|
|
function nearestAngle(current, target) {
|
|
let delta = (target - current + Math.PI) % (Math.PI * 2) - Math.PI;
|
|
if (delta < -Math.PI) delta += Math.PI * 2;
|
|
return current + delta;
|
|
}
|
|
|
|
function setVisualMode(mode, evt = null, strength = 1) {
|
|
const cfg = MODE_CONFIG[mode] || MODE_CONFIG.living;
|
|
const now = performance.now();
|
|
state.mode = mode;
|
|
state.modeUntil = cfg.duration ? Math.max(state.modeUntil, now + cfg.duration) : 0;
|
|
state.targetEnergy = mode === 'living' ? 0.08 : Math.min(1.35, 0.62 + strength * 0.58);
|
|
state.zoomTarget = mode === 'living' ? 1.02 : mode === 'thinking' ? 1.13 : mode === 'researching' ? 1.10 : 1.08;
|
|
const badge = $('visualMode');
|
|
if (badge) {
|
|
badge.className = `mode-status ${cfg.className}`;
|
|
const b = badge.querySelector('b');
|
|
const small = badge.querySelector('small');
|
|
if (b) b.textContent = cfg.label;
|
|
if (small) small.textContent = cfg.detail;
|
|
}
|
|
if (evt?.node_ids?.length) {
|
|
const focus = state.nodeById.get(evt.node_ids[0]);
|
|
if (focus) focusCamera(focus);
|
|
}
|
|
}
|
|
|
|
function focusCamera(node) {
|
|
state.focusNodeID = node.id;
|
|
state.focusClusterKey = node.clusterKey || '';
|
|
const radial = Math.max(0.05, Math.hypot(node.x, node.z));
|
|
state.cameraTargetYaw = nearestAngle(state.yaw, Math.atan2(node.x, node.z));
|
|
state.cameraTargetPitch = Math.max(-0.62, Math.min(0.62, Math.atan2(node.y, radial)));
|
|
}
|
|
|
|
function updateVisualState(now, dt) {
|
|
if (state.mode !== 'living' && now > state.modeUntil) {
|
|
state.mode = 'living';
|
|
state.focusClusterKey = '';
|
|
state.focusNodeID = '';
|
|
state.cameraTargetYaw = null;
|
|
state.cameraTargetPitch = null;
|
|
setVisualMode('living');
|
|
}
|
|
state.activityEnergy += (state.targetEnergy - state.activityEnergy) * Math.min(1, dt * 3.1);
|
|
if (state.mode === 'living') state.targetEnergy = 0.06 + Math.sin(now * 0.00043) * 0.025;
|
|
state.zoom += (state.zoomTarget - state.zoom) * Math.min(1, dt * 2.4);
|
|
if (!state.dragging && state.cameraTargetYaw !== null && state.mode !== 'living') {
|
|
state.yaw += (state.cameraTargetYaw - state.yaw) * Math.min(1, dt * 0.68);
|
|
state.pitch += (state.cameraTargetPitch - state.pitch) * Math.min(1, dt * 0.68);
|
|
} else if (state.autoRotate && !state.dragging) {
|
|
const speed = state.mode === 'living' ? 0.026 : 0.009;
|
|
state.yaw += dt * speed;
|
|
}
|
|
for (const [key, value] of state.clusterActive) {
|
|
const next = value - dt * (state.mode === 'living' ? 0.16 : 0.23);
|
|
if (next <= 0) state.clusterActive.delete(key); else state.clusterActive.set(key, next);
|
|
}
|
|
autonomousLivingPulse(now);
|
|
}
|
|
|
|
function autonomousLivingPulse(now) {
|
|
if (state.mode !== 'living' || now < state.nextAmbientAt || !state.clusters.length) return;
|
|
const candidates = state.clusters.slice(0, Math.min(18, state.clusters.length));
|
|
const cluster = candidates[state.ambientCursor % candidates.length];
|
|
state.ambientCursor++;
|
|
state.nextAmbientAt = now + 2400 + pseudo(cluster.key, state.ambientCursor) * 3600;
|
|
const count = Math.min(cluster.nodes.length, 3 + Math.floor(pseudo(cluster.key, state.ambientCursor + 1) * 4));
|
|
state.clusterActive.set(cluster.key, Math.max(state.clusterActive.get(cluster.key) || 0, 0.34));
|
|
for (let i = 0; i < count; i++) {
|
|
const node = cluster.nodes[(state.ambientCursor * 7 + i * 13) % cluster.nodes.length];
|
|
state.active.set(node.id, Math.max(state.active.get(node.id) || 0, 0.2 + pseudo(node.id, i) * 0.15));
|
|
const edges = (state.adjacency.get(node.id) || []).filter(edge => {
|
|
const otherID = edge.source === node.id ? edge.target : edge.source;
|
|
return state.nodeById.get(otherID)?.clusterKey === cluster.key;
|
|
});
|
|
if (edges.length) {
|
|
const edge = edges[(state.ambientCursor + i) % edges.length];
|
|
state.edgeActive.set(edge.id, Math.max(state.edgeActive.get(edge.id) || 0, 0.18));
|
|
state.particles.push({edgeId: edge.id, t: -0.03 * i, speed: 0.18 + pseudo(edge.id, i) * 0.22, color: clusterFill(cluster, 0.65), size: 0.62});
|
|
}
|
|
}
|
|
}
|
|
|
|
function eventMode(evt) {
|
|
if (!evt || evt.type === 'brain.idle') return 'living';
|
|
if (evt.type?.includes('research')) return 'researching';
|
|
if (evt.type?.includes('think')) return 'thinking';
|
|
if (evt.source === 'agent' || evt.source === 'knowledgebase' || evt.type?.includes('query')) return 'processing';
|
|
if (evt.type === 'graph.updated' || evt.type === 'scan.started' || evt.type === 'embedding.batch') return 'learning';
|
|
return 'processing';
|
|
}
|
|
|
|
function modeParticleColor(mode) {
|
|
return rgba((MODE_CONFIG[mode] || MODE_CONFIG.living).color, 0.92);
|
|
}
|
|
|
|
function projectAnimatedNode(node, now) {
|
|
const cluster = node.cluster;
|
|
let x = node.x, y = node.y, z = node.z;
|
|
if (cluster) {
|
|
const clusterEnergy = state.clusterActive.get(cluster.key) || 0;
|
|
const ambient = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * 0.012;
|
|
const expansion = ambient + clusterEnergy * 0.055 + (cluster.key === state.focusClusterKey ? state.activityEnergy * 0.028 : 0);
|
|
x = cluster.x + (node.x - cluster.x) * expansion;
|
|
y = cluster.y + (node.y - cluster.y) * expansion;
|
|
z = cluster.z + (node.z - cluster.z) * expansion;
|
|
}
|
|
const cy = Math.cos(state.yaw), sy = Math.sin(state.yaw);
|
|
const cp = Math.cos(state.pitch), sp = Math.sin(state.pitch);
|
|
const x1 = x * cy - z * sy;
|
|
const z1 = x * sy + z * cy;
|
|
const y1 = y * cp - z1 * sp;
|
|
const z2 = y * sp + z1 * cp;
|
|
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
|
|
const centerX = state.width / 2 + panelOffset;
|
|
const centerY = state.height / 2 - 4;
|
|
const usableW = Math.max(360, state.width - (state.width > 900 ? 470 : 40));
|
|
const scale = Math.min(usableW * 0.42, state.height * 0.45) * state.zoom;
|
|
const perspective = 2.9 / (3.3 - z2 * 0.42);
|
|
return {x: centerX + x1 * scale * perspective, y: centerY + y1 * scale * perspective, z: z2, p: perspective};
|
|
}
|
|
|
|
function drawBackground(now) {
|
|
const g = ctx.createRadialGradient(state.width * 0.56, state.height * 0.48, 24, state.width * 0.56, state.height * 0.48, Math.max(state.width, state.height) * 0.8);
|
|
g.addColorStop(0, '#071827');
|
|
g.addColorStop(0.52, '#020711');
|
|
g.addColorStop(1, '#010207');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, state.width, state.height);
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.16;
|
|
for (let i = 0; i < 90; i++) {
|
|
const x = (i * 193.7 + now * 0.002 * (i % 3 + 1)) % state.width;
|
|
const y = (i * 97.3) % state.height;
|
|
ctx.fillStyle = i % 9 === 0 ? '#52e7ff' : '#5d7890';
|
|
const size = i % 11 === 0 ? 1.5 : 0.8;
|
|
ctx.fillRect(x, y, size, size);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderActivityAura(now) {
|
|
const cfg = MODE_CONFIG[state.mode] || MODE_CONFIG.living;
|
|
const panelOffset = state.width > 1000 ? 110 : state.width > 780 ? 60 : 0;
|
|
const cx = state.width / 2 + panelOffset;
|
|
const cy = state.height / 2 - 4;
|
|
const radius = Math.min(state.height * 0.48, Math.max(280, state.width * 0.34));
|
|
const energy = Math.max(0.035, state.activityEnergy);
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'screen';
|
|
const aura = ctx.createRadialGradient(cx, cy, radius * 0.08, cx, cy, radius * 1.18);
|
|
aura.addColorStop(0, rgba(cfg.color, 0.025 + energy * 0.038));
|
|
aura.addColorStop(0.52, rgba(cfg.color, 0.012 + energy * 0.025));
|
|
aura.addColorStop(1, rgba(cfg.color, 0));
|
|
ctx.fillStyle = aura;
|
|
ctx.fillRect(cx - radius * 1.25, cy - radius * 1.25, radius * 2.5, radius * 2.5);
|
|
if (state.mode !== 'living') {
|
|
const beat = 0.5 + 0.5 * Math.sin(now * 0.0075);
|
|
ctx.strokeStyle = rgba(cfg.color, 0.035 + energy * 0.07 * beat);
|
|
ctx.lineWidth = 1 + energy * 1.5;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius * (0.58 + beat * 0.025), 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderClusterClouds(now) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'screen';
|
|
for (const cluster of state.clusters) {
|
|
const p = project(cluster);
|
|
cluster.screen = p;
|
|
const active = state.clusterActive.get(cluster.key) || 0;
|
|
const focus = cluster.key === state.focusClusterKey ? state.activityEnergy : 0;
|
|
const breathe = 1 + Math.sin(now * 0.00072 + hashString(cluster.key) * 0.00001) * 0.035;
|
|
const radius = Math.max(44, cluster.radius * 230 * p.p * breathe * (1 + active * 0.08 + focus * 0.1));
|
|
const alpha = 0.045 + cluster.importance * 0.026 + active * 0.08 + focus * 0.09;
|
|
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
|
|
g.addColorStop(0, clusterFill(cluster, Math.min(0.25, alpha * 1.8)));
|
|
g.addColorStop(0.32, clusterFill(cluster, Math.min(0.13, alpha)));
|
|
g.addColorStop(0.78, clusterFill(cluster, 0.012 + focus * 0.025));
|
|
g.addColorStop(1, clusterFill(cluster, 0));
|
|
ctx.fillStyle = g;
|
|
ctx.beginPath();
|
|
ctx.ellipse(p.x, p.y, radius, radius * (0.68 + Math.max(-0.12, Math.min(0.12, p.z * 0.16))), p.z * 0.18, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
if (state.cortexVisible && (cluster.importance > 0.62 || active > 0.15 || focus > 0.1)) {
|
|
ctx.setLineDash([3, 7]);
|
|
ctx.strokeStyle = clusterFill(cluster, 0.055 + active * 0.13 + focus * 0.16);
|
|
ctx.lineWidth = 0.65 + focus * 1.1;
|
|
ctx.beginPath();
|
|
ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, p.z * 0.18, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderCortexLabels() {
|
|
if (!state.cortexVisible || state.width < 850) return;
|
|
const visible = state.clusters
|
|
.filter(cluster => cluster.screen && (cluster.importance > 0.52 || cluster.key === state.focusClusterKey))
|
|
.sort((a, b) => (b.key === state.focusClusterKey) - (a.key === state.focusClusterKey) || b.mass - a.mass)
|
|
.slice(0, state.width > 1450 ? 12 : 8);
|
|
const occupied = [];
|
|
ctx.save();
|
|
ctx.font = '600 10px Inter, system-ui';
|
|
ctx.textBaseline = 'middle';
|
|
for (const cluster of visible) {
|
|
const p = cluster.screen;
|
|
const label = cluster.label.length > 28 ? cluster.label.slice(0, 26) + '…' : cluster.label;
|
|
const count = cluster.size.toLocaleString('de-DE');
|
|
const text = `${label} · ${count}`;
|
|
const w = ctx.measureText(text).width + 16;
|
|
let x = p.x + (cluster.side === 'left' ? -w - 16 : 16);
|
|
let y = p.y - 8;
|
|
x = Math.max(372, Math.min(state.width - w - 24, x));
|
|
y = Math.max(100, Math.min(state.height - 78, y));
|
|
if (occupied.some(box => Math.abs(box.x - x) < (box.w + w) * 0.48 && Math.abs(box.y - y) < 22)) continue;
|
|
occupied.push({x, y, w});
|
|
const focus = cluster.key === state.focusClusterKey;
|
|
ctx.strokeStyle = clusterFill(cluster, focus ? 0.75 : 0.28);
|
|
ctx.lineWidth = focus ? 1.25 : 0.6;
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x, p.y);
|
|
ctx.lineTo(cluster.side === 'left' ? x + w : x, y + 8);
|
|
ctx.stroke();
|
|
ctx.fillStyle = focus ? 'rgba(3,10,18,.94)' : 'rgba(3,10,18,.78)';
|
|
ctx.fillRect(x, y, w, 17);
|
|
ctx.strokeStyle = clusterFill(cluster, focus ? 0.65 : 0.18);
|
|
ctx.strokeRect(x, y, w, 17);
|
|
ctx.fillStyle = clusterFill(cluster, focus ? 1 : 0.82);
|
|
ctx.fillText(text, x + 8, y + 8.5);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderEdges() {
|
|
if (!state.edgesVisible) return;
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'screen';
|
|
for (const e of state.edges) {
|
|
const a = state.nodeById.get(e.source), b = state.nodeById.get(e.target);
|
|
if (!a?.screen || !b?.screen) continue;
|
|
const active = state.edgeActive.get(e.id) || 0;
|
|
const base = active > 0.01 ? 0.08 + active * 0.7 : e.origin === 'ai-inference' ? 0.03 : e.type === 'categorized_as' ? 0.02 : 0.009;
|
|
const alpha = Math.min(0.85, base);
|
|
if (alpha < 0.012) continue;
|
|
const mx = (a.screen.x + b.screen.x) / 2;
|
|
const my = (a.screen.y + b.screen.y) / 2 - Math.abs(a.screen.x - b.screen.x) * 0.035;
|
|
ctx.beginPath();
|
|
ctx.moveTo(a.screen.x, a.screen.y);
|
|
ctx.quadraticCurveTo(mx, my, b.screen.x, b.screen.y);
|
|
ctx.strokeStyle = e.origin === 'ai-inference' ? `rgba(255,180,82,${alpha})` : `rgba(82,181,255,${alpha})`;
|
|
ctx.lineWidth = 0.25 + active * 1.7 + (e.confidence || 0) * 0.2;
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderParticles(dt) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'lighter';
|
|
for (let i = state.particles.length - 1; i >= 0; i--) {
|
|
const p = state.particles[i];
|
|
p.t += dt * p.speed;
|
|
const e = state.edgeById.get(p.edgeId), a = e && state.nodeById.get(e.source), b = e && state.nodeById.get(e.target);
|
|
if (!e || !a?.screen || !b?.screen || p.t > 1.08) {
|
|
state.particles.splice(i, 1);
|
|
continue;
|
|
}
|
|
const q = Math.min(1, p.t);
|
|
const ease = q * q * (3 - 2 * q);
|
|
const x = a.screen.x + (b.screen.x - a.screen.x) * ease;
|
|
const y = a.screen.y + (b.screen.y - a.screen.y) * ease - Math.sin(q * Math.PI) * 18;
|
|
const size = 8 * (p.size || 1) * (0.82 + state.activityEnergy * 0.28);
|
|
const grad = ctx.createRadialGradient(x, y, 0, x, y, size);
|
|
grad.addColorStop(0, 'rgba(255,255,255,.95)');
|
|
grad.addColorStop(0.22, p.color);
|
|
grad.addColorStop(1, 'rgba(82,231,255,0)');
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function renderNodes(now, dt) {
|
|
state.projected = [];
|
|
for (const n of state.nodes) {
|
|
n.screen = projectAnimatedNode(n, now);
|
|
state.projected.push(n);
|
|
}
|
|
state.projected.sort((a, b) => a.screen.z - b.screen.z);
|
|
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'lighter';
|
|
for (const n of state.projected) {
|
|
let act = state.active.get(n.id) || 0;
|
|
act = Math.max(0, act - dt * 0.44);
|
|
if (act > 0) state.active.set(n.id, act); else state.active.delete(n.id);
|
|
const hover = state.hover?.id === n.id;
|
|
const selected = state.selected?.id === n.id;
|
|
const degree = Math.min(18, (state.adjacency.get(n.id) || []).length);
|
|
const baseWeight = Math.max(0.25, Math.min(2.5, (n.weight || 1) * 0.85 + Math.sqrt(degree) * 0.05));
|
|
const breathe = 0.5 + 0.5 * Math.sin(now * 0.0014 + n.x * 7 + n.y * 9);
|
|
const clusterEnergy = state.clusterActive.get(n.clusterKey) || 0;
|
|
const focused = n.clusterKey === state.focusClusterKey ? state.activityEnergy : 0;
|
|
const r = (1.05 + baseWeight * 0.7 + n.screen.p * 0.55) * (1 + act * 0.48 + clusterEnergy * 0.06 + focused * 0.025) + (hover || selected ? 1.6 : 0);
|
|
const idleDim = state.mode === 'living' ? 0 : 0.025;
|
|
const alpha = Math.min(1, 0.17 - idleDim + n.screen.p * 0.1 + Math.min(0.22, degree * 0.006) + act * 0.46 + clusterEnergy * 0.08 + focused * 0.04 + (hover || selected ? 0.18 : 0));
|
|
if (act > 0.05 || hover || selected || n.kind === 'ai-think') {
|
|
const halo = ctx.createRadialGradient(n.screen.x, n.screen.y, 0, n.screen.x, n.screen.y, r * (3 + act * 4));
|
|
halo.addColorStop(0, nodeColor(n, 0.48 + act * 0.35));
|
|
halo.addColorStop(0.24, nodeColor(n, 0.14 + act * 0.2));
|
|
halo.addColorStop(1, nodeColor(n, 0));
|
|
ctx.fillStyle = halo;
|
|
ctx.beginPath();
|
|
ctx.arc(n.screen.x, n.screen.y, r * (3 + act * 4), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.fillStyle = nodeColor(n, alpha);
|
|
ctx.beginPath();
|
|
ctx.arc(n.screen.x, n.screen.y, r * (0.84 + breathe * 0.1), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
if (n.kind === 'ai-think') {
|
|
ctx.strokeStyle = nodeColor(n, 0.55);
|
|
ctx.lineWidth = 0.7;
|
|
ctx.beginPath();
|
|
ctx.arc(n.screen.x, n.screen.y, r * 2 + breathe * 1.8, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
|
|
if (state.labels) {
|
|
ctx.save();
|
|
ctx.font = '10px Inter, system-ui';
|
|
ctx.textBaseline = 'middle';
|
|
for (const n of state.projected) {
|
|
const act = state.active.get(n.id) || 0;
|
|
if (!(act > 0.45 || state.hover?.id === n.id || state.selected?.id === n.id || (n.kind === 'ai-think' && n.screen.p > 1.02))) continue;
|
|
const label = n.label.length > 36 ? n.label.slice(0, 34) + '…' : n.label;
|
|
const w = ctx.measureText(label).width + 12;
|
|
ctx.fillStyle = 'rgba(2,7,14,.82)';
|
|
ctx.fillRect(n.screen.x + 8, n.screen.y - 8, w, 16);
|
|
ctx.fillStyle = nodeColor(n, 0.95);
|
|
ctx.fillText(label, n.screen.x + 14, n.screen.y);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
function renderWaves(dt) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = 'screen';
|
|
for (let i = state.waves.length - 1; i >= 0; i--) {
|
|
const w = state.waves[i];
|
|
w.life -= dt * (w.decay || 1);
|
|
w.r += dt * (w.speed || 130);
|
|
if (w.life <= 0) {
|
|
state.waves.splice(i, 1);
|
|
continue;
|
|
}
|
|
const color = w.color || MODE_CONFIG.living.color;
|
|
ctx.strokeStyle = rgba(color, w.life * (w.alpha || 0.22));
|
|
ctx.lineWidth = (w.width || 1.3) * (0.8 + state.activityEnergy * 0.45);
|
|
if (w.dashed) ctx.setLineDash([4, 8]);
|
|
ctx.beginPath();
|
|
ctx.arc(w.x, w.y, w.r, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
for (let i = state.bursts.length - 1; i >= 0; i--) {
|
|
const burst = state.bursts[i];
|
|
burst.life -= dt * 0.72;
|
|
if (burst.life <= 0) {
|
|
state.bursts.splice(i, 1);
|
|
continue;
|
|
}
|
|
const rayCount = burst.rays || 16;
|
|
const length = (1 - burst.life) * (burst.length || 115) + 18;
|
|
ctx.strokeStyle = rgba(burst.color, burst.life * 0.18);
|
|
ctx.lineWidth = 0.7;
|
|
for (let ray = 0; ray < rayCount; ray++) {
|
|
const angle = ray / rayCount * Math.PI * 2 + burst.spin;
|
|
const inner = 9 + (1 - burst.life) * 13;
|
|
ctx.beginPath();
|
|
ctx.moveTo(burst.x + Math.cos(angle) * inner, burst.y + Math.sin(angle) * inner);
|
|
ctx.lineTo(burst.x + Math.cos(angle) * length, burst.y + Math.sin(angle) * length);
|
|
ctx.stroke();
|
|
}
|
|
burst.spin += dt * 0.35;
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function frame(now) {
|
|
const dt = Math.min(0.05, (now - state.last) / 1000);
|
|
state.last = now;
|
|
updateVisualState(now, dt);
|
|
drawBackground(now);
|
|
renderActivityAura(now);
|
|
renderClusterClouds(now);
|
|
renderEdges();
|
|
renderParticles(dt);
|
|
renderNodes(now, dt);
|
|
renderWaves(dt);
|
|
renderCortexLabels();
|
|
for (const [id, value] of state.edgeActive) {
|
|
const next = value - dt * (state.mode === 'living' ? 0.34 : 0.5);
|
|
if (next <= 0) state.edgeActive.delete(id); else state.edgeActive.set(id, next);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
|
|
function activate(evt) {
|
|
const strength = Math.max(0.15, Math.min(1.4, evt.strength || 0.6));
|
|
const mode = eventMode(evt);
|
|
const substep = evt.type === 'node.activated' || evt.type === 'edges.traversed';
|
|
if (evt.type !== 'brain.idle' && !substep) setVisualMode(mode, evt, strength);
|
|
if (substep && !state.focusClusterKey && evt.node_ids?.length) {
|
|
const focus = state.nodeById.get(evt.node_ids[0]);
|
|
if (focus) focusCamera(focus);
|
|
}
|
|
const modeColor = (MODE_CONFIG[mode] || MODE_CONFIG.living).color;
|
|
for (const id of evt.node_ids || []) {
|
|
const node = state.nodeById.get(id);
|
|
state.active.set(id, Math.max(state.active.get(id) || 0, strength));
|
|
if (node?.clusterKey) state.clusterActive.set(node.clusterKey, Math.max(state.clusterActive.get(node.clusterKey) || 0, strength));
|
|
}
|
|
for (const id of evt.edge_ids || []) {
|
|
state.edgeActive.set(id, Math.max(state.edgeActive.get(id) || 0, strength));
|
|
const count = evt.type === 'brain.idle' ? 1 : Math.ceil(3 + strength * (mode === 'thinking' ? 7 : 5));
|
|
for (let i = 0; i < count; i++) {
|
|
state.particles.push({edgeId: id, t: -i * 0.065, speed: 0.32 + Math.random() * (mode === 'thinking' ? 0.9 : 0.62), color: modeParticleColor(mode), size: mode === 'thinking' ? 1.12 : mode === 'researching' ? 0.95 : 0.86});
|
|
}
|
|
}
|
|
if (evt.type !== 'brain.idle') {
|
|
state.pulses++;
|
|
$('pulseCount').textContent = state.pulses.toLocaleString('de-DE');
|
|
const focus = evt.node_ids?.[0] ? state.nodeById.get(evt.node_ids[0]) : null;
|
|
const x = focus?.screen?.x ?? state.width / 2;
|
|
const y = focus?.screen?.y ?? state.height / 2;
|
|
const waveCount = mode === 'thinking' ? 3 : mode === 'researching' ? 2 : 1;
|
|
for (let i = 0; i < waveCount; i++) {
|
|
state.waves.push({x, y, r: 18 + i * 17, life: 1 - i * 0.08, color: modeColor, speed: 118 + i * 35, width: mode === 'thinking' ? 1.8 : 1.25, alpha: mode === 'thinking' ? 0.3 : 0.24, dashed: mode === 'researching' && i === 1});
|
|
}
|
|
if (mode === 'thinking' || mode === 'researching') state.bursts.push({x, y, life: 1, color: modeColor, rays: mode === 'thinking' ? 22 : 16, length: mode === 'thinking' ? 150 : 115, spin: pseudo(evt.id || evt.type, 9) * Math.PI});
|
|
}
|
|
addLog(evt);
|
|
if (evt.type === 'graph.updated') loadGraph();
|
|
}
|
|
|
|
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.started', 'think.created', 'think.rejected', 'think.failed', 'think.paused', 'research.started', 'agent.run']);
|
|
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;
|
|
const now = Date.now();
|
|
const cooldown = evt.type === 'embedding.batch' ? 45000 : evt.type === 'graph.updated' ? 15000 : 4000;
|
|
if (now - last < cooldown) return false;
|
|
state.lastLogFingerprint.set(fingerprint, now);
|
|
return true;
|
|
}
|
|
|
|
function formatEvent(evt) {
|
|
const time = new Date(evt.timestamp || Date.now()).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
|
|
const nodeObjects = (evt.node_ids || []).map(id => state.nodeById.get(id)).filter(Boolean);
|
|
const nodes = nodeObjects.map(node => node.label);
|
|
const regions = [...new Set(nodeObjects.map(node => node.clusterKey).filter(Boolean))].slice(0, 3);
|
|
const titleMap = {
|
|
'scan.started': 'Synchronisation',
|
|
'graph.updated': 'Graph aktualisiert',
|
|
'embedding.batch': 'Embeddings',
|
|
'query.started': evt.source === 'knowledgebase' ? 'Knowledgebase-Suche' : evt.source === 'agent' ? 'Agent-Suche' : 'Suchanfrage',
|
|
'query.completed': 'Suche abgeschlossen',
|
|
'think.started': 'AI-THINK prüft Zusammenhang',
|
|
'think.created': 'AI-THINK erstellt Beitrag',
|
|
'think.rejected': 'AI-THINK verwirft Edge',
|
|
'think.failed': 'AI-THINK Fehler',
|
|
'think.paused': 'AI-THINK pausiert',
|
|
'research.started': 'Recherche gestartet',
|
|
'agent.run': 'Agent-Lauf'
|
|
};
|
|
const title = titleMap[evt.type] || evt.phase || evt.source || 'Aktivität';
|
|
const meta = [];
|
|
if (evt.metadata?.nodes) meta.push(`${Number(evt.metadata.nodes).toLocaleString('de-DE')} Nodes`);
|
|
if (evt.metadata?.edges) meta.push(`${Number(evt.metadata.edges).toLocaleString('de-DE')} Edges`);
|
|
if (evt.metadata?.duration_ms) meta.push(`${Number(evt.metadata.duration_ms).toLocaleString('de-DE')} ms`);
|
|
if (evt.metadata?.hit_count) meta.push(`${Number(evt.metadata.hit_count).toLocaleString('de-DE')} Treffer`);
|
|
if (evt.metadata?.used_nodes) meta.push(`${Number(evt.metadata.used_nodes).toLocaleString('de-DE')} Quellen`);
|
|
if (evt.metadata?.batch_count) meta.push(`Batch ${Number(evt.metadata.batch_count).toLocaleString('de-DE')}`);
|
|
if (evt.metadata?.semantic_similarity !== undefined) meta.push(`${Math.round(Number(evt.metadata.semantic_similarity) * 100)}% Nähe`);
|
|
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.node_ids || []).length) meta.push(`${evt.node_ids.length} aktive Knoten`);
|
|
if ((evt.edge_ids || []).length) meta.push(`${evt.edge_ids.length} aktive Kanten`);
|
|
if (evt.metadata?.ticket_id) meta.push(`Ticket ${evt.metadata.ticket_id}`);
|
|
if (evt.metadata?.outcome) meta.push(String(evt.metadata.outcome));
|
|
if (evt.metadata?.path) meta.push(String(evt.metadata.path).split('/').slice(-2).join('/'));
|
|
const eventQuery = evt.query || evt.metadata?.research_query || '';
|
|
if (eventQuery) meta.push('Query');
|
|
let message = evt.message || eventQuery || evt.type;
|
|
if ((evt.type === 'think.started' || evt.type === 'think.created' || evt.type === 'think.rejected' || evt.type === 'research.started') && nodes.length >= 2) {
|
|
message = `${message} · ${nodes.slice(0, 2).join(' ↔ ')}`;
|
|
} 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') ? 'think' : evt.type?.includes('research') ? 'research' : evt.type === 'graph.updated' || evt.type === 'scan.started' ? 'graph' : evt.source === 'agent' ? 'agent' : ''};
|
|
}
|
|
|
|
function addLog(evt) {
|
|
if (!shouldLog(evt)) return;
|
|
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>` : ''}`;
|
|
const log = $('activityLog');
|
|
log.prepend(item);
|
|
while (log.children.length > 18) log.removeChild(log.lastChild);
|
|
}
|
|
|
|
const stream = new EventSource('/api/stream');
|
|
stream.addEventListener('activity', e => {
|
|
try {
|
|
activate(JSON.parse(e.data));
|
|
setSystem('lebt', true);
|
|
} catch {}
|
|
});
|
|
stream.onerror = () => setSystem('verbindet', false);
|
|
stream.onopen = () => setSystem('lebt', true);
|
|
|
|
$('toggleLabels').addEventListener('click', e => {
|
|
state.labels = !state.labels;
|
|
e.currentTarget.classList.toggle('active', state.labels);
|
|
});
|
|
$('toggleEdges').addEventListener('click', e => {
|
|
state.edgesVisible = !state.edgesVisible;
|
|
e.currentTarget.classList.toggle('active', state.edgesVisible);
|
|
});
|
|
$('toggleRotate').addEventListener('click', e => {
|
|
state.autoRotate = !state.autoRotate;
|
|
e.currentTarget.classList.toggle('active', state.autoRotate);
|
|
});
|
|
$('toggleCortex').addEventListener('click', e => {
|
|
state.cortexVisible = !state.cortexVisible;
|
|
e.currentTarget.classList.toggle('active', state.cortexVisible);
|
|
});
|
|
$('resetView').addEventListener('click', () => {
|
|
state.yaw = 0.18;
|
|
state.pitch = -0.12;
|
|
state.zoom = 1.02;
|
|
});
|
|
$('clearLog').addEventListener('click', () => {$('activityLog').innerHTML = '';});
|
|
|
|
canvas.addEventListener('pointerdown', e => {
|
|
state.dragging = true;
|
|
state.moved = false;
|
|
state.lastX = e.clientX;
|
|
state.lastY = e.clientY;
|
|
canvas.setPointerCapture(e.pointerId);
|
|
});
|
|
|
|
canvas.addEventListener('pointermove', e => {
|
|
if (state.dragging) {
|
|
const dx = e.clientX - state.lastX;
|
|
const dy = e.clientY - state.lastY;
|
|
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) state.moved = true;
|
|
state.yaw += dx * 0.006;
|
|
state.pitch = Math.max(-1, Math.min(1, state.pitch + dy * 0.005));
|
|
state.lastX = e.clientX;
|
|
state.lastY = e.clientY;
|
|
return;
|
|
}
|
|
let best = null, dist = 14;
|
|
for (const n of state.projected) {
|
|
if (!n.screen) continue;
|
|
const d = Math.hypot(n.screen.x - e.clientX, n.screen.y - e.clientY);
|
|
if (d < dist) {
|
|
dist = d;
|
|
best = n;
|
|
}
|
|
}
|
|
state.hover = best;
|
|
const tip = $('tooltip');
|
|
if (best) {
|
|
const degree = (state.adjacency.get(best.id) || []).length;
|
|
tip.classList.remove('hidden');
|
|
tip.innerHTML = `<strong>${escapeHTML(best.label)}</strong><small>${escapeHTML((best.categories || []).slice(0, 3).join(' · ') || best.kind || 'Knoten')}<br>${degree} Verbindungen · ${escapeHTML(best.status || 'aktiv')}</small>`;
|
|
tip.style.left = Math.min(state.width - 260, e.clientX + 14) + 'px';
|
|
tip.style.top = Math.min(state.height - 70, e.clientY + 14) + 'px';
|
|
} else {
|
|
tip.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('pointerup', e => {
|
|
if (!state.moved && state.hover) {
|
|
state.selected = state.hover;
|
|
state.active.set(state.hover.id, 1.2);
|
|
}
|
|
state.dragging = false;
|
|
});
|
|
canvas.addEventListener('pointercancel', () => state.dragging = false);
|
|
canvas.addEventListener('wheel', e => {
|
|
e.preventDefault();
|
|
state.zoom = Math.max(0.55, Math.min(2.4, state.zoom * Math.exp(-e.deltaY * 0.001)));
|
|
}, {passive: false});
|
|
|
|
function escapeHTML(v) {
|
|
return String(v ?? '').replace(/[&<>'"]/g, c => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[c]));
|
|
}
|
|
|
|
loadGraph();
|
|
setInterval(loadGraph, 30000);
|
|
})();
|