Monorepo for Aesthetic.Computer
aesthetic.computer
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});