1/* 2 * dro-agent/app.js â orchestrates the dro Agent page: composer, attachments, 3 * voice messages, streaming turns over SSE (JSON fallback), suggestion cards 4 * with prefilled deep links, the conversation sidebar and the sign-in gate. 5 * Glue only; rendering lives in ui.js, transport in api.js, files in 6 * attachments.js. Exposes window.DP_DroAgentApp.init(). 7 */ 8(function () { 9 'use strict'; 10 if (window.DP_DroAgentApp) return; 11 12 var Api, Ui, Files; 13 var $ = function (id) { return document.getElementById(id); }; 14 function toast(m, o) { if (window.dpToast) window.dpToast(m, o); } 15 16 var EFFORT_KEY = 'dp:da:effort'; 17 var RAIL_KEY = 'dp:da:rail'; 18 var DEFAULT_EFFORT = 'balanced'; 19 // Mirrors services/dro-agent/effort.js; the server re-sends the real list 20 // (labels + prices) from GET /dro-agent/features and we re-render then. 21 var EFFORT_FALLBACK = [ 22 { id: 'fast', label: 'Fast', hint: 'Straight answer, lowest latency', units: 1 }, 23 { id: 'balanced', label: 'Balanced', hint: 'Thinks before it answers', units: 1, default: true }, 24 { id: 'deep', label: 'Deep', hint: 'Plans, answers, then checks its own work', units: 3 }, 25 ]; 26 var state = { conversationId: null, refinement: null, sending: false, loading: false, draftReady: false, conversations: [], history: [], signedIn: false, account: '', effort: DEFAULT_EFFORT, levels: EFFORT_FALLBACK, connectors: [], connectionFilter: 'ready', connectionPopup: null, pending: null, unresolved: false, controller: null, selection: 0, historyRequest: 0, connectionRequest: 0, memoryRequest: 0 }; 27 var DRAFT_PREFIX = 'dp:da:user:'; 28 var SESSION_OWNER = 'dp:da:session-owner'; 29 // How long a turn whose outcome never arrived keeps the composer locked. 30 var STALE_PENDING_MS = 10 * 60 * 1000; 31 // â¦and how long the notice itself is worth anything. A marker this old has 32 // no live question behind it, so it is dropped on sight rather than checked: 33 // whatever it warned about was decided long ago. 34 var PENDING_EXPIRY_MS = 60 * 60 * 1000; 35 var failedViews = null; 36 var els = {}; 37 38 39 // i18n.js is deferred, so at init <html lang> is still the shipped 'en'; 40 // the stored preference is what the visitor actually reads. 41 function lang() { 42 var l = (document.documentElement.getAttribute('lang') || '').slice(0, 2); 43 if (!l || l === 'en') { try { l = (localStorage.getItem('DROPPROMPT_LANG') || l || 'en').slice(0, 2); } catch (_) { l = l || 'en'; } } 44 return l || 'en'; 45 } 46 function isAr() { return lang() === 'ar'; } 47 function t(key, fb) { return Ui ? Ui.t(key, fb) : fb; } 48 49 // ââ init ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 50 function init() { 51 if (els.input) return; 52 Api = window.DP_DroAgentApi; 53 els = { 54 main: $('da-main'), sidebar: $('da-sidebar'), scrim: $('da-scrim'), menu: $('da-menu'), sideClose: $('da-side-close'), 55 newChat: $('da-new'), list: $('da-conversations'), search: $('da-search'), 56 newTop: $('da-new-top'), exportChat: $('da-export'), chatTitle: $('da-chat-title'), jumpLatest: $('da-jump-latest'), 57 scroll: $('da-scroll'), messages: $('da-messages'), empty: $('da-empty'), gate: $('da-gate'), 58 composer: $('da-composer'), input: $('dro-agent-input'), send: $('da-send'), stop: $('da-stop'), sendNote: $('da-send-note'), attach: $('da-attach'), file: $('da-file'), 59 record: $('da-record'), recChip: $('da-rec-chip'), recTime: $('da-rec-time'), tray: $('da-tray'), cost: $('da-cost'), costNum: $('da-cost-num'), 60 costHelp: $('da-cost-help'), costBase: $('da-cost-base'), costMedia: $('da-cost-media'), costLevel: $('da-cost-level'), characterCount: $('da-character-count'), 61 drop: $('da-drop'), chat: $('da-chat'), 62 effort: $('da-effort'), effortBtn: $('da-effort-btn'), effortLabel: $('da-effort-label'), effortMenu: $('da-effort-menu'), 63 sideHide: $('da-side-hide'), 64 connect: $('da-connect'), connCount: $('da-conn-count'), connSheet: $('da-conn-sheet'), connList: $('da-conn-list'), connClose: $('da-conn-close'), connSearch: $('da-conn-search'), connResult: $('da-conn-result'), connApprovals: $('da-conn-approvals'), 65 memory: $('da-memory'), memSheet: $('da-mem-sheet'), memList: $('da-mem-list'), memClose: $('da-mem-close'), memClear: $('da-mem-clear'), 66 }; 67 Ui = window.DP_DroAgentUi.create({ 68 onToolDecision: decideToolAndContinue, 69 onToolStatus: function (id) { return Api.toolStatus(id); }, 70 messagesEl: els.messages, scrollEl: els.scroll, emptyEl: els.empty, listEl: els.list, mainEl
70: els.main, 71 onOpenFeature: openFeature, 72 onRefineTask: function (task) { 73 var lead = lang() === 'ar' ? 'عدÙ٠اÙÙ ÙÙ Ø© «' + task.title + '»: ' : 'Refine "' + task.title + '": '; 74 state.refinement = { id: task.id, lead: lead.trim() }; 75 els.input.value = (els.input.value ? els.input.value + '\n' : '') + lead; 76 saveDraft(); syncComposer(); focusComposer(); 77 }, 78 onReuse: reuseMessage, jumpEl: els.jumpLatest, 79 }); 80 Files = window.DP_DroAgentAttachments.create({ 81 trayEl: els.tray, fileInput: els.file, onChange: syncComposer, onRecording: onRecording, 82 }); 83 84 state.signedIn = Api.isSignedIn(); 85 state.account = Api.sessionKey(); 86 if (window.DP_OutputTry) window.DP_OutputTry.setHandler(function (output) { 87 var account = state.account; 88 var conversation = state.conversationId; 89 var old = els.messages.querySelector('.dpt-review'); if (old) old.remove(); 90 els.empty.hidden = true; 91 els.main.classList.remove('da-idle'); 92 window.DP_OutputTry.review(els.messages, output, { 93 valid: function () { return account === state.account && conversation === state.conversationId; }, 94 onRun: function (source, sample, trial) { return send(source, sample, trial); }, 95 onCancel: function () { syncComposer(); focusComposer(); }, 96 }); 97 els.scroll.scrollTop = els.scroll.scrollHeight; 98 }); 99 claimDraftStorage(); 100 try { state.effort = localStorage.getItem(EFFORT_KEY) || DEFAULT_EFFORT; } catch (_) { /* private mode */ } 101 try { if (localStorage.getItem(RAIL_KEY) === 'hidden') setRail(false); } catch (_) { /* private mode */ } 102 if (window.DP_HomeChatEmbed) els.main.classList.add('is-rail-hidden'); 103 renderEffort(); 104 loadEffortLevels(); 105 if (state.signedIn) loadConnections(); 106 wire(); 107 syncSidebar(); 108 syncShellHeight(); 109 if (window.ResizeObserver) { 110 var chromeObserver = new window.ResizeObserver(syncShellHeight); 111 document.querySelectorAll('[data-dp-header], .dp-promo').forEach(function (node) { chromeObserver.observe(node); }); 112 } 113 if (window.visualViewport) window.visualViewport.addEventListener('resize', syncShellHeight); 114 restoreEntry(); 115 syncComposer(); 116 if (state.signedIn) { 117 loadConversations(); 118 els.gate.hidden = true; 119 } else { 120 els.gate.hidden = false; 121 } 122 // Sign-in from the gate (or the header) refreshes the page state. 123 window.addEventListener('storage', function (e) { if (e.key === 'dro_token' || e.key === 'dro_user') onAuthChange(); }); 124 document.addEventListener('dp:auth-change', onAuthChange); 125 if (els.connect) els.connect.addEventListener('click', openConnections); 126 if (els.connClose) els.connClose.addEventListener('click', closeConnections); 127 if (els.connSearch) els.connSearch.addEventListener('input', renderConnections); 128 var customOpen = $('da-conn-custom-open'), customBack = $('da-conn-back'); 129 if (customOpen) customOpen.addEventListener('click', function () { showCustomConnection(); }); 130 if (customBack) customBack.addEventListener('click', browseConnections); 131 document.querySelectorAll('[data-conn-filter]').forEach(function (button) { 132 button.addEventListener('click', function () { state.connectionFilter = button.getAttribute('data-conn-filter'); renderConnections(); }); 133 }); 134 var customForm = document.getElementById('da-conn-custom'); 135 if (customForm) customForm.addEventListener('submit', addCustomServer); 136 if (els.connSheet) els.connSheet.addEventListener('click', function (e) { if (e.target === els.connSheet) closeConnections(); }); 137 if (els.memory) els.memory.addEventListener('click', openMemory); 138 if (els.memClose) els.memClose.addEventListener('click', function () { els.memSheet.hidden = true; }); 139 if (els.memClear) els.memClear.addEventListener('click', function () { forget(''); }); 140 if (els.memSheet) els.memSheet.addEventListener('click', function (e) { if (e.target === els.memSheet) els.memSheet.hidden = true; }); 141 document.addEventListener('keydown', function (e) { 142 if (els.connSheet && !els.connSheet.hidden && e.key === 'Tab') {
143 var stops = Array.prototype.filter.call(els.connSheet.querySelectorAll('button, input, a[href]'), function (el) { return !el.disabled && el.getClientRects().length; }); 144 var first = stops[0], last = stops[stops.length - 1]; 145 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 146 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 147 } 148 if (e.key !== 'Escape') return; 149 if (els.connSheet && !els.connSheet.hidden) closeConnections(); 150 if (els.memSheet && !els.memSheet.hidden) els.memSheet.hidden = true; 151 }); 152 window.addEventListener('message', function (e) { 153 if (e.origin !== location.origin) return; // only our own callback page 154 var d = e.data; 155 if (!d || d.source !== 'dro-agent-connect' || !state.connectionPopup || e.source !== state.connectionPopup) return; 156 state.connectionPopup = null; 157 if (d.ok) { toast(t('droagent.connected', 'Connected.'), { type: 'success' }); loadConnections(); } 158 else toast(t('droagent.connect_failed', 'Could not start that connection.'), { type: 'error' }); 159 }); 160 window.addEventListener('popstate', function () { 161 if (state.sending) { persistConversation(state.conversationId); return; } 162 var id = new URLSearchParams(location.search).get('conversation'); 163 if (id) openConversation({ id: id }); else newChat(); 164 }); 165 window.addEventListener('pagehide', saveDraft); 166 document.addEventListener('droprompt:languagechange', function () { renderEffort(); loadEffortLevels(); renderConversationList(); if (state.signedIn) loadConnections(); }); 167 if (new URLSearchParams(location.search).get('connections') === '1' && state.signedIn) openConnections(); 168 } 169 170 function accountPrefix(account) { return DRAFT_PREFIX + (account || state.account) + ':'; } 171 function draftKey(id) { return accountPrefix() + 'draft:' + (id || 'new'); } 172 function clearAccountStorage(account) { 173 if (!account) return; 174 try { 175 var prefix = accountPrefix(account); 176 for (var i = sessionStorage.length - 1; i >= 0; i--) { 177 var key = sessionStorage.key(i); 178 if (key && key.indexOf(prefix) === 0) sessionStorage.removeItem(key); 179 } 180 } catch (_) {} 181 } 182 function claimDraftStorage() { 183 try { 184 var previous = sessionStorage.getItem(SESSION_OWNER); 185 if (previous && previous !== state.account) clearAccountStorage(previous); 186 if (state.account) sessionStorage.setItem(SESSION_OWNER, state.account); 187 else sessionStorage.removeItem(SESSION_OWNER); 188 } catch (_) {} 189 } 190 function saveDraft() { 191 if (!state.account || !els.input || !state.draftReady) return; 192 if (state.refinement && !els.input.value.includes(state.refinement.lead)) state.refinement = null; 193 try { 194 if (els.input.value || state.pending) sessionStorage.setItem(draftKey(state.conversationId), JSON.stringify({ text: els.input.value, pending: state.pending, refinement: state.refinement })); 195 else sessionStorage.removeItem(draftKey(state.conversationId)); 196 } catch (_) {} 197 } 198 function restoreDraft() { 199 state.draftReady = true; 200 state.refinement = null; 201 els.input.value = ''; 202 state.pending = null; 203 state.unresolved = false; 204 try { 205 var draft = JSON.parse(sessionStorage.getItem(draftKey(state.conversationId)) || '{}'); 206 els.input.value = String(draft.text || '').slice(0, 8000); 207 state.pending = draft.pending || null; 208 if (state.pending && Date.now() - (state.pending.at || 0) >= PENDING_EXPIRY_MS) state.pending = null; 209 state.refinement = draft.refinement && els.input.value.includes(draft.refinement.lead) ? draft.refinement : null; 210 // The lock exists so a turn whose outcome is still open is not sent 211 // twice. One that has settled needs no lock: the server is done with it. 212 state.unresolved = !!state.pending && !settledPending(state.pending); 213 } catch (_) {} 214 autosize(); 215 syncComposer(); 216 } 217 function persistConversation(id) { 218 var oldKey = draftKey(state.conversationId); 219 state.conversationId = id || null; 220 try { 221 var url = new URL(location.href); 222 if (id) url.searchParams.set('conversation', id); else url.searchParams.delete('conversation'); 223 url.searchParams.delete('q'); url.searchParams.delete('idea'); 224 window.history.replaceState(null, '', url.pathname + url.search + url.hash); 225 } catch (_) {} 226 // A new conversation takes ownership of its in-flight draft. 227 if (state.sending && oldKey !== draftKey(state.conversationId)) {
228 try { sessionStorage.removeItem(oldKey); } catch (_) {} 229 saveDraft(); 230 } 231 } 232 function restoreEntry() { 233 var params = new URLSearchParams(location.search); 234 // Only a link that names a chat reopens one. Arriving at /dro-agent starts 235 // a new one, rather than dropping the user back into yesterday's thread. 236 var id = params.get('conversation'); 237 var q = params.get('q') || params.get('idea'); 238 if (id && state.signedIn) openConversation({ id: id }, q); 239 else { 240 restoreDraft(); 241 if (q) { els.input.value = q.slice(0, 8000); saveDraft(); autosize(); } 242 // A settled turn gets no card until the check below has looked for it. 243 if (state.pending) { 244 if (!settledPending(state.pending)) showPendingRecovery(); 245 checkSavedReply({ quiet: true }).catch(function () {}); 246 } 247 } 248 } 249 function onAuthChange() { 250 var now = Api.isSignedIn(); 251 var account = Api.sessionKey(); 252 if (now === state.signedIn && account === state.account) return; 253 if (state.controller) state.controller.abort(); 254 clearAccountStorage(state.account); 255 state.selection++; state.historyRequest++; state.connectionRequest++; state.memoryRequest++; 256 state.signedIn = now; state.account = account; 257 state.sending = false; state.loading = false; state.controller = null; 258 state.pending = null; state.unresolved = false; failedViews = null; 259 state.history = []; state.conversations = []; state.connectors = []; 260 claimDraftStorage(); 261 persistConversation(null); 262 Ui.clear(); Files.clear(); 263 if (Files.setLocked) Files.setLocked(false); 264 els.input.value = ''; state.draftReady = true; 265 if (els.memList) els.memList.innerHTML = ''; 266 if (els.memSheet) els.memSheet.hidden = true; 267 if (els.connSheet) closeConnections(); 268 if (state.connectionPopup) { try { state.connectionPopup.close(); } catch (_) {} state.connectionPopup = null; } 269 if (els.connApprovals) els.connApprovals.innerHTML = ''; 270 renderConnections(); renderConversationList(); 271 els.gate.hidden = now; 272 autosize(); syncComposer(); 273 if (now) { loadConversations(); loadConnections(); } 274 } 275 276 // ââ wiring ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 277 function wire() { 278 els.composer.addEventListener('submit', function (e) { e.preventDefault(); send(); }); 279 els.input.addEventListener('input', function () { autosize(); saveDraft(); syncComposer(); }); 280 els.input.addEventListener('keydown', function (e) { 281 if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } 282 }); 283 els.attach.addEventListener('click', function () { els.file.click(); }); 284 if (window.dpVoice) window.dpVoice.attach(els.input, { button: els.record, mode: 'inline', languageHost: document.querySelector('.da-tools'), contextKey: function () { return state.account + ':' + state.selection; } }); 285 else els.record.addEventListener('click', toggleRecording); 286 if (els.stop) els.stop.addEventListener('click', function () { if (state.controller) state.controller.abort(); }); 287 if (els.search) els.search.addEventListener('input', renderConversationList); 288 document.querySelectorAll('[data-da-starter]').forEach(function (button) { 289 button.addEventListener('click', function () { 290 if (state.sending || state.loading) return; 291 els.input.value = t(button.getAttribute('data-da-starter'), button.getAttribute('data-da-prompt') || ''); 292 saveDraft(); autosize(); syncComposer(); focusComposer(); 293 }); 294 }); 295 window.addEventListener('resize', function () { syncSidebar(); syncShellHeight(); autosize(); }); 296 els.newChat.addEventListener('click', function () { newChat(); closeDrawer(); }); 297 if (els.newTop) els.newTop.addEventListener('click', function () { newChat(); closeDrawer(); }); 298 if (els.exportChat) els.exportChat.addEventListener('click', downloadChat); 299 document.addEventListener('click', function (e) { 300 if (els.costHelp && els.costHelp.open && !els.costHelp.contains(e.target)) els.costHelp.open = false;
301 }); 302 if (els.menu) els.menu.addEventListener('click', function () { if (isMobile()) openDrawer(); else setRail(true); }); 303 if (els.sideHide) els.sideHide.addEventListener('click', function () { setRail(false); }); 304 if (els.sideClose) els.sideClose.addEventListener('click', closeDrawer); 305 if (els.scrim) els.scrim.addEventListener('click', closeDrawer); 306 document.addEventListener('keydown', function (e) { 307 if (e.key === 'Escape') closeDrawer(); 308 if (e.key === 'Escape' && els.costHelp && els.costHelp.open) { els.costHelp.open = false; els.cost.focus(); } 309 if (e.key !== 'Tab' || !isMobile() || !els.main.classList.contains('is-drawer-open')) return; 310 var focusable = Array.prototype.filter.call(els.sidebar.querySelectorAll('button, input, a[href], [tabindex="0"]'), function (node) { return !node.disabled && !node.hidden && node.offsetParent !== null; }); 311 var first = focusable[0]; var last = focusable[focusable.length - 1]; 312 if (!first) return; 313 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 314 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 315 }); 316 317 if (els.effortBtn) { 318 els.effortBtn.addEventListener('click', function () { if (els.effortMenu.hidden) openEffort(); else closeEffort(true); }); 319 els.effort.addEventListener('keydown', function (e) { 320 if (e.key === 'Escape') { closeEffort(true); return; } 321 if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; 322 e.preventDefault(); 323 if (els.effortMenu.hidden) { openEffort(); return; } 324 var opts = Array.prototype.slice.call(els.effortMenu.children); 325 var at = opts.indexOf(document.activeElement); 326 var next = opts[(at + (e.key === 'ArrowDown' ? 1 : opts.length - 1) + opts.length) % opts.length]; 327 if (next) next.focus(); 328 }); 329 } 330 331 // Drag & drop anywhere on the chat column. 332 var depth = 0; 333 els.chat.addEventListener('dragenter', function (e) { if (hasFiles(e)) { e.preventDefault(); depth++; els.drop.hidden = false; } }); 334 els.chat.addEventListener('dragover', function (e) { if (hasFiles(e)) e.preventDefault(); }); 335 els.chat.addEventListener('dragleave', function (e) { if (hasFiles(e)) { depth = Math.max(0, depth - 1); if (!depth) els.drop.hidden = true; } }); 336 els.chat.addEventListener('drop', function (e) { 337 if (!hasFiles(e)) return; 338 e.preventDefault(); depth = 0; els.drop.hidden = true; 339 if (state.sending || state.loading) return; 340 Files.addFiles(e.dataTransfer.files); 341 focusComposer(); 342 }); 343 // Paste an image (screenshot) straight into the composer. 344 els.input.addEventListener('paste', function (e) { 345 if (state.sending || state.loading) return; 346 var items = e.clipboardData && e.clipboardData.items; 347 if (!items) return; 348 var files = []; 349 for (var i = 0; i < items.length; i++) if (items[i].kind === 'file') { var f = items[i].getAsFile(); if (f) files.push(f); } 350 if (files.length) { e.preventDefault(); Files.addFiles(files); } 351 }); 352 // Gate buttons 353 els.gate.querySelectorAll('[data-da-auth]').forEach(function (b) { 354 b.addEventListener('click', function () { if (window.dpOpenAuth) window.dpOpenAuth(b.getAttribute('data-da-auth')); }); 355 }); 356 } 357 358 function hasFiles(e) { 359 var types = e.dataTransfer && e.dataTransfer.types; 360 return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1; 361 } 362 363 function autosize() { 364 els.input.style.height = 'auto'; 365 els.input.style.height = Math.min(200, els.input.scrollHeight) + 'px'; 366 } 367 368 function syncComposer() { 369 var hasText = !!els.input.value.trim(); 370 var hasFilesReady = Files && Files.count() > 0; 371 els.send.disabled = state.sending || state.loading || state.unresolved || Files.busy() || !(hasText || hasFilesReady); 372 els.input.readOnly = state.sending || state.loading; 373 els.composer.setAttribute('aria-busy', String(state.sending || state.loading)); 374 els.send.hidden = state.sending; 375 if (els.stop) els.stop.hidden = !state.sending; 376 if (els.newChat) els.newChat.disabled = state.sending; 377 if (els.newTop) els.newTop.disabled = state.sending; 378 syncConversationTools(); 379 if (els.record) els.record.disabled = state.sending || state.loading; 380 if (els.effortBtn) els.effortBtn.disabled = state.sending || state.loading; 381 var level = currentLevel(); 382 var units = (level && level.units) || 1;
383 // 5 credits per unit of thinking + 5 per media attachment (mirrors 384 // config/credits.js). Files.quote() already counts the attachments. 385 var total = (Files ? Files.quote() : 5) + (units - 1) * 5; 386 if (els.costNum) els.costNum.textContent = String(total); 387 if (els.costBase) els.costBase.textContent = String(units * 5); 388 if (els.costMedia) els.costMedia.textContent = String(Math.max(0, total - units * 5)); 389 if (els.costLevel) els.costLevel.textContent = level ? t('droagent.effort_' + level.id, level.label || level.id) : ''; 390 var note = t('droagent.send_cost', '{credits} credits per message').replace('{credits}', String(total)); 391 if (els.cost) els.cost.title = note; 392 if (els.sendNote) els.sendNote.textContent = note; 393 if (els.characterCount) { 394 els.characterCount.hidden = els.input.value.length < 6000; 395 els.characterCount.textContent = els.input.value.length.toLocaleString() + ' / 8,000'; 396 } 397 els.send.setAttribute('aria-label', t('droagent.send', 'Send') + ' · ' + note); 398 els.attach.disabled = state.sending || state.loading || Files.count() >= 6; 399 } 400 401 function syncConversationTools() { 402 var current = state.conversations.find(function (c) { return String(c.id) === String(state.conversationId); }); 403 if (els.chatTitle) { els.chatTitle.textContent = current ? current.title || '' : ''; els.chatTitle.hidden = !els.chatTitle.textContent; } 404 if (els.exportChat) els.exportChat.disabled = state.sending || state.loading || !(Ui && Ui.hasMessages && Ui.hasMessages()); 405 } 406 407 function downloadChat() { 408 if (state.sending || state.loading || !Ui || !Ui.exportMarkdown) return; 409 var text = Ui.exportMarkdown(els.chatTitle && els.chatTitle.textContent); 410 if (!text) return; 411 var url = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' })); 412 var link = document.createElement('a'); 413 link.href = url; link.download = 'dro-agent-chat.md'; 414 document.body.appendChild(link); link.click(); link.remove(); 415 setTimeout(function () { URL.revokeObjectURL(url); }, 1000); 416 } 417 418 async function reuseMessage(message) { 419 if (state.sending || state.loading || state.unresolved) { 420 toast(t('droagent.reuse_wait', 'Finish the current request before reusing a message.')); 421 return; 422 } 423 var text = String(message.content || '').slice(0, 8000); 424 if (!text) return; 425 var account = state.account; var selection = state.selection; 426 var original = els.input.value; 427 if (original.trim() && original !== text) { 428 var approved = window.dpConfirm 429 ? await window.dpConfirm({ title: t('droagent.replace_draft', 'Replace your current draft?'), message: t('droagent.replace_draft_note', 'The message will be placed in the composer for you to edit. Nothing is sent yet.'), confirmText: t('droagent.replace', 'Replace draft') }) 430 : window.confirm(t('droagent.replace_draft', 'Replace your current draft?')); 431 if (!approved) return; 432 } 433 if (account !== state.account || selection !== state.selection || state.sending || state.loading || state.unresolved || els.input.value !== original) return; 434 els.input.value = text; saveDraft(); autosize(); syncComposer(); focusComposer(); 435 if (message.attachments && message.attachments.length) toast(t('droagent.reattach_files', 'Text added. Reattach any files you want to use.')); 436 } 437 438 // ââ effort picker âââââââââââââââââââââââââââââââââââââââââââââââââââââ 439 function currentLevel() { 440 for (var i = 0; i < state.levels.length; i++) if (state.levels[i].id === state.effort) return state.levels[i]; 441 return state.levels[1] || state.levels[0]; 442 } 443 444 function renderEffort() { 445 if (!els.effortMenu) return; 446 var active = currentLevel(); 447 if (els.effortLabel) els.effortLabel.textContent = active ? t('droagent.effort_' + active.id, active.label || active.id) : ''; 448 els.effortMenu.innerHTML = ''; 449 state.levels.forEach(function (l) { 450 var on = l.id === state.effort; 451 var b = document.createElement('button'); 452 b.type = 'button'; 453 b.className = 'da-menu-opt' + (on ? ' is-on' : ''); 454 b.setAttribute('role', 'menuitemradio'); 455 b.setAttribute('aria-checked', String(on)); 456 b.innerHTML = '<span class="da-menu-check" aria-hidden="true">' 457 + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12.5 4.5 4.5L19 7"/></svg>' 458 + '</span><span class="da-menu-text"><b></b><i></i></span><span class="da-menu-cost"></span>'; 459 b.querySelector('b').textContent = t('droagent.effort_' + l.id, l.label || l.id); 460 b.querySelector('i').textContent = t('droagent.effort_' + l.id + '_hint', l.hint || ''); 461 b.querySelector('.da-menu-cost').textContent = String(((l.units || 1) * 5)) + ' ' + t('droagent.credits', 'credits'); 462 b.addEventListener('click', function () { setEffort(l.id); closeEffort(true); }); 463 els.effortMenu.appendChild(b); 464 }); 465 syncComposer(); 466 } 467 468 function openEffort() { 469 if (!els.effortMenu || !els.effortMenu.hidden) return; 470 els.effortMenu.hidden = false;
471 els.effortBtn.setAttribute('aria-expanded', 'true'); 472 var first = els.effortMenu.querySelector('.da-menu-opt.is-on') || els.effortMenu.firstElementChild; 473 if (first) first.focus(); 474 document.addEventListener('click', onEffortOutside, true); 475 } 476 477 function closeEffort(refocus) { 478 if (!els.effortMenu || els.effortMenu.hidden) return; 479 els.effortMenu.hidden = true; 480 els.effortBtn.setAttribute('aria-expanded', 'false'); 481 document.removeEventListener('click', onEffortOutside, true); 482 if (refocus) els.effortBtn.focus(); 483 } 484 485 function onEffortOutside(e) { 486 if (!els.effort.contains(e.target)) closeEffort(false); 487 } 488 489 function setEffort(id) { 490 if (state.sending || id === state.effort) return; 491 state.effort = id; 492 try { localStorage.setItem(EFFORT_KEY, id); } catch (_) { /* private mode */ } 493 renderEffort(); 494 } 495 496 async function loadEffortLevels() { 497 try { 498 var data = await Api.features(lang()); 499 var levels = (data && (data.effortLevels || (data.data && data.data.effortLevels))) || null; 500 if (Array.isArray(levels) && levels.length) { 501 state.levels = levels; 502 if (!levels.some(function (l) { return l.id === state.effort; })) state.effort = DEFAULT_EFFORT; 503 renderEffort(); 504 } 505 } catch (_) { /* the fallback list is already on screen */ } 506 } 507 508 // ââ connected services ââââââââââââââââââââââââââââââââââââââââââââââââ 509 async function decideToolAndContinue(id, approve, alwaysApprove, onProgress) { 510 var account = state.account; 511 var result; 512 try { result = await Api.decideTool(id, approve, alwaysApprove); } 513 catch (err) { 514 // The write may have reached the server. Recover with a GET, never a 515 // second approval POST that could create another paid generation. 516 result = await Api.toolStatus(id); 517 if (result.status === 'pending') throw err; 518 } 519 var deadline = Date.now() + 300000; 520 while (account === state.account && (result.status === 'executing' || result.continuationStatus === 'pending' || result.continuationStatus === 'running')) { 521 if (onProgress) onProgress(result.status === 'executing' ? 'running' : 'fetching'); 522 if (Date.now() >= deadline) throw new Error((lang() === 'ar' ? 'ÙØ³Ù Ø¨ÙØªØ§Ø¨Ø¹ ÙØªÙجة Ø§ÙØ·Ùب. Ø§ÙØªØ Ø§ÙØ´Ø§Øª تاÙÙ ÙØ§Ø³ØªØ±Ø¬Ø§Ø¹ÙØ§Ø Ù Ø§ تبعتش ÙÙØ³ Ø§ÙØ·Ùب Ù Ù Ø¬Ø¯ÙØ¯.' : 'The action is still being checked. Reopen this chat to retrieve its result; do not submit it again.')); 523 await new Promise(function (resolve) { setTimeout(resolve, 1800); }); 524 result = await Api.toolStatus(id); 525 } 526 if (account !== state.account) return { status: 'unknown', text: '' }; 527 if (alwaysApprove) loadConnections(); 528 var continuation = result.continuation; 529 if (continuation && String(continuation.conversationId) === String(state.conversationId) && !state.sending) { 530 var data = await Api.conversation(state.conversationId); 531 if (account === state.account && String(data.conversation.id) === String(state.conversationId)) { 532 state.history = data.messages || []; Ui.renderHistory(state.history); 533 } 534 } 535 return { ...result, text: continuation && continuation.reply || result.text || result.status }; 536 } 537 538 async function loadConnections() { 539 var request = ++state.connectionRequest; 540 var account = state.account; 541 try { 542 var data = await Api.connections(lang()); 543 if (request !== state.connectionRequest || account !== state.account) return; 544 state.connectors = (data && (data.connectors || (data.data && data.data.connectors))) || []; 545 renderConnections(); 546 var approvals = await Api.toolApprovals().catch(function () { return { data: [] }; }); 547 if (request !== state.connectionRequest || account !== state.account) return; 548 if (els.connApprovals) { 549 els.connApprovals.innerHTML = ''; 550 (approvals.data || (Array.isArray(approvals) ? approvals : [])).forEach(function (approval) { els.connApprovals.appendChild(Ui.approvalCard(approval)); }); 551 } 552 } catch (_) { 553 if (request !== state.connectionRequest || account !== state.account) return;
554 showLoadError(els.connList, t('droagent.connections_failed', 'Could not load connected services.'), loadConnections); 555 } 556 } 557 558 function showLoadError(container, message, retry) { 559 if (!container) return; 560 container.innerHTML = ''; 561 var notice = document.createElement('p'); 562 notice.className = 'da-error'; notice.setAttribute('role', 'status'); notice.textContent = message; 563 var button = document.createElement('button'); 564 button.type = 'button'; button.className = 'da-btn'; 565 button.textContent = t('droagent.retry', 'Retry'); 566 button.addEventListener('click', retry); 567 container.appendChild(notice); container.appendChild(button); 568 } 569 570 function renderConnections() { 571 var connected = state.connectors.filter(function (c) { return c.connected; }); 572 if (els.connCount) { els.connCount.hidden = !connected.length; els.connCount.textContent = String(connected.length); } 573 if (!els.connList) return; 574 var query = (els.connSearch && els.connSearch.value || '').trim().toLocaleLowerCase(); 575 var filter = state.connectionFilter; 576 document.querySelectorAll('[data-conn-filter]').forEach(function (button) { 577 button.setAttribute('aria-pressed', String(button.getAttribute('data-conn-filter') === filter)); 578 }); 579 var connectors = state.connectors.filter(function (c) { 580 if (filter === 'ready' && !c.configured && !c.connected) return false; 581 if (filter === 'connected' && !c.connected) return false; 582 if (filter === 'design' && c.category !== 'design') return false; 583 return !query || [c.name, c.vendor, c.blurb, c.label].concat(c.aliases || []).join(' ').toLocaleLowerCase().indexOf(query) !== -1; 584 }).sort(function (a, b) { return Number(b.connected) - Number(a.connected) || Number(b.configured) - Number(a.configured); }); 585 if (els.connResult) els.connResult.textContent = connectors.length + ' ' + t('droagent.services_shown', 'services shown'); 586 els.connList.innerHTML = ''; 587 if (!connectors.length) { 588 var empty = document.createElement('p'); empty.className = 'da-conn-empty'; 589 empty.textContent = query ? t('droagent.connections_no_match', 'No matching connections. Try another search or All services.') 590 : filter === 'connected' ? t('droagent.connections_no_connected', 'Your connected tools will appear here. Choose a service to get started.') 591 : t('droagent.connections_none', 'No services are ready yet. You can add your own MCP server.'); 592 els.connList.appendChild(empty); return; 593 } 594 connectors.forEach(function (c) { 595 var row = document.createElement('div'); 596 row.className = 'da-conn' + (c.connected ? ' is-on' : ''); 597 row.innerHTML = '<span class="da-conn-ic" aria-hidden="true"></span>' 598 + '<span class="da-conn-body"><b class="da-conn-name"></b><span class="da-conn-blurb"></span><span class="da-conn-state"></span></span>' 599 + '<span class="da-conn-actions"><button type="button" class="da-btn da-conn-btn"></button></span>'; 600 row.querySelector('.da-conn-ic').innerHTML = window.DP_DroAgentLogos.svg(c.id); 601 row.querySelector('.da-conn-name').textContent = c.name; 602 row.querySelector('.da-conn-blurb').textContent = c.blurb || ''; 603 row.querySelector('.da-conn-state').textContent = c.connected 604 ? t('droagent.connections_connected', 'Connected') + (c.label ? ' · ' + c.label : '') 605 : !c.configured ? c.setupNote || t('droagent.connection_needs_setup', 'Requires setup') 606 : c.auth === 'token' ? t('droagent.connection_api_key', 'API key') : t('droagent.connection_signin', 'Sign in to connect'); 607 var btn = row.querySelector('.da-conn-btn'); 608 btn.textContent = c.connected ? t('droagent.disconnect', 'Disconnect') 609 : !c.configured || c.custom ? t('droagent.set_up', 'Set up') 610 : c.auth === 'token' ? t('droagent.add_key', 'Add key') : t('droagent.connect', 'Connect'); 611 btn.setAttribute('aria-label', btn.textContent + ' · ' + c.name); 612 btn.addEventListener('click', function () { 613 if (c.connected) { toggleConnection(c, btn); return; } 614 if (!c.configured && c.setupAction === 'guide' && c.docsUrl) { window.open(c.docsUrl, '_blank', 'noopener,noreferrer'); return; } 615 if (!c.configured || c.custom) { showCustomConnection(c); return; } 616 if (c.auth === 'token') { askForToken(c, row, btn); return; } 617 toggleConnection(c, btn); 618 }); 619 if (c.connected) { 620 var preference = document.createElement('button'); preference.type = 'button'; preference.className = 'da-btn da-approval-pref'; 621 var always = c.approvalMode === 'always'; 622 preference.textContent = always ? 'Always approve â' : 'Ask before actions'; 623 preference.setAttribute('aria-pressed', String(always)); 624 preference.setAttribute('aria-label', 'Always approve · ' + c.name); 625 preference.addEventListener('click', async function () { 626 preference.disabled = true; 627 try { await Api.approvalMode(c.id, always ? 'ask' : 'always'); await loadConnections(); } 628 catch (err) { toast(err.message, { type: 'error' }); preference.disabled = false; } 629 }); 630 row.querySelector('.da-conn-actions').appendChild(preference); 631 } 632 els.connList.appendChild(row); 633 }); 634 } 635 636 function closeConnections() { 637 els.connSheet.hidden = true; 638 var form = $('da-conn-custom'); 639 if (form) form.reset(); 640 els.connSheet.querySelectorAll('input[type="password"]').forEach(function (input) { input.value = ''; }); 641 if (els.connect) els.connect.focus(); 642 } 643 644 function browseConnections() { 645 $('da-conn-custom').hidden = true; 646 $('da-conn-toolbar').hidden = false;
647 $('da-conn-title').textContent = t('droagent.connections_title', 'Connect your tools'); 648 $('da-conn-browse').hidden = false; 649 $('da-conn-custom-open').setAttribute('aria-expanded', 'false'); 650 els.connSearch.disabled = false; 651 $('da-conn-token').value = ''; 652 els.connSearch.focus(); 653 } 654 655 function showCustomConnection(connector) { 656 var form = $('da-conn-custom'); 657 form.reset(); form.hidden = false; 658 $('da-conn-toolbar').hidden = true; 659 $('da-conn-title').textContent = t('droagent.custom_title', 'Add an MCP server'); 660 $('da-conn-browse').hidden = true; 661 $('da-conn-custom-open').setAttribute('aria-expanded', 'true'); 662 els.connSearch.disabled = true; 663 $('da-conn-name').value = connector ? connector.name : ''; 664 $('da-conn-custom-hint').textContent = connector && !connector.custom 665 ? t('droagent.custom_provider_hint', 'Direct sign-in for this service needs setup. Connect a hosted MCP server for it using the URL from your provider.') 666 : t('droagent.custom_sub', 'Use the hosted MCP URL from your provider. Add a token only if the server requires one.'); 667 var guide = $('da-conn-custom-guide'); 668 guide.hidden = !connector || !connector.docsUrl; 669 if (!guide.hidden) guide.href = connector.docsUrl; 670 $('da-conn-custom-error').hidden = true; 671 (connector ? $('da-conn-url') : $('da-conn-name')).focus(); 672 } 673 674 function askForToken(c, row, opener) { 675 var existing = row.querySelector('.da-conn-token'); 676 if (existing) { existing.querySelector('input').focus(); return; } 677 var form = document.createElement('form'); form.className = 'da-conn-token'; 678 form.innerHTML = '<label></label><input type="password" autocomplete="off" spellcheck="false" maxlength="500" required>' 679 + '<button type="submit" class="da-btn da-btn-primary"></button><button type="button" class="da-btn"></button>' 680 + '<a class="da-conn-where" target="_blank" rel="noopener noreferrer"></a><p class="da-conn-feedback" role="status" hidden></p>'; 681 var input = form.querySelector('input'), submit = form.querySelector('button[type="submit"]'); 682 input.id = 'da-provider-key-' + c.id; 683 input.placeholder = t('droagent.token_ph', 'Paste your API token'); 684 var label = form.querySelector('label'); label.htmlFor = input.id; 685 label.textContent = c.name + ' · ' + t('droagent.connection_api_key', 'API key'); 686 submit.textContent = t('droagent.connect', 'Connect'); 687 var cancel = form.querySelector('button[type="button"]'); 688 cancel.textContent = t('droagent.cancel', 'Cancel'); 689 cancel.addEventListener('click', function () { input.value = ''; form.remove(); opener.focus(); }); 690 var where = form.querySelector('.da-conn-where'); 691 if (c.tokenDocsUrl) { where.href = c.tokenDocsUrl; where.textContent = t('droagent.token_where', 'Where do I get one?'); } 692 else where.remove(); 693 form.addEventListener('submit', async function (e) { 694 e.preventDefault(); if (submit.disabled || !input.value.trim()) return; 695 var account = state.account, feedback = form.querySelector('.da-conn-feedback'); 696 submit.disabled = true; feedback.hidden = true; 697 try { 698 await Api.connectToken(c.id, input.value.trim()); 699 input.value = ''; 700 if (account !== state.account) return; 701 toast(t('droagent.connected', 'Connected.'), { type: 'success' }); 702 await loadConnections(); 703 } catch (err) { 704 if (account !== state.account) return; 705 feedback.textContent = (err && err.message) || t('droagent.connect_failed', 'Could not start that connection.'); 706 feedback.hidden = false; 707 } finally { submit.disabled = false; } 708 }); 709 row.appendChild(form); input.focus(); 710 } 711 712 async function toggleConnection(connector, btn) { 713 if (btn.disabled) return; 714 var account = state.account, previousLabel = btn.textContent, popup = null; 715 btn.disabled = true; 716 btn.textContent = t('droagent.connection_working', 'Connectingâ¦'); 717 try { 718 if (connector.connected) { 719 await Api.disconnect(connector.id); 720 if (account !== state.account) return; 721 toast(t('droagent.disconnected', 'Disconnected.'), { type: 'info' }); 722 await loadConnections(); return; 723 } 724 saveDraft(); 725 // Reserve the popup inside the actual click, before awaiting the API. 726 // Safari otherwise blocks it and unexpectedly navigates away from chat. 727 popup = window.open('about:blank', 'dro-agent-connect', 'width=520,height=680'); 728 state.connectionPopup = popup;
729 try { if (popup) { popup.document.title = 'droPrompt'; popup.document.body.textContent = t('droagent.connection_working', 'Connectingâ¦'); } } catch (_) { /* a reused popup may still be navigating */ } 730 var data = await Api.connectStart(connector.id); 731 if (account !== state.account) { if (popup) popup.close(); return; } 732 var url = new URL((data && (data.url || (data.data && data.data.url))) || ''); 733 if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Invalid connection URL.'); 734 if (!popup) { location.href = url.href; return; } 735 if (popup.closed) return; 736 popup.location.replace(url.href); 737 } catch (err) { 738 if (popup) popup.close(); 739 if (state.connectionPopup === popup) state.connectionPopup = null; 740 if (account === state.account) toast((err && err.message) || t('droagent.connect_failed', 'Could not start that connection.'), { type: 'error' }); 741 } finally { btn.disabled = false; btn.textContent = previousLabel; } 742 } 743 744 async function addCustomServer(e) { 745 e.preventDefault(); 746 var btn = $('da-conn-add'), feedback = $('da-conn-custom-error'); 747 var label = ($('da-conn-name').value || '').trim(); 748 var url = ($('da-conn-url').value || '').trim(); 749 var tok = ($('da-conn-token').value || '').trim(); 750 if (!label || !url || btn.disabled) return; 751 var account = state.account; 752 btn.disabled = true; feedback.hidden = true; 753 try { 754 await Api.connectCustom({ label: label, serverUrl: url, token: tok }); 755 if (account !== state.account) return; 756 $('da-conn-custom').reset(); 757 state.connectionFilter = 'connected'; els.connSearch.value = ''; browseConnections(); 758 toast(t('droagent.connected', 'Connected.'), { type: 'success' }); 759 await loadConnections(); 760 } catch (err) { 761 if (account !== state.account) return; 762 feedback.textContent = (err && err.message) || t('droagent.connect_failed', 'Could not start that connection.'); feedback.hidden = false; 763 } finally { btn.disabled = false; } 764 } 765 766 // ââ memory ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 767 var MEM_KIND = { profile: 'droagent.mem_profile', preference: 'droagent.mem_pref', project: 'droagent.mem_project', style: 'droagent.mem_style' }; 768 var MEM_KIND_FB = { profile: 'About you', preference: 'How you like answers', project: 'What you are working on', style: 'How you write' }; 769 770 async function openMemory() { 771 if (!Api.isSignedIn()) { if (window.dpOpenAuth) window.dpOpenAuth('login'); return; } 772 var request = ++state.memoryRequest; 773 var account = state.account; 774 els.memSheet.hidden = false; 775 renderMemory(null); 776 try { 777 var data = await Api.memory(); 778 if (request !== state.memoryRequest || account !== state.account) return; 779 renderMemory((data && (data.memories || (data.data && data.data.memories))) || []); 780 } catch (_) { 781 if (request !== state.memoryRequest || account !== state.account) return; 782 showLoadError(els.memList, t('droagent.memory_failed', 'Could not load memory. Your saved memory has not changed.'), openMemory); 783 } 784 } 785 786 /** @param {Array|null} rows null renders the loading line. */ 787 function renderMemory(rows) { 788 if (!els.memList) return; 789 els.memList.innerHTML = ''; 790 if (els.memClear) els.memClear.hidden = !rows || !rows.length; 791 if (rows === null) { 792 els.memList.appendChild(Object.assign(document.createElement('p'), { 793 className: 'da-mem-empty', textContent: t('droagent.memory_loading', 'Readingâ¦'), 794 })); 795 return; 796 } 797 if (els.memClear) els.memClear.hidden = !rows.length; 798 if (!rows.length) { 799 els.memList.appendChild(Object.assign(document.createElement('p'), { 800 className: 'da-mem-empty',
801 textContent: t('droagent.memory_none', 'Nothing yet â it starts remembering once you have talked for a bit.'), 802 })); 803 return; 804 } 805 rows.forEach(function (m) { 806 var row = document.createElement('div'); 807 row.className = 'da-mem'; 808 row.innerHTML = '<span class="da-mem-body"><span class="da-mem-kind"></span><span class="da-mem-value" data-no-translate translate="no" dir="auto"></span></span>' 809 + '<button type="button" class="da-act da-mem-drop"></button>'; 810 row.querySelector('.da-mem-kind').textContent = t(MEM_KIND[m.kind] || MEM_KIND.profile, MEM_KIND_FB[m.kind] || MEM_KIND_FB.profile); 811 row.querySelector('.da-mem-value').textContent = m.value; 812 var drop = row.querySelector('.da-mem-drop'); 813 drop.textContent = t('droagent.forget', 'Forget'); 814 drop.addEventListener('click', function () { forget(m.key, row); }); 815 els.memList.appendChild(row); 816 }); 817 } 818 819 async function forget(key, row) { 820 var account = state.account; 821 if (!key) { 822 var ok = window.dpConfirm 823 ? await window.dpConfirm({ title: t('droagent.memory_clear', 'Forget everything'), message: t('droagent.memory_clear_sub', 'droAgent will start from nothing next time you write.'), confirmText: t('droagent.forget', 'Forget'), danger: true }) 824 : window.confirm('Forget everything?'); 825 if (!ok || account !== state.account) return; 826 } 827 if (account !== state.account) return; 828 try { 829 await Api.forget(key); 830 if (account !== state.account) return; 831 if (key && row) await openMemory(); 832 else renderMemory([]); 833 toast(t('droagent.forgotten', 'Forgotten.'), { type: 'info' }); 834 } catch (err) { 835 if (account !== state.account) return; 836 toast((err && err.message) || t('error.generic', 'Something went wrong. Try again.'), { type: 'error' }); 837 } 838 } 839 840 function focusComposer() { 841 if ((els.connSheet && !els.connSheet.hidden) || (els.memSheet && !els.memSheet.hidden)) return; 842 els.input.focus({ preventScroll: true }); 843 } 844 845 function openConnections() { 846 if (!Api.isSignedIn()) { if (window.dpOpenAuth) window.dpOpenAuth('login'); return; } 847 loadConnections(); 848 els.connSheet.hidden = false; 849 browseConnections(); 850 } 851 852 853 // ââ conversations rail ââââââââââââââââââââââââââââââââââââââââââââââââ 854 function isMobile() { return window.matchMedia('(max-width: 820px)').matches; } 855 856 function syncShellHeight() { 857 if (!els.main || !els.main.getBoundingClientRect) return; 858 var viewport = window.visualViewport; 859 var top = Math.max(0, els.main.getBoundingClientRect().top - (viewport ? viewport.offsetTop : 0)); 860 els.main.style.setProperty('--da-chrome-height', Math.ceil(top) + 'px'); 861 if (viewport) els.main.style.setProperty('--da-viewport-height', Math.floor(viewport.height) + 'px'); 862 } 863 864 function syncSidebar() { 865 var open = isMobile() ? els.main.classList.contains('is-drawer-open') : !els.main.classList.contains('is-rail-hidden'); 866 if (!open && els.sidebar.contains(document.activeElement) && els.menu) els.menu.focus(); 867 els.sidebar.inert = !open; 868 if (els.chat) els.chat.inert = isMobile() && open; 869 els.sidebar.setAttribute('aria-hidden', String(!open)); 870 if (els.menu) els.menu.setAttribute('aria-expanded', String(open)); 871 if (els.scrim) els.scrim.hidden = !isMobile() || !open; 872 } 873 874 /** Show or hide the rail on desktop; the drawer covers the mobile case. */ 875 function setRail(show) { 876 els.main.classList.toggle('is-rail-hidden', !show); 877 if (els.sideHide) els.sideHide.setAttribute('aria-expanded', String(!!show)); 878 syncSidebar(); 879 try { localStorage.setItem(RAIL_KEY, show ? 'shown' : 'hidden'); } catch (_) {} 880 } 881 882 function openDrawer() { 883 els.main.classList.add('is-drawer-open'); 884 syncSidebar(); 885 if (els.sideClose) els.sideClose.focus(); 886 } 887 function closeDrawer() { 888 if (!els.main.classList.contains('is-drawer-open')) return; 889 els.main.classList.remove('is-drawer-open'); 890 syncSidebar(); 891 if (els.menu) els.menu.focus(); 892 } 893 894 // ââ recording âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 895 function toggleRecording() { 896 if (state.sending || state.loading) return; 897 if (Files.isRecording()) Files.stopRecording(); 898 else Files.startRecording(); 899 } 900 function onRecording(info) { 901 els.record.classList.toggle('is-rec', !!info.recording); 902 els.record.setAttribute('aria-pressed', String(!!info.recording)); 903 els.record.setAttribute('aria-label', info.recording ? t('droagent.stop', 'Stop recording') : t('droagent.record', 'Record a voice message')); 904 if (els.recChip) { 905 els.recChip.hidden = !info.recording; 906 if (els.recTime) { 907 var s = info.seconds || 0; 908 els.recTime.textContent = Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); 909 } 910 } 911 } 912 913 // ââ conversations âââââââââââââââââââââââââââââââââââââââââââââââââââââ 914 function renderConversationList() { 915 syncConversationTools(); 916 var query = els.search ? els.search.value.trim().toLocaleLowerCase() : ''; 917 var list = state.conversations.filter(function (c) { return !query || String(c.title || '').toLocaleLowerCase().indexOf(query) !== -1; }); 918 Ui.renderConversations(list, state.conversationId, { 919 onOpen: openConversation, onDelete: deleteConversation, onRename: renameConversation, 920 emptyText: query ? t('droagent.no_matches', 'No matching chats.') : null, 921 }); 922 } 923 async function loadConversations() { 924 var request = ++state.historyRequest; 925 var account = state.account; 926 if (!state.conversations.length) Ui.renderConversations([], state.conversationId, { emptyText: t('droagent.history_loading', 'Loading chatsâ¦') }); 927 try { 928 var data = await Api.conversations(); 929 if (request !== state.historyRequest || account !== state.account) return; 930 state.conversations = (data && data.conversations) || []; 931 renderConversationList(); 932 } catch (err) { 933 if (request !== state.historyRequest || account !== state.account) return; 934 if (err.needsAuth) { onAuthChange(); els.gate.hidden = false; } 935 showLoadError(els.list, t('droagent.history_failed', 'Could not load your chats. Your saved chats have not changed.'), loadConversations); 936 } 937 } 938 939 async function openConversation(c, prefill) { 940 if (state.sending) return; 941 saveDraft(); 942 var importCompanion = !state.draftReady && new URLSearchParams(location.search).get('conversation') === String(c.id
942); 943 var request = ++state.selection; 944 var account = state.account; 945 state.loading = true; 946 if (Files.setLocked) Files.setLocked(true); 947 syncComposer(); closeDrawer(); 948 try { 949 var data = await Api.conversation(c.id); 950 if (request !== state.selection || account !== state.account) return; 951 persistConversation(c.id); 952 state.history = data.messages || []; 953 Ui.renderHistory(state.history); 954 Files.clear(); failedViews = null; 955 restoreDraft(); 956 // The floating companion uses the same account identity. Import only 957 // when the expanded chat has no draft of its own. 958 if (!els.input.value && !state.pending && importCompanion) { 959 try { 960 var companionKey = 'dp_companion_draft:' + state.account; 961 var companionDraft = sessionStorage.getItem(companionKey); 962 if (companionDraft) { 963 els.input.value = companionDraft.slice(0, 8000); saveDraft(); 964 if (sessionStorage.getItem(draftKey(state.conversationId))) sessionStorage.removeItem(companionKey); 965 autosize(); 966 } 967 } catch (_) {} 968 } 969 if (prefill) { els.input.value = prefill.slice(0, 8000); saveDraft(); autosize(); } 970 if (state.pending) { 971 if (savedReply(state.history, state.pending)) clearPendingDraft(); 972 else if (settledPending(state.pending)) dropSettledPending(); 973 else showPendingRecovery(); 974 } 975 renderConversationList(); 976 } catch (err) { 977 if (request !== state.selection || account !== state.account) return; 978 if (!state.draftReady) { 979 var typed = els.input.value; 980 persistConversation(null); restoreDraft(); 981 if (typed) { els.input.value = typed; saveDraft(); autosize(); } 982 } 983 toast(t('droagent.chat_open_failed', 'Could not open that chat. Please try again.'), { type: 'error' }); 984 } finally { 985 if (request === state.selection && account === state.account) { 986 state.loading = false; 987 if (Files.setLocked) Files.setLocked(false); 988 syncComposer(); focusComposer(); 989 } 990 } 991 } 992 993 async function deleteConversation(c) { 994 if (state.sending) return; 995 var account = state.account; 996 var ok = window.dpConfirm 997 ? await window.dpConfirm({ title: t('droagent.delete_title', 'Delete this chat?'), message: c.title || '', confirmText: t('droagent.delete', 'Delete'), danger: true }) 998 : window.confirm('Delete this chat?'); 999 if (!ok || account !== state.account) return; 1000 try { 1001 await Api.remove(c.id); 1002 if (account !== state.account) return; 1003 state.selection++; state.loading = false; 1004 if (Files.setLocked) Files.setLocked(false); 1005 syncComposer(); 1006 if (String(state.conversationId) === String(c.id)) newChat(); 1007 await loadConversations(); 1008 } catch (err) { if (account !== state.account) return; toast(err.message || 'Could not delete.', { type: 'error' }); } 1009 } 1010 1011 async function renameConversation(c) { 1012 if (state.sending) return; 1013 var account = state.account; 1014 var title = window.prompt(t('droagent.rename', 'Rename chat'), c.title || ''); 1015 if (title == null || account !== state.account) return; 1016 title = title.trim(); 1017 if (!title) return; 1018 try { 1019 await Api.rename(c.id, title); 1020 if (account !== state.account) return; 1021 await loadConversations(); 1022 } catch (err) { if (account !== state.account) return; toast(err.message || 'Could not rename.', { type: 'error' }); } 1023 } 1024 1025 function newChat() { 1026 if (state.sending) return; 1027 saveDraft(); 1028 state.selection++; state.loading = false; 1029 persistConversation(null); 1030 state.history = []; failedViews = null; 1031 Ui.clear(); Files.clear(); 1032 if (Files.setLocked) Files.setLocked(false); 1033 restoreDraft(); 1034 if (state.pending) showPendingRecovery(); 1035 renderConversationList(); focusComposer(); 1036 } 1037 1038 /** True once the server has certainly finished with a turn, answered or not. */ 1039 function settledPending(pending) { 1040 return Date.now() - ((pending && pending.at) || 0) >= STALE_PENDING_MS; 1041 } 1042 1043 /** 1044 * The turn is old, its chat has been read, and no reply was ever saved: it 1045 * produced nothing, so the notice has nothing left to warn about. The 1046 * request goes back to the composer instead of a red card that returns on 1047 * every visit. 1048 */ 1049 function dropSettledPending() { 1050 if (state.pending && state.pending.text && !els.input.value.trim()) els.input.value = state.pending.text; 1051 state.pending = null; state.unresolved = false;
1052 if (failedViews) { 1053 if (failedViews.user) failedViews.user.remove(); 1054 failedViews.bot.remove(); failedViews = null; 1055 } 1056 saveDraft(); autosize(); syncComposer(); 1057 } 1058 1059 function clearPendingDraft() { 1060 var pending = state.pending; 1061 var names = Files.list().map(function (a) { return a.name; }).join('|'); 1062 // The composer emptied when the turn went out, so anything in it now was 1063 // typed or attached since: that is a new draft, and it stays. 1064 var edited = !!els.input.value.trim() || !!(names && names !== ((pending && pending.fileNames) || []).join('|')); 1065 state.pending = null; state.unresolved = false; 1066 if (!edited) { els.input.value = ''; Files.clear(); } 1067 saveDraft(); autosize(); syncComposer(); 1068 } 1069 1070 function savedReply(messages, pending) { 1071 var before = (pending && pending.beforeIds) || []; 1072 for (var i = 0; i < messages.length - 1; i++) { 1073 var user = messages[i]; 1074 var assistant = messages[i + 1]; 1075 if (user.role !== 'user' || assistant.role !== 'assistant' || !assistant.content) continue; 1076 if (before.indexOf(String(user.id)) !== -1 || before.indexOf(String(assistant.id)) !== -1) continue; 1077 if (pending.text && user.content === pending.text) return true; 1078 if (!pending.text && pending.fileNames && pending.fileNames.length && Array.isArray(user.attachments) 1079 && pending.fileNames.join('|') === user.attachments.map(function (a) { return a.name; }).join('|')) return true; 1080 } 1081 return false; 1082 } 1083 1084 /** 1085 * The chat a dropped turn landed in when the page never learned its id: the 1086 * server creates the conversation before it answers, so the newest one 1087 * touched since the request went out is that turn's. 1088 */ 1089 function conversationSince(pending) { 1090 var sent = (pending && pending.at) || 0; 1091 for (var i = 0; i < state.conversations.length; i++) { // newest first 1092 var touched = Date.parse(state.conversations[i].last_message_at || state.conversations[i].updated_at || ''); 1093 if (touched && touched >= sent - 60000) return state.conversations[i]; 1094 } 1095 return null; 1096 } 1097 1098 async function checkSavedReply(options) { 1099 var quiet = !!(options && options.quiet); 1100 var missing = function () { 1101 if (settledPending(state.pending)) return dropSettledPending(); 1102 if (!quiet) toast(t('droagent.no_saved_reply', 'No completed reply is saved yet. Check again shortly, or edit your request to send a new message.'), { type: 'info', duration: 7000 }); 1103 }; 1104 var account = state.account; 1105 var pending = state.pending; 1106 if (!pending) return; 1107 if (!state.conversationId) { 1108 await loadConversations(); 1109 if (account !== state.account || pending !== state.pending) return; 1110 var landed = conversationSince(pending); 1111 if (!landed) { missing(); return; } 1112 try { 1113 var found = await Api.conversation(landed.id); 1114 if (account !== state.account || pending !== state.pending || state.conversationId) return; 1115 if (!savedReply(found.messages || [], pending)) { missing(); return; } 1116 // Clear before adopting the chat: the notice is stored under the key 1117 // this turn was sent with, and that key is about to change. 1118 clearPendingDraft(); 1119 persistConversation(landed.id); 1120 state.history = found.messages || []; 1121 Ui.renderHistory(state.history); failedViews = null; 1122 syncCredits({}); loadConversations(); 1123 } catch (_) { 1124 if (!quiet) toast(t('droagent.chat_open_failed', 'Could not open that chat. Please try again.'), { type: 'error' }); 1125 } 1126 return; 1127 } 1128 var id = state.conversationId; 1129 try { 1130 var data = await Api.conversation(id); 1131 if (account !== state.account || id !== state.conversationId || pending !== state.pending) return; 1132 if (savedReply(data.messages || [], pending)) { 1133 state.history = data.messages || []; 1134 Ui.renderHistory(state.history); failedViews = null; 1135 clearPendingDraft(); syncCredits({}); loadConversations(); 1136 } else { 1137 missing(); 1138 } 1139 } catch (_) { toast(t('droagent.chat_open_failed', 'Could not open that chat. Please try again.'), { type: 'error' }); } 1140 } 1141 1142 function editPendingRequest() { 1143 if (state.sending) return; 1144 if (state.pending && state.pending.text && !els.input.value.trim()) els.input.value = state.pending.text; 1145 state.unresolved = false; state.pending = null; 1146 autosize(); 1147 if (failedViews) { 1148 if (failedViews.user) failedViews.user.remove(); 1149 failedViews.bot.remove(); failedViews = null; 1150 } 1151 saveDraft(); syncComposer(); focusComposer(); 1152 } 1153
1154 function recoveryActions(ambiguous) { 1155 var actions = []; 1156 if (ambiguous) actions.push({ label: t('droagent.check_saved', 'Check saved chat'), onClick: checkSavedReply }); 1157 else actions.push({ label: t('droagent.retry', 'Retry'), onClick: function () { editPendingRequest(); send(); } }); 1158 actions.push({ label: t('droagent.edit_request', 'Edit request'), onClick: editPendingRequest }); 1159 return actions; 1160 } 1161 1162 function showPendingRecovery() { 1163 var bot = Ui.beginAssistant(); 1164 bot.fail(t('droagent.dropped', 'The connection ended before completion. The reply may still be saved; check the chat before sending again.'), { actions: recoveryActions(true) }); 1165 failedViews = { bot: bot, user: null }; 1166 } 1167 1168 // ââ one turn ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1169 async function send(trialSource, sample, trial) { 1170 // DOM event listeners pass an Event; only an explicit output starts Try. 1171 if (!trialSource || typeof trialSource.content !== 'string') trialSource = null; 1172 if (state.sending || state.loading || state.unresolved) return; 1173 if (!Api.isSignedIn()) { 1174 toast(t('droagent.signin_toast', 'Sign in to chat with droAgent.'), { type: 'info' }); 1175 if (window.dpOpenAuth) window.dpOpenAuth('login'); 1176 return; 1177 } 1178 if (Files.isRecording()) { Files.stopRecording(); return; } 1179 if (Files.busy()) { toast(t('droagent.wait_files', 'Your files are still being preparedâ¦'), { type: 'info' }); return; } 1180 var text = trialSource ? (lang() === 'ar' ? 'Ø¬Ø±ÙØ¨: ' : 'Try: ') + trialSource.title + (sample ? '\n\n' + sample : '') : els.input.value.trim(); 1181 var attachments = trialSource ? [] : Files.list(); 1182 if (!text && !attachments.length) return; 1183 if (failedViews) { if (failedViews.user) failedViews.user.remove(); failedViews.bot.remove(); failedViews = null; } 1184 1185 var account = state.account; 1186 var controller = new AbortController(); 1187 state.controller = controller; 1188 state.sending = true; 1189 state.pending = { text: text, trial: !!trialSource, at: Date.now(), fileNames: attachments.map(function (a) { return a.name; }), beforeIds: state.history.map(function (m) { return String(m.id); }) }; 1190 saveDraft(); 1191 if (Files.setLocked) Files.setLocked(true); 1192 closeEffort(false); syncComposer(); 1193 var previews = attachments.map(function (a) { 1194 return { name: a.name, kind: a.kind, previewUrl: a.kind === 'image' ? ('data:' + a.mime + ';base64,' + a.data) : '' }; 1195 }); 1196 var userView = Ui.addUser({ content: text, attachments: previews }); 1197 var body = { 1198 conversationId: state.conversationId, 1199 message: text, 1200 taskId: !trialSource && state.refinement && text.includes(state.refinement.lead) ? state.refinement.id : undefined, 1201 intent: trialSource ? 'try' : 'chat', 1202 trial: trialSource ? trial : undefined, 1203 context: trialSource ? { builder: trialSource.kind, output: trialSource.content, idea: '' } : undefined, 1204 attachments: attachments.map(function (a) { return { name: a.name, mime: a.mime, data: a.data }; }), 1205 locale: lang(), page: pageFromReferrer(), effort: trialSource ? 'balanced' : state.effort, 1206 }; 1207 // The composer empties as the message leaves, the way every chat composer 1208 // does. The text is kept on the pending record, so a turn that never lands 1209 // can still be put back for editing or a retry. 1210 if (!trialSource) { els.input.value = ''; autosize(); saveDraft(); } 1211 var bot = Ui.beginAssistant(); 1212 var finished = false; 1213 var suggestions = null; 1214 function current() { return account === state.account && state.controller === controller; } 1215 1216 function onEvent(type, data) { 1217 if (!current() || finished) return; 1218 data = data || {}; 1219 switch (type) { 1220 case 'status': bot.setStatus(data.stage); break; 1221 case 'conversation': 1222 if (data.id) persistConversation(data.id); 1223 if (data.isNew) loadConversations(); 1224 break; 1225 case 'perception': userView.setPerception(data.attachments); break; 1226 case 'delta': bot.append(data.text); break; 1227 case 'replace': bot.replace(data.text); break; 1228 case 'artifact': bot.addArtifact(data); break; 1229 case 'tool_start': bot.setToolProgress(data); break; 1230 case 'tool': bot.addToolCall(data); break; 1231 case 'suggestions': suggestions = data.features || []; bot.setSuggestions(suggestions); break; 1232 case 'done': finished = true; complete(data.data || {}, data); break; 1233 case 'error': finished = true; failTurn(data, false); break; 1234 default: break; 1235 } 1236 } 1237 function complete(result, envelope) { 1238 bot.finish({ id: result.assistantMessageId, content: result.reply, toolCalls: result.toolCalls || [], suggestions: result.suggestions || suggestions, tasks: result.tasks || [] }); 1239 if (result.conversationId) persistConversation(result.conversationId); 1240 if (result.messageId) state.history.push({ id: result.messageId, role: 'user', content: text }); 1241 if (result.assistantMessageId) state.history.push({ id: result.assistantMessageId, role: 'assistant', content: result.reply, toolCalls: result.toolCalls || [], tasks: result.tasks || [] }); 1242 clearPendingDraft(); 1243 syncCredits(envelope || result); loadConversations(); 1244 } 1245 function failTurn(err, ambiguous) { 1246 var code = err && err.code; 1247 var message = (err && (err.error || err.message)) || t('error.generic', 'Something went wrong. Try again.');
1248 if (ambiguous) { 1249 if (code === 'STREAM_ABORTED') message = t('droagent.stopped', 'Stopped receiving. The reply may still finish and use credits. Check saved chat before sending again.'); 1250 else if (code === 'STREAM_TIMEOUT') message = t('droagent.timeout', 'The reply timed out. It may still finish and use credits. Check saved chat before sending again.'); 1251 else message = t('droagent.dropped', 'The connection ended before completion. The reply may still be saved; check the chat before sending again.'); 1252 } 1253 state.unresolved = ambiguous; 1254 if (!ambiguous) state.pending = null; 1255 saveDraft(); 1256 failedViews = { user: userView, bot: bot }; 1257 var actions = recoveryActions(ambiguous); 1258 if (trialSource && !ambiguous) actions = [{ label: lang() === 'ar' ? '٠راجعة Ø§ÙØªØ¬Ø±Ø¨Ø©' : 'Review trial', onClick: function () { editPendingRequest(); window.DP_OutputTry.open(trialSource).catch(window.DP_OutputTry.report); } }]; 1259 bot.fail(message, { actions: actions, cancelled: code === 'STREAM_ABORTED' }); 1260 if (code === 'PLAN_FEATURE_NOT_INCLUDED' || code === 'FREE_DAILY_CAP_REACHED' || code === 'DAILY_LIMIT') { 1261 toast(message, { type: 'warning', duration: 6000, action: { label: t('nav.upgrade', 'Upgrade'), href: '/pricing' } }); 1262 } else if (err && (err.needsAuth || err.status === 401 || code === 'AUTH_REQUIRED')) { 1263 els.gate.hidden = false; 1264 if (window.dpOpenAuth) window.dpOpenAuth('login'); 1265 } 1266 syncCredits({}); loadConversations(); 1267 } 1268 try { 1269 await Api.chatStream(body, onEvent, { signal: controller.signal }); 1270 if (!finished && current()) failTurn({ code: 'STREAM_INCOMPLETE' }, true); 1271 } catch (err) { 1272 if (!finished && current()) failTurn(err, !err.status || err.status >= 500); 1273 } finally { 1274 if (current()) { 1275 state.sending = false; state.controller = null; 1276 if (Files.setLocked) Files.setLocked(false); 1277 syncComposer(); focusComposer(); 1278 } 1279 } 1280 return true; 1281 } 1282 1283 function syncCredits(payload) { 1284 try { if (window.dpSyncCreditStateFromResponse) window.dpSyncCreditStateFromResponse(payload); } catch (_) {} 1285 try { 1286 var user = window.dpCreditSystem && window.dpCreditSystem.readCachedCreditUser ? window.dpCreditSystem.readCachedCreditUser() : null; 1287 if (user && window.dpRenderCreditMeter) window.dpRenderCreditMeter(user); 1288 } catch (_) {} 1289 if (window.DP_HomeChatEmbed) document.dispatchEvent(new CustomEvent('dp:chat-usage')); 1290 } 1291 1292 function pageFromReferrer() { 1293 try { 1294 var ref = document.referrer ? new URL(document.referrer) : null; 1295 if (ref && ref.origin === location.origin && ref.pathname && ref.pathname !== location.pathname) return ref.pathname.slice(0, 120); 1296 } catch (_) {} 1297 return location.pathname; 1298 } 1299 1300 1301 function openFeature(f) { 1302 if (!f || !f.href) return; 1303 var url; 1304 try { url = new URL(f.href, location.origin); } catch (_) { return; } 1305 if (url.protocol !== 'https:' && url.protocol !== 'http:') return; 1306 if (url.origin !== location.origin) { 1307 if (f.external) window.open(url.href, '_blank', 'noopener,noreferrer'); 1308 return; 1309 } 1310 var allowed = ['dro_prefill_prompt', 'dp:wf:stash', 'dp:da:handoff:agent', 'dp:da:handoff:skill', 'dp:da:handoff:automation']; 1311 var prefill = f.prefill; 1312 if (prefill) { 1313 var writes = []; 1314 var previous = [];
1315 try { 1316 if (prefill.storage !== 'session' || allowed.indexOf(prefill.key) === -1 || typeof prefill.value !== 'string') throw new Error('Invalid handoff'); 1317 writes.push([prefill.key, prefill.value]); 1318 if (prefill.key === 'dro_prefill_prompt') { 1319 writes.push(['dro_prefill_mode', prefill.mode === 'refine' ? 'refine' : 'new']); 1320 writes.push(['dro_prefill_category', ['general', 'image', 'video'].indexOf(prefill.category) !== -1 ? prefill.category : 'general']); 1321 writes.push(['dro_prefill_source', 'dro-agent']); 1322 } 1323 previous = writes.map(function (entry) { return [entry[0], sessionStorage.getItem(entry[0])]; }); 1324 writes.forEach(function (entry) { 1325 sessionStorage.setItem(entry[0], entry[1]); 1326 if (sessionStorage.getItem(entry[0]) !== entry[1]) throw new Error('Handoff was not saved'); 1327 }); 1328 } catch (_) { 1329 previous.forEach(function (entry) { try { if (entry[1] === null) sessionStorage.removeItem(entry[0]); else sessionStorage.setItem(entry[0], entry[1]); } catch (_) {} }); 1330 toast(t('droagent.handoff_failed', 'Could not transfer the prompt. Copy it from this reply and paste it into the builder.'), { type: 'error', duration: 7000 }); 1331 return; 1332 } 1333 } 1334 1335 saveDraft(); 1336 var homeBuilder = url.pathname === '/' || url.pathname === '/index.html'; 1337 if (homeBuilder && !url.hash) url.hash = 'builders'; 1338 if (window.DP_HomeChatEmbed) { 1339 // This mode is enabled only for the same-origin homepage parent. 1340 if (homeBuilder && typeof window.parent.dpOpenHomePromptBuilder === 'function' && 1341 window.parent.dpOpenHomePromptBuilder({ prefill: !!(prefill && prefill.key === 'dro_prefill_prompt'), category: url.searchParams.get('cat') })) return; 1342 window.parent.location.href = url.href; 1343 return; 1344 } 1345 location.href = url.href; 1346 } 1347 1348 window.DP_DroAgentApp = { init: init }; 1349})();
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.