Monorepo for Aesthetic.Computer aesthetic.computer
at main 356 lines 11 kB view raw
1import http from 'http'; 2import { URL } from 'url'; 3import { WebSocketServer } from 'ws'; 4import { MongoClient } from 'mongodb'; 5import dotenv from 'dotenv'; 6import { fileURLToPath } from 'url'; 7import { dirname, join } from 'path'; 8import { readFile } from 'fs/promises'; 9 10// Load environment variables from vault 11const __filename = fileURLToPath(import.meta.url); 12const __dirname = dirname(__filename); 13// Try local .env first (for production), then vault (for development) 14const localEnvPath = join(__dirname, '.env'); 15const vaultEnvPath = join(__dirname, '../aesthetic-computer-vault/judge/.env'); 16dotenv.config({ path: localEnvPath }); 17dotenv.config({ path: vaultEnvPath }); // Will only load if local doesn't exist 18 19const OLLAMA_API = 'http://localhost:11434/api/generate'; 20const MODEL = 'gemma2:2b'; 21const PORT = 3000; 22 23// MongoDB connection 24const MONGODB_CONNECTION_STRING = process.env.MONGODB_CONNECTION_STRING || 'mongodb://localhost:27017'; 25const MONGODB_NAME = process.env.MONGODB_NAME || 'aesthetic'; 26const COLLECTION_NAME = process.env.COLLECTION_NAME || 'chat-system'; 27let mongoClient = null; 28let chatCollection = null; 29 30async function connectMongo() { 31 if (!mongoClient) { 32 mongoClient = new MongoClient(MONGODB_CONNECTION_STRING, { 33 serverSelectionTimeoutMS: 2000, // Timeout after 2 seconds 34 connectTimeoutMS: 2000 35 }); 36 await mongoClient.connect(); 37 chatCollection = mongoClient.db(MONGODB_NAME).collection(COLLECTION_NAME); 38 console.log('📦 Connected to MongoDB'); 39 } 40 return chatCollection; 41} 42 43// PG-13 content filter prompt 44const systemPrompt = `You are a PG-13 content filter for a chat room. Respond in s-expression format. 45 46CRITICAL: ALWAYS include the (why "...") part in your response! 47 48ALWAYS reply with BOTH parts: 49(yes) (why "short lowercase explanation") 50OR 51(no) (why "short lowercase explanation") 52 53Reply (yes) for URLs (http://, https://, www., or domain.tld patterns). 54 55Reply (no) if the message contains: 56- Sexual content or innuendo 57- Body functions (bathroom humor, gross-out content) 58- Profanity or explicit language 59- Violence or threats 60- Drug references 61- Hate speech or slurs 62 63Reply (yes) for: 64- Normal conversation 65- URLs and links 66- Questions and discussions 67- Greetings and casual chat 68 69Example responses (ALWAYS include both parts): 70(yes) (why "normal greeting") 71(yes) (why "asking a question") 72(yes) (why "url link") 73(no) (why "contains profanity") 74(no) (why "sexual content") 75(no) (why "inappropriate language") 76 77Keep all explanations lowercase and brief. NEVER skip the (why "...") part!`; 78 79async function filterMessage(message, onChunk = null) { 80 const prompt = `${systemPrompt}\n\nMessage: "${message}"\n\nReply (t/f):`; 81 82 const response = await fetch(OLLAMA_API, { 83 method: 'POST', 84 headers: { 'Content-Type': 'application/json' }, 85 body: JSON.stringify({ 86 model: MODEL, 87 prompt: prompt, 88 stream: true, // Enable streaming 89 }), 90 }); 91 92 if (!response.ok) { 93 throw new Error(`Ollama API error: ${response.status}`); 94 } 95 96 let fullResponse = ''; 97 let totalDuration = 0; 98 99 // Stream the response 100 const reader = response.body.getReader(); 101 const decoder = new TextDecoder(); 102 103 while (true) { 104 const { done, value } = await reader.read(); 105 if (done) break; 106 107 const chunk = decoder.decode(value); 108 const lines = chunk.split('\n').filter(line => line.trim()); 109 110 for (const line of lines) { 111 try { 112 const data = JSON.parse(line); 113 if (data.response) { 114 fullResponse += data.response; 115 if (onChunk) { 116 onChunk({ chunk: data.response, full: fullResponse }); 117 } 118 } 119 if (data.total_duration) { 120 totalDuration = data.total_duration; 121 } 122 } catch (e) { 123 // Skip invalid JSON lines 124 } 125 } 126 } 127 128 const finalResponse = fullResponse.trim(); 129 130 // Parse s-expression: (yes) or (no) and extract why 131 const decision = finalResponse.toLowerCase().includes('(yes)') ? 't' : 'f'; 132 133 // Extract (why "...") - proper s-expression format 134 let reason = ''; 135 const reasonMatch = finalResponse.match(/\(why\s+"((?:[^"\\]|\\.)*)"\)/is); 136 if (reasonMatch) { 137 reason = reasonMatch[1].toLowerCase().trim(); // Force lowercase and trim 138 } 139 140 return { 141 decision: decision, 142 sentiment: finalResponse, 143 reason: reason, 144 responseTime: totalDuration / 1e9, // Convert to seconds 145 }; 146} 147 148const server = http.createServer(async (req, res) => { 149 // CORS headers 150 res.setHeader('Access-Control-Allow-Origin', '*'); 151 res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); 152 res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); 153 154 if (req.method === 'OPTIONS') { 155 res.writeHead(200); 156 res.end(); 157 return; 158 } 159 160 if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) { 161 // Serve the dashboard HTML 162 try { 163 const html = await readFile(join(__dirname, 'index.html'), 'utf-8'); 164 res.writeHead(200, { 'Content-Type': 'text/html' }); 165 res.end(html); 166 } catch (error) { 167 res.writeHead(500, { 'Content-Type': 'application/json' }); 168 res.end(JSON.stringify({ error: 'Failed to load dashboard' })); 169 } 170 } else if (req.method === 'GET' && req.url.startsWith('/api/chat-messages')) { 171 // Fetch random sample of chat messages for auto-testing 172 try { 173 console.log('📡 Fetching chat messages from MongoDB...'); 174 console.log(' Connection string:', MONGODB_CONNECTION_STRING ? 'Set ✓' : 'Not set ✗'); 175 console.log(' Database:', MONGODB_NAME); 176 console.log(' Collection:', COLLECTION_NAME); 177 178 const collection = await connectMongo(); 179 const messages = await collection 180 .aggregate([ 181 { $match: { text: { $exists: true, $ne: '' } } }, 182 { $sample: { size: 100 } }, 183 { $project: { text: 1, _id: 0 } } 184 ]) 185 .toArray(); 186 187 console.log(` Found ${messages.length} messages`); 188 189 res.writeHead(200, { 'Content-Type': 'application/json' }); 190 res.end(JSON.stringify(messages.map(m => m.text))); 191 } catch (error) { 192 console.error('MongoDB error:', error); 193 // Fallback to test messages if MongoDB unavailable 194 const testMessages = [ 195 "hello world", 196 "how are you doing today?", 197 "fuck you", 198 "shit this sucks", 199 "nice painting!", 200 "what time is it?", 201 "https://example.com/image.png", 202 "i hate you so much", 203 "beautiful colors", 204 "damn that's cool", 205 "check out my site www.example.com", 206 "you're an idiot", 207 "love this piece", 208 "kill yourself", 209 "great work!", 210 "this is garbage", 211 "can you help me?", 212 "stupid ass program", 213 "amazing art", 214 "go to hell" 215 ]; 216 res.writeHead(200, { 'Content-Type': 'application/json' }); 217 res.end(JSON.stringify(testMessages)); 218 } 219 } else if (req.method === 'POST' && req.url === '/api/filter') { 220 let body = ''; 221 222 req.on('data', chunk => { 223 body += chunk.toString(); 224 }); 225 226 req.on('end', async () => { 227 try { 228 const { message } = JSON.parse(body); 229 230 if (!message || typeof message !== 'string') { 231 res.writeHead(400, { 'Content-Type': 'application/json' }); 232 res.end(JSON.stringify({ error: 'Invalid message' })); 233 return; 234 } 235 236 console.log(`[${new Date().toISOString()}] Testing: "${message.substring(0, 50)}${message.length > 50 ? '...' : ''}"`); 237 238 const result = await filterMessage(message); 239 240 console.log(` → Decision: ${result.decision === 't' ? 'PASS' : 'FAIL'} (${(result.responseTime * 1000).toFixed(0)}ms)`); 241 242 res.writeHead(200, { 'Content-Type': 'application/json' }); 243 res.end(JSON.stringify(result)); 244 } catch (error) { 245 console.error('Error:', error); 246 res.writeHead(500, { 'Content-Type': 'application/json' }); 247 res.end(JSON.stringify({ error: error.message })); 248 } 249 }); 250 } else { 251 res.writeHead(404, { 'Content-Type': 'application/json' }); 252 res.end(JSON.stringify({ error: 'Not found' })); 253 } 254}); 255 256// Track warmup state 257let isWarmedUp = false; 258let warmupPromise = null; 259 260async function warmupModel() { 261 if (isWarmedUp) return; 262 if (warmupPromise) return warmupPromise; 263 264 warmupPromise = (async () => { 265 console.log(`⏳ Warming up model...`); 266 try { 267 await filterMessage('hello'); 268 isWarmedUp = true; 269 console.log(`✅ Model warmed up and ready!\n`); 270 } catch (error) { 271 console.log(`⚠️ Model warmup failed: ${error.message}`); 272 console.log(` First request may be slower.\n`); 273 } 274 })(); 275 276 return warmupPromise; 277} 278 279server.listen(PORT, async () => { 280 console.log(`🛡️ Content Filter API running on http://localhost:${PORT}`); 281 console.log(`📊 Dashboard available via Caddy on http://localhost:8080`); 282 console.log(`🤖 Using model: ${MODEL}\n`); 283 284 // Start warmup (don't block server startup) 285 warmupModel().catch(err => console.error('❌ Warmup error:', err)); 286}); 287 288// Handle uncaught errors 289process.on('uncaughtException', (err) => { 290 console.error('❌ Uncaught exception:', err); 291}); 292 293process.on('unhandledRejection', (err) => { 294 console.error('❌ Unhandled rejection:', err); 295}); 296 297// WebSocket server for live streaming 298const wss = new WebSocketServer({ server, path: '/ws' }); 299 300wss.on('connection', (ws) => { 301 console.log('📡 WebSocket client connected'); 302 303 ws.on('message', async (data) => { 304 try { 305 const { message } = JSON.parse(data); 306 307 if (!message || typeof message !== 'string') { 308 ws.send(JSON.stringify({ error: 'Invalid message' })); 309 return; 310 } 311 312 // Wait for warmup to complete before processing 313 if (!isWarmedUp) { 314 ws.send(JSON.stringify({ type: 'warming', message: 'Model warming up...' })); 315 await warmupPromise; 316 } 317 318 console.log(`[${new Date().toISOString()}] Streaming test: "${message.substring(0, 50)}${message.length > 50 ? '...' : ''}"`); 319 320 const startTime = Date.now(); 321 322 // Send start event 323 ws.send(JSON.stringify({ type: 'start' })); 324 325 // Filter with streaming 326 const result = await filterMessage(message, (update) => { 327 ws.send(JSON.stringify({ 328 type: 'chunk', 329 chunk: update.chunk, 330 full: update.full 331 })); 332 }); 333 334 const responseTime = Date.now() - startTime; 335 336 console.log(` → Decision: ${result.decision === 't' ? 'PASS' : 'FAIL'} (${responseTime}ms)`); 337 338 // Send final result 339 ws.send(JSON.stringify({ 340 type: 'complete', 341 decision: result.decision, 342 sentiment: result.sentiment, 343 reason: result.reason, 344 responseTime: responseTime 345 })); 346 347 } catch (error) { 348 console.error('WebSocket error:', error); 349 ws.send(JSON.stringify({ type: 'error', error: error.message })); 350 } 351 }); 352 353 ws.on('close', () => { 354 console.log('📡 WebSocket client disconnected'); 355 }); 356});