Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>TUM Neural Knowledge Network</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| } | |
| .glass-panel { | |
| background: rgba(255, 255, 255, 0.9); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .gradient-text { | |
| background: linear-gradient(135deg, #3070b3 0%, #e37222 100%); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| /* Particle background Canvas styles */ | |
| #particle-canvas { | |
| position: fixed ; | |
| top: 0 ; | |
| left: 0 ; | |
| width: 100% ; | |
| height: 100% ; | |
| z-index: -10 ; | |
| background: #0f172a ; | |
| display: block ; | |
| pointer-events: none; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-slate-900 text-slate-200 min-h-screen"> | |
| <!-- Particle Background Canvas --> | |
| <canvas id="particle-canvas"></canvas> | |
| <!-- Navbar --> | |
| <nav class="bg-slate-900/60 backdrop-blur-md border-b border-slate-800/50 sticky top-0 z-50"> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
| <div class="flex justify-between h-16"> | |
| <div class="flex items-center gap-8"> | |
| <a href="/" class="flex items-center gap-2 group"> | |
| <div | |
| class="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold shadow-md group-hover:shadow-lg transition-all"> | |
| N</div> | |
| <span class="text-xl font-bold tracking-tight gradient-text">TUM Neural Net</span> | |
| </a> | |
| <div class="hidden md:flex items-center space-x-6 text-sm font-medium text-slate-400"> | |
| <a href="/" class="hover:text-cyan-400 transition-colors">Home</a> | |
| <a href="#" class="hover:text-cyan-400 transition-colors">Knowledge Graph</a> | |
| <a href="#" class="hover:text-cyan-400 transition-colors">About</a> | |
| </div> | |
| </div> | |
| <div class="flex items-center space-x-4"> | |
| <div id="status-indicator" | |
| class="flex items-center text-xs font-medium text-emerald-400 bg-emerald-500/20 px-3 py-1.5 rounded-full border border-emerald-500/30"> | |
| <span class="w-2 h-2 bg-emerald-400 rounded-full mr-2 animate-pulse"></span> | |
| System Active | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </nav> | |
| <!-- Progress Notification (Hidden by default) --> | |
| <div id="progress-toast" | |
| class="fixed bottom-6 right-6 z-50 transform transition-all duration-300 translate-y-24 opacity-0"> | |
| <div class="bg-slate-800/90 backdrop-blur-md rounded-xl shadow-xl border border-slate-700/50 p-4 w-80"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h4 class="text-sm font-bold text-slate-100" id="progress-title">Ingesting Knowledge...</h4> | |
| <span id="progress-count" class="text-xs font-mono text-cyan-400 bg-cyan-500/20 px-2 py-0.5 rounded-full">0 items</span> | |
| </div> | |
| <!-- Determinate Progress Bar (for URL crawling) --> | |
| <div id="progress-bar-container" class="hidden w-full bg-slate-700/50 rounded-full h-2 mb-2 overflow-hidden"> | |
| <div id="progress-bar" class="bg-gradient-to-r from-cyan-500 to-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div> | |
| </div> | |
| <!-- Indeterminate Progress Bar (for other tasks) --> | |
| <div id="progress-bar-indeterminate" class="w-full bg-slate-700/50 rounded-full h-1.5 mb-2 overflow-hidden"> | |
| <div class="bg-cyan-500 h-1.5 rounded-full animate-progress-indeterminate"></div> | |
| </div> | |
| <p id="progress-detail" class="text-xs text-slate-400 truncate">Initializing...</p> | |
| <p id="progress-url" class="text-xs text-slate-500 truncate mt-1 hidden"></p> | |
| </div> | |
| </div> | |
| <style> | |
| @keyframes progress-indeterminate { | |
| 0% { | |
| width: 0%; | |
| margin-left: 0%; | |
| } | |
| 50% { | |
| width: 70%; | |
| margin-left: 30%; | |
| } | |
| 100% { | |
| width: 0%; | |
| margin-left: 100%; | |
| } | |
| } | |
| .animate-progress-indeterminate { | |
| animation: progress-indeterminate 1.5s infinite ease-in-out; | |
| } | |
| </style> | |
| <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> | |
| <!-- Hero Section --> | |
| <div class="text-center mb-16"> | |
| <h1 class="text-4xl sm:text-5xl font-extrabold text-slate-100 mb-4"> | |
| Query the <span class="bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-blue-500">Collective Intelligence</span> | |
| </h1> | |
| <p class="text-lg text-slate-400 max-w-2xl mx-auto"> | |
| Access the distributed knowledge of the Technical University of Munich. | |
| Powered by semantic understanding and transitive trust algorithms. | |
| </p> | |
| </div> | |
| <!-- Search Interface --> | |
| <div class="max-w-3xl mx-auto mb-16"> | |
| <div class="relative group"> | |
| <div | |
| class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-orange-600 rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"> | |
| </div> | |
| <div class="relative"> | |
| <input type="text" id="search-input" | |
| class="block w-full p-5 pl-6 text-lg text-slate-100 border border-slate-700/50 rounded-lg bg-slate-800/50 backdrop-blur-sm shadow-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500/50 outline-none transition-all placeholder:text-slate-500" | |
| placeholder="Ask the network (e.g., 'Engineering Degrees')..." required> | |
| <button onclick="performSearch()" | |
| class="absolute right-2.5 bottom-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 text-white hover:from-cyan-600 hover:to-blue-700 focus:ring-4 focus:outline-none focus:ring-cyan-300 font-medium rounded-lg text-sm px-6 py-3 transition-all shadow-lg shadow-cyan-500/50"> | |
| Ignite | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Educational Cards (How it Works) --> | |
| <div id="intro-cards" class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16"> | |
| <!-- Card 1 --> | |
| <div class="bg-slate-800/50 backdrop-blur-sm p-6 rounded-xl shadow-lg border border-slate-700/50 hover:shadow-xl hover:border-cyan-500/50 transition-all"> | |
| <div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mb-4 text-blue-600"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"> | |
| </path> | |
| </svg> | |
| </div> | |
| <h3 class="text-lg font-bold text-slate-100 mb-2">Knowledge Commons</h3> | |
| <p class="text-sm text-slate-400"> | |
| The raw data layer where all crawled information resides. It serves as the foundation for semantic | |
| retrieval. | |
| </p> | |
| </div> | |
| <!-- Card 2 --> | |
| <div class="bg-slate-800/50 backdrop-blur-sm p-6 rounded-xl shadow-lg border border-slate-700/50 hover:shadow-xl hover:border-cyan-500/50 transition-all"> | |
| <div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center mb-4 text-orange-600"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> | |
| </svg> | |
| </div> | |
| <h3 class="text-lg font-bold text-slate-100 mb-2">Curated Core</h3> | |
| <p class="text-sm text-slate-400"> | |
| A curated layer of "Novelty Anchors". Only unique, high-entropy knowledge is promoted here to form | |
| the network's backbone. | |
| </p> | |
| </div> | |
| <!-- Card 3 --> | |
| <div class="bg-slate-800/50 backdrop-blur-sm p-6 rounded-xl shadow-lg border border-slate-700/50 hover:shadow-xl hover:border-cyan-500/50 transition-all"> | |
| <div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center mb-4 text-purple-600"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M13 10V3L4 14h7v7l9-11h-7z"></path> | |
| </svg> | |
| </div> | |
| <h3 class="text-lg font-bold text-slate-100 mb-2">Transitive Trust</h3> | |
| <p class="text-sm text-slate-400"> | |
| Authority flows through user navigation. If you trust A, and A links to B, then B gains trust. | |
| </p> | |
| </div> | |
| </div> | |
| <!-- Results Section with Tabs --> | |
| <div id="results-section" class="max-w-7xl mx-auto hidden"> | |
| <!-- Tab Navigation --> | |
| <div class="flex items-center justify-center mb-6 gap-2"> | |
| <button id="tab-list" onclick="switchTab('list')" | |
| class="px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 bg-cyan-500/20 text-cyan-400 border-2 border-cyan-500/50 hover:bg-cyan-500/30 active-tab"> | |
| <svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"></path> | |
| </svg> | |
| List View | |
| </button> | |
| <button id="tab-graph" onclick="switchTab('graph')" | |
| class="px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 bg-slate-800/50 text-slate-400 border-2 border-slate-700/50 hover:bg-slate-700/50 hover:text-cyan-400 hover:border-cyan-500/50"> | |
| <svg class="w-5 h-5 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"></path> | |
| </svg> | |
| Graph View | |
| </button> | |
| </div> | |
| <!-- List View --> | |
| <div id="list-view" class="space-y-6"> | |
| <div id="results-area" class="max-w-4xl mx-auto space-y-6"> | |
| <!-- Results will be injected here --> | |
| </div> | |
| </div> | |
| <!-- Graph View --> | |
| <div id="graph-view" class="hidden"> | |
| <div id="graph-container" style="width: 100%; height: 700px; background: rgba(15, 23, 42, 0.5); border-radius: 12px; border: 1px solid rgba(148, 163, 184, 0.2);"></div> | |
| </div> | |
| </div> | |
| <!-- Injection Panel (Upload) --> | |
| <div class="mt-24 border-t border-slate-700/50 pt-12"> | |
| <h2 class="text-2xl font-bold text-slate-100 mb-6 text-center">Inject Knowledge</h2> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto"> | |
| <!-- XML Dump Upload --> | |
| <div class="bg-slate-800/50 backdrop-blur-sm p-6 rounded-xl border border-slate-700/50 md:col-span-2"> | |
| <h3 class="font-semibold mb-4 flex items-center text-slate-100"> | |
| <span class="bg-purple-100 text-purple-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">XML</span> | |
| Wiki Dump Import | |
| </h3> | |
| <div class="space-y-3"> | |
| <div class="flex gap-2 items-center"> | |
| <input type="file" id="xml-dump-file-input" accept=".xml,.bz2,.gz" | |
| class="hidden"> | |
| <label for="xml-dump-file-input" | |
| class="flex-1 bg-slate-700/50 border border-slate-600/50 text-slate-100 text-sm rounded-lg focus:ring-cyan-500 focus:border-cyan-500/50 block p-2.5 cursor-pointer hover:bg-slate-700/70 transition-colors"> | |
| <span id="xml-dump-file-name" class="text-slate-400">Select XML Dump file...</span> | |
| </label> | |
| <button onclick="document.getElementById('xml-dump-file-input').click()" | |
| class="text-white bg-purple-600 hover:bg-purple-700 focus:ring-4 focus:ring-purple-300 font-medium rounded-lg text-sm px-4 py-2.5">Select File</button> | |
| </div> | |
| <div class="flex gap-2"> | |
| <input type="text" id="xml-dump-base-url" | |
| class="flex-1 bg-slate-700/50 border border-slate-600/50 text-slate-100 text-sm rounded-lg focus:ring-cyan-500 focus:border-cyan-500/50 block p-2.5 placeholder:text-slate-500" | |
| placeholder="Wiki base URL (optional, e.g.: https://wiki.example.com)"> | |
| <input type="number" id="xml-dump-max-pages" | |
| class="w-32 bg-slate-700/50 border border-slate-600/50 text-slate-100 text-sm rounded-lg focus:ring-cyan-500 focus:border-cyan-500/50 block p-2.5 placeholder:text-slate-500" | |
| placeholder="Max pages (optional, for testing)"> | |
| <button onclick="uploadXMLDump()" id="xml-dump-upload-btn" | |
| class="text-white bg-purple-600 hover:bg-purple-700 focus:ring-4 focus:ring-purple-300 font-medium rounded-lg text-sm px-5 py-2.5">Import Dump</button> | |
| </div> | |
| <!-- Upload Progress Bar (hidden by default) --> | |
| <div id="xml-dump-progress-container" class="hidden mt-3"> | |
| <div class="flex items-center justify-between mb-1"> | |
| <span class="text-xs font-medium text-purple-400">Uploading...</span> | |
| <span id="xml-dump-progress-percent" class="text-xs font-mono text-purple-400">0%</span> | |
| </div> | |
| <div class="w-full bg-slate-700/50 rounded-full h-2 overflow-hidden"> | |
| <div id="xml-dump-progress-bar" class="bg-gradient-to-r from-purple-600 to-purple-400 h-2 rounded-full transition-all duration-300" style="width: 0%"></div> | |
| </div> | |
| <div class="flex items-center justify-between mt-1"> | |
| <span id="xml-dump-progress-size" class="text-xs text-slate-500">0 KB / 0 KB</span> | |
| <span id="xml-dump-progress-speed" class="text-xs text-slate-500">-- KB/s</span> | |
| </div> | |
| </div> | |
| <div class="text-xs text-slate-400"> | |
| 💡 Supports MediaWiki/Wikipedia XML Dump files (.xml, .xml.bz2, .xml.gz). Automatically parses pages and link relationships, no crawler needed. | |
| </div> | |
| </div> | |
| </div> | |
| <!-- URL Injection --> | |
| <div class="bg-slate-800/50 backdrop-blur-sm p-6 rounded-xl border border-slate-700/50"> | |
| <h3 class="font-semibold mb-4 flex items-center"> | |
| <span | |
| class="bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">URL</span> | |
| Crawl & Absorb | |
| </h3> | |
| <div class="space-y-2"> | |
| <input type="text" id="url-input" | |
| class="w-full bg-slate-700/50 border border-slate-600/50 text-slate-100 text-sm rounded-lg focus:ring-cyan-500 focus:border-cyan-500/50 block p-2.5 placeholder:text-slate-500" | |
| placeholder="https://tum.de/..."> | |
| <div class="flex gap-2"> | |
| <input type="password" id="url-password" | |
| class="flex-1 bg-slate-700/50 border border-slate-600/50 text-slate-100 text-sm rounded-lg focus:ring-cyan-500 focus:border-cyan-500/50 block p-2.5 placeholder:text-slate-500" | |
| placeholder="Enter password..." | |
| onkeypress="if(event.key === 'Enter') uploadUrl()"> | |
| <button onclick="uploadUrl()" | |
| class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5">Inject</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Text Injection --> | |
| <div class="bg-slate-800/50 backdrop-blur-sm p-6 rounded-xl border border-slate-700/50"> | |
| <h3 class="font-semibold mb-4 flex items-center text-slate-100"> | |
| <span | |
| class="bg-slate-700/50 text-slate-300 text-xs font-medium mr-2 px-2.5 py-0.5 rounded">TEXT</span> | |
| Direct Input | |
| </h3> | |
| <div class="flex gap-2"> | |
| <input type="text" id="text-input" | |
| class="flex-1 bg-slate-700/50 border border-slate-600/50 text-slate-100 text-sm rounded-lg focus:ring-cyan-500 focus:border-cyan-500/50 block p-2.5 placeholder:text-slate-500" | |
| placeholder="Paste knowledge snippet..."> | |
| <button onclick="uploadText()" | |
| class="text-white bg-gray-700 hover:bg-gray-800 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5">Inject</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Trending Section --> | |
| <div class="max-w-7xl mx-auto mb-16 px-4"> | |
| <h2 class="text-2xl font-bold text-slate-100 mb-6 flex items-center gap-2"> | |
| <span class="w-2 h-8 bg-orange-500 rounded-full"></span> | |
| 🔥 Trending Now | |
| </h2> | |
| <div id="trending-area" class="grid grid-cols-1 md:grid-cols-3 gap-6"> | |
| <!-- Trending items will be injected here --> | |
| <div class="col-span-full text-center py-8 text-slate-400">Loading hot topics...</div> | |
| </div> | |
| </div> | |
| <!-- Knowledge Feed Section --> | |
| <div class="max-w-7xl mx-auto mb-16 px-4"> | |
| <h2 class="text-2xl font-bold text-slate-100 mb-6 flex items-center gap-2"> | |
| <span class="w-2 h-8 bg-blue-600 rounded-full"></span> | |
| Recent Knowledge Ingestions | |
| </h2> | |
| <div id="feed-area" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | |
| <!-- Feed items will be injected here --> | |
| <div class="col-span-full text-center py-8 text-slate-400">Loading feed...</div> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Notification Toast --> | |
| <div id="notification-toast" | |
| class="fixed bottom-5 right-5 bg-slate-900 text-white px-6 py-4 rounded-lg shadow-2xl transform translate-y-24 transition-transform duration-300 flex items-center gap-4 z-50 max-w-md"> | |
| <div class="flex-1"> | |
| <h4 class="font-bold text-sm text-blue-400 mb-1">System Update</h4> | |
| <p class="text-xs text-slate-300" id="notification-msg">Network synchronized.</p> | |
| </div> | |
| <button onclick="hideNotification()" class="text-slate-500 hover:text-white">✕</button> | |
| </div> | |
| <script> | |
| // ========================================== | |
| // 3D PARTICLE NETWORK BACKGROUND | |
| // ========================================== | |
| // Ensure DOM is loaded before initializing particle effect | |
| // Use IIFE, execute immediately if DOM is loaded, otherwise wait for DOMContentLoaded | |
| (function initParticleNetwork() { | |
| function startParticleNetwork() { | |
| const canvas = document.getElementById('particle-canvas'); | |
| if (!canvas) { | |
| console.error('Particle canvas not found!'); | |
| return; | |
| } | |
| try { | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) { | |
| console.error('Failed to get 2D context from canvas'); | |
| return; | |
| } | |
| let animationFrameId = null; | |
| let width = 0; | |
| let height = 0; | |
| let particles = []; | |
| const particleCount = 60; | |
| const connectionDistance = 150; | |
| const mouseDistance = 200; | |
| let mouse = { x: null, y: null }; | |
| let animationRunning = false; | |
| const resize = () => { | |
| // Get window dimensions | |
| width = window.innerWidth || document.documentElement.clientWidth || 1920; | |
| height = window.innerHeight || document.documentElement.clientHeight || 1080; | |
| // Ensure dimensions are valid (at least 1) | |
| if (width <= 0) width = 1920; | |
| if (height <= 0) height = 1080; | |
| // Set Canvas dimensions | |
| canvas.width = width; | |
| canvas.height = height; | |
| console.log(`✅ Canvas resized to ${width}x${height}`); | |
| // Only initialize if particles are empty | |
| if (particles.length === 0) { | |
| initParticles(); | |
| } else { | |
| // If particles exist, reposition them to fit new dimensions | |
| particles.forEach(p => { | |
| p.x = Math.min(p.x, width); | |
| p.y = Math.min(p.y, height); | |
| }); | |
| } | |
| }; | |
| class Particle { | |
| constructor() { | |
| // Use current actual width and height, or default values if not initialized | |
| const w = width || window.innerWidth || 1920; | |
| const h = height || window.innerHeight || 1080; | |
| this.x = Math.random() * w; | |
| this.y = Math.random() * h; | |
| this.vx = (Math.random() - 0.5) * 0.5; | |
| this.vy = (Math.random() - 0.5) * 0.5; | |
| this.size = Math.random() * 2 + 1; | |
| this.color = `rgba(${Math.random() * 50 + 100}, ${Math.random() * 100 + 155}, 255, ${Math.random() * 0.5 + 0.2})`; | |
| } | |
| update() { | |
| this.x += this.vx; | |
| this.y += this.vy; | |
| if (this.x < 0 || this.x > width) this.vx *= -1; | |
| if (this.y < 0 || this.y > height) this.vy *= -1; | |
| if (mouse.x != null) { | |
| let dx = mouse.x - this.x; | |
| let dy = mouse.y - this.y; | |
| let distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < mouseDistance && distance > 0) { | |
| const forceDirectionX = dx / distance; | |
| const forceDirectionY = dy / distance; | |
| const force = (mouseDistance - distance) / mouseDistance; | |
| this.vx += forceDirectionX * force * 0.6; | |
| this.vy += forceDirectionY * force * 0.6; | |
| } | |
| } | |
| // Limit velocity | |
| const maxSpeed = 2.0; | |
| const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy); | |
| if (speed > maxSpeed) { | |
| this.vx = (this.vx / speed) * maxSpeed; | |
| this.vy = (this.vy / speed) * maxSpeed; | |
| } | |
| } | |
| draw() { | |
| ctx.beginPath(); | |
| ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); | |
| ctx.fillStyle = this.color; | |
| ctx.fill(); | |
| } | |
| } | |
| const initParticles = () => { | |
| particles = []; | |
| for (let i = 0; i < particleCount; i++) { | |
| particles.push(new Particle()); | |
| } | |
| }; | |
| const animate = () => { | |
| if (!ctx || !animationRunning) return; | |
| if (width <= 0 || height <= 0 || particles.length === 0) { | |
| console.warn('Animation skipped: invalid dimensions or no particles'); | |
| animationFrameId = requestAnimationFrame(animate); | |
| return; | |
| } | |
| try { | |
| ctx.clearRect(0, 0, width, height); | |
| // Draw connection lines | |
| for (let i = 0; i < particles.length; i++) { | |
| for (let j = i + 1; j < particles.length; j++) { | |
| let dx = particles[i].x - particles[j].x; | |
| let dy = particles[i].y - particles[j].y; | |
| let distance = Math.sqrt(dx * dx + dy * dy); | |
| if (distance < connectionDistance && distance > 0) { | |
| ctx.beginPath(); | |
| ctx.strokeStyle = `rgba(100, 200, 255, ${1 - distance / connectionDistance})`; | |
| ctx.lineWidth = 0.5; | |
| ctx.moveTo(particles[i].x, particles[i].y); | |
| ctx.lineTo(particles[j].x, particles[j].y); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| // Update and draw particles | |
| particles.forEach(particle => { | |
| particle.update(); | |
| particle.draw(); | |
| }); | |
| animationFrameId = requestAnimationFrame(animate); | |
| } catch (error) { | |
| console.error('Error in animation loop:', error); | |
| animationRunning = false; | |
| } | |
| }; | |
| const handleMouseMove = (e) => { | |
| mouse.x = e.clientX || e.x; | |
| mouse.y = e.clientY || e.y; | |
| }; | |
| const handleMouseLeave = () => { | |
| mouse.x = null; | |
| mouse.y = null; | |
| }; | |
| // Cleanup function | |
| const cleanup = () => { | |
| window.removeEventListener('resize', resize); | |
| window.removeEventListener('mousemove', handleMouseMove); | |
| window.removeEventListener('mouseleave', handleMouseLeave); | |
| if (animationFrameId) { | |
| cancelAnimationFrame(animationFrameId); | |
| } | |
| }; | |
| // Bind events | |
| window.addEventListener('resize', resize); | |
| window.addEventListener('mousemove', handleMouseMove); | |
| window.addEventListener('mouseleave', handleMouseLeave); | |
| // Initialize dimensions and particles | |
| resize(); | |
| // Verify initialization success | |
| if (particles.length === 0) { | |
| console.error('Failed to initialize particles!'); | |
| return; | |
| } | |
| if (width <= 0 || height <= 0) { | |
| console.error(`Invalid canvas dimensions: ${width}x${height}`); | |
| return; | |
| } | |
| // Start animation loop | |
| animationRunning = true; | |
| setTimeout(() => { | |
| animate(); | |
| console.log('✅ Particle network initialized successfully'); | |
| console.log(` Canvas: ${canvas.width}x${canvas.height}`); | |
| console.log(` Particles: ${particles.length}`); | |
| }, 150); | |
| } catch (error) { | |
| console.error('Error initializing particle network:', error); | |
| } | |
| } | |
| // Prevent duplicate initialization | |
| let isInitialized = false; | |
| // Multiple checks to ensure Canvas element exists before execution | |
| function tryInitParticleNetwork() { | |
| // Prevent duplicate initialization | |
| if (isInitialized) { | |
| console.log('Particle network already initialized, skipping...'); | |
| return; | |
| } | |
| const canvas = document.getElementById('particle-canvas'); | |
| if (canvas) { | |
| // Mark as initialized to avoid duplicates | |
| isInitialized = true; | |
| canvas.dataset.initialized = 'true'; | |
| startParticleNetwork(); | |
| } else { | |
| // If Canvas is not ready yet, wait and retry (max 10 attempts) | |
| const maxRetries = 10; | |
| let retryCount = 0; | |
| function retry() { | |
| retryCount++; | |
| const canvas = document.getElementById('particle-canvas'); | |
| if (canvas) { | |
| isInitialized = true; | |
| canvas.dataset.initialized = 'true'; | |
| startParticleNetwork(); | |
| } else if (retryCount < maxRetries) { | |
| console.warn(`Canvas not found, retrying... (${retryCount}/${maxRetries})`); | |
| setTimeout(retry, 200); | |
| } else { | |
| console.error('Failed to find Canvas element after multiple attempts!'); | |
| } | |
| } | |
| setTimeout(retry, 100); | |
| } | |
| } | |
| // Choose initialization timing based on DOM state | |
| if (document.readyState === 'loading') { | |
| // DOM is still loading, wait for DOMContentLoaded | |
| document.addEventListener('DOMContentLoaded', function() { | |
| console.log('DOMContentLoaded event fired, initializing particle network...'); | |
| setTimeout(tryInitParticleNetwork, 100); | |
| }); | |
| } else if (document.readyState === 'interactive' || document.readyState === 'complete') { | |
| // DOM is loaded or fully loaded | |
| console.log(`DOM ready state: ${document.readyState}, initializing particle network...`); | |
| setTimeout(tryInitParticleNetwork, 100); | |
| } | |
| // As final fallback, listen to window.onload | |
| window.addEventListener('load', function() { | |
| if (!isInitialized) { | |
| console.log('Window load event fired, attempting particle network initialization...'); | |
| setTimeout(tryInitParticleNetwork, 200); | |
| } | |
| }); | |
| })(); | |
| // ========================================== | |
| // WebSocket for Real-time Updates | |
| // ========================================== | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const ws = new WebSocket(`${protocol}//${window.location.host}/ws`); | |
| const progressToast = document.getElementById('progress-toast'); | |
| const progressCount = document.getElementById('progress-count'); | |
| const progressDetail = document.getElementById('progress-detail'); | |
| let progressTimer; | |
| const progressBarContainer = document.getElementById('progress-bar-container'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const progressBarIndeterminate = document.getElementById('progress-bar-indeterminate'); | |
| const progressTitle = document.getElementById('progress-title'); | |
| const progressUrl = document.getElementById('progress-url'); | |
| // WebSocket connection handlers | |
| ws.onopen = function() { | |
| console.log('✅ WebSocket connected successfully'); | |
| // 连接成功后,立即测试连接状态 | |
| fetch('/api/debug/websocket') | |
| .then(r => r.json()) | |
| .then(data => { | |
| console.log('📊 WebSocket debug info:', data); | |
| if (data.active_connections === 0) { | |
| console.error('⚠️ WARNING: No active connections reported by server!'); | |
| } | |
| }) | |
| .catch(e => console.error('Failed to get debug info:', e)); | |
| }; | |
| ws.onerror = function(error) { | |
| console.error('❌ WebSocket error:', error); | |
| }; | |
| ws.onclose = function(event) { | |
| console.log('⚠️ WebSocket closed:', event.code, event.reason); | |
| }; | |
| ws.onmessage = function (event) { | |
| try { | |
| const data = JSON.parse(event.data); | |
| console.log('WebSocket message received:', data); | |
| if (data.type === 'progress') { | |
| // Show toast - check if elements exist | |
| if (!progressToast) { | |
| console.error('Progress toast element not found!'); | |
| return; | |
| } | |
| progressToast.classList.remove('translate-y-24', 'opacity-0'); | |
| console.log('Processing progress message:', data); | |
| // Check if this is URL crawling progress with detailed info | |
| if (data.task_type === 'url') { | |
| console.log('URL crawling progress detected, updating progress bar...'); | |
| // 即使没有total,也要显示进度条 | |
| const hasTotal = data.total !== undefined; | |
| if (hasTotal) { | |
| // Use determinate progress bar for URL crawling | |
| if (progressBarContainer) progressBarContainer.classList.remove('hidden'); | |
| if (progressBarIndeterminate) progressBarIndeterminate.classList.add('hidden'); | |
| if (progressUrl) progressUrl.classList.remove('hidden'); | |
| // Update progress bar | |
| if (progressBar) { | |
| const percent = Math.min(data.percent || 0, 100); | |
| progressBar.style.width = percent + '%'; | |
| } | |
| // Update title and count | |
| if (progressTitle) progressTitle.textContent = "Crawling URLs..."; | |
| if (progressCount) { | |
| const percent = Math.min(data.percent || 0, 100); | |
| progressCount.textContent = `${data.count || 0}/${data.total || 0} (${percent}%)`; | |
| progressCount.className = "text-xs font-mono text-cyan-400 bg-cyan-500/20 px-2 py-0.5 rounded-full"; | |
| } | |
| // Update detail and URL | |
| if (progressDetail) progressDetail.textContent = data.message || `Processing page ${data.count || 0}`; | |
| if (progressUrl && data.current_url) { | |
| progressUrl.textContent = data.current_url; | |
| } | |
| } else { | |
| // URL task but no total yet, show indeterminate with message | |
| if (progressBarContainer) progressBarContainer.classList.add('hidden'); | |
| if (progressBarIndeterminate) progressBarIndeterminate.classList.remove('hidden'); | |
| if (progressUrl && data.current_url) { | |
| progressUrl.textContent = data.current_url; | |
| progressUrl.classList.remove('hidden'); | |
| } | |
| if (progressTitle) progressTitle.textContent = "Starting Crawl..."; | |
| if (progressCount) progressCount.textContent = "Initializing..."; | |
| if (progressDetail) progressDetail.textContent = data.message || "Initializing crawler..."; | |
| } | |
| } else { | |
| // Use indeterminate progress bar for other tasks | |
| if (progressBarContainer) progressBarContainer.classList.add('hidden'); | |
| if (progressBarIndeterminate) progressBarIndeterminate.classList.remove('hidden'); | |
| if (progressUrl) progressUrl.classList.add('hidden'); | |
| if (progressTitle) progressTitle.textContent = "Ingesting Knowledge..."; | |
| if (progressCount) progressCount.textContent = `${data.count || 0} items`; | |
| if (progressDetail) progressDetail.textContent = data.details || data.message || "Processing..."; | |
| } | |
| // Auto hide after inactivity | |
| clearTimeout(progressTimer); | |
| progressTimer = setTimeout(() => { | |
| progressToast.classList.add('translate-y-24', 'opacity-0'); | |
| }, 5000); // Keep visible for 5s after last update | |
| } | |
| else if (data.type === 'system_update') { | |
| // Show completion message | |
| if (!progressToast) return; | |
| progressToast.classList.remove('translate-y-24', 'opacity-0'); | |
| if (data.task_type === 'url') { | |
| // For URL tasks, show final count | |
| if (progressBarContainer) progressBarContainer.classList.remove('hidden'); | |
| if (progressBarIndeterminate) progressBarIndeterminate.classList.add('hidden'); | |
| if (progressBar) progressBar.style.width = '100%'; | |
| if (progressTitle) progressTitle.textContent = "Crawl Complete!"; | |
| if (progressCount) { | |
| progressCount.textContent = "Done"; | |
| progressCount.className = "text-xs font-mono text-emerald-400 bg-emerald-500/20 px-2 py-0.5 rounded-full"; | |
| } | |
| if (progressDetail) progressDetail.textContent = data.message || `Processed ${data.count || 0} pages`; | |
| if (progressUrl) progressUrl.classList.add('hidden'); | |
| } else { | |
| // For other tasks | |
| if (progressBarContainer) progressBarContainer.classList.add('hidden'); | |
| if (progressBarIndeterminate) progressBarIndeterminate.classList.remove('hidden'); | |
| if (progressTitle) progressTitle.textContent = "Complete!"; | |
| if (progressCount) { | |
| progressCount.textContent = "Done"; | |
| progressCount.className = "text-xs font-mono text-emerald-400 bg-emerald-500/20 px-2 py-0.5 rounded-full"; | |
| } | |
| if (progressDetail) progressDetail.textContent = data.message || "Synchronization Complete."; | |
| if (progressUrl) progressUrl.classList.add('hidden'); | |
| } | |
| // Refresh feeds | |
| loadFeed(); | |
| loadTrending(); | |
| // Hide after 5s | |
| setTimeout(() => { | |
| progressToast.classList.add('translate-y-24', 'opacity-0'); | |
| }, 5000); | |
| } | |
| else if (data.type === 'error') { | |
| alert("System Error: " + data.message); | |
| } | |
| } catch (error) { | |
| console.error('Error parsing WebSocket message:', error, event.data); | |
| } | |
| }; | |
| function showNotification(msg, type = "success") { | |
| const toast = document.getElementById('notification-toast'); | |
| const msgElement = document.getElementById('notification-msg'); | |
| msgElement.textContent = msg; | |
| // Set different styles based on type | |
| if (type === "error") { | |
| toast.classList.remove('bg-green-500', 'bg-blue-500'); | |
| toast.classList.add('bg-red-500'); | |
| } else { | |
| toast.classList.remove('bg-red-500'); | |
| toast.classList.add('bg-green-500'); | |
| } | |
| toast.classList.remove('translate-y-24'); | |
| setTimeout(hideNotification, 5000); | |
| } | |
| function hideNotification() { | |
| document.getElementById('notification-toast').classList.add('translate-y-24'); | |
| } | |
| async function recordInteraction(itemId, url) { | |
| const formData = new FormData(); | |
| formData.append('item_id', itemId); // Use UUID for tracking | |
| formData.append('action', 'click'); | |
| try { | |
| await fetch('/api/feedback', { | |
| method: 'POST', | |
| body: formData, | |
| keepalive: true | |
| }); | |
| console.log("Interaction recorded for", itemId); | |
| } catch (e) { | |
| console.error("Failed to record interaction", e); | |
| } | |
| } | |
| async function loadFeed() { | |
| const feedDiv = document.getElementById('feed-area'); | |
| try { | |
| const response = await fetch('/api/feed?limit=6'); | |
| const data = await response.json(); | |
| if (!data.points || data.points.length === 0) { | |
| feedDiv.innerHTML = '<div class="col-span-full text-center py-8 text-slate-400">No knowledge found in the Commons.</div>'; | |
| return; | |
| } | |
| feedDiv.innerHTML = ''; | |
| data.points.forEach(pt => { | |
| const p = pt.payload; | |
| const card = document.createElement('div'); | |
| card.className = 'bg-slate-800/50 backdrop-blur-sm p-5 rounded-lg border border-slate-700/50 shadow-lg hover:shadow-xl hover:border-cyan-500/50 transition-all'; | |
| card.innerHTML = ` | |
| <div class="text-xs font-bold text-slate-400 mb-2 uppercase tracking-wider">${p.type || 'Unknown'}</div> | |
| <a href="/view/${pt.id}" class="block text-base font-bold text-slate-100 hover:text-cyan-400 mb-2 line-clamp-1">${p.url || 'Untitled'}</a> | |
| <div class="text-sm text-slate-400 line-clamp-3 mb-3">${p.content_preview || ''}</div> | |
| <div class="text-xs text-slate-400 font-mono">ID: ${pt.id.substring(0, 6)}...</div> | |
| `; | |
| feedDiv.appendChild(card); | |
| }); | |
| } catch (e) { | |
| feedDiv.innerHTML = `<div class="col-span-full text-center text-red-400">Failed to load feed: ${e.message}</div>`; | |
| } | |
| } | |
| let currentView = 'list'; | |
| let currentSearchResults = null; | |
| let graphChart = null; | |
| function switchTab(view) { | |
| currentView = view; | |
| const listTab = document.getElementById('tab-list'); | |
| const graphTab = document.getElementById('tab-graph'); | |
| const listView = document.getElementById('list-view'); | |
| const graphView = document.getElementById('graph-view'); | |
| if (view === 'list') { | |
| listTab.className = 'px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 bg-cyan-500/20 text-cyan-400 border-2 border-cyan-500/50 hover:bg-cyan-500/30 active-tab'; | |
| graphTab.className = 'px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 bg-slate-800/50 text-slate-400 border-2 border-slate-700/50 hover:bg-slate-700/50 hover:text-cyan-400 hover:border-cyan-500/50'; | |
| listView.classList.remove('hidden'); | |
| graphView.classList.add('hidden'); | |
| } else { | |
| listTab.className = 'px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 bg-slate-800/50 text-slate-400 border-2 border-slate-700/50 hover:bg-slate-700/50 hover:text-cyan-400 hover:border-cyan-500/50'; | |
| graphTab.className = 'px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 bg-cyan-500/20 text-cyan-400 border-2 border-cyan-500/50 hover:bg-cyan-500/30 active-tab'; | |
| listView.classList.add('hidden'); | |
| graphView.classList.remove('hidden'); | |
| // Render graph if we have search results | |
| if (currentSearchResults) { | |
| renderGraphView(currentSearchResults); | |
| } | |
| } | |
| } | |
| async function renderGraphView(query) { | |
| const graphContainer = document.getElementById('graph-container'); | |
| // Show loading | |
| graphContainer.innerHTML = '<div class="flex items-center justify-center h-full"><div class="text-center"><div class="animate-spin rounded-full h-12 w-12 border-b-2 border-cyan-600 mx-auto"></div><p class="mt-4 text-slate-400">Building neural network graph...</p></div></div>'; | |
| try { | |
| const response = await fetch(`/api/search/graph?q=${encodeURIComponent(query)}`); | |
| const graphData = await response.json(); | |
| if (!graphData.nodes || graphData.nodes.length === 0) { | |
| graphContainer.innerHTML = '<div class="flex items-center justify-center h-full text-slate-400">No connections found. Try a different search.</div>'; | |
| return; | |
| } | |
| // Initialize ECharts | |
| if (graphChart) { | |
| graphChart.dispose(); | |
| } | |
| graphChart = echarts.init(graphContainer); | |
| // Prepare nodes and edges for ECharts | |
| const nodes = graphData.nodes.map((node, idx) => ({ | |
| id: node.id, | |
| name: node.name, | |
| value: node.value || 30, | |
| category: node.isCenter ? 0 : 1, // 0 = center, 1 = related | |
| symbolSize: node.isCenter ? Math.max(40, node.value || 40) : Math.max(20, (node.value || 20) * 0.8), | |
| itemStyle: { | |
| color: node.isCenter ? '#06b6d4' : '#8b5cf6', // cyan for center, purple for related | |
| borderColor: node.isCenter ? '#0891b2' : '#7c3aed', | |
| borderWidth: 2, | |
| shadowBlur: 10, | |
| shadowColor: node.isCenter ? 'rgba(6, 182, 212, 0.5)' : 'rgba(139, 92, 246, 0.5)' | |
| }, | |
| label: { | |
| show: true, | |
| fontSize: 12, | |
| fontWeight: node.isCenter ? 'bold' : 'normal', | |
| color: node.isCenter ? '#06b6d4' : '#cbd5e1', | |
| formatter: (params) => { | |
| const name = params.data.name || 'Node'; | |
| return name.length > 20 ? name.substring(0, 17) + '...' : name; | |
| } | |
| }, | |
| url: node.url, | |
| content: node.content | |
| })); | |
| const edges = graphData.edges.map(edge => ({ | |
| source: edge.source, | |
| target: edge.target, | |
| value: edge.value || 1, | |
| lineStyle: { | |
| color: '#475569', | |
| width: Math.max(1, edge.value * 2), | |
| curveness: 0.3, | |
| opacity: 0.6 | |
| }, | |
| label: { | |
| show: false | |
| } | |
| })); | |
| const option = { | |
| backgroundColor: 'transparent', | |
| tooltip: { | |
| trigger: 'item', | |
| formatter: (params) => { | |
| if (params.dataType === 'node') { | |
| return ` | |
| <div style="padding: 8px;"> | |
| <div style="font-weight: bold; color: #06b6d4; margin-bottom: 4px;">${params.data.name}</div> | |
| ${params.data.url ? `<div style="font-size: 11px; color: #94a3b8; margin-bottom: 4px;">${params.data.url}</div>` : ''} | |
| ${params.data.content ? `<div style="font-size: 11px; color: #cbd5e1; max-width: 300px;">${params.data.content}</div>` : ''} | |
| </div> | |
| `; | |
| } | |
| return ''; | |
| }, | |
| backgroundColor: 'rgba(15, 23, 42, 0.95)', | |
| borderColor: '#06b6d4', | |
| borderWidth: 1, | |
| textStyle: { | |
| color: '#e2e8f0' | |
| } | |
| }, | |
| series: [{ | |
| type: 'graph', | |
| layout: 'force', | |
| data: nodes, | |
| links: edges, | |
| categories: [ | |
| { name: 'Center', itemStyle: { color: '#06b6d4' } }, | |
| { name: 'Related', itemStyle: { color: '#8b5cf6' } } | |
| ], | |
| roam: true, | |
| label: { | |
| show: true, | |
| position: 'right', | |
| formatter: '{b}' | |
| }, | |
| labelLayout: { | |
| hideOverlap: true | |
| }, | |
| emphasis: { | |
| focus: 'adjacency', | |
| lineStyle: { | |
| width: 4 | |
| } | |
| }, | |
| force: { | |
| repulsion: 200, | |
| gravity: 0.1, | |
| edgeLength: 150, | |
| layoutAnimation: true | |
| }, | |
| lineStyle: { | |
| opacity: 0.6, | |
| curveness: 0.3 | |
| } | |
| }] | |
| }; | |
| graphChart.setOption(option); | |
| // Handle node click | |
| graphChart.on('click', (params) => { | |
| if (params.dataType === 'node' && params.data.url) { | |
| recordInteraction(params.data.id, params.data.url); | |
| window.location.href = `/view/${params.data.id}`; | |
| } | |
| }); | |
| // Handle window resize | |
| window.addEventListener('resize', () => { | |
| if (graphChart) { | |
| graphChart.resize(); | |
| } | |
| }); | |
| } catch (e) { | |
| console.error('Graph render error:', e); | |
| graphContainer.innerHTML = `<div class="flex items-center justify-center h-full text-red-400">Error rendering graph: ${e.message}</div>`; | |
| } | |
| } | |
| async function performSearch() { | |
| const query = document.getElementById('search-input').value; | |
| if (!query) return; | |
| const resultsSection = document.getElementById('results-section'); | |
| const resultsDiv = document.getElementById('results-area'); | |
| const introCards = document.getElementById('intro-cards'); | |
| // Hide intro cards and show results section | |
| if (introCards) introCards.style.display = 'none'; | |
| if (resultsSection) { | |
| resultsSection.classList.remove('hidden'); | |
| // Reset to list view | |
| switchTab('list'); | |
| } | |
| // Store query for graph view | |
| currentSearchResults = query; | |
| resultsDiv.innerHTML = '<div class="text-center py-12"><div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div><p class="mt-4 text-slate-500">Traversing the neural pathways...</p></div>'; | |
| try { | |
| const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); | |
| const data = await response.json(); | |
| resultsDiv.innerHTML = ''; | |
| if (data.results.length === 0) { | |
| resultsDiv.innerHTML = '<div class="text-center py-12 text-slate-500">No neural connections found. Try injecting more knowledge.</div>'; | |
| return; | |
| } | |
| data.results.forEach(item => { | |
| const title = item.url || "Untitled Node"; | |
| const snippet = item.content || "No preview available."; | |
| const score = (item.score * 100).toFixed(1); | |
| // HTML escape helper function (define before use) | |
| const escapeHtml = (text) => { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| }; | |
| // Process highlighted snippet (if available) | |
| let highlightedSnippet = snippet; | |
| if (item.highlighted_snippet) { | |
| // First replace highlight markers with temporary placeholders | |
| const placeholderStart = '___HIGHLIGHT_START___'; | |
| const placeholderEnd = '___HIGHLIGHT_END___'; | |
| let textWithPlaceholders = item.highlighted_snippet | |
| .replace(/\[\[HIGHLIGHT\]\](.*?)\[\[\/HIGHLIGHT\]\]/gi, | |
| placeholderStart + '$1' + placeholderEnd); | |
| let escapedText = escapeHtml(textWithPlaceholders); | |
| // Replace placeholders with HTML tags | |
| highlightedSnippet = escapedText | |
| .replace(new RegExp(escapeHtml(placeholderStart), 'g'), | |
| '<strong class="font-bold text-cyan-400 bg-cyan-500/20 px-1 rounded">') | |
| .replace(new RegExp(escapeHtml(placeholderEnd), 'g'), '</strong>'); | |
| } else { | |
| // If no highlighted snippet, still escape HTML | |
| highlightedSnippet = escapeHtml(snippet); | |
| } | |
| const card = document.createElement('div'); | |
| card.className = 'bg-slate-800/50 backdrop-blur-sm p-6 rounded-xl shadow-lg border border-slate-700/50 hover:shadow-xl hover:border-cyan-500/50 transition-all hover:-translate-y-1 duration-200 cursor-pointer'; | |
| // Add click tracking | |
| card.onclick = (e) => { | |
| // Prevent default if it was a link click, we handle navigation | |
| if (!e.target.closest('a')) { | |
| recordInteraction(item.id, item.url); | |
| window.location.href = `/view/${item.id}`; | |
| } | |
| }; | |
| // Create card elements | |
| const titleLink = document.createElement('a'); | |
| titleLink.href = `/view/${item.id}`; | |
| titleLink.className = 'text-lg font-bold text-cyan-400 hover:text-cyan-300 transition-colors line-clamp-1'; | |
| titleLink.textContent = title; | |
| titleLink.onclick = (e) => { | |
| e.stopPropagation(); | |
| recordInteraction(item.id, item.url); | |
| }; | |
| const scoreBadge = document.createElement('span'); | |
| scoreBadge.className = 'bg-cyan-500/20 text-cyan-400 text-xs font-bold px-2 py-1 rounded-full border border-cyan-500/30'; | |
| scoreBadge.textContent = `${score}% Match`; | |
| const headerDiv = document.createElement('div'); | |
| headerDiv.className = 'flex justify-between items-start mb-2'; | |
| headerDiv.appendChild(titleLink); | |
| headerDiv.appendChild(scoreBadge); | |
| const urlDiv = document.createElement('div'); | |
| urlDiv.className = 'text-xs text-slate-400 mb-3 font-mono truncate'; | |
| urlDiv.textContent = item.url; | |
| const snippetPara = document.createElement('p'); | |
| snippetPara.className = 'text-slate-300 text-sm leading-relaxed line-clamp-3'; | |
| snippetPara.innerHTML = highlightedSnippet; // Directly use innerHTML to support HTML highlighting | |
| const footerDiv = document.createElement('div'); | |
| footerDiv.className = 'mt-4 pt-4 border-t border-slate-700/50 flex justify-between items-center'; | |
| footerDiv.innerHTML = ` | |
| <span class="text-xs text-slate-400">ID: ${item.id.substring(0, 8)}...</span> | |
| <span class="text-sm font-medium text-slate-100 flex items-center gap-1"> | |
| Explore Node | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg> | |
| </span> | |
| `; | |
| card.appendChild(headerDiv); | |
| card.appendChild(urlDiv); | |
| card.appendChild(snippetPara); | |
| card.appendChild(footerDiv); | |
| resultsDiv.appendChild(card); | |
| }); | |
| } catch (e) { | |
| resultsDiv.innerHTML = `<div class="text-center text-red-500 py-8">Neural Link Error: ${e.message}</div>`; | |
| } | |
| // Also prepare graph data in background | |
| if (currentView === 'graph') { | |
| renderGraphView(query); | |
| } | |
| } | |
| async function uploadUrl() { | |
| const url = document.getElementById('url-input').value; | |
| const password = document.getElementById('url-password').value; | |
| if (!url) { | |
| showNotification("Please enter URL", "error"); | |
| return; | |
| } | |
| if (!password) { | |
| showNotification("Please enter password", "error"); | |
| document.getElementById('url-password').focus(); | |
| return; | |
| } | |
| try { | |
| const formData = new FormData(); | |
| formData.append('url', url); | |
| formData.append('password', password); | |
| const response = await fetch('/api/upload/url', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const result = await response.json(); | |
| if (response.ok) { | |
| document.getElementById('url-input').value = ''; | |
| document.getElementById('url-password').value = ''; | |
| // Immediately show progress bar when URL upload starts | |
| const progressToast = document.getElementById('progress-toast'); | |
| const progressBarContainer = document.getElementById('progress-bar-container'); | |
| const progressBarIndeterminate = document.getElementById('progress-bar-indeterminate'); | |
| const progressTitle = document.getElementById('progress-title'); | |
| const progressCount = document.getElementById('progress-count'); | |
| const progressDetail = document.getElementById('progress-detail'); | |
| const progressUrl = document.getElementById('progress-url'); | |
| if (progressToast) { | |
| progressToast.classList.remove('translate-y-24', 'opacity-0'); | |
| // Show indeterminate progress bar while waiting for WebSocket messages | |
| if (progressBarContainer) progressBarContainer.classList.add('hidden'); | |
| if (progressBarIndeterminate) progressBarIndeterminate.classList.remove('hidden'); | |
| if (progressUrl) progressUrl.classList.add('hidden'); | |
| if (progressTitle) progressTitle.textContent = "Starting URL Crawl..."; | |
| if (progressCount) { | |
| progressCount.textContent = "0/1000 (0%)"; | |
| progressCount.className = "text-xs font-mono text-cyan-400 bg-cyan-500/20 px-2 py-0.5 rounded-full"; | |
| } | |
| if (progressDetail) progressDetail.textContent = "Waiting for crawler to start..."; | |
| console.log('Progress bar shown after URL upload'); | |
| } else { | |
| console.error('Progress toast element not found!'); | |
| } | |
| showNotification(result.message || "URL injected into the cortex. Processing..."); | |
| } else { | |
| showNotification(result.detail || "Incorrect password, crawl rejected", "error"); | |
| } | |
| } catch (error) { | |
| console.error("Failed to upload URL", error); | |
| showNotification("Upload failed: " + error.message, "error"); | |
| } | |
| } | |
| async function uploadText() { | |
| const text = document.getElementById('text-input').value; | |
| if (!text) return; | |
| const formData = new FormData(); | |
| formData.append('text', text); | |
| await fetch('/api/upload/text', { method: 'POST', body: formData }); | |
| document.getElementById('text-input').value = ''; | |
| showNotification("Text fragment absorbed. Processing..."); | |
| } | |
| function formatBytes(bytes) { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| } | |
| function uploadXMLDump() { | |
| const fileInput = document.getElementById('xml-dump-file-input'); | |
| const baseUrl = document.getElementById('xml-dump-base-url').value; | |
| const maxPages = document.getElementById('xml-dump-max-pages').value; | |
| const uploadBtn = document.getElementById('xml-dump-upload-btn'); | |
| const progressContainer = document.getElementById('xml-dump-progress-container'); | |
| const progressBar = document.getElementById('xml-dump-progress-bar'); | |
| const progressPercent = document.getElementById('xml-dump-progress-percent'); | |
| const progressSize = document.getElementById('xml-dump-progress-size'); | |
| const progressSpeed = document.getElementById('xml-dump-progress-speed'); | |
| if (!fileInput.files || !fileInput.files[0]) { | |
| showNotification("Please select an XML Dump file", "error"); | |
| return; | |
| } | |
| const file = fileInput.files[0]; | |
| // Check file extension | |
| const validExtensions = ['.xml', '.bz2', '.gz']; | |
| const fileName = file.name.toLowerCase(); | |
| const hasValidExtension = validExtensions.some(ext => fileName.endsWith(ext)) || | |
| fileName.endsWith('.xml.bz2') || fileName.endsWith('.xml.gz'); | |
| if (!hasValidExtension) { | |
| showNotification("Only XML dump file formats are supported (.xml, .xml.bz2, .xml.gz)", "error"); | |
| return; | |
| } | |
| // Prepare form data | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| if (baseUrl) { | |
| formData.append('base_url', baseUrl); | |
| } | |
| if (maxPages) { | |
| formData.append('max_pages', maxPages); | |
| } | |
| // Setup progress tracking | |
| const totalSize = file.size; | |
| let uploadedSize = 0; | |
| let startTime = Date.now(); | |
| let lastUploaded = 0; | |
| let lastTime = startTime; | |
| // Show progress bar | |
| progressContainer.classList.remove('hidden'); | |
| progressBar.style.width = '0%'; | |
| progressPercent.textContent = '0%'; | |
| progressSize.textContent = `0 KB / ${formatBytes(totalSize)}`; | |
| progressSpeed.textContent = '-- KB/s'; | |
| // Disable upload button | |
| uploadBtn.disabled = true; | |
| uploadBtn.textContent = 'Uploading...'; | |
| // Use XMLHttpRequest for upload progress support | |
| const xhr = new XMLHttpRequest(); | |
| // Upload progress event | |
| xhr.upload.addEventListener('progress', (e) => { | |
| if (e.lengthComputable) { | |
| uploadedSize = e.loaded; | |
| const percent = Math.round((e.loaded / e.total) * 100); | |
| // Update progress bar | |
| progressBar.style.width = percent + '%'; | |
| progressPercent.textContent = percent + '%'; | |
| progressSize.textContent = `${formatBytes(e.loaded)} / ${formatBytes(e.total)}`; | |
| // Calculate upload speed | |
| const currentTime = Date.now(); | |
| const timeDelta = (currentTime - lastTime) / 1000; // seconds | |
| if (timeDelta >= 0.5) { // Update speed every 500ms | |
| const bytesDelta = e.loaded - lastUploaded; | |
| const speed = bytesDelta / timeDelta; // bytes per second | |
| progressSpeed.textContent = formatBytes(speed) + '/s'; | |
| lastUploaded = e.loaded; | |
| lastTime = currentTime; | |
| } | |
| } | |
| }); | |
| // Load event (upload complete) | |
| xhr.addEventListener('load', () => { | |
| if (xhr.status >= 200 && xhr.status < 300) { | |
| try { | |
| const result = JSON.parse(xhr.responseText); | |
| showNotification(result.message || "XML Dump file received, parsing and importing..."); | |
| // Hide progress bar after a short delay | |
| setTimeout(() => { | |
| progressContainer.classList.add('hidden'); | |
| }, 1000); | |
| // Clear form | |
| fileInput.value = ''; | |
| document.getElementById('xml-dump-file-name').textContent = 'Select XML Dump file...'; | |
| document.getElementById('xml-dump-base-url').value = ''; | |
| document.getElementById('xml-dump-max-pages').value = ''; | |
| } catch (e) { | |
| console.error("Failed to parse response", e); | |
| showNotification("Upload completed but failed to parse response", "error"); | |
| progressContainer.classList.add('hidden'); | |
| } | |
| } else { | |
| try { | |
| const result = JSON.parse(xhr.responseText); | |
| showNotification(result.detail || "XML Dump import failed", "error"); | |
| } catch (e) { | |
| showNotification("Upload failed with status " + xhr.status, "error"); | |
| } | |
| progressContainer.classList.add('hidden'); | |
| } | |
| uploadBtn.disabled = false; | |
| uploadBtn.textContent = 'Import Dump'; | |
| }); | |
| // Error event | |
| xhr.addEventListener('error', () => { | |
| showNotification("Upload failed: Network error", "error"); | |
| progressContainer.classList.add('hidden'); | |
| uploadBtn.disabled = false; | |
| uploadBtn.textContent = 'Import Dump'; | |
| }); | |
| // Abort event | |
| xhr.addEventListener('abort', () => { | |
| showNotification("Upload cancelled", "error"); | |
| progressContainer.classList.add('hidden'); | |
| uploadBtn.disabled = false; | |
| uploadBtn.textContent = 'Import Dump'; | |
| }); | |
| // Send request | |
| xhr.open('POST', '/api/upload/xml-dump'); | |
| xhr.send(formData); | |
| } | |
| // Enter key to search | |
| document.getElementById('search-input').addEventListener('keypress', function (e) { | |
| if (e.key === 'Enter') performSearch(); | |
| }); | |
| // XML Dump file input change handler | |
| document.getElementById('xml-dump-file-input').addEventListener('change', function(e) { | |
| const fileName = e.target.files[0] ? e.target.files[0].name : 'Select XML Dump file...'; | |
| document.getElementById('xml-dump-file-name').textContent = fileName; | |
| }); | |
| // Load feed on start | |
| loadFeed(); | |
| loadTrending(); | |
| async function loadTrending() { | |
| const trendingDiv = document.getElementById('trending-area'); | |
| try { | |
| const response = await fetch('/api/trending?limit=3'); | |
| const data = await response.json(); | |
| if (!data.results || data.results.length === 0) { | |
| trendingDiv.innerHTML = '<div class="col-span-full text-center py-8 text-slate-400">No trending topics yet. Start exploring!</div>'; | |
| return; | |
| } | |
| trendingDiv.innerHTML = ''; | |
| data.results.forEach((item, index) => { | |
| const p = item.payload; | |
| const card = document.createElement('div'); | |
| card.className = 'bg-gradient-to-br from-orange-50 to-white p-5 rounded-xl border border-orange-100 shadow-sm hover:shadow-md transition-all relative overflow-hidden cursor-pointer'; | |
| card.onclick = () => window.location.href = `/view/${item.id}`; | |
| card.innerHTML = ` | |
| <div class="absolute top-0 right-0 bg-orange-100 text-orange-600 text-xs font-bold px-3 py-1 rounded-bl-xl">#${index + 1}</div> | |
| <div class="text-xs font-bold text-orange-400 mb-2 uppercase tracking-wider">Hot Topic</div> | |
| <div class="text-lg font-bold text-slate-100 mb-2 line-clamp-1">${p.url || 'Untitled'}</div> | |
| <div class="text-sm text-slate-500 line-clamp-2 mb-3">${p.content_preview || ''}</div> | |
| <div class="flex items-center gap-2 text-xs text-slate-400 font-mono"> | |
| <span class="flex items-center gap-1 text-orange-500 font-bold"> | |
| <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z"/></svg> | |
| ${item.clicks} Clicks | |
| </span> | |
| </div> | |
| `; | |
| trendingDiv.appendChild(card); | |
| }); | |
| } catch (e) { | |
| trendingDiv.innerHTML = `<div class="col-span-full text-center text-red-400">Failed to load trending: ${e.message}</div>`; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |