Monorepo for Aesthetic.Computer aesthetic.computer
at main 685 lines 23 kB view raw
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>