1/** 2 * apps/faq/public-app.js â the public FAQ chat surface (st_85ca4f3c). 3 * 4 * This is a faithful duplicate of apps/chat/public-app.js's initPublicChat(), 5 * lifted out of the middleware-gated /chat/* tree so an anonymous visitor can 6 * actually fetch the scripts. The chat tree is gated by the Edge middleware on 7 * /chat and /chat/:path* (see middleware.js); /faq sits on its own URL prefix 8 * that the middleware matcher does not include. 9 * 10 * Why duplicate (vs. import from /chat/*): even though some chat/* paths are 11 * explicitly passed through in middleware.js (PASS_THROUGH_STATIC_ASSETS for 12 * /chat/app.js, /chat/public-app.js, /chat/style.css, /chat/modules/*, 13 * /chat/components/*), routing the public faq surface through paths the 14 * middleware ALSO uses for the authed chat shell is a sharp edge â any future 15 * middleware tightening on /chat would break this page. Owning the public 16 * paths under /faq/* keeps the public surface independent of the authed 17 * routing contract. 18 * 19 * Rendering is byte-faithful to the chat app â same marked + DOMPurify + 20 * highlight stack, same message DOM (<div class="message {role}">), same 21 * shell.css base bubble styles, same chat-style overrides. The only thing 22 * stripped is anything that requires a session. 23 * 24 * What is intentionally NOT here: 25 * - initShell(): renders the authed global topbar + sidebar 26 * - every authenticated API call (whoami, belt, apps, models, prefs, 27 * labels, conversations, authed chat stream, prefetch, ttft-estimate, 28 * server-health, ambient chat events) 29 * - warm-cache reads, drop-folder + secure-input components 30 * - keyboard shortcut layer (it depends on the chat sidebar) 31 * 32 * What this surface fires: 33 * - On page load: no model call. The composer paints immediately. 34 * - POST /api/public-chat/stream â fires once per user-submitted turn. 35 */ 36import { streamChatTurn } from './modules/stream-client.js'; 37 38const $ = (sel) => document.querySelector(sel); 39 40// ââ Markdown rendering â inlined from /static/shared/utils.js ââââââââââââââ 41// 42// utils.js cannot be loaded here because it is auth-coupled: it owns 43// _redirectToLogin(), the offline-banner, fetchJSON()'s 401 redirect, and 44// other behavior that only makes sense inside the authed shell. The pure 45// renderer is small and stable, so we lift it verbatim. Same marked + 46// DOMPurify pipeline â identical output to the chat app's renderProseMarkdown. 47 48function escapeText(s) { 49 const d = document.createElement('div'); 50 d.textContent = s; 51 return d.innerHTML; 52} 53 54function renderProseMarkdown(text, options = {}) { 55 const raw = String(text || ''); 56 try { 57 if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') { 58 return escapeText(raw).replace(/\n/g, '<br>'); 59 } 60 const parsed = marked.parse(raw, { mangle: false, headerIds: false }); 61 const clean = DOMPurify.sanitize(parsed, { 62 USE_PROFILES: { html: true }, 63 FORBID_TAGS: ['style', 'script', 'iframe', 'object', 'embed'], 64 FORBID_ATTR: ['style'], 65 }); 66 const template = document.createElement('template'); 67 template.innerHTML = clean; 68 template.content.querySelectorAll('a[href]').forEach((link) => { 69 const href = link.getAttribute('href') || ''; 70 const isHash = href.startsWith('#'); 71 const isLocal = href.startsWith('/') || href.startsWith(location.origin); 72 if (!isHash && !isLocal) { 73 link.setAttribute('target', '_blank'); 74 link.setAttribute('rel', 'noopener noreferrer'); 75 } 76 }); 77 template.content.querySelectorAll('table').forEach((table) => { 78 if (table.parentElement?.classList.contains('rd-table-scroll')) return; 79 const wrap = document.createElement('div'); 80 wrap.className = 'rd-table-scroll'; 81 table.parentNode.insertBefore(wrap, table); 82 wrap.appendChild(table); 83 }); 84 if (options.highlight !== false && typeof hljs !== 'undefined') { 85 template.content.querySelectorAll('pre code').forEach((block) => { 86 try { hljs.highlightElement(block); } catch {} 87 }); 88 } 89 return template.innerHTML; 90 } catch (e) { 91 console.error('[faq.renderProseMarkdown]', e.message); 92 return escapeText(raw).replace(/\n/g, '<br>'); 93 } 94} 95 96// ââ Message DOM â mirrors the chat app's addMessage exactly ââââââââââââââââ 97// 98// Same class names (`.message {role}`, `.role`, `.content`) and same DOM 99// shape as apps/chat/public-app.js â shell.css's base bubble styles apply 100// without modification. The result on screen is indistinguishable from /chat. 101 102function addMessage(role, content = '') { 103 const wrap = document.createElement('div'); 104 wrap.className = `message ${role}`;
105 const label = document.createElement('div'); 106 label.className = 'role'; 107 label.textContent = role === 'user' ? 'You' : 'Miyagi'; 108 const body = document.createElement('div'); 109 body.className = 'content'; 110 body.innerHTML = renderProseMarkdown(content); 111 wrap.appendChild(label); 112 wrap.appendChild(body); 113 $('#messagesInner').appendChild(wrap); 114 $('#messages')?.scrollTo({ top: $('#messages').scrollHeight }); 115 return body; 116} 117 118// ââ Composer â same DOM/classes as the chat app's input toolbar ââââââââââââ 119 120function renderComposer(onSend) { 121 const area = $('#inputArea'); 122 area.innerHTML = ` 123 <div class="input-container public-composer"> 124 <div class="input-main"> 125 <textarea class="chat-input" data-testid="multimodal-input" rows="1" placeholder="Ask anything about Robot Dojo"></textarea> 126 <button class="send-circle" data-testid="send-button" type="button" title="Send" disabled> 127 <span class="material-symbols-outlined">arrow_upward</span> 128 </button> 129 </div> 130 <div class="input-toolbar"> 131 <span class="tb-btn public-source-pill" aria-hidden="true"> 132 <span class="material-symbols-outlined icon-sm">verified</span> 133 Public repo docs only 134 </span> 135 <span class="tb-spacer"></span> 136 </div> 137 </div> 138 `; 139 const input = area.querySelector('.chat-input'); 140 const send = area.querySelector('.send-circle'); 141 const refresh = () => { 142 const hasText = !!input.value.trim(); 143 send.disabled = !hasText; 144 send.classList.toggle('has-text', hasText); 145 }; 146 input.addEventListener('input', refresh); 147 input.addEventListener('keydown', (e) => { 148 if (e.key === 'Enter' && !e.shiftKey) { 149 e.preventDefault(); 150 if (!send.disabled) onSend(input); 151 } 152 }); 153 send.addEventListener('click', () => onSend(input)); 154} 155 156// ââ Streaming a single public-chat turn ââââââââââââââââââââââââââââââââââââ 157// 158// Wraps streamChatTurn so the user-submit call site has the full flow in 159// one place. (As of st_85ca4f3c the opening message is precomputed and no 160// longer streams; this helper now serves user follow-ups only.) Returns 161// the streamed answer text. The history array is mutated in place so 162// follow-ups carry coherent context. 163 164async function streamPublicTurn({ question, assistantNode, history, sessionRef, publicContext }) { 165 let answer = ''; 166 await streamChatTurn({ 167 endpoint: '/api/public-chat/stream', 168 body: { sessionId: sessionRef.id, context: publicContext, messages: history }, 169 rehydrateFromDB: null, 170 onEvent(ev) { 171 if (ev.type === 'status' && ev.sessionId) sessionRef.id = ev.sessionId; 172 if (ev.type === 'status' && ev.status) { 173 assistantNode.textContent = ev.status === 'streaming' 174 ? 'Answering from public docsâ¦' 175 : 'Checking the public Robot Dojo docsâ¦'; 176 } 177 if (ev.type === 'delta') { 178 answer += ev.text || ''; 179 assistantNode.innerHTML = renderProseMarkdown(answer); 180 } 181 if (ev.type === 'error') { 182 assistantNode.innerHTML = renderProseMarkdown( 183 `${ev.message || 'Public chat is unavailable.'}\n\nRetry in a moment.`, 184 ); 185 } 186 }, 187 onError(err) { 188 assistantNode.innerHTML = renderProseMarkdown( 189 `${err?.message || 'Public chat is unavailable.'}\n\nRetry in a moment.`, 190 ); 191 }, 192 onDone() { 193 if (answer.trim()) history.push({ role: 'assistant', content: answer }); 194 }, 195 stallMs: 30000, 196 }); 197 return answer; 198} 199 200// ââ Public init ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 201 202export function initPublicChat() { 203 // body.public-chat-mode flips shell.css + faq/style.css overrides on: 204 // hides the global topbar, sidebar, and conv list, centers the chat 205 // surface. Same flag the chat app sets in its setChromeForPublicMode(). 206 document.body.classList.add('public-chat-mode'); 207 $('#appBody')?.setAttribute('data-public-ask', 'true'); 208 209 const history = []; 210 const sessionRef = { id: null }; 211 // The public context lookup matches what chat/public-app.js maps for the 212 // default (no ?topic=) case â the FAQ corpus. Owner can deep-link 213 // /faq?topic=install&prompt=⦠in the future with the same encoding the 214 // chat app uses; we keep just the default for now to avoid carrying 215 // half-implemented topic switching. 216 const publicContext = 'faq-context'; 217 let sending = false;
218 219 // Core turn: render the user message, then stream the assistant reply. 220 const sendText = async (raw) => { 221 if (sending) return; 222 const text = (raw || '').trim(); 223 if (!text) return; 224 history.push({ role: 'user', content: text }); 225 addMessage('user', text); 226 const assistant = addMessage('assistant', 'Checking the public Robot Dojo docsâ¦'); 227 sending = true; 228 try { 229 await streamPublicTurn({ 230 question: text, 231 assistantNode: assistant, 232 history, 233 sessionRef, 234 publicContext, 235 }); 236 } finally { 237 sending = false; 238 } 239 }; 240 241 const send = (input) => { 242 const text = input.value.trim(); 243 if (!text) return; 244 input.value = ''; 245 input.dispatchEvent(new Event('input')); 246 sendText(text); 247 }; 248 249 renderComposer(send); 250 251 document.body.classList.add('app-ready'); 252}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.