/ index.html
index.html
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>AURYN</title> 7 <style> 8 @font-face { 9 font-family: 'TeX Gyre Termes'; 10 src: url('fonts/texgyretermes-regular.otf') format('opentype'); 11 font-weight: normal; 12 font-style: normal; 13 } 14 * { margin: 0; padding: 0; box-sizing: border-box; } 15 body { 16 background: #000; color: #e0e0e0; 17 font-family: 'TeX Gyre Termes', Georgia, serif; 18 width: 100vw; height: 100vh; 19 display: flex; flex-direction: column; 20 overflow: hidden; 21 } 22 23 /* ===== CONTEXT ZONE (upper half) ===== */ 24 #context-zone { 25 height: 40vh; min-height: 160px; 26 display: flex; align-items: center; justify-content: center; 27 position: relative; overflow: hidden; 28 flex-shrink: 0; 29 } 30 31 #auryn-symbol { 32 width: 120px; height: 120px; 33 border-radius: 50%; overflow: hidden; 34 cursor: pointer; position: relative; 35 z-index: 2; flex-shrink: 0; 36 border: 2px solid rgba(255, 215, 0, 0.3); 37 transition: border-color 0.3s, transform 0.2s; 38 } 39 #auryn-symbol:hover { 40 border-color: rgba(255, 215, 0, 0.7); 41 transform: scale(1.05); 42 } 43 #auryn-symbol img { 44 width: 100%; height: 100%; object-fit: cover; 45 } 46 47 /* Petals container — positioned around the AURYN symbol */ 48 #petals-ring { 49 position: absolute; top: 50%; left: 50%; 50 width: 0; height: 0; 51 z-index: 1; 52 } 53 54 .petal { 55 position: absolute; 56 width: 52px; height: 52px; 57 border-radius: 50%; overflow: hidden; 58 cursor: pointer; 59 transition: transform 0.3s ease, opacity 0.3s ease; 60 border: 2px solid rgba(100, 149, 237, 0.6); 61 } 62 .petal.dreamer { border-color: rgba(205, 92, 92, 0.6); } 63 .petal:hover { transform: scale(1.15) !important; } 64 .petal img { 65 width: 100%; height: 100%; object-fit: cover; 66 } 67 .petal .petal-remove { 68 position: absolute; top: -4px; right: -4px; 69 width: 16px; height: 16px; border-radius: 50%; 70 background: #333; color: #aaa; border: none; 71 font-size: 10px; cursor: pointer; 72 display: none; align-items: center; justify-content: center; 73 line-height: 1; 74 } 75 .petal:hover .petal-remove { display: flex; } 76 .petal .petal-label { 77 position: absolute; bottom: -18px; left: 50%; 78 transform: translateX(-50%); 79 font-size: 9px; color: #555; 80 white-space: nowrap; max-width: 80px; 81 overflow: hidden; text-overflow: ellipsis; 82 font-family: -apple-system, sans-serif; 83 pointer-events: none; opacity: 0; 84 transition: opacity 0.2s; 85 } 86 .petal:hover .petal-label { opacity: 1; } 87 88 /* No-image petal fallback */ 89 .petal .petal-initials { 90 width: 100%; height: 100%; 91 display: flex; align-items: center; justify-content: center; 92 background: #1a1a1a; color: #888; 93 font-size: 14px; font-weight: bold; 94 font-family: -apple-system, sans-serif; 95 } 96 97 /* ===== CHAT ZONE (lower half) ===== */ 98 #chat-zone { 99 flex: 1; display: flex; flex-direction: column; 100 min-height: 0; /* allow flex shrink */ 101 border-top: 1px solid #111; 102 } 103 104 #messages { 105 flex: 1; overflow-y: auto; padding: 16px 16px; 106 display: flex; flex-direction: column; gap: 16px; 107 scrollbar-width: thin; scrollbar-color: #333 #000; 108 } 109 #messages::-webkit-scrollbar { width: 6px; } 110 #messages::-webkit-scrollbar-track { background: #000; } 111 #messages::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } 112 113 .msg { border-radius: 12px; line-height: 1.6; } 114 .msg.user { 115 align-self: flex-end; max-width: 85%; background: #1a1a1a; color: #ccc; 116 border: 1px solid #333; font-size: 16px; padding: 10px 16px; 117 } 118 .msg.assistant { 119 align-self: flex-start; width: 100%; position: relative; padding: 0; 120 } 121 .msg.transcript { 122 align-self: flex-start; width: 100%; position: relative; padding: 0; 123 } 124 125 /* The rendered markdown text layer */ 126 .msg.assistant .text-layer, .msg.transcript .text-layer { 127 font-family: 'TeX Gyre Termes', Georgia, serif; 128 font-size: 20px; color: #fff; line-height: 1.6; 129 word-wrap: break-word; 130 } 131 .msg.transcript .text-layer { color: #b0b0b0; } 132 .msg.assistant .text-layer p { margin-bottom: 0.6em; } 133 .msg.assistant .text-layer p:last-child { margin-bottom: 0; } 134 .msg.assistant .text-layer strong { font-weight: bold; } 135 .msg.assistant .text-layer em { font-style: italic; } 136 .msg.assistant .text-layer code { 137 background: #1a1a1a; padding: 1px 5px; border-radius: 3px; 138 font-family: monospace; font-size: 0.9em; 139 } 140 .msg.assistant .text-layer .list-block { 141 margin: 0.3em 0; 142 } 143 .msg.assistant .text-layer .list-item { 144 padding-left: 0; 145 } 146 147 /* SVG animation overlay — positioned over the text layer */ 148 .msg.assistant .svg-overlay, .msg.transcript .svg-overlay { 149 position: absolute; top: 0; left: 0; width: 100%; height: 100%; 150 pointer-events: none; z-index: 1; 151 } 152 .msg.assistant .svg-overlay svg, .msg.transcript .svg-overlay svg { 153 width: 100%; height: 100%; display: block; 154 } 155 156 .streaming-cursor { 157 display: inline-block; width: 2px; height: 1em; 158 background: #FFD700; margin-left: 2px; 159 animation: blink 0.8s step-end infinite; 160 vertical-align: text-bottom; 161 } 162 @keyframes blink { 50% { opacity: 0; } } 163 164 /* Write animation keyframes */ 165 @keyframes stroke-draw { to { stroke-dashoffset: 0; } } 166 @keyframes fill-reveal { to { fill-opacity: 1; } } 167 @keyframes stroke-fade { to { stroke-opacity: 0; } } 168 @keyframes overlay-fadeout { to { opacity: 0; } } 169 170 .char-path-stroke { 171 fill: transparent; stroke: #fff; stroke-width: 1.5; 172 fill-opacity: 0; stroke-opacity: 1; 173 } 174 .char-path-fill { 175 fill: #fff; fill-opacity: 0; stroke: none; 176 } 177 /* Transcript uses muted color */ 178 .msg.transcript .char-path-stroke { stroke: #b0b0b0; } 179 .msg.transcript .char-path-fill { fill: #b0b0b0; } 180 181 /* ===== INPUT AREA ===== */ 182 #input-area { 183 padding: 12px 16px; border-top: 1px solid #1a1a1a; 184 display: flex; gap: 10px; align-items: flex-end; 185 background: #000; position: relative; 186 } 187 #input-area textarea { 188 flex: 1; background: #111; border: 1px solid #333; 189 border-radius: 8px; color: #e0e0e0; padding: 10px 14px; 190 font-family: inherit; font-size: 16px; resize: none; 191 outline: none; min-height: 42px; max-height: 40vh; 192 line-height: 1.4; 193 } 194 #input-area textarea:focus { border-color: #555; } 195 #input-area textarea::placeholder { color: #444; } 196 197 #send-btn, #stop-btn, #mic-btn { 198 width: 42px; height: 42px; border-radius: 50%; border: none; 199 cursor: pointer; display: flex; align-items: center; 200 justify-content: center; flex-shrink: 0; 201 } 202 #send-btn { background: #FFD700; color: #000; } 203 #send-btn.garden-mode { background: #2d5a27; color: #7fff7f; } 204 #send-btn:disabled { background: #333; color: #666; cursor: default; } 205 #send-btn svg { width: 18px; height: 18px; } 206 #stop-btn { background: #8B0000; color: #fff; display: none; } 207 #stop-btn svg { width: 16px; height: 16px; } 208 #mic-btn { background: #222; color: #888; } 209 #mic-btn:disabled { opacity: 0.3; cursor: default; } 210 #mic-btn svg { width: 18px; height: 18px; } 211 #mic-btn.recording { background: #8B0000; color: #fff; animation: pulse 1.5s infinite; } 212 @keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.08); } } 213 214 /* ===== AUTOCOMPLETE DROPDOWN ===== */ 215 #autocomplete { 216 display: none; position: absolute; 217 bottom: 100%; left: 16px; right: 68px; 218 max-height: 240px; overflow-y: auto; 219 background: #111; border: 1px solid #333; 220 border-radius: 8px 8px 0 0; 221 z-index: 50; 222 scrollbar-width: thin; scrollbar-color: #333 #111; 223 } 224 #autocomplete::-webkit-scrollbar { width: 4px; } 225 #autocomplete::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } 226 227 .ac-item { 228 padding: 8px 12px; cursor: pointer; 229 display: flex; align-items: center; gap: 10px; 230 border-bottom: 1px solid #1a1a1a; 231 transition: background 0.1s; 232 } 233 .ac-item:last-child { border-bottom: none; } 234 .ac-item:hover, .ac-item.selected { background: #1a1a1a; } 235 .ac-item .ac-thumb { 236 width: 28px; height: 28px; border-radius: 50%; 237 overflow: hidden; flex-shrink: 0; 238 border: 1.5px solid rgba(100, 149, 237, 0.5); 239 } 240 .ac-item .ac-thumb.dreamer { border-color: rgba(205, 92, 92, 0.5); } 241 .ac-item .ac-thumb img { width: 100%; height: 100%; object-fit: cover; } 242 .ac-item .ac-thumb .ac-initials { 243 width: 100%; height: 100%; 244 display: flex; align-items: center; justify-content: center; 245 background: #1a1a1a; color: #555; font-size: 10px; 246 font-family: -apple-system, sans-serif; 247 } 248 .ac-item .ac-title { 249 color: #ccc; font-size: 14px; 250 font-family: -apple-system, sans-serif; 251 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 252 } 253 .ac-item .ac-type { 254 margin-left: auto; font-size: 10px; color: #444; 255 font-family: -apple-system, sans-serif; 256 flex-shrink: 0; 257 } 258 259 #fallback-msg { 260 display: none; position: absolute; top: 50%; left: 50%; 261 transform: translate(-50%, -50%); text-align: center; 262 color: #444; font-size: 16px; line-height: 1.6; 263 } 264 #fallback-msg .accent { color: #FFD700; } 265 266 /* Drag-and-drop overlay */ 267 #drop-overlay { 268 display: none; position: fixed; inset: 0; 269 background: rgba(0,0,0,0.8); z-index: 100; 270 border: 3px dashed #FFD700; 271 flex-direction: column; align-items: center; justify-content: center; 272 color: #FFD700; font-size: 20px; 273 } 274 #drop-overlay.active { display: flex; } 275 276 /* Transcript label */ 277 .transcript-label { 278 font-size: 11px; color: #555; margin-bottom: 4px; 279 font-family: -apple-system, sans-serif; 280 } 281 282 /* Connection status indicator */ 283 #connection-status { 284 display: none; padding: 4px 16px; background: #0a0a0a; 285 border-bottom: 1px solid #1a1a1a; font-size: 11px; 286 font-family: -apple-system, sans-serif; color: #555; 287 } 288 #connection-status.connected { color: #4a7a4a; } 289 #connection-status.disconnected { color: #7a4a4a; } 290 #reload-btn { 291 position: fixed; top: 4px; right: 8px; z-index: 100; 292 background: none; border: 1px solid #333; border-radius: 4px; 293 color: #555; font-size: 10px; padding: 2px 8px; cursor: pointer; 294 } 295 #reload-btn:hover { color: #aaa; border-color: #555; } 296 297 /* Vocab debug panel */ 298 #vocab-debug { 299 display: none; padding: 6px 16px; background: #0a0a0a; 300 border-top: 1px solid #1a1a1a; font-size: 10px; line-height: 1.5; 301 font-family: -apple-system, sans-serif; color: #555; 302 max-height: 80px; overflow-y: auto; 303 } 304 #vocab-debug.active { display: block; } 305 #vocab-debug .vocab-label { color: #666; margin-right: 4px; } 306 #vocab-debug .vocab-core { color: #555; } 307 #vocab-debug .vocab-pinned { color: #FFD700; } 308 #vocab-debug .vocab-ephemeral { color: #4a7a9a; } 309 </style> 310 </head> 311 <body> 312 <div id="connection-status"></div> 313 <button id="reload-btn" onclick="reloadServer()" title="Restart AURYN server">reload</button> 314 315 <!-- CONTEXT ZONE: AURYN symbol + context petals --> 316 <div id="context-zone"> 317 <div id="petals-ring"></div> 318 <div id="auryn-symbol" title="Select AURYN in dreamspace"> 319 <img src="AURYN.jpg" alt="AURYN"> 320 </div> 321 </div> 322 323 <!-- CHAT ZONE: messages + input --> 324 <div id="chat-zone"> 325 <div id="messages"></div> 326 <div id="input-area"> 327 <div id="autocomplete"></div> 328 <textarea id="input" rows="1" placeholder="Talk to AURYN... (@ to reference DreamNodes)" autofocus></textarea> 329 <button id="mic-btn" title="Record voice"> 330 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg> 331 </button> 332 <button id="send-btn" title="Send"> 333 <svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg> 334 </button> 335 <button id="stop-btn" title="Stop"> 336 <svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg> 337 </button> 338 </div> 339 </div> 340 341 <div id="vocab-debug"></div> 342 <div id="drop-overlay">Drop audio file to transcribe</div> 343 <div id="fallback-msg"> 344 Open in the <span class="accent">InterBrain</span> to chat with AURYN 345 </div> 346 <!-- Hidden link so html-loader resolves the font path to an app:// URL --> 347 <link id="font-resource" href="fonts/texgyretermes-regular.otf" rel="preload" as="font" type="font/otf" crossorigin style="display:none"> 348 349 <script src="https://cdn.jsdelivr.net/npm/opentype.js@1.3.4/dist/opentype.min.js"></script> 350 <script> 351 // ============================================================ 352 // 1. STATE 353 // ============================================================ 354 const messagesEl = document.getElementById('messages'); 355 const inputEl = document.getElementById('input'); 356 const sendBtn = document.getElementById('send-btn'); 357 const stopBtn = document.getElementById('stop-btn'); 358 const micBtn = document.getElementById('mic-btn'); 359 const fallbackEl = document.getElementById('fallback-msg'); 360 const dropOverlay = document.getElementById('drop-overlay'); 361 const connectionStatus = document.getElementById('connection-status'); 362 const autocompleteEl = document.getElementById('autocomplete'); 363 const petalsRing = document.getElementById('petals-ring'); 364 const aurynSymbol = document.getElementById('auryn-symbol'); 365 366 let conversation = []; 367 let isStreaming = false; 368 let currentRequestId = null; 369 let font = null; 370 let fontLoaded = false; 371 let aiBridgeReady = false; 372 let standaloneMode = false; 373 374 // Transcription state 375 let isRecording = false; 376 let mediaRecorder = null; 377 let transcriptionWs = null; 378 let transcriptMsg = null; // the active transcript message object 379 380 // Context state — DreamNodes loaded into context 381 let contextNodes = []; // array of { id, title, type, dreamTalkUrl, repoPath, absPath } 382 383 // Autocomplete state 384 let acVisible = false; 385 let acSelectedIndex = 0; 386 let acFilteredItems = []; 387 let acTriggerPos = -1; // cursor position where @ was typed 388 389 // DreamNode catalog — populated from InterBrain or dummy data 390 let dreamNodeCatalog = []; 391 392 const SYSTEM_PROMPT = `You are AURYN, the cultivation layer of DreamOS. Named after the Ouroboros amulet from The Neverending Story — "Do what you will." 393 394 You help users tend their knowledge garden: creating DreamNodes, weaving DreamSongs, discovering connections through semantic search, and nurturing visions from seeds to harvest. You speak with warmth and clarity, like a wise gardener who knows the soil intimately. 395 396 Keep responses concise and grounded. Use metaphor when it illuminates, not to obscure. Never use emojis. Respond in plain text with minimal markdown — use **bold** sparingly for emphasis, use bullet lists with dashes when listing items. Do not use headers.`; 397 398 // ============================================================ 399 // 1b. DREAMNODE CATALOG 400 // ============================================================ 401 // Request catalog from InterBrain via postMessage, fall back to 402 // scanning via CLI bridge, or use empty list. 403 404 // Mock catalog for testing — replaced by InterBrain response when in iframe 405 const MOCK_CATALOG = [ 406 { id: '53a3bb7a-3df8-4f36-af99-23787a8cec25', title: 'A U R Y N', type: 'dream', repoPath: 'AURYN', dreamTalkUrl: '' }, 407 { id: 'mock-interBrain', title: 'InterBrain', type: 'dream', repoPath: 'InterBrain', dreamTalkUrl: '' }, 408 { id: 'mock-prism', title: 'PRISM', type: 'dream', repoPath: 'PRISM', dreamTalkUrl: '' }, 409 { id: 'mock-dreamtalk', title: 'DreamTalk', type: 'dream', repoPath: 'DreamTalk', dreamTalkUrl: '' }, 410 { id: 'mock-ataraxia', title: 'A T A R A X I A', type: 'dream', repoPath: 'ATARAXIA', dreamTalkUrl: '' }, 411 { id: 'mock-project-liminality', title: 'Project Liminality', type: 'dream', repoPath: 'Project Liminality', dreamTalkUrl: '' }, 412 { id: 'mock-software-gardening', title: 'Software Gardening', type: 'dream', repoPath: 'Software Gardening', dreamTalkUrl: '' }, 413 { id: 'mock-david', title: 'David', type: 'dreamer', repoPath: 'David', dreamTalkUrl: '' }, 414 { id: 'mock-kronos', title: 'Kronos', type: 'dream', repoPath: 'Kronos', dreamTalkUrl: '' }, 415 { id: 'mock-songlines', title: 'Songlines', type: 'dream', repoPath: 'Songlines', dreamTalkUrl: '' }, 416 { id: 'mock-tutorial-system', title: 'Tutorial System', type: 'dream', repoPath: 'Tutorial System', dreamTalkUrl: '' }, 417 { id: 'mock-spring-launch', title: 'Spring Launch', type: 'dream', repoPath: 'Spring Launch', dreamTalkUrl: '' }, 418 ]; 419 420 function requestDreamNodeCatalog() { 421 if (isInIframe) { 422 window.parent.postMessage({ type: 'dreamnode-catalog-request' }, '*'); 423 } 424 // Use mock data immediately so autocomplete works for testing 425 if (dreamNodeCatalog.length === 0) { 426 setDreamNodeCatalog(MOCK_CATALOG); 427 } 428 } 429 430 function setDreamNodeCatalog(nodes) { 431 dreamNodeCatalog = nodes; 432 console.log('[AURYN] DreamNode catalog loaded:', nodes.length, 'nodes'); 433 } 434 435 function findCatalogNode(folder, title, uuid) { 436 // Match by UUID first, then folder/repoPath, then title 437 if (uuid) { 438 const byUuid = dreamNodeCatalog.find(n => n.id === uuid); 439 if (byUuid) return byUuid; 440 } 441 if (folder) { 442 const byFolder = dreamNodeCatalog.find(n => 443 n.repoPath === folder || n.repoPath === folder.replace(/ /g, '') 444 ); 445 if (byFolder) return byFolder; 446 } 447 if (title) { 448 const byTitle = dreamNodeCatalog.find(n => 449 n.title.toLowerCase() === title.toLowerCase() 450 ); 451 if (byTitle) return byTitle; 452 } 453 // If not in catalog, create a temporary entry so the petal still shows 454 if (folder || title) { 455 return { 456 id: uuid || `detected-${(folder || title).toLowerCase().replace(/\s+/g, '-')}`, 457 title: title || folder, 458 type: 'dream', 459 repoPath: folder || '', 460 dreamTalkUrl: '' 461 }; 462 } 463 return null; 464 } 465 466 // ============================================================ 467 // 1c. CONTEXT MANAGEMENT (petals) 468 // ============================================================ 469 function addToContext(node) { 470 if (contextNodes.find(n => n.id === node.id)) return; // already in context 471 contextNodes.push(node); 472 renderPetals(); 473 updateSendButton(); 474 } 475 476 function removeFromContext(nodeId) { 477 contextNodes = contextNodes.filter(n => n.id !== nodeId); 478 renderPetals(); 479 updateSendButton(); 480 } 481 482 function renderPetals() { 483 petalsRing.innerHTML = ''; 484 const count = contextNodes.length; 485 if (count === 0) return; 486 487 const radius = 82; // distance from center 488 489 contextNodes.forEach((node, i) => { 490 const angle = (i / count) * Math.PI * 2 - Math.PI / 2; // start from top 491 const x = Math.cos(angle) * radius - 26; // 26 = half petal width 492 const y = Math.sin(angle) * radius - 26; 493 494 const petal = document.createElement('div'); 495 petal.className = 'petal' + (node.type === 'dreamer' ? ' dreamer' : ''); 496 petal.style.transform = `translate(${x}px, ${y}px)`; 497 petal.title = node.title; 498 499 if (node.dreamTalkUrl) { 500 const img = document.createElement('img'); 501 img.src = node.dreamTalkUrl; 502 img.alt = node.title; 503 img.onerror = () => { 504 img.remove(); 505 const initials = document.createElement('div'); 506 initials.className = 'petal-initials'; 507 initials.textContent = node.title.slice(0, 2).toUpperCase(); 508 petal.insertBefore(initials, petal.firstChild); 509 }; 510 petal.appendChild(img); 511 } else { 512 const initials = document.createElement('div'); 513 initials.className = 'petal-initials'; 514 initials.textContent = node.title.slice(0, 2).toUpperCase(); 515 petal.appendChild(initials); 516 } 517 518 // Label 519 const label = document.createElement('div'); 520 label.className = 'petal-label'; 521 label.textContent = node.title; 522 petal.appendChild(label); 523 524 // Remove button 525 const removeBtn = document.createElement('button'); 526 removeBtn.className = 'petal-remove'; 527 removeBtn.textContent = '\u00d7'; 528 removeBtn.addEventListener('click', (e) => { 529 e.stopPropagation(); 530 removeFromContext(node.id); 531 }); 532 petal.appendChild(removeBtn); 533 534 // Click to select in dreamspace 535 petal.addEventListener('click', () => selectInDreamspace(node.id)); 536 537 petalsRing.appendChild(petal); 538 }); 539 } 540 541 function selectInDreamspace(nodeId) { 542 // postMessage to InterBrain parent to select this node 543 if (isInIframe) { 544 window.parent.postMessage({ 545 type: 'select-dreamnode', 546 nodeId: nodeId 547 }, '*'); 548 } 549 console.log('[AURYN] Select in dreamspace:', nodeId); 550 } 551 552 // AURYN symbol click → select AURYN itself 553 aurynSymbol.addEventListener('click', () => { 554 selectInDreamspace('53a3bb7a-3df8-4f36-af99-23787a8cec25'); // AURYN's UUID 555 }); 556 557 // ============================================================ 558 // 1d. AUTOCOMPLETE 559 // ============================================================ 560 function showAutocomplete(query) { 561 const q = query.toLowerCase(); 562 acFilteredItems = dreamNodeCatalog.filter(n => 563 n.title.toLowerCase().includes(q) 564 ).slice(0, 12); 565 566 if (acFilteredItems.length === 0) { 567 hideAutocomplete(); 568 return; 569 } 570 571 acSelectedIndex = 0; 572 acVisible = true; 573 renderAutocomplete(); 574 autocompleteEl.style.display = 'block'; 575 } 576 577 function hideAutocomplete() { 578 acVisible = false; 579 acTriggerPos = -1; 580 autocompleteEl.style.display = 'none'; 581 autocompleteEl.innerHTML = ''; 582 } 583 584 function renderAutocomplete() { 585 autocompleteEl.innerHTML = ''; 586 acFilteredItems.forEach((node, i) => { 587 const item = document.createElement('div'); 588 item.className = 'ac-item' + (i === acSelectedIndex ? ' selected' : ''); 589 590 // Thumbnail 591 const thumb = document.createElement('div'); 592 thumb.className = 'ac-thumb' + (node.type === 'dreamer' ? ' dreamer' : ''); 593 if (node.dreamTalkUrl) { 594 const img = document.createElement('img'); 595 img.src = node.dreamTalkUrl; 596 img.onerror = () => { 597 img.remove(); 598 const init = document.createElement('div'); 599 init.className = 'ac-initials'; 600 init.textContent = node.title.slice(0, 2).toUpperCase(); 601 thumb.appendChild(init); 602 }; 603 thumb.appendChild(img); 604 } else { 605 const init = document.createElement('div'); 606 init.className = 'ac-initials'; 607 init.textContent = node.title.slice(0, 2).toUpperCase(); 608 thumb.appendChild(init); 609 } 610 item.appendChild(thumb); 611 612 // Title 613 const title = document.createElement('div'); 614 title.className = 'ac-title'; 615 title.textContent = node.title; 616 item.appendChild(title); 617 618 // Type badge 619 const type = document.createElement('div'); 620 type.className = 'ac-type'; 621 type.textContent = node.type === 'dreamer' ? 'person' : 'idea'; 622 item.appendChild(type); 623 624 item.addEventListener('click', () => acceptAutocomplete(i)); 625 item.addEventListener('mouseenter', () => { 626 acSelectedIndex = i; 627 renderAutocomplete(); 628 }); 629 630 autocompleteEl.appendChild(item); 631 }); 632 633 // Scroll selected into view 634 const selectedEl = autocompleteEl.querySelector('.ac-item.selected'); 635 if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' }); 636 } 637 638 function acceptAutocomplete(index) { 639 const node = acFilteredItems[index]; 640 if (!node) return; 641 642 // Replace @query with the title 643 const text = inputEl.value; 644 const before = text.slice(0, acTriggerPos); 645 const after = text.slice(inputEl.selectionStart); 646 inputEl.value = before + node.title + ' ' + after; 647 inputEl.selectionStart = inputEl.selectionEnd = before.length + node.title.length + 1; 648 649 // Add to context 650 addToContext(node); 651 652 hideAutocomplete(); 653 inputEl.focus(); 654 } 655 656 // ============================================================ 657 // 2. AI BRIDGE 658 // ============================================================ 659 const instanceId = crypto.randomUUID(); 660 661 // postMessage handler for iframe AI bridge 662 window.addEventListener('message', (e) => { 663 const d = e.data; 664 if (!d || !d.type) return; 665 666 if (d.type === 'ai-bridge-ready') { 667 if (d.instanceId && d.instanceId !== instanceId) return; 668 aiBridgeReady = true; 669 console.log('[AURYN] AI bridge ready via iframe (v' + d.version + ')'); 670 fallbackEl.style.display = 'none'; 671 document.getElementById('chat-zone').style.display = ''; 672 document.getElementById('context-zone').style.display = ''; 673 // Request catalog once bridge is ready 674 requestDreamNodeCatalog(); 675 return; 676 } 677 678 // DreamNode catalog response from InterBrain 679 if (d.type === 'dreamnode-catalog-response') { 680 setDreamNodeCatalog(d.catalog || []); 681 return; 682 } 683 684 if (d.requestId !== currentRequestId) return; 685 686 if (d.type === 'ai-inference-stream-chunk') { 687 appendChunk(d.chunk); 688 } else if (d.type === 'ai-inference-stream-done') { 689 finishStream(); 690 } else if (d.type === 'ai-inference-stream-error') { 691 finishStream(d.error); 692 } 693 }); 694 695 // Check if we're inside an iframe (InterBrain) or standalone 696 const isInIframe = window.parent !== window; 697 698 if (isInIframe) { 699 fallbackEl.style.display = 'block'; 700 document.getElementById('chat-zone').style.display = 'none'; 701 document.getElementById('context-zone').style.display = 'none'; 702 703 window.parent.postMessage({ type: 'ai-bridge-probe', instanceId }, '*'); 704 setTimeout(() => { 705 if (!aiBridgeReady) { 706 console.log('[AURYN] No AI bridge — showing fallback'); 707 } 708 }, 500); 709 } else { 710 // Standalone mode — connect to AI bridge WebSocket server 711 standaloneMode = true; 712 fallbackEl.style.display = 'none'; 713 document.getElementById('chat-zone').style.display = ''; 714 document.getElementById('context-zone').style.display = ''; 715 connectionStatus.style.display = 'block'; 716 connectionStatus.textContent = 'Connecting to InterBrain...'; 717 connectionStatus.className = 'disconnected'; 718 console.log('[AURYN] Standalone mode — connecting to AI bridge WebSocket'); 719 requestDreamNodeCatalog(); 720 connectAIBridgeWs(); 721 } 722 723 function sendStreamRequest(messages) { 724 if (standaloneMode) { 725 sendWsBridgeRequest(messages); 726 return; 727 } 728 currentRequestId = crypto.randomUUID(); 729 window.parent.postMessage({ 730 type: 'ai-inference-stream-request', 731 requestId: currentRequestId, 732 messages, 733 complexity: 'standard' 734 }, '*'); 735 } 736 737 function cancelStream() { 738 if (standaloneMode) { 739 if (currentRequestId && aiBridgeWs && aiBridgeWs.readyState === WebSocket.OPEN) { 740 aiBridgeWs.send(JSON.stringify({ 741 type: 'ai-inference-stream-cancel', 742 requestId: currentRequestId 743 })); 744 } 745 finishStream(); 746 return; 747 } 748 if (currentRequestId) { 749 window.parent.postMessage({ 750 type: 'ai-inference-stream-cancel', 751 requestId: currentRequestId 752 }, '*'); 753 } 754 finishStream(); 755 } 756 757 // ============================================================ 758 // 2b. AI BRIDGE WEBSOCKET (standalone mode) 759 // ============================================================ 760 // Connects to the InterBrain plugin's AI Bridge WebSocket server. 761 // Same protocol as the iframe postMessage bridge — the plugin 762 // routes to whatever LLM provider is configured in settings. 763 764 const AI_BRIDGE_PORT = 27182; 765 let aiBridgeWs = null; 766 767 function getAIBridgeWsUrl() { 768 // When served by server.py, the InterBrain is on the same machine 769 // Use the page's hostname (works for both localhost and Tailscale IP) 770 const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 771 const host = window.location.hostname || 'localhost'; 772 return `${wsProtocol}//${host}:${AI_BRIDGE_PORT}`; 773 } 774 775 function connectAIBridgeWs() { 776 const url = getAIBridgeWsUrl(); 777 console.log('[AURYN] Connecting AI bridge WS:', url); 778 779 try { 780 aiBridgeWs = new WebSocket(url); 781 782 aiBridgeWs.onopen = () => { 783 console.log('[AURYN] AI bridge WS connected'); 784 connectionStatus.textContent = 'Connected to InterBrain'; 785 connectionStatus.className = 'connected'; 786 // Hide after 3s if connected 787 setTimeout(() => { 788 if (aiBridgeWs && aiBridgeWs.readyState === WebSocket.OPEN) { 789 connectionStatus.style.display = 'none'; 790 } 791 }, 3000); 792 }; 793 794 aiBridgeWs.onmessage = (e) => { 795 const data = JSON.parse(e.data); 796 797 if (data.type === 'ai-bridge-ready') { 798 console.log('[AURYN] AI bridge ready via WebSocket (v' + data.version + ')'); 799 return; 800 } 801 802 if (data.requestId !== currentRequestId) return; 803 804 if (data.type === 'ai-inference-stream-chunk') { 805 appendChunk(data.chunk); 806 } else if (data.type === 'ai-inference-stream-done') { 807 finishStream(); 808 } else if (data.type === 'ai-inference-stream-error') { 809 finishStream(data.error); 810 } 811 }; 812 813 aiBridgeWs.onclose = () => { 814 console.log('[AURYN] AI bridge WS disconnected'); 815 aiBridgeWs = null; 816 connectionStatus.style.display = 'block'; 817 connectionStatus.textContent = 'Disconnected from InterBrain — reconnecting...'; 818 connectionStatus.className = 'disconnected'; 819 setTimeout(connectAIBridgeWs, 3000); 820 }; 821 822 aiBridgeWs.onerror = () => { 823 console.log('[AURYN] AI bridge WS error'); 824 }; 825 } catch (e) { 826 console.log('[AURYN] Cannot connect to AI bridge:', e.message); 827 connectionStatus.style.display = 'block'; 828 connectionStatus.textContent = 'Cannot reach InterBrain'; 829 connectionStatus.className = 'disconnected'; 830 setTimeout(connectAIBridgeWs, 5000); 831 } 832 } 833 834 function sendWsBridgeRequest(messages) { 835 if (!aiBridgeWs || aiBridgeWs.readyState !== WebSocket.OPEN) { 836 finishStream('Not connected to InterBrain. Make sure Obsidian is running.'); 837 return; 838 } 839 840 currentRequestId = crypto.randomUUID(); 841 aiBridgeWs.send(JSON.stringify({ 842 type: 'ai-inference-stream-request', 843 requestId: currentRequestId, 844 messages, 845 complexity: 'standard' 846 })); 847 } 848 849 // ============================================================ 850 // 3. FONT LOADING 851 // ============================================================ 852 async function loadFont() { 853 try { 854 const fontLink = document.getElementById('font-resource'); 855 const fontUrl = fontLink ? fontLink.href : 'fonts/texgyretermes-regular.otf'; 856 console.log('[AURYN] Loading font from:', fontUrl); 857 858 try { 859 font = await opentype.load(fontUrl); 860 } catch (_) { 861 const resp = await fetch(fontUrl); 862 if (resp.ok) { 863 const buf = await resp.arrayBuffer(); 864 font = opentype.parse(buf); 865 } 866 } 867 868 if (font) { 869 fontLoaded = true; 870 console.log('[AURYN] Font loaded:', font.names.fontFamily.en); 871 } else { 872 console.warn('[AURYN] Font not found, using text fallback'); 873 } 874 } catch (err) { 875 console.warn('[AURYN] Font load error:', err.message); 876 } 877 } 878 loadFont(); 879 880 // ============================================================ 881 // 4. MARKDOWN RENDERING 882 // ============================================================ 883 function renderMarkdown(text) { 884 // Convert markdown to HTML 885 let html = escapeHtml(text); 886 887 // Bold: **text** 888 html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); 889 // Italic: *text* 890 html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); 891 // Inline code: `text` 892 html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); 893 894 // Split into paragraphs on double newline 895 const blocks = html.split('\n\n'); 896 const rendered = blocks.map(block => { 897 block = block.trim(); 898 if (!block) return ''; 899 900 // Check if block is a list (lines starting with - ) 901 const lines = block.split('\n'); 902 const isList = lines.every(l => /^[-]\s/.test(l.trim()) || !l.trim()); 903 if (isList) { 904 // Render as dash-prefixed lines to match SVG animation exactly 905 const items = lines 906 .filter(l => l.trim()) 907 .map(l => '<div class="list-item">' + l.trim() + '</div>') 908 .join(''); 909 return '<div class="list-block">' + items + '</div>'; 910 } 911 912 return '<p>' + block.replace(/\n/g, '<br>') + '</p>'; 913 }).join(''); 914 915 return rendered; 916 } 917 918 function escapeHtml(s) { 919 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 920 } 921 922 // ============================================================ 923 // 5. MARKDOWN STREAM PARSER 924 // ============================================================ 925 function createMarkdownParser() { 926 let buf = ''; 927 let bold = false; 928 let italic = false; 929 let emitted = 0; 930 931 return { 932 feed(chunk) { 933 buf += chunk; 934 const tokens = []; 935 let i = 0; 936 937 while (i < buf.length) { 938 if (buf[i] === '*' && buf[i + 1] === '*') { 939 if (i + 1 < buf.length) { 940 bold = !bold; 941 i += 2; 942 continue; 943 } else { 944 break; 945 } 946 } 947 if (buf[i] === '*' && buf[i + 1] !== '*') { 948 italic = !italic; 949 i += 1; 950 continue; 951 } 952 if (buf[i] === '*' && i === buf.length - 1) { 953 break; 954 } 955 if (buf[i] === '`') { 956 i++; 957 while (i < buf.length && buf[i] !== '`') { 958 tokens.push({ char: buf[i], bold: false, italic: false, code: true }); 959 i++; 960 } 961 if (i < buf.length) i++; 962 continue; 963 } 964 965 tokens.push({ char: buf[i], bold, italic, code: false }); 966 i++; 967 } 968 969 buf = buf.slice(i); 970 const newTokens = tokens.slice(0); 971 return newTokens; 972 }, 973 974 flush() { 975 const tokens = []; 976 for (const ch of buf) { 977 if (ch !== '*' && ch !== '`') { 978 tokens.push({ char: ch, bold, italic, code: false }); 979 } 980 } 981 buf = ''; 982 return tokens; 983 } 984 }; 985 } 986 987 // ============================================================ 988 // 6. WRITE ANIMATION ENGINE 989 // ============================================================ 990 const STROKE_DURATION = 0.5; 991 const FILL_DELAY = 0.1; 992 const CASCADE_DELAY = 0.0075; 993 const ANIM_FONT_SIZE = 20; 994 const ANIM_LINE_HEIGHT = ANIM_FONT_SIZE * 1.6; 995 const PARAGRAPH_GAP = ANIM_FONT_SIZE * 0.6; 996 const BOLD_STROKE_WIDTH = 2.5; 997 const NORMAL_STROKE_WIDTH = 1.5; 998 const ITALIC_SKEW = -12; 999 1000 let activeMsg = null; 1001 1002 function createAnimatedMessage(cssClass) { 1003 const div = document.createElement('div'); 1004 div.className = 'msg ' + cssClass; 1005 1006 const textLayer = document.createElement('div'); 1007 textLayer.className = 'text-layer'; 1008 div.appendChild(textLayer); 1009 1010 messagesEl.appendChild(div); 1011 1012 const msg = { 1013 el: div, 1014 textLayer: textLayer, 1015 fullText: '', 1016 mdParser: createMarkdownParser(), 1017 svgOverlay: null, 1018 svgEl: null, 1019 xOffset: 0, 1020 lineData: [], 1021 animatedChars: 0, 1022 wordBuffer: [], 1023 pendingNewlines: 0, 1024 strokeColor: cssClass === 'transcript' ? '#b0b0b0' : '#fff', 1025 fillColor: cssClass === 'transcript' ? '#b0b0b0' : '#fff', 1026 }; 1027 1028 if (fontLoaded) { 1029 textLayer.style.visibility = 'hidden'; 1030 1031 const overlay = document.createElement('div'); 1032 overlay.className = 'svg-overlay'; 1033 const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); 1034 svgEl.style.overflow = 'visible'; 1035 overlay.appendChild(svgEl); 1036 div.appendChild(overlay); 1037 1038 msg.svgOverlay = overlay; 1039 msg.svgEl = svgEl; 1040 startNewLineFor(msg); 1041 } 1042 1043 scrollToBottom(); 1044 return msg; 1045 } 1046 1047 function createAssistantMessage() { 1048 activeMsg = createAnimatedMessage('assistant'); 1049 return activeMsg.el; 1050 } 1051 1052 function getContainerWidthFor(msg) { 1053 if (!msg || !msg.el) return 500; 1054 return msg.el.clientWidth || 500; 1055 } 1056 1057 function getContainerWidth() { 1058 return getContainerWidthFor(activeMsg); 1059 } 1060 1061 function startNewLineFor(msg, extraGap) { 1062 if (!msg) return; 1063 let newY; 1064 if (msg.lineData.length === 0) { 1065 newY = ANIM_FONT_SIZE; 1066 } else { 1067 const prevY = msg.lineData[msg.lineData.length - 1].y; 1068 newY = prevY + ANIM_LINE_HEIGHT + (extraGap || 0); 1069 } 1070 msg.lineData.push({ y: newY }); 1071 msg.xOffset = 0; 1072 } 1073 1074 function startNewLine(extraGap) { 1075 startNewLineFor(activeMsg, extraGap); 1076 } 1077 1078 function measureTokens(tokens) { 1079 if (!font) return tokens.length * ANIM_FONT_SIZE * 0.5; 1080 const scale = ANIM_FONT_SIZE / font.unitsPerEm; 1081 let w = 0; 1082 for (const tok of tokens) { 1083 const g = font.charToGlyph(tok.char); 1084 w += (g.advanceWidth ?? ANIM_FONT_SIZE * 0.6) * scale; 1085 } 1086 return w; 1087 } 1088 1089 function flushWordFor(msg) { 1090 if (!msg || msg.wordBuffer.length === 0) return; 1091 1092 const tokens = msg.wordBuffer; 1093 const containerWidth = getContainerWidthFor(msg); 1094 const wordWidth = measureTokens(tokens); 1095 1096 if (msg.xOffset > 0 && msg.xOffset + wordWidth > containerWidth) { 1097 startNewLineFor(msg); 1098 } 1099 1100 for (const tok of tokens) { 1101 renderCharToSVGFor(msg, tok.char, msg.animatedChars, tok.bold, tok.italic); 1102 msg.animatedChars++; 1103 } 1104 1105 msg.wordBuffer = []; 1106 } 1107 1108 function flushWord() { 1109 flushWordFor(activeMsg); 1110 } 1111 1112 function renderCharToSVGFor(msg, char, charIndex, isBold, isItalic) { 1113 if (!font || !msg || !msg.svgEl) return; 1114 1115 const svgEl = msg.svgEl; 1116 const scale = ANIM_FONT_SIZE / font.unitsPerEm; 1117 const glyph = font.charToGlyph(char); 1118 const advance = (glyph.advanceWidth ?? ANIM_FONT_SIZE * 0.6) * scale; 1119 1120 const x = msg.xOffset; 1121 const y = msg.lineData[msg.lineData.length - 1].y; 1122 1123 const path = glyph.getPath(x, y, ANIM_FONT_SIZE); 1124 const pathStr = path.toSVG(2); 1125 const dMatch = pathStr.match(/d="([^"]+)"/); 1126 1127 if (dMatch && char !== ' ') { 1128 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 1129 1130 if (isItalic) { 1131 const skewRad = ITALIC_SKEW * Math.PI / 180; 1132 const yCenter = y - ANIM_FONT_SIZE * 0.4; 1133 const xShift = -yCenter * Math.tan(skewRad); 1134 g.setAttribute('transform', `translate(${xShift}, 0) skewX(${ITALIC_SKEW})`); 1135 } 1136 1137 const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 1138 tempPath.setAttribute('d', dMatch[1]); 1139 svgEl.appendChild(tempPath); 1140 const pathLength = tempPath.getTotalLength() || 100; 1141 svgEl.removeChild(tempPath); 1142 1143 const delay = charIndex * CASCADE_DELAY; 1144 const strokeWidth = isBold ? BOLD_STROKE_WIDTH : NORMAL_STROKE_WIDTH; 1145 1146 const strokePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 1147 strokePath.setAttribute('d', dMatch[1]); 1148 strokePath.setAttribute('class', 'char-path-stroke'); 1149 strokePath.style.stroke = msg.strokeColor; 1150 strokePath.style.strokeWidth = strokeWidth; 1151 strokePath.style.strokeDasharray = pathLength; 1152 strokePath.style.strokeDashoffset = pathLength; 1153 strokePath.style.animation = 1154 `stroke-draw ${STROKE_DURATION}s ${delay}s ease-in-out forwards, ` + 1155 `stroke-fade 0.3s ${delay + STROKE_DURATION + FILL_DELAY}s ease-out forwards`; 1156 g.appendChild(strokePath); 1157 1158 const fillPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 1159 fillPath.setAttribute('d', dMatch[1]); 1160 fillPath.setAttribute('class', 'char-path-fill'); 1161 fillPath.style.fill = msg.fillColor; 1162 fillPath.style.animation = 1163 `fill-reveal 0.3s ${delay + STROKE_DURATION * 0.6}s ease-in forwards`; 1164 g.appendChild(fillPath); 1165 1166 svgEl.appendChild(g); 1167 } 1168 1169 msg.xOffset += advance; 1170 1171 const totalHeight = msg.lineData.length * ANIM_LINE_HEIGHT + ANIM_FONT_SIZE * 0.5; 1172 const containerWidth = getContainerWidthFor(msg); 1173 svgEl.setAttribute('viewBox', `0 0 ${containerWidth} ${totalHeight}`); 1174 svgEl.style.height = totalHeight + 'px'; 1175 } 1176 1177 function renderCharToSVG(char, charIndex, isBold, isItalic) { 1178 renderCharToSVGFor(activeMsg, char, charIndex, isBold, isItalic); 1179 } 1180 1181 function processTokensFor(msg, tokens) { 1182 if (!msg) return; 1183 1184 for (const tok of tokens) { 1185 if (tok.char === '\n') { 1186 flushWordFor(msg); 1187 msg.pendingNewlines++; 1188 } else { 1189 if (msg.pendingNewlines > 0) { 1190 if (msg.pendingNewlines >= 2) { 1191 startNewLineFor(msg, PARAGRAPH_GAP); 1192 } else { 1193 startNewLineFor(msg); 1194 } 1195 msg.pendingNewlines = 0; 1196 } 1197 1198 if (tok.char === ' ') { 1199 flushWordFor(msg); 1200 renderCharToSVGFor(msg, ' ', msg.animatedChars, false, false); 1201 msg.animatedChars++; 1202 } else { 1203 msg.wordBuffer.push(tok); 1204 } 1205 } 1206 } 1207 } 1208 1209 function processTokens(tokens) { 1210 processTokensFor(activeMsg, tokens); 1211 } 1212 1213 function appendChunkTo(msg, chunk) { 1214 if (!msg) return; 1215 msg.fullText += chunk; 1216 1217 msg.textLayer.innerHTML = renderMarkdown(msg.fullText); 1218 1219 if (fontLoaded && msg.svgEl) { 1220 const tokens = msg.mdParser.feed(chunk); 1221 processTokensFor(msg, tokens); 1222 } 1223 1224 scrollToBottom(); 1225 } 1226 1227 function appendChunk(chunk) { 1228 appendChunkTo(activeMsg, chunk); 1229 } 1230 1231 function finishAnimatedMessage(msg, error) { 1232 if (!msg) return; 1233 1234 if (msg.mdParser) { 1235 const remaining = msg.mdParser.flush(); 1236 processTokensFor(msg, remaining); 1237 } 1238 if (msg.wordBuffer && msg.wordBuffer.length > 0) { 1239 flushWordFor(msg); 1240 } 1241 1242 if (error) { 1243 const errEl = document.createElement('div'); 1244 errEl.style.color = '#8B0000'; 1245 errEl.style.fontSize = '13px'; 1246 errEl.style.marginTop = '8px'; 1247 errEl.textContent = 'Error: ' + error; 1248 msg.el.appendChild(errEl); 1249 } 1250 1251 if (msg.svgOverlay && msg.svgEl) { 1252 const lastCharDelay = msg.animatedChars * CASCADE_DELAY; 1253 const totalAnimTime = (lastCharDelay + STROKE_DURATION + FILL_DELAY + 0.5) * 1000; 1254 1255 setTimeout(() => { 1256 if (!msg.el) return; 1257 msg.svgOverlay.style.transition = 'opacity 0.4s ease-out'; 1258 msg.svgOverlay.style.opacity = '0'; 1259 msg.textLayer.style.visibility = 'visible'; 1260 msg.textLayer.style.opacity = '0'; 1261 msg.textLayer.style.transition = 'opacity 0.4s ease-in'; 1262 msg.textLayer.offsetHeight; 1263 msg.textLayer.style.opacity = '1'; 1264 1265 setTimeout(() => { 1266 if (msg.svgOverlay && msg.svgOverlay.parentNode) { 1267 msg.svgOverlay.remove(); 1268 } 1269 }, 500); 1270 }, totalAnimTime); 1271 } else { 1272 msg.textLayer.style.visibility = 'visible'; 1273 } 1274 } 1275 1276 function finishStream(error) { 1277 isStreaming = false; 1278 currentRequestId = null; 1279 sendBtn.style.display = ''; 1280 stopBtn.style.display = 'none'; 1281 updateSendButton(); 1282 1283 if (activeMsg) { 1284 finishAnimatedMessage(activeMsg, error); 1285 1286 if (activeMsg.fullText) { 1287 conversation.push({ role: 'assistant', content: activeMsg.fullText }); 1288 } 1289 1290 activeMsg = null; 1291 } 1292 1293 inputEl.focus(); 1294 } 1295 1296 function scrollToBottom() { 1297 requestAnimationFrame(() => { 1298 messagesEl.scrollTop = messagesEl.scrollHeight; 1299 }); 1300 } 1301 1302 // ============================================================ 1303 // 7. CHAT LOGIC 1304 // ============================================================ 1305 function sendMessage(text) { 1306 if (!text) text = inputEl.value.trim(); 1307 if (!text || isStreaming) return; 1308 1309 // Hide autocomplete on send 1310 hideAutocomplete(); 1311 1312 // Check for /do command — route to Claude Code 1313 if (text.startsWith('/do ')) { 1314 const doPrompt = text.slice(4).trim(); 1315 if (doPrompt) { 1316 // Show user message 1317 const userMsg = document.createElement('div'); 1318 userMsg.className = 'msg user'; 1319 userMsg.textContent = text; 1320 messagesEl.appendChild(userMsg); 1321 conversation.push({ role: 'user', content: text }); 1322 inputEl.value = ''; 1323 inputEl.style.height = 'auto'; 1324 updateSendButton(); 1325 startClaudeCode(doPrompt); 1326 return; 1327 } 1328 } 1329 1330 const userMsg = document.createElement('div'); 1331 userMsg.className = 'msg user'; 1332 userMsg.textContent = text; 1333 messagesEl.appendChild(userMsg); 1334 1335 conversation.push({ role: 'user', content: text }); 1336 1337 inputEl.value = ''; 1338 inputEl.style.height = 'auto'; 1339 updateSendButton(); 1340 1341 const messages = [ 1342 { role: 'system', content: SYSTEM_PROMPT }, 1343 ...conversation 1344 ]; 1345 1346 isStreaming = true; 1347 sendBtn.style.display = 'none'; 1348 stopBtn.style.display = 'flex'; 1349 1350 createAssistantMessage(); 1351 sendStreamRequest(messages); 1352 scrollToBottom(); 1353 } 1354 1355 // ============================================================ 1356 // 7b. VOCAB DEBUG 1357 // ============================================================ 1358 function renderVocabDebug(data) { 1359 const el = document.getElementById('vocab-debug'); 1360 const tag = (cls, items) => items.length 1361 ? `<span class="vocab-label">${cls}:</span>` + 1362 items.map(t => `<span class="vocab-${cls}">${t}</span>`).join(', ') + ' ' 1363 : ''; 1364 el.innerHTML = 1365 tag('core', data.core || []) + 1366 tag('pinned', data.pinned || []) + 1367 tag('ephemeral', data.ephemeral || []); 1368 el.classList.add('active'); 1369 } 1370 1371 // ============================================================ 1372 // 8. TRANSCRIPTION (VOICE) 1373 // ============================================================ 1374 function getTranscriptionWsUrl() { 1375 if (standaloneMode) { 1376 const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 1377 return `${wsProtocol}//${window.location.host}/ws/transcribe`; 1378 } 1379 return `ws://localhost:3002/ws/transcribe`; 1380 } 1381 1382 function connectTranscriptionWs() { 1383 const url = getTranscriptionWsUrl(); 1384 console.log('[AURYN] Connecting transcription WS:', url); 1385 1386 try { 1387 transcriptionWs = new WebSocket(url); 1388 1389 transcriptionWs.onopen = () => { 1390 console.log('[AURYN] Transcription WS connected'); 1391 micBtn.disabled = false; 1392 micBtn.title = 'Record voice'; 1393 }; 1394 1395 transcriptionWs.onmessage = (e) => { 1396 const data = JSON.parse(e.data); 1397 1398 if (data.type === 'transcript_chunk') { 1399 // Insert at cursor position in the textarea 1400 const start = inputEl.selectionStart; 1401 const end = inputEl.selectionEnd; 1402 const val = inputEl.value; 1403 inputEl.value = val.substring(0, start) + data.text + val.substring(end); 1404 inputEl.selectionStart = inputEl.selectionEnd = start + data.text.length; 1405 inputEl.style.height = 'auto'; 1406 inputEl.style.height = inputEl.scrollHeight + 'px'; 1407 inputEl.focus(); 1408 } else if (data.type === 'dreamnode_detected') { 1409 // A DreamNode name was recognized in the transcript — add to context petals 1410 const node = findCatalogNode(data.folder, data.title, data.uuid); 1411 if (node) { 1412 if (data.path) node.absPath = data.path; 1413 addToContext(node); 1414 console.log('[AURYN] DreamNode detected in voice:', node.title); 1415 } 1416 } else if (data.type === 'stream_ended') { 1417 // Leave text in the input field for editing — don't auto-send 1418 document.getElementById('vocab-debug').classList.remove('active'); 1419 inputEl.focus(); 1420 inputEl.style.height = 'auto'; 1421 inputEl.style.height = inputEl.scrollHeight + 'px'; 1422 // Add any pinned DreamNodes that weren't already in context 1423 if (data.pinned_dreamnodes && Array.isArray(data.pinned_dreamnodes)) { 1424 for (const folder of data.pinned_dreamnodes) { 1425 const node = findCatalogNode(folder); 1426 if (node) addToContext(node); 1427 } 1428 } 1429 } else if (data.type === 'vocab_update') { 1430 renderVocabDebug(data); 1431 } else if (data.type === 'session_started') { 1432 console.log('[AURYN] Transcription session:', data.session_id); 1433 // Show vocab debug panel during transcription 1434 document.getElementById('vocab-debug').classList.add('active'); 1435 } 1436 }; 1437 1438 transcriptionWs.onclose = () => { 1439 console.log('[AURYN] Transcription WS disconnected'); 1440 micBtn.disabled = true; 1441 micBtn.title = 'Transcription server unavailable'; 1442 transcriptionWs = null; 1443 setTimeout(connectTranscriptionWs, 5000); 1444 }; 1445 1446 transcriptionWs.onerror = () => { 1447 console.log('[AURYN] Transcription WS error'); 1448 }; 1449 } catch (e) { 1450 console.log('[AURYN] Cannot connect to transcription server:', e.message); 1451 micBtn.disabled = true; 1452 micBtn.title = 'Transcription server unavailable'; 1453 } 1454 } 1455 1456 async function startRecording() { 1457 if (!transcriptionWs || transcriptionWs.readyState !== WebSocket.OPEN) { 1458 console.log('[AURYN] No transcription server'); 1459 return; 1460 } 1461 1462 try { 1463 const stream = await navigator.mediaDevices.getUserMedia({ 1464 audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 } 1465 }); 1466 1467 transcriptionWs.send(JSON.stringify({ type: 'start_stream' })); 1468 1469 mediaRecorder = new MediaRecorder(stream, { 1470 mimeType: 'audio/webm;codecs=opus' 1471 }); 1472 1473 mediaRecorder.ondataavailable = async (e) => { 1474 if (e.data.size > 0 && transcriptionWs && transcriptionWs.readyState === WebSocket.OPEN) { 1475 const buffer = await e.data.arrayBuffer(); 1476 transcriptionWs.send(buffer); 1477 } 1478 }; 1479 1480 mediaRecorder.onstop = () => { 1481 stream.getTracks().forEach(track => track.stop()); 1482 if (transcriptionWs && transcriptionWs.readyState === WebSocket.OPEN) { 1483 transcriptionWs.send(JSON.stringify({ type: 'end_stream' })); 1484 } 1485 }; 1486 1487 inputEl.focus(); 1488 mediaRecorder.start(500); 1489 isRecording = true; 1490 micBtn.classList.add('recording'); 1491 micBtn.title = 'Stop recording'; 1492 1493 scrollToBottom(); 1494 } catch (err) { 1495 console.error('[AURYN] Mic error:', err); 1496 } 1497 } 1498 1499 function stopRecording() { 1500 if (mediaRecorder && mediaRecorder.state !== 'inactive') { 1501 mediaRecorder.stop(); 1502 } 1503 isRecording = false; 1504 micBtn.classList.remove('recording'); 1505 micBtn.title = 'Record voice'; 1506 } 1507 1508 function finishRecording(fullText) { 1509 // Text is already in the textarea — just focus for editing 1510 inputEl.focus(); 1511 inputEl.style.height = 'auto'; 1512 inputEl.style.height = inputEl.scrollHeight + 'px'; 1513 } 1514 1515 // ============================================================ 1516 // 9. DRAG AND DROP AUDIO 1517 // ============================================================ 1518 let dragCounter = 0; 1519 1520 document.addEventListener('dragenter', (e) => { 1521 e.preventDefault(); 1522 dragCounter++; 1523 if (hasAudioFile(e)) { 1524 dropOverlay.classList.add('active'); 1525 } 1526 }); 1527 1528 document.addEventListener('dragleave', (e) => { 1529 e.preventDefault(); 1530 dragCounter--; 1531 if (dragCounter <= 0) { 1532 dragCounter = 0; 1533 dropOverlay.classList.remove('active'); 1534 } 1535 }); 1536 1537 document.addEventListener('dragover', (e) => { 1538 e.preventDefault(); 1539 }); 1540 1541 document.addEventListener('drop', async (e) => { 1542 e.preventDefault(); 1543 dragCounter = 0; 1544 dropOverlay.classList.remove('active'); 1545 1546 const files = [...e.dataTransfer.files].filter(f => 1547 f.type.startsWith('audio/') || 1548 /\.(webm|mp3|wav|m4a|ogg|mp4|aac|flac)$/i.test(f.name) 1549 ); 1550 1551 if (files.length === 0) return; 1552 1553 for (const file of files) { 1554 await transcribeFile(file); 1555 } 1556 }); 1557 1558 function hasAudioFile(e) { 1559 if (e.dataTransfer.items) { 1560 for (const item of e.dataTransfer.items) { 1561 if (item.kind === 'file' && (item.type.startsWith('audio/') || item.type.startsWith('video/'))) { 1562 return true; 1563 } 1564 } 1565 } 1566 return true; 1567 } 1568 1569 async function transcribeFile(file) { 1570 const label = document.createElement('div'); 1571 label.className = 'transcript-label'; 1572 label.textContent = `transcribing ${file.name}...`; 1573 messagesEl.appendChild(label); 1574 1575 const msg = createAnimatedMessage('transcript'); 1576 appendChunkTo(msg, 'Processing audio...'); 1577 scrollToBottom(); 1578 1579 const uploadUrl = standaloneMode 1580 ? '/upload/audio' 1581 : 'http://localhost:3002/upload/audio'; 1582 1583 try { 1584 const form = new FormData(); 1585 form.append('file', file, file.name); 1586 1587 const resp = await fetch(uploadUrl, { method: 'POST', body: form }); 1588 const data = await resp.json(); 1589 1590 msg.fullText = ''; 1591 msg.textLayer.innerHTML = ''; 1592 if (msg.svgEl) { 1593 while (msg.svgEl.firstChild) msg.svgEl.removeChild(msg.svgEl.firstChild); 1594 msg.animatedChars = 0; 1595 msg.lineData = []; 1596 msg.xOffset = 0; 1597 msg.mdParser = createMarkdownParser(); 1598 startNewLineFor(msg); 1599 } 1600 1601 if (data.text) { 1602 appendChunkTo(msg, data.text); 1603 label.textContent = `transcribed: ${file.name}`; 1604 finishAnimatedMessage(msg); 1605 1606 setTimeout(() => { sendMessage(data.text.trim()); }, 800); 1607 } else if (data.error) { 1608 label.textContent = `failed: ${file.name}`; 1609 appendChunkTo(msg, data.error); 1610 finishAnimatedMessage(msg, data.error); 1611 } 1612 } catch (err) { 1613 label.textContent = `error: ${file.name}`; 1614 finishAnimatedMessage(msg, err.message); 1615 } 1616 } 1617 1618 // ============================================================ 1619 // 9b. KNOWLEDGE GARDENING 1620 // ============================================================ 1621 1622 const SEND_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>'; 1623 const GARDEN_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 22c4.97 0 9-4.03 9-9-4.97 0-9 4.03-9 9zM5.6 10.25c0 1.38 1.12 2.5 2.5 2.5.53 0 1.01-.16 1.42-.44l-.02.19c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5l-.02-.19c.4.28.89.44 1.42.44 1.38 0 2.5-1.12 2.5-2.5 0-1-.59-1.85-1.43-2.25.84-.4 1.43-1.25 1.43-2.25 0-1.38-1.12-2.5-2.5-2.5-.53 0-1.01.16-1.42.44l.02-.19C14.5 2.12 13.38 1 12 1S9.5 2.12 9.5 3.5l.02.19c-.4-.28-.89-.44-1.42-.44-1.38 0-2.5 1.12-2.5 2.5 0 1 .59 1.85 1.43 2.25-.84.4-1.43 1.25-1.43 2.25zM12 5.5c1.38 0 2.5 1.12 2.5 2.5s-1.12 2.5-2.5 2.5S9.5 9.38 9.5 8s1.12-2.5 2.5-2.5zM3 13c0 4.97 4.03 9 9 9-4.97 0-9-4.03-9-9z"/></svg>'; 1624 1625 let gardenWs = null; 1626 let isGardening = false; 1627 1628 function updateSendButton() { 1629 const hasText = inputEl.value.trim().length > 0; 1630 const hasContext = contextNodes.length > 0; 1631 if (!hasText && hasContext && !isStreaming && !isGardening) { 1632 sendBtn.innerHTML = GARDEN_ICON; 1633 sendBtn.classList.add('garden-mode'); 1634 sendBtn.title = 'Garden — route insights to DreamNodes'; 1635 sendBtn.disabled = false; 1636 } else { 1637 sendBtn.innerHTML = SEND_ICON; 1638 sendBtn.classList.remove('garden-mode'); 1639 sendBtn.title = 'Send'; 1640 sendBtn.disabled = isStreaming; 1641 } 1642 } 1643 1644 function getGardenWsUrl() { 1645 const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 1646 return `${wsProtocol}//${window.location.host}/ws/garden`; 1647 } 1648 1649 function startGardening() { 1650 if (isGardening || conversation.length === 0) return; 1651 if (contextNodes.length === 0) return; 1652 1653 isGardening = true; 1654 sendBtn.disabled = true; 1655 1656 // Show gardening status in chat 1657 const statusMsg = document.createElement('div'); 1658 statusMsg.className = 'msg assistant'; 1659 statusMsg.id = 'garden-status'; 1660 statusMsg.textContent = 'Gardening...'; 1661 messagesEl.appendChild(statusMsg); 1662 scrollToBottom(); 1663 1664 try { 1665 const ws = new WebSocket(getGardenWsUrl()); 1666 gardenWs = ws; 1667 1668 ws.onopen = () => { 1669 // Send the full conversation + context with paths 1670 const context = contextNodes.map(n => ({ 1671 title: n.title, 1672 id: n.id, 1673 path: n.absPath || '', 1674 })); 1675 1676 ws.send(JSON.stringify({ 1677 type: 'garden', 1678 conversation: conversation, 1679 context: context, 1680 })); 1681 }; 1682 1683 ws.onmessage = (e) => { 1684 const data = JSON.parse(e.data); 1685 const el = document.getElementById('garden-status'); 1686 1687 if (data.type === 'garden_status') { 1688 if (el) el.textContent = data.message; 1689 } else if (data.type === 'garden_result') { 1690 if (el) { 1691 let text = data.message || 'Done.'; 1692 if (data.edits) { 1693 text += '\n\n'; 1694 for (const edit of data.edits) { 1695 const icon = edit.status === 'ok' ? '✓' : edit.status === 'warning' ? '⚠' : '✗'; 1696 text += `${icon} ${edit.dreamnode}: ${edit.reason}\n`; 1697 } 1698 } 1699 el.textContent = text; 1700 } 1701 finishGardening(); 1702 } 1703 }; 1704 1705 ws.onerror = () => { 1706 const el = document.getElementById('garden-status'); 1707 if (el) el.textContent = 'Garden error — could not connect.'; 1708 finishGardening(); 1709 }; 1710 1711 ws.onclose = () => { gardenWs = null; }; 1712 1713 } catch (e) { 1714 const el = document.getElementById('garden-status'); 1715 if (el) el.textContent = 'Garden error: ' + e.message; 1716 finishGardening(); 1717 } 1718 } 1719 1720 function finishGardening() { 1721 isGardening = false; 1722 if (gardenWs) { gardenWs.close(); gardenWs = null; } 1723 updateSendButton(); 1724 } 1725 1726 // ============================================================ 1727 // 9c. CLAUDE CODE SUB-AGENT (/do command) 1728 // ============================================================ 1729 1730 let claudeWs = null; 1731 let isClaudeWorking = false; 1732 1733 function getClaudeWsUrl() { 1734 const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 1735 return `${wsProtocol}//${window.location.host}/ws/claude`; 1736 } 1737 1738 function startClaudeCode(prompt) { 1739 if (isClaudeWorking) return; 1740 isClaudeWorking = true; 1741 1742 // Show status in chat 1743 const statusMsg = document.createElement('div'); 1744 statusMsg.className = 'msg assistant'; 1745 statusMsg.id = 'claude-status'; 1746 statusMsg.textContent = 'Spawning Claude Code...'; 1747 messagesEl.appendChild(statusMsg); 1748 scrollToBottom(); 1749 1750 try { 1751 const ws = new WebSocket(getClaudeWsUrl()); 1752 claudeWs = ws; 1753 1754 ws.onopen = () => { 1755 const context = contextNodes.map(n => ({ 1756 title: n.title, 1757 id: n.id, 1758 path: n.absPath || '', 1759 })); 1760 1761 ws.send(JSON.stringify({ 1762 type: 'claude_code', 1763 prompt: prompt, 1764 context: context, 1765 conversation: conversation, 1766 })); 1767 }; 1768 1769 ws.onmessage = (e) => { 1770 const data = JSON.parse(e.data); 1771 const el = document.getElementById('claude-status'); 1772 1773 if (data.type === 'claude_status') { 1774 if (el) el.textContent = data.message; 1775 } else if (data.type === 'claude_result') { 1776 if (el) { 1777 let text = data.message || 'Done.'; 1778 if (data.cost) text += `\n\nCost: $${data.cost.toFixed(4)}`; 1779 if (data.status === 'done') { 1780 // Check if changes might need a reload 1781 text += '\n\n(Refresh page for UI changes. Use reload button for server changes.)'; 1782 } 1783 el.textContent = text; 1784 el.removeAttribute('id'); 1785 } 1786 // Add to conversation so AURYN knows what happened 1787 conversation.push({ role: 'assistant', content: '[Claude Code] ' + (data.message || 'Done.') }); 1788 finishClaudeCode(); 1789 } 1790 }; 1791 1792 ws.onerror = () => { 1793 const el = document.getElementById('claude-status'); 1794 if (el) el.textContent = 'Claude Code error — could not connect.'; 1795 finishClaudeCode(); 1796 }; 1797 1798 ws.onclose = () => { claudeWs = null; }; 1799 1800 } catch (e) { 1801 const el = document.getElementById('claude-status'); 1802 if (el) el.textContent = 'Claude Code error: ' + e.message; 1803 finishClaudeCode(); 1804 } 1805 } 1806 1807 function finishClaudeCode() { 1808 isClaudeWorking = false; 1809 if (claudeWs) { claudeWs.close(); claudeWs = null; } 1810 updateSendButton(); 1811 } 1812 1813 // Server reload 1814 function reloadServer() { 1815 const protocol = window.location.protocol; 1816 const host = window.location.host; 1817 fetch(`${protocol}//${host}/reload`, { method: 'POST' }) 1818 .then(() => { 1819 const msg = document.createElement('div'); 1820 msg.className = 'msg assistant'; 1821 msg.textContent = 'Server restarting... reconnecting in 5s.'; 1822 messagesEl.appendChild(msg); 1823 scrollToBottom(); 1824 // Reconnect after restart 1825 setTimeout(() => { window.location.reload(); }, 5000); 1826 }) 1827 .catch(e => { 1828 const msg = document.createElement('div'); 1829 msg.className = 'msg assistant'; 1830 msg.textContent = 'Reload failed: ' + e.message; 1831 messagesEl.appendChild(msg); 1832 }); 1833 } 1834 1835 // ============================================================ 1836 // 10. EVENT HANDLERS 1837 // ============================================================ 1838 sendBtn.addEventListener('click', () => { 1839 const hasText = inputEl.value.trim().length > 0; 1840 if (hasText) { 1841 sendMessage(); 1842 } else if (contextNodes.length > 0) { 1843 startGardening(); 1844 } 1845 }); 1846 stopBtn.addEventListener('click', () => { cancelStream(); }); 1847 1848 micBtn.addEventListener('click', () => { 1849 if (isRecording) { 1850 stopRecording(); 1851 } else { 1852 startRecording(); 1853 } 1854 }); 1855 1856 // Autocomplete-aware keydown handler 1857 inputEl.addEventListener('keydown', (e) => { 1858 if (acVisible) { 1859 if (e.key === 'ArrowDown') { 1860 e.preventDefault(); 1861 acSelectedIndex = Math.min(acSelectedIndex + 1, acFilteredItems.length - 1); 1862 renderAutocomplete(); 1863 return; 1864 } 1865 if (e.key === 'ArrowUp') { 1866 e.preventDefault(); 1867 acSelectedIndex = Math.max(acSelectedIndex - 1, 0); 1868 renderAutocomplete(); 1869 return; 1870 } 1871 if (e.key === 'Enter' || e.key === 'Tab') { 1872 e.preventDefault(); 1873 acceptAutocomplete(acSelectedIndex); 1874 return; 1875 } 1876 if (e.key === 'Escape') { 1877 e.preventDefault(); 1878 hideAutocomplete(); 1879 return; 1880 } 1881 } 1882 1883 if (e.key === 'Enter' && !e.shiftKey) { 1884 e.preventDefault(); 1885 const hasText = inputEl.value.trim().length > 0; 1886 if (hasText) { 1887 sendMessage(); 1888 } else if (contextNodes.length > 0) { 1889 startGardening(); 1890 } 1891 } 1892 }); 1893 1894 inputEl.addEventListener('input', () => { 1895 inputEl.style.height = 'auto'; 1896 inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px'; 1897 updateSendButton(); 1898 1899 // Check for @ autocomplete trigger 1900 const text = inputEl.value; 1901 const cursor = inputEl.selectionStart; 1902 1903 // Find the last @ before cursor 1904 let atPos = -1; 1905 for (let i = cursor - 1; i >= 0; i--) { 1906 if (text[i] === '@') { 1907 atPos = i; 1908 break; 1909 } 1910 // Stop searching if we hit a space before finding @ 1911 if (text[i] === ' ' && i < cursor - 1) break; 1912 // Also stop at newlines 1913 if (text[i] === '\n') break; 1914 } 1915 1916 if (atPos >= 0 && dreamNodeCatalog.length > 0) { 1917 const query = text.slice(atPos + 1, cursor); 1918 acTriggerPos = atPos; 1919 showAutocomplete(query); 1920 } else { 1921 hideAutocomplete(); 1922 } 1923 }); 1924 1925 // Close autocomplete when clicking outside 1926 document.addEventListener('click', (e) => { 1927 if (!inputEl.contains(e.target) && !autocompleteEl.contains(e.target)) { 1928 hideAutocomplete(); 1929 } 1930 }); 1931 1932 // ============================================================ 1933 // 11. INIT 1934 // ============================================================ 1935 connectTranscriptionWs(); 1936 updateSendButton(); 1937 </script> 1938 </body> 1939 </html>