PageSourceSearch

https://robotdojo.ai/faq/public-app.js

js robotdojo.ai collected 2026-10-05 15:25:20 UTC 10,236 bytes, 252 lines download raw bytes

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.