Monorepo for Aesthetic.Computer
aesthetic.computer
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>censor · Aesthetic Computer</title>
7 <link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png" />
8 <style>
9 * {
10 margin: 0;
11 padding: 0;
12 box-sizing: border-box;
13 }
14
15 ::-webkit-scrollbar {
16 display: none;
17 }
18
19 html {
20 cursor: url("/aesthetic.computer/cursors/precise.svg") 12 12, auto;
21 }
22
23 body {
24 font-family: monospace;
25 font-size: 22px;
26 -webkit-text-size-adjust: none;
27 min-height: 100vh;
28 margin: 0;
29 padding: 16px;
30 }
31
32 .container {
33 max-width: 800px;
34 margin: 0 auto;
35 }
36
37 h1 {
38 font-weight: normal;
39 font-size: 22px;
40 margin: 0 0 0.5em 0;
41 }
42
43 h1 a, h1 a:visited {
44 text-decoration: none;
45 }
46
47 .subtitle {
48 margin-bottom: 1.5em;
49 font-size: 18px;
50 opacity: 0.7;
51 }
52
53 .test-section {
54 margin-bottom: 1em;
55 }
56
57 label {
58 display: block;
59 font-weight: normal;
60 margin-bottom: 0.5em;
61 }
62
63 textarea {
64 width: 100%;
65 padding: 12px;
66 border: 1px solid;
67 border-radius: 2px;
68 font-family: monospace;
69 font-size: 18px;
70 resize: vertical;
71 min-height: 100px;
72 }
73
74 textarea:focus {
75 outline: none;
76 }
77
78 .button-group {
79 display: flex;
80 gap: 8px;
81 margin-bottom: 1.5em;
82 }
83
84 button {
85 flex: 1;
86 padding: 12px;
87 border: 1px solid;
88 border-radius: 2px;
89 font-size: 18px;
90 font-family: monospace;
91 cursor: pointer;
92 transition: opacity 0.2s;
93 }
94
95 button:hover {
96 opacity: 0.8;
97 }
98
99 button:active {
100 opacity: 0.6;
101 }
102
103 .btn-test {
104 background: rgb(92, 205, 155);
105 color: rgb(25, 0, 25);
106 border-color: rgb(92, 205, 155);
107 }
108
109 .btn-clear {
110 background: transparent;
111 border-color: rgba(255, 255, 255, 0.3);
112 }
113
114 .result {
115 padding: 16px;
116 border-radius: 4px;
117 margin-bottom: 1.5em;
118 display: none;
119 animation: fadeIn 0.3s ease;
120 }
121
122 @keyframes fadeIn {
123 from { opacity: 0; }
124 to { opacity: 1; }
125 }
126
127 .result-icon {
128 font-size: 2em;
129 margin-bottom: 0.3em;
130 }
131
132 .result-title {
133 font-size: 1.2em;
134 margin-bottom: 0.5em;
135 }
136
137 .result-details {
138 opacity: 0.8;
139 margin-bottom: 0.5em;
140 font-size: 16px;
141 }
142
143 .result-sentiment {
144 padding: 8px;
145 border-radius: 2px;
146 font-size: 14px;
147 margin-top: 0.5em;
148 opacity: 0.7;
149 }
150
151 .loading {
152 display: none;
153 text-align: center;
154 padding: 1.5em;
155 }
156
157 .spinner {
158 width: 32px;
159 height: 32px;
160 margin: 0 auto 0.5em;
161 animation: spin 1s linear infinite;
162 }
163
164 @keyframes spin {
165 0% { transform: rotate(0deg); }
166 100% { transform: rotate(360deg); }
167 }
168
169 .model-info {
170 padding: 12px;
171 border-radius: 4px;
172 margin-bottom: 1.5em;
173 font-size: 14px;
174 opacity: 0.8;
175 }
176
177 .syntax-highlighted {
178 font-family: monospace;
179 font-size: 13px;
180 }
181
182 /* Dark mode */
183 @media (prefers-color-scheme: dark) {
184 body {
185 background-color: rgb(64, 56, 74);
186 color: rgba(255, 255, 255, 0.85);
187 }
188
189 h1 a, h1 a:visited {
190 color: rgba(255, 255, 255, 0.85);
191 }
192
193 h1 a:hover {
194 color: rgb(205, 92, 155);
195 }
196
197 textarea {
198 background: rgb(25, 0, 25);
199 color: rgba(255, 255, 255, 0.85);
200 border-color: rgba(205, 92, 155, 0.3);
201 }
202
203 textarea:focus {
204 border-color: rgb(205, 92, 155);
205 }
206
207 button {
208 background: rgb(25, 0, 25);
209 color: rgba(255, 255, 255, 0.85);
210 border-color: rgba(205, 92, 155, 0.3);
211 }
212
213 button:hover {
214 border-color: rgb(205, 92, 155);
215 }
216
217 .btn-test {
218 background: rgb(92, 205, 155);
219 color: rgb(25, 0, 25);
220 border-color: rgb(92, 205, 155);
221 }
222
223 .btn-clear {
224 background: transparent;
225 border-color: rgba(255, 255, 255, 0.3);
226 color: rgba(255, 255, 255, 0.85);
227 }
228
229 .result.pass {
230 background: rgba(92, 205, 155, 0.1);
231 border: 1px solid rgba(92, 205, 155, 0.5);
232 }
233
234 .result.fail {
235 background: rgba(205, 92, 92, 0.1);
236 border: 1px solid rgba(205, 92, 92, 0.5);
237 }
238
239 .result.pass .result-title {
240 color: rgb(92, 205, 155);
241 }
242
243 .result.fail .result-title {
244 color: rgb(205, 92, 92);
245 }
246
247 .result-sentiment {
248 background: rgb(25, 0, 25);
249 }
250
251 .spinner {
252 border: 2px solid rgba(205, 92, 155, 0.2);
253 border-top-color: rgb(205, 92, 155);
254 border-radius: 50%;
255 }
256
257 .model-info {
258 background: rgba(205, 92, 155, 0.05);
259 border: 1px solid rgba(205, 92, 155, 0.2);
260 }
261 }
262
263 /* Light mode */
264 @media (prefers-color-scheme: light) {
265 body {
266 background-color: rgba(244, 235, 250);
267 color: rgb(64, 56, 74);
268 }
269
270 h1 a, h1 a:visited {
271 color: rgb(64, 56, 74);
272 }
273
274 h1 a:hover {
275 color: rgb(205, 92, 155);
276 }
277
278 textarea {
279 background: white;
280 border-color: rgba(64, 56, 74, 0.2);
281 }
282
283 textarea:focus {
284 border-color: rgb(205, 92, 155);
285 }
286
287 button {
288 background: white;
289 border-color: rgba(64, 56, 74, 0.2);
290 }
291
292 button:hover {
293 border-color: rgb(205, 92, 155);
294 }
295
296 .btn-test {
297 background: rgb(92, 205, 155);
298 color: white;
299 border-color: rgb(92, 205, 155);
300 }
301
302 .btn-clear {
303 background: transparent;
304 border-color: rgba(64, 56, 74, 0.2);
305 color: rgb(64, 56, 74);
306 }
307
308 .result.pass {
309 background: rgba(76, 175, 80, 0.08);
310 border: 1px solid rgba(76, 175, 80, 0.3);
311 }
312
313 .result.fail {
314 background: rgba(244, 67, 54, 0.08);
315 border: 1px solid rgba(244, 67, 54, 0.3);
316 }
317
318 .result.pass .result-title {
319 color: rgb(46, 125, 50);
320 }
321
322 .result.fail .result-title {
323 color: rgb(198, 40, 40);
324 }
325
326 .result-sentiment {
327 background: rgba(0, 0, 0, 0.03);
328 }
329
330 .spinner {
331 border: 2px solid rgba(205, 92, 155, 0.2);
332 border-top-color: rgb(205, 92, 155);
333 border-radius: 50%;
334 }
335
336 .model-info {
337 background: rgba(205, 92, 155, 0.03);
338 border: 1px solid rgba(205, 92, 155, 0.15);
339 }
340 }
341 </style>
342</head>
343<body>
344 <div class="container">
345 <h1><a href="/">censor</a></h1>
346 <p class="subtitle">pg-13 content filter</p>
347
348 <div class="test-section">
349 <label for="messageInput">enter something inappropriate</label>
350 <textarea
351 id="messageInput"
352 placeholder="type or paste a message..."
353 ></textarea>
354 </div>
355
356 <div class="button-group">
357 <button class="btn-test" onclick="testMessage()">test</button>
358 <button class="btn-clear" onclick="clearForm()">clear</button>
359 </div>
360
361 <div class="loading" id="loading">
362 <div class="spinner"></div>
363 <div>testing...</div>
364 </div>
365
366 <div class="result" id="result"></div>
367
368 <div class="model-info">
369 gemma2:2b · 250mb ram · ~1.3s/msg
370 </div>
371
372 <div style="margin-top: 2em; padding-top: 2em; border-top: 1px solid rgba(205, 92, 155, 0.3);">
373 <h2 style="font-weight: normal; font-size: 22px; margin-bottom: 0.5em;">auto-test</h2>
374 <p style="opacity: 0.7; margin-bottom: 1em; font-size: 16px;">test against real chat messages from mongodb</p>
375
376 <div class="button-group">
377 <button class="btn-test" onclick="startAutoTest()">start test</button>
378 <button class="btn-clear" onclick="stopAutoTest()">stop</button>
379 </div>
380
381 <div id="autoTestStatus" style="margin-top: 1em; padding: 12px; border-radius: 4px; display: none;"></div>
382 <div id="autoTestResults" style="margin-top: 1em;"></div>
383 </div>
384 </div>
385
386 <script>
387 // WebSocket connection
388 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
389 let ws = null;
390 let reconnectTimer = null;
391
392 function connectWebSocket() {
393 ws = new WebSocket(`${protocol}//localhost:3000/ws`);
394
395 ws.onopen = () => {
396 console.log('🟢 WebSocket connected');
397 };
398
399 ws.onclose = () => {
400 console.log('🔴 WebSocket disconnected');
401 // Reconnect after 2 seconds
402 reconnectTimer = setTimeout(connectWebSocket, 2000);
403 };
404
405 ws.onerror = (error) => {
406 console.error('WebSocket error:', error);
407 };
408 }
409
410 connectWebSocket();
411
412 function clearForm() {
413 document.getElementById('messageInput').value = '';
414 document.getElementById('result').style.display = 'none';
415 }
416
417 async function testMessage() {
418 const message = document.getElementById('messageInput').value.trim();
419
420 if (!message) {
421 alert('Please enter a message to test');
422 return;
423 }
424
425 if (!ws || ws.readyState !== WebSocket.OPEN) {
426 console.log('WebSocket not ready, state:', ws ? ws.readyState : 'null');
427 if (!ws || ws.readyState === WebSocket.CLOSED) {
428 connectWebSocket();
429 }
430 // Retry after a short delay
431 setTimeout(() => testMessage(), 1000);
432 return;
433 }
434
435 const loading = document.getElementById('loading');
436 const result = document.getElementById('result');
437
438 loading.style.display = 'block';
439 result.style.display = 'none';
440
441 const startTime = Date.now();
442 let streamedText = '';
443
444 // Set up message handler for this test
445 ws.onmessage = (event) => {
446 const data = JSON.parse(event.data);
447
448 if (data.type === 'warming') {
449 // Model warming up
450 loading.innerHTML = `
451 <div class="spinner"></div>
452 <div>⏳ ${data.message}</div>
453 `;
454
455 } else if (data.type === 'start') {
456 // Test started
457 streamedText = '';
458
459 } else if (data.type === 'chunk') {
460 // Streaming chunk received
461 streamedText = data.full;
462
463 // Update loading display with streaming text
464 loading.innerHTML = `
465 <div class="spinner"></div>
466 <div>testing...</div>
467 <div class="result-sentiment" style="margin-top: 0.5em;">${streamedText}_</div>
468 `;
469
470 } else if (data.type === 'complete') {
471 // Test complete
472 const responseTime = Date.now() - startTime;
473
474 // Show result
475 loading.style.display = 'none';
476 result.className = 'result ' + (data.decision === 't' ? 'pass' : 'fail');
477
478 const decision = data.decision === 't' ? '(yes)' : '(no)';
479
480 result.innerHTML = `
481 <div class="result-icon">${data.decision === 't' ? '✓' : '✗'}</div>
482 <div class="result-title">${decision}</div>
483 ${data.reason ? `
484 <div class="result-details">
485 (why "${data.reason}")
486 </div>
487 ` : ''}
488 <div class="result-sentiment">
489 ${data.sentiment || ''}
490 </div>
491 `;
492 result.style.display = 'block';
493
494 // Apply syntax highlighting
495 const sentimentEl = result.querySelector('.result-sentiment');
496 if (sentimentEl) {
497 const text = sentimentEl.textContent;
498 sentimentEl.innerHTML = highlightSExpression(text);
499 }
500
501 } else if (data.type === 'error') {
502 loading.style.display = 'none';
503 result.className = 'result fail';
504 result.innerHTML = `
505 <div class="result-icon">✗</div>
506 <div class="result-title">error</div>
507 <div class="result-details">
508 failed to test message
509 </div>
510 <div class="result-sentiment">
511 ${data.error}
512 </div>
513 `;
514 result.style.display = 'block';
515 }
516 };
517
518 // Send message to WebSocket
519 ws.send(JSON.stringify({ message }));
520 }
521
522 // Allow Enter to submit (Shift+Enter for new line)
523 document.getElementById('messageInput').addEventListener('keydown', function(e) {
524 if (e.key === 'Enter' && !e.shiftKey) {
525 e.preventDefault();
526 testMessage();
527 }
528 });
529
530 // Auto-test functionality
531 let autoTestMessages = [];
532 let autoTestIndex = 0;
533 let autoTestRunning = false;
534 let autoTestStats = { total: 0, passed: 0, blocked: 0 };
535
536 async function startAutoTest() {
537 if (autoTestRunning) return;
538
539 const status = document.getElementById('autoTestStatus');
540 status.style.display = 'block';
541 status.style.background = 'rgba(205, 92, 155, 0.1)';
542 status.style.border = '1px solid rgba(205, 92, 155, 0.3)';
543 status.textContent = '⏳ Loading messages from MongoDB...';
544
545 try {
546 const response = await fetch('/api/chat-messages');
547 autoTestMessages = await response.json();
548 autoTestIndex = 0;
549 autoTestStats = { total: 0, passed: 0, blocked: 0 };
550 autoTestRunning = true;
551
552 status.textContent = `▶️ Testing ${autoTestMessages.length} messages...`;
553 document.getElementById('autoTestResults').innerHTML = '';
554
555 runNextAutoTest();
556 } catch (error) {
557 status.textContent = `❌ Error: ${error.message}`;
558 status.style.background = 'rgba(205, 92, 92, 0.1)';
559 status.style.border = '1px solid rgba(205, 92, 92, 0.5)';
560 }
561 }
562
563 function stopAutoTest() {
564 autoTestRunning = false;
565 const status = document.getElementById('autoTestStatus');
566 status.textContent = `⏸️ Stopped at ${autoTestStats.total} messages (${autoTestStats.passed} passed, ${autoTestStats.blocked} blocked)`;
567 }
568
569 async function runNextAutoTest() {
570 if (!autoTestRunning || autoTestIndex >= autoTestMessages.length) {
571 autoTestRunning = false;
572 const status = document.getElementById('autoTestStatus');
573 status.textContent = `✅ Complete: ${autoTestStats.total} tested (${autoTestStats.passed} passed, ${autoTestStats.blocked} blocked)`;
574 status.style.background = 'rgba(92, 205, 155, 0.1)';
575 status.style.border = '1px solid rgba(92, 205, 155, 0.5)';
576 return;
577 }
578
579 const message = autoTestMessages[autoTestIndex];
580 autoTestIndex++;
581
582 if (!ws || ws.readyState !== WebSocket.OPEN) {
583 setTimeout(runNextAutoTest, 100);
584 return;
585 }
586
587 // Set up one-time listener for this test
588 const originalOnMessage = ws.onmessage;
589 let streamedText = '';
590
591 ws.onmessage = (event) => {
592 const data = JSON.parse(event.data);
593
594 if (data.type === 'chunk') {
595 streamedText = data.full;
596 } else if (data.type === 'complete') {
597 autoTestStats.total++;
598 if (data.decision === 't') {
599 autoTestStats.passed++;
600 } else {
601 autoTestStats.blocked++;
602 }
603
604 // Add result to display with syntax highlighting
605 const resultsDiv = document.getElementById('autoTestResults');
606 const resultItem = document.createElement('div');
607 resultItem.style.marginBottom = '8px';
608 resultItem.style.padding = '8px';
609 resultItem.style.borderRadius = '4px';
610 resultItem.style.fontSize = '14px';
611 resultItem.style.background = data.decision === 't' ? 'rgba(92, 205, 155, 0.05)' : 'rgba(205, 92, 92, 0.05)';
612 resultItem.style.border = data.decision === 't' ? '1px solid rgba(92, 205, 155, 0.3)' : '1px solid rgba(205, 92, 92, 0.3)';
613
614 const icon = data.decision === 't' ? '✓' : '✗';
615 const truncated = message.length > 60 ? message.substring(0, 60) + '...' : message;
616
617 resultItem.innerHTML = `
618 <span style="opacity: 0.5;">${autoTestStats.total}.</span>
619 ${icon} ${escapeHtml(truncated)}
620 <span style="opacity: 0.7; margin-left: 8px;">${highlightSExpression(data.sentiment || '')}</span>
621 `;
622
623 resultsDiv.insertBefore(resultItem, resultsDiv.firstChild);
624
625 // Keep only last 20 results
626 while (resultsDiv.children.length > 20) {
627 resultsDiv.removeChild(resultsDiv.lastChild);
628 }
629
630 // Update status
631 const status = document.getElementById('autoTestStatus');
632 status.textContent = `▶️ ${autoTestStats.total}/${autoTestMessages.length} (${autoTestStats.passed} ✓, ${autoTestStats.blocked} ✗)`;
633
634 // Restore original handler and continue
635 ws.onmessage = originalOnMessage;
636 setTimeout(runNextAutoTest, 100); // Small delay between tests
637 }
638 };
639
640 // Send test message
641 ws.send(JSON.stringify({ message }));
642 }
643
644 function escapeHtml(text) {
645 const div = document.createElement('div');
646 div.textContent = text;
647 return div.innerHTML;
648 }
649
650 function highlightSExpression(text) {
651 if (!text) return '';
652
653 // Syntax highlighting for proper s-expressions: (yes) (why "reason") or (no) (why "reason")
654 return text
655 // (yes) - cyan
656 .replace(/\(yes\)/g, '<span style="color: rgb(0, 255, 255);">(yes)</span>')
657 // (no) - orange-red
658 .replace(/\(no\)/g, '<span style="color: rgb(255, 99, 71);">(no)</span>')
659 // (why ...) - parentheses purple, keyword lime, string yellow
660 .replace(/\(why\s+"([^"]*)"\)/g, function(match, reason) {
661 return '<span style="color: rgb(147, 112, 219);">(</span>' +
662 '<span style="color: rgb(50, 205, 50);">why</span> ' +
663 '<span style="color: rgb(255, 215, 0);">"' + reason + '"</span>' +
664 '<span style="color: rgb(147, 112, 219);">)</span>';
665 });
666 }
667
668 // Apply syntax highlighting to sentiment displays
669 function applySyntaxHighlighting() {
670 document.querySelectorAll('.result-sentiment').forEach(el => {
671 const text = el.textContent;
672 el.innerHTML = highlightSExpression(text);
673 });
674 document.querySelectorAll('.syntax-highlighted').forEach(el => {
675 const text = el.textContent;
676 el.innerHTML = highlightSExpression(text);
677 });
678 }
679
680 // Apply highlighting after each result
681 const originalOnMessage = ws?.onmessage;
682
683 </script>
684</body>
685</html>