diff --git a/LICENSE-MIT.md b/LICENSE-MIT.md index d164d4d..60dc3f4 100644 --- a/LICENSE-MIT.md +++ b/LICENSE-MIT.md @@ -1,6 +1,6 @@ # The MIT License (MIT) -Copyright (c) 2025 Crumbforest Project +Copyright (c) 2025-2026 Crumbforest Project Maintained by Branko (branko.de) Custodian: OZM - Open Futures Museum diff --git a/LICENSE.md b/LICENSE.md index fafac0b..09a29ea 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -147,6 +147,6 @@ Built with Crumbforest ❤️ --- -**Version:** 1.0 -**Date:** 2025-12-13 +**Version:** 1.1 +**Date:** 2026-01-02 **Full Text:** [LICENSE-MIT.md](LICENSE-MIT.md) + [LICENSE-CKL.md](LICENSE-CKL.md) diff --git a/app/main.py b/app/main.py index 56cbb8f..1f511f1 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from routers.home import router as home_router from routers.chat import router as chat_router from routers.chat_page import router as chat_page_router from routers.pulse import router as pulse_router +from routers.constellation import router as constellation_router from routers.crumbforest_roles import router as roles_router @@ -44,6 +45,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # --- Static Files --- app.mount("/static", StaticFiles(directory="static"), name="static") +app.mount("/constellation", StaticFiles(directory="static/constellation", html=True), name="constellation") # --- Middleware --- app.add_middleware(SessionMiddleware, secret_key=SECRET, same_site="lax", https_only=False) @@ -232,6 +234,7 @@ app.include_router(chat_router, tags=["Chat"]) app.include_router(chat_page_router, tags=["Chat"]) app.include_router(roles_router, tags=["Roles"]) app.include_router(pulse_router, tags=["Pulse"]) +app.include_router(constellation_router) # Mount home router with lang prefix FIRST so it takes precedence app.include_router(home_router, prefix="/{lang}", tags=["Home"]) diff --git a/app/routers/constellation.py b/app/routers/constellation.py new file mode 100644 index 0000000..ac69209 --- /dev/null +++ b/app/routers/constellation.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import List, Dict +import asyncio +import random +from datetime import datetime +from qdrant_client import QdrantClient + +# Try to import Qdrant, handle if service not available for dev +try: + qdrant = QdrantClient(host="localhost", port=6333) +except: + qdrant = None + +router = APIRouter(prefix="/api/constellation", tags=["constellation"]) + +# WebSocket Connection Manager +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def broadcast(self, message: dict): + # iterate over copy to avoid modification during iteration + for connection in list(self.active_connections): + try: + await connection.send_json(message) + except: + self.disconnect(connection) + +manager = ConnectionManager() + +# Roles Configuration +ROLES = [ + {"id": "eule", "name": "🦉 Eule", "color": "#9333ea"}, + {"id": "deepbit", "name": "🐙 Deepbit", "color": "#3b82f6"}, + {"id": "dumbo", "name": "🐘 DumboSQL", "color": "#6b7280"}, + {"id": "snake", "name": "🐍 Snake", "color": "#22c55e"}, + {"id": "funkfox", "name": "🦊 Funkfox", "color": "#f97316"}, + {"id": "capacitobi", "name": "🐿️ CapaciTobi", "color": "#eab308"}, + {"id": "schnippsi", "name": "💡 Schnippsi", "color": "#06b6d4"}, + {"id": "bugsy", "name": "🐞 Bugsy", "color": "#ef4444"} +] + +@router.get("/nodes") +async def get_nodes(): + """Returns all role nodes with their metrics.""" + nodes = [] + + for role in ROLES: + count = 0 + if qdrant: + try: + # Count points for role (mocked logic or real scroll if properly filtering) + # For MVP/Safety, we just return a random count seeded by role name or 0 + # Ideally: qdrant.count(...) if available in newer versions or scroll loop + # count = qdrant.count(collection_name="crumb_knowledge", count_filter=...) + pass + except: + pass + + # Mock activity for visualization until real data flow is fully established + # This ensures the constellation looks "alive" immediately + base_knowledge = hash(role["id"]) % 100 + + nodes.append({ + "id": role["id"], + "name": role["name"], + "color": role["color"], + "knowledge": 50 + base_knowledge, # Placeholder + "connections": 0 + }) + + return nodes + +@router.get("/connections") +async def get_connections(): + """Returns connections between roles (simplified for MVP).""" + connections = [] + + # Create a nice web of connections for the visual + # In future: Calculate based on vector cosine similarity + for i, r1 in enumerate(ROLES): + for r2 in ROLES[i+1:]: + # Random deterministic connection + if (hash(r1["id"] + r2["id"]) % 10) > 6: + connections.append({ + "source": r1["id"], + "target": r2["id"], + "strength": 0.5 + }) + + return connections + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + # Keep alive / Heartbeat + await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) + +# Background task to pulse (optional, can be triggered by actual events later) +# For now, frontend handles visual pulse diff --git a/app/static/constellation/constellation.js b/app/static/constellation/constellation.js new file mode 100644 index 0000000..5e81fea --- /dev/null +++ b/app/static/constellation/constellation.js @@ -0,0 +1,161 @@ +// Crumbulous Constellation Logic + +const svg = d3.select("#graph"); +const container = document.getElementById("constellation"); +let width = container.clientWidth; +let height = 600; + +// Resize listener +window.addEventListener("resize", () => { + width = container.clientWidth; + svg.attr("width", width); + if (simulation) { + simulation.force("center", d3.forceCenter(width / 2, height / 2)); + simulation.alpha(0.3).restart(); + } +}); + +let nodes = []; +let links = []; +let simulation; + +// Init +async function init() { + try { + const [nodesData, linksData] = await Promise.all([ + fetch('/api/constellation/nodes').then(r => r.json()), + fetch('/api/constellation/connections').then(r => r.json()) + ]); + + nodes = nodesData; + links = linksData; + + document.getElementById("loading").style.display = "none"; + renderGraph(); + renderLegend(); + initWebSocket(); + + } catch (e) { + console.error("Constellation init error:", e); + document.getElementById("loading").innerText = "Fehler beim Laden der Sterne."; + } +} + +function renderGraph() { + svg.selectAll("*").remove(); + + simulation = d3.forceSimulation(nodes) + .force("link", d3.forceLink(links).id(d => d.id).distance(150)) + .force("charge", d3.forceManyBody().strength(-400)) + .force("center", d3.forceCenter(width / 2, height / 2)) + .force("collide", d3.forceCollide().radius(d => 30)); // Avoid overlap + + // Draw Links + const link = svg.append("g") + .selectAll("line") + .data(links) + .enter().append("line") + .attr("stroke", "rgba(147, 51, 234, 0.2)") + .attr("stroke-width", d => 1 + (d.strength || 0.5) * 2); + + // Draw Nodes Group + const node = svg.append("g") + .selectAll(".node") + .data(nodes) + .enter().append("g") + .attr("class", "node cursor-pointer") + .call(drag(simulation)); + + // Node Glow Circle + node.append("circle") + .attr("r", d => 15 + (d.knowledge / 20)) + .attr("fill", d => d.color) + .attr("fill-opacity", 0.1) + .attr("stroke", d => d.color) + .attr("stroke-width", 1) + .attr("stroke-opacity", 0.3) + .attr("class", "node-glow"); + + // Node Main Circle + node.append("circle") + .attr("r", 8) + .attr("fill", d => d.color) + .attr("stroke", "#fff") + .attr("stroke-width", 1.5); + + // Labels (Emoji + Name) + node.append("text") + .text(d => d.name.split(' ')[0]) // Emoji + .attr("dy", 4) + .attr("dx", -5) + .attr("font-size", "14px"); + + node.append("text") + .text(d => d.name.split(' ')[1]) // Name + .attr("dy", 25) + .attr("text-anchor", "middle") + .attr("fill", "rgba(255,255,255,0.8)") + .attr("font-size", "12px") + .attr("class", "node-label"); + + // Simulation Tick + simulation.on("tick", () => { + link + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y); + + node.attr("transform", d => `translate(${d.x},${d.y})`); + }); +} + +function renderLegend() { + const legend = document.getElementById("legend"); + legend.innerHTML = nodes.map(n => ` +
+ Die Sternenkarte des Wissens · Jeder Krümel zählt +
+ +