@aliwey/bmo 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
@@ -0,0 +1,1293 @@
1
+ lucide.createIcons();
2
+
3
+ const socket = io();
4
+ const chatArea = document.getElementById('chatArea');
5
+ const emptyState = document.getElementById('emptyState');
6
+ const input = document.getElementById('msgInput');
7
+ const sendBtn = document.getElementById('sendBtn');
8
+ const typingEl = document.getElementById('typingIndicator');
9
+ const statusDot = document.getElementById('statusDot');
10
+ const statusText = document.getElementById('statusText');
11
+ const connBanner = document.getElementById('connBanner');
12
+ const sessionList = document.getElementById('sessionList');
13
+ const sidebar = document.getElementById('sidebar');
14
+ const sidebarOverlay = document.getElementById('sidebarOverlay');
15
+
16
+ let sessions = [];
17
+ let activeSessionId = null;
18
+ let currentChatId = null;
19
+ let disconnectTimer = null;
20
+ let currentModel = localStorage.getItem('selectedModel') || 'opencode/big-pickle';
21
+ let currentAgent = localStorage.getItem('selectedAgent') || 'None';
22
+
23
+ // Socket.IO
24
+ socket.on('connect', () => {
25
+ clearTimeout(disconnectTimer);
26
+ connBanner.classList.remove('show');
27
+ if (activeSessionId) socket.emit('set_session', activeSessionId);
28
+ setStatus('online');
29
+ });
30
+
31
+ socket.on('disconnect', () => {
32
+ disconnectTimer = setTimeout(() => {
33
+ connBanner.classList.add('show');
34
+ }, 2000);
35
+ setStatus('offline');
36
+ });
37
+
38
+ socket.on('message', (msg) => {
39
+ try {
40
+ if (msg.session_id && msg.session_id !== activeSessionId) {
41
+ console.log('[socket] filtered message: session_id', msg.session_id, '!= active', activeSessionId);
42
+ return;
43
+ }
44
+ emptyState.style.display = 'none';
45
+ const div = document.createElement('div');
46
+ div.className = 'msg ' + (msg.role || 'bmo');
47
+ if (msg.id) div.setAttribute('data-ts', msg.id);
48
+ div.innerHTML = renderMessage(msg.text, msg.reasoning) + `<div class="time">${new Date(msg.time).toLocaleTimeString()}</div>`;
49
+ chatArea.appendChild(div);
50
+ lucide.createIcons();
51
+ chatArea.scrollTop = chatArea.scrollHeight;
52
+ if (msg.role === 'assistant' || msg.role === 'bmo') {
53
+ setStatus('online');
54
+ const liveEl = document.getElementById('liveReasoning');
55
+ if (liveEl) liveEl.remove();
56
+ if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
57
+ }
58
+ console.log('[socket] rendered message:', msg.role, (msg.text || '').slice(0, 40));
59
+ } catch (e) {
60
+ console.error('[socket] message handler error:', e);
61
+ }
62
+ });
63
+
64
+ socket.on('bmo_status', (data) => {
65
+ if (data.status === 'typing') setStatus('typing');
66
+ else setStatus('online');
67
+ });
68
+
69
+ socket.on('error', (data) => {
70
+ showToast(data.message || 'An error occurred', 'error');
71
+ });
72
+
73
+ socket.on('reasoning', (data) => {
74
+ if (data.session_id && data.session_id !== activeSessionId) return;
75
+ if (!data.text) {
76
+ const el = document.getElementById('liveReasoning');
77
+ if (el) el.remove();
78
+ return;
79
+ }
80
+ let el = document.getElementById('liveReasoning');
81
+ if (!el) {
82
+ el = document.createElement('div');
83
+ el.id = 'liveReasoning';
84
+ el.className = 'msg bmo reasoning-live';
85
+ chatArea.appendChild(el);
86
+ emptyState.style.display = 'none';
87
+ }
88
+ el.innerHTML = `<details class="thinking-block" open><summary><i data-lucide="sparkles" class="inline-icon"></i> Thinking...</summary><div class="thinking-content">${escapeHtml(data.text).replace(/\n/g, '<br>')}</div></details>`;
89
+ chatArea.scrollTop = chatArea.scrollHeight;
90
+ lucide.createIcons();
91
+ });
92
+
93
+ socket.on('session_title_updated', (data) => {
94
+ const session = sessions.find(s => s.session_id === data.session_id);
95
+ if (session) {
96
+ session.title = data.title;
97
+ renderSidebar();
98
+ }
99
+ });
100
+
101
+ // Sidebar Toggle
102
+ document.getElementById('sidebarToggle').onclick = () => {
103
+ sidebar.classList.toggle('collapsed');
104
+ sidebarOverlay.classList.toggle('show');
105
+ updateSidebarIcon();
106
+ };
107
+
108
+ document.getElementById('sidebarClose').onclick = () => {
109
+ sidebar.classList.add('collapsed');
110
+ sidebarOverlay.classList.remove('show');
111
+ updateSidebarIcon();
112
+ };
113
+
114
+ sidebarOverlay.onclick = () => {
115
+ sidebar.classList.add('collapsed');
116
+ sidebarOverlay.classList.remove('show');
117
+ updateSidebarIcon();
118
+ };
119
+
120
+ function updateSidebarIcon() {
121
+ const toggleBtn = document.getElementById('sidebarToggle');
122
+ const isCollapsed = sidebar.classList.contains('collapsed');
123
+ const svg = toggleBtn.querySelector('svg');
124
+ if (svg) svg.remove();
125
+ const i = document.createElement('i');
126
+ i.setAttribute('data-lucide', isCollapsed ? 'panel-left-open' : 'panel-left-close');
127
+ toggleBtn.appendChild(i);
128
+ lucide.createIcons();
129
+ }
130
+
131
+ // ── URL Param Session Loading ───────────────────────────────────
132
+ function getUrlParams() {
133
+ const params = new URLSearchParams(window.location.search);
134
+ return {
135
+ chat_id: params.get('chat_id'),
136
+ session_id: params.get('session_id'),
137
+ };
138
+ }
139
+
140
+ async function initFromUrlParams() {
141
+ const { chat_id, session_id } = getUrlParams();
142
+ if (chat_id && session_id) {
143
+ try {
144
+ const r = await fetch('/api/set-session', {
145
+ method: 'POST',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({ chat_id: parseInt(chat_id), session_id })
148
+ });
149
+ if (r.ok) {
150
+ currentChatId = parseInt(chat_id);
151
+ _sessionIdFromUrl = session_id;
152
+ activeSessionId = session_id;
153
+ const profileId = document.getElementById('profileId');
154
+ if (profileId) profileId.textContent = `ID: ${currentChatId}`;
155
+ await loadSessions();
156
+ await loadMessages(session_id);
157
+ socket.emit('set_session', session_id);
158
+ return true;
159
+ }
160
+ } catch (e) {
161
+ console.error('Failed to load session from URL params:', e);
162
+ }
163
+ }
164
+ return false;
165
+ }
166
+
167
+ async function initFallbackSession() {
168
+ try {
169
+ await loadSessions();
170
+ if (activeSessionId) {
171
+ const profileId = document.getElementById('profileId');
172
+ if (profileId && currentChatId) profileId.textContent = `ID: ${currentChatId}`;
173
+ await loadMessages(activeSessionId);
174
+ socket.emit('set_session', activeSessionId);
175
+ }
176
+ } catch (e) {
177
+ console.error('Fallback session load failed:', e);
178
+ }
179
+ }
180
+
181
+ // Sessions
182
+ let _sessionIdFromUrl = null;
183
+
184
+ async function loadSessions() {
185
+ try {
186
+ const cid = currentChatId || '';
187
+ const r = await fetch(`/api/sessions?chat_id=${cid}`);
188
+ sessions = await r.json();
189
+ renderSidebar();
190
+ const active = sessions.find(s => s.is_active);
191
+ if (active) activeSessionId = active.session_id;
192
+ else if (sessions.length && !_sessionIdFromUrl) activeSessionId = sessions[0].session_id;
193
+ document.getElementById('profileSessions').textContent = sessions.length;
194
+ } catch (e) {}
195
+ }
196
+
197
+ function renderSidebar() {
198
+ sessionList.innerHTML = sessions.map((s, i) => `
199
+ <div class="session-item ${s.session_id === activeSessionId ? 'active' : ''}"
200
+ data-sid="${s.session_id}" style="animation-delay: ${0.25 + i * 0.05}s">
201
+ <div class="session-title">
202
+ <span class="dot"></span>
203
+ ${escapeHtml(s.title)}
204
+ </div>
205
+ <div class="session-meta">
206
+ <i data-lucide="message-circle"></i>
207
+ ${s.msg_count} msgs · ${formatTime(s.updated_at)}
208
+ </div>
209
+ </div>
210
+ `).join('');
211
+ sessionList.querySelectorAll('.session-item').forEach(el => {
212
+ el.addEventListener('click', () => switchSession(el.dataset.sid));
213
+ });
214
+ lucide.createIcons();
215
+ }
216
+
217
+ function formatTime(ts) {
218
+ if (!ts) return '';
219
+ const d = new Date(ts * 1000);
220
+ const now = new Date();
221
+ const diff = now - d;
222
+ if (diff < 60000) return 'now';
223
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
224
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
225
+ return d.toLocaleDateString();
226
+ }
227
+
228
+ async function switchSession(sid) {
229
+ await fetch('/api/switch-session', {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({ chat_id: currentChatId, session_id: sid })
233
+ });
234
+ activeSessionId = sid;
235
+ await loadSessions();
236
+ await loadMessages(sid);
237
+ socket.emit('set_session', sid);
238
+ // Close sidebar on mobile
239
+ if (window.innerWidth < 768) {
240
+ sidebar.classList.add('collapsed');
241
+ sidebarOverlay.classList.remove('show');
242
+ }
243
+ }
244
+
245
+ // Messages
246
+ async function loadMessages(sid) {
247
+ if (!sid) { chatArea.innerHTML = ''; emptyState.style.display = 'block'; return; }
248
+ try {
249
+ const r = await fetch(`/api/messages?session_id=${sid}&limit=100`);
250
+ const msgs = await r.json();
251
+ chatArea.innerHTML = '';
252
+ if (msgs.length === 0) { emptyState.style.display = 'block'; return; }
253
+ emptyState.style.display = 'none';
254
+ msgs.forEach(m => {
255
+ const div = document.createElement('div');
256
+ div.className = 'msg ' + (m.sender === 'user' ? 'user' : 'bmo');
257
+ div.setAttribute('data-ts', m.timestamp);
258
+ div.innerHTML = renderMessage(m.content, m.reasoning) + `<div class="time">${new Date(m.timestamp * 1000).toLocaleTimeString()}</div>`;
259
+ chatArea.appendChild(div);
260
+ });
261
+ chatArea.scrollTop = chatArea.scrollHeight;
262
+ lucide.createIcons();
263
+ } catch (e) { console.error('Failed to load messages:', e); }
264
+ }
265
+
266
+ function renderMessage(text, reasoning) {
267
+ let html = '';
268
+
269
+ // Thinking block
270
+ if (reasoning) {
271
+ html += `<details class="thinking-block"><summary><i data-lucide="brain" class="inline-icon"></i> Thinking</summary><div class="thinking-content">${escapeHtml(String(reasoning)).replace(/\n/g, '<br>')}</div></details>`;
272
+ }
273
+
274
+ // Markdown rendering
275
+ const content = String(text || '');
276
+ if (typeof marked !== 'undefined') {
277
+ // Configure marked
278
+ marked.setOptions({
279
+ highlight: function(code, lang) {
280
+ if (lang && typeof hljs !== 'undefined' && hljs.getLanguage(lang)) {
281
+ try {
282
+ return hljs.highlight(code, { language: lang }).value;
283
+ } catch (e) {}
284
+ }
285
+ if (typeof hljs !== 'undefined') {
286
+ try {
287
+ return hljs.highlightAuto(code).value;
288
+ } catch (e) {}
289
+ }
290
+ return escapeHtml(code);
291
+ },
292
+ langPrefix: 'hljs language-',
293
+ breaks: true,
294
+ gfm: true
295
+ });
296
+
297
+ // Custom renderer for code blocks with copy button and line numbers
298
+ const renderer = new marked.Renderer();
299
+ const originalCode = renderer.code.bind(renderer);
300
+ renderer.code = function(token) {
301
+ const code = token.text;
302
+ const lang = token.lang || 'text';
303
+ let highlighted;
304
+ if (typeof hljs !== 'undefined' && hljs.getLanguage(lang)) {
305
+ try {
306
+ highlighted = hljs.highlight(code, { language: lang }).value;
307
+ } catch (e) {
308
+ highlighted = escapeHtml(code);
309
+ }
310
+ } else if (typeof hljs !== 'undefined') {
311
+ try {
312
+ highlighted = hljs.highlightAuto(code).value;
313
+ } catch (e) {
314
+ highlighted = escapeHtml(code);
315
+ }
316
+ } else {
317
+ highlighted = escapeHtml(code);
318
+ }
319
+
320
+ // Add line numbers
321
+ const lines = highlighted.split('\n');
322
+ const lineNumbers = lines.map((_, i) => `<span class="line-number">${i + 1}</span>`).join('\n');
323
+
324
+ return `<div class="code-block-wrapper">
325
+ <div class="code-header">
326
+ <span class="code-lang">${escapeHtml(lang)}</span>
327
+ <button class="copy-btn" onclick="copyCode(this)"><i data-lucide="copy" class="inline-icon"></i> Copy</button>
328
+ </div>
329
+ <div class="code-block-with-lines">
330
+ <div class="line-numbers">${lineNumbers}</div>
331
+ <pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>
332
+ </div>
333
+ </div>`;
334
+ };
335
+
336
+ marked.setOptions({ renderer });
337
+ html += `<div class="md-content">${marked.parse(content)}</div>`;
338
+ } else {
339
+ // Fallback: basic rendering if marked not loaded
340
+ html += content.replace(/<pre>([\s\S]*?)<\/pre>/g, '<div class="code-block"><code>$1</code></div>').replace(/\n/g, '<br>');
341
+ }
342
+
343
+ return html;
344
+ }
345
+
346
+ // Copy code function
347
+ function copyCode(btn) {
348
+ const wrapper = btn.closest('.code-block-wrapper');
349
+ const code = wrapper.querySelector('code');
350
+ const text = code.textContent;
351
+ navigator.clipboard.writeText(text).then(() => {
352
+ const originalHTML = btn.innerHTML;
353
+ btn.innerHTML = '<i data-lucide="check" class="inline-icon"></i> Copied!';
354
+ btn.classList.add('copied');
355
+ setTimeout(() => {
356
+ btn.innerHTML = originalHTML;
357
+ btn.classList.remove('copied');
358
+ lucide.createIcons();
359
+ }, 2000);
360
+ lucide.createIcons();
361
+ });
362
+ }
363
+
364
+ // Modal System
365
+ function openModal(id) {
366
+ document.getElementById(id).classList.add('show');
367
+ document.body.style.overflow = 'hidden';
368
+ }
369
+
370
+ function closeModal(id) {
371
+ document.getElementById(id).classList.remove('show');
372
+ document.body.style.overflow = '';
373
+ }
374
+
375
+ document.querySelectorAll('[data-close]').forEach(btn => {
376
+ btn.onclick = () => closeModal(btn.dataset.close);
377
+ });
378
+
379
+ document.querySelectorAll('.modal-overlay').forEach(overlay => {
380
+ overlay.onclick = (e) => {
381
+ if (e.target === overlay) closeModal(overlay.id);
382
+ };
383
+ });
384
+
385
+ // Models Modal
386
+ document.getElementById('btnModels').onclick = async () => {
387
+ openModal('modelsModal');
388
+ const body = document.getElementById('modelsBody');
389
+ body.innerHTML = '<div class="modal-loading"><i data-lucide="loader-2" class="spin"></i> Loading models...</div>';
390
+ lucide.createIcons();
391
+
392
+ try {
393
+ const r = await fetch('/api/models');
394
+ const data = await r.json();
395
+ const providers = data.providers || [];
396
+
397
+ if (!providers.length) {
398
+ body.innerHTML = '<div class="modal-empty"><i data-lucide="alert-circle"></i><p>No models available. Add API keys in settings first.</p></div>';
399
+ lucide.createIcons();
400
+ return;
401
+ }
402
+
403
+ renderModelPage(body, providers, 0, 0);
404
+ } catch (e) {
405
+ body.innerHTML = '<div class="modal-empty"><i data-lucide="alert-triangle"></i><p>Failed to load models</p></div>';
406
+ lucide.createIcons();
407
+ }
408
+ };
409
+
410
+ function renderModelPage(body, providers, pIdx, mPage) {
411
+ const modelsPerPage = 10;
412
+ const currentP = providers[pIdx];
413
+ const allModels = currentP.models || [];
414
+ const maxMPage = Math.max(0, Math.ceil(allModels.length / modelsPerPage) - 1);
415
+ mPage = Math.max(0, Math.min(mPage, maxMPage));
416
+
417
+ const start = mPage * modelsPerPage;
418
+ const end = start + modelsPerPage;
419
+ const pageModels = allModels.slice(start, end);
420
+
421
+ let html = `
422
+ <div class="model-header-bar">
423
+ <span class="model-page-title"><i data-lucide="cpu"></i> Select a Model</span>
424
+ <span class="model-page-info">${escapeHtml(currentP.name)} (${pIdx + 1}/${providers.length}) · Page ${mPage + 1}/${maxMPage + 1}</span>
425
+ </div>
426
+ <div class="model-grid">
427
+ `;
428
+
429
+ for (const model of pageModels) {
430
+ const isSelected = currentModel === model.id;
431
+ html += `
432
+ <div class="model-card ${isSelected ? 'selected' : ''}" data-model="${model.id}" data-provider="${model.pid}">
433
+ <div class="model-name">${model.is_free ? '<i data-lucide="gift" class="inline-icon"></i> ' : '<i data-lucide="zap" class="inline-icon"></i> '}${escapeHtml(model.name)}</div>
434
+ <div class="model-provider">${escapeHtml(model.pid)}</div>
435
+ </div>
436
+ `;
437
+ }
438
+
439
+ html += '</div>';
440
+
441
+ // Pagination controls
442
+ html += '<div class="model-pagination">';
443
+ if (mPage > 0) {
444
+ html += `<button class="model-nav-btn" data-action="prev-model"><i data-lucide="chevron-left"></i> Prev Models</button>`;
445
+ }
446
+ if (mPage < maxMPage) {
447
+ html += `<button class="model-nav-btn" data-action="next-model">Next Models <i data-lucide="chevron-right"></i></button>`;
448
+ }
449
+ html += '</div>';
450
+
451
+ html += '<div class="model-provider-nav">';
452
+ if (pIdx > 0) {
453
+ html += `<button class="model-nav-btn secondary" data-action="prev-provider"><i data-lucide="chevrons-left"></i> Previous Provider</button>`;
454
+ }
455
+ if (pIdx < providers.length - 1) {
456
+ html += `<button class="model-nav-btn secondary" data-action="next-provider">Next Provider <i data-lucide="chevrons-right"></i></button>`;
457
+ }
458
+ html += '</div>';
459
+
460
+ body.innerHTML = html;
461
+
462
+ body.querySelectorAll('.model-card').forEach(card => {
463
+ card.onclick = () => selectModel(card.dataset.provider, card.dataset.model);
464
+ });
465
+
466
+ body.querySelectorAll('.model-nav-btn').forEach(btn => {
467
+ btn.onclick = () => {
468
+ const action = btn.dataset.action;
469
+ let newPIdx = pIdx;
470
+ let newMPage = mPage;
471
+
472
+ if (action === 'prev-model') newMPage--;
473
+ else if (action === 'next-model') newMPage++;
474
+ else if (action === 'prev-provider') { newPIdx--; newMPage = 0; }
475
+ else if (action === 'next-provider') { newPIdx++; newMPage = 0; }
476
+
477
+ renderModelPage(body, providers, newPIdx, newMPage);
478
+ };
479
+ });
480
+
481
+ // Re-render icons after dynamic content
482
+ setTimeout(() => lucide.createIcons(), 50);
483
+ }
484
+
485
+ function updateModelBadge() {
486
+ const el = document.getElementById('modelBadgeText');
487
+ if (el) el.textContent = currentModel.split('/').pop();
488
+ }
489
+
490
+ function selectModel(provider, model) {
491
+ currentModel = model;
492
+ localStorage.setItem('selectedModel', model);
493
+ document.getElementById('profileModel').textContent = model.split('/').pop();
494
+ updateModelBadge();
495
+
496
+ document.querySelectorAll('.model-card').forEach(c => c.classList.remove('selected'));
497
+ document.querySelector(`.model-card[data-model="${model}"]`)?.classList.add('selected');
498
+
499
+ showToast(`Model: ${model.split('/').pop()}`, 'success');
500
+
501
+ if (activeSessionId) {
502
+ fetch('/api/switch-model', {
503
+ method: 'POST',
504
+ headers: { 'Content-Type': 'application/json' },
505
+ body: JSON.stringify({ session_id: activeSessionId, provider, model })
506
+ }).catch(e => showToast(`Failed to switch model: ${e.message}`, 'error'));
507
+ }
508
+ }
509
+
510
+ // Agents Modal
511
+ document.getElementById('btnAgents').onclick = async () => {
512
+ openModal('agentsModal');
513
+ const body = document.getElementById('agentsBody');
514
+ body.innerHTML = '<div class="modal-loading"><i data-lucide="loader-2" class="spin"></i> Loading agents...</div>';
515
+ lucide.createIcons();
516
+
517
+ try {
518
+ const r = await fetch(`/api/agents?chat_id=${currentChatId}`);
519
+ const agents = await r.json();
520
+
521
+ if (!agents.length) {
522
+ body.innerHTML = `
523
+ <div class="modal-empty">
524
+ <i data-lucide="users"></i>
525
+ <p>No agents yet</p>
526
+ <button class="btn-primary" onclick="showCreateAgent()"><i data-lucide="plus"></i> Create Agent</button>
527
+ </div>
528
+ `;
529
+ lucide.createIcons();
530
+ return;
531
+ }
532
+
533
+ let html = '<div class="agent-list">';
534
+ for (const agent of agents) {
535
+ const isSelected = currentAgent === agent.name;
536
+ html += `
537
+ <div class="agent-card ${isSelected ? 'active' : ''}" data-agent="${agent.name}">
538
+ <div class="agent-name">
539
+ <i data-lucide="bot"></i>
540
+ ${escapeHtml(agent.name)}
541
+ ${isSelected ? '<span class="agent-badge">Active</span>' : ''}
542
+ </div>
543
+ <div class="agent-desc">${escapeHtml(agent.description || 'No description')}</div>
544
+ <div class="agent-actions">
545
+ <button class="agent-action-btn" onclick="event.stopPropagation(); selectAgent('${escapeHtml(agent.name)}')">
546
+ <i data-lucide="check"></i> Select
547
+ </button>
548
+ <button class="agent-action-btn" onclick="event.stopPropagation(); editAgent(${agent.agent_id}, '${escapeHtml(agent.name)}', '${escapeHtml(agent.description || '')}', '${escapeHtml(agent.system_prompt || '')}')">
549
+ <i data-lucide="edit"></i> Edit
550
+ </button>
551
+ <button class="agent-action-btn danger" onclick="event.stopPropagation(); deleteAgent(${agent.agent_id}, '${escapeHtml(agent.name)}')">
552
+ <i data-lucide="trash-2"></i> Delete
553
+ </button>
554
+ </div>
555
+ </div>
556
+ `;
557
+ }
558
+ html += `
559
+ <div class="agent-actions-footer">
560
+ <button class="btn-primary create-agent-btn" onclick="showCreateAgent()">
561
+ <i data-lucide="plus"></i> Create New Agent
562
+ </button>
563
+ <button class="btn-secondary restore-agent-btn" onclick="restoreDefaultAgent()">
564
+ <i data-lucide="rotate-ccw"></i> Restore Default
565
+ </button>
566
+ </div>
567
+ </div>`;
568
+ body.innerHTML = html;
569
+
570
+ body.querySelectorAll('.agent-card').forEach(card => {
571
+ card.onclick = () => selectAgent(card.dataset.agent);
572
+ });
573
+ lucide.createIcons();
574
+ } catch (e) {
575
+ body.innerHTML = '<div class="modal-empty"><i data-lucide="alert-triangle"></i><p>Failed to load agents</p></div>';
576
+ lucide.createIcons();
577
+ }
578
+ };
579
+
580
+ function selectAgent(name) {
581
+ currentAgent = name;
582
+ localStorage.setItem('selectedAgent', name);
583
+ document.getElementById('profileAgent').textContent = name;
584
+
585
+ document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('active'));
586
+ document.querySelector(`.agent-card[data-agent="${name}"]`)?.classList.add('active');
587
+
588
+ showToast(`Agent: ${name}`, 'success');
589
+ }
590
+
591
+ function editAgent(id, name, description, systemPrompt) {
592
+ const body = document.getElementById('agentsBody');
593
+ body.innerHTML = `
594
+ <div class="create-agent-form">
595
+ <h4 style="margin-bottom:16px;display:flex;align-items:center;gap:8px">
596
+ <i data-lucide="edit"></i> Edit Agent
597
+ </h4>
598
+ <input type="hidden" id="agentEditId" value="${id}">
599
+ <div class="form-group">
600
+ <label><i data-lucide="user"></i> Agent Name</label>
601
+ <input type="text" id="agentNameInput" value="${escapeHtml(name)}">
602
+ </div>
603
+ <div class="form-group">
604
+ <label><i data-lucide="message-square"></i> Description</label>
605
+ <input type="text" id="agentDescInput" value="${escapeHtml(description)}">
606
+ </div>
607
+ <div class="form-group">
608
+ <label><i data-lucide="file-text"></i> System Prompt</label>
609
+ <textarea id="agentPromptInput" rows="4">${escapeHtml(systemPrompt)}</textarea>
610
+ </div>
611
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
612
+ <button class="btn-primary" onclick="updateAgent()"><i data-lucide="save"></i> Save Changes</button>
613
+ <button class="btn-secondary" onclick="document.getElementById('btnAgents').click()"><i data-lucide="arrow-left"></i> Cancel</button>
614
+ </div>
615
+ </div>
616
+ `;
617
+ lucide.createIcons();
618
+ }
619
+
620
+ async function updateAgent() {
621
+ const id = document.getElementById('agentEditId').value;
622
+ const name = document.getElementById('agentNameInput').value.trim();
623
+ const desc = document.getElementById('agentDescInput').value.trim();
624
+ const prompt = document.getElementById('agentPromptInput').value.trim();
625
+
626
+ if (!name) {
627
+ showToast('Agent name is required', 'error');
628
+ return;
629
+ }
630
+
631
+ try {
632
+ await fetch('/api/update-agent', {
633
+ method: 'POST',
634
+ headers: { 'Content-Type': 'application/json' },
635
+ body: JSON.stringify({ agent_id: id, name, description: desc, system_prompt: prompt })
636
+ });
637
+ showToast(`Agent "${name}" updated!`, 'success');
638
+ document.getElementById('btnAgents').click();
639
+ } catch (e) {
640
+ showToast('Failed to update agent', 'error');
641
+ }
642
+ }
643
+
644
+ async function deleteAgent(id, name) {
645
+ if (!confirm(`Delete agent "${name}"?`)) return;
646
+
647
+ try {
648
+ await fetch('/api/delete-agent', {
649
+ method: 'POST',
650
+ headers: { 'Content-Type': 'application/json' },
651
+ body: JSON.stringify({ agent_id: id })
652
+ });
653
+ showToast(`Agent "${name}" deleted`, 'success');
654
+ document.getElementById('btnAgents').click();
655
+ } catch (e) {
656
+ showToast('Failed to delete agent', 'error');
657
+ }
658
+ }
659
+
660
+ function restoreDefaultAgent() {
661
+ currentAgent = 'BMO Default';
662
+ localStorage.setItem('selectedAgent', 'BMO Default');
663
+ document.getElementById('profileAgent').textContent = 'BMO Default';
664
+ showToast('Restored to BMO Default', 'success');
665
+ closeModal('agentsModal');
666
+ }
667
+
668
+ function showCreateAgent() {
669
+ const body = document.getElementById('agentsBody');
670
+ body.innerHTML = `
671
+ <div class="create-agent-form">
672
+ <div class="form-group">
673
+ <label><i data-lucide="user"></i> Agent Name</label>
674
+ <input type="text" id="agentNameInput" placeholder="e.g., Code Reviewer">
675
+ </div>
676
+ <div class="form-group">
677
+ <label><i data-lucide="message-square"></i> Description</label>
678
+ <input type="text" id="agentDescInput" placeholder="What does this agent do?">
679
+ </div>
680
+ <div class="form-group">
681
+ <label><i data-lucide="file-text"></i> System Prompt</label>
682
+ <textarea id="agentPromptInput" rows="4" placeholder="Define the agent's behavior..."></textarea>
683
+ </div>
684
+ <div id="agentGenProgress" style="display:none" class="agent-progress">
685
+ <div class="progress-bar-container">
686
+ <div class="progress-bar" id="agentProgressBar"></div>
687
+ </div>
688
+ <p id="agentProgressText">Generating...</p>
689
+ </div>
690
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
691
+ <button class="btn-primary" onclick="createAgent()"><i data-lucide="check"></i> Create</button>
692
+ <button class="btn-secondary" onclick="generateAgentPrompt()"><i data-lucide="wand-2"></i> AI Generate</button>
693
+ <button class="btn-secondary" onclick="document.getElementById('btnAgents').click()"><i data-lucide="arrow-left"></i> Back</button>
694
+ </div>
695
+ </div>
696
+ `;
697
+ lucide.createIcons();
698
+ }
699
+
700
+ async function generateAgentPrompt() {
701
+ const desc = document.getElementById('agentDescInput').value.trim();
702
+ if (!desc) {
703
+ showToast('Enter a description first', 'error');
704
+ return;
705
+ }
706
+
707
+ const progressEl = document.getElementById('agentGenProgress');
708
+ const progressBar = document.getElementById('agentProgressBar');
709
+ const progressText = document.getElementById('agentProgressText');
710
+ const promptInput = document.getElementById('agentPromptInput');
711
+
712
+ progressEl.style.display = 'block';
713
+ promptInput.disabled = true;
714
+ progressBar.style.width = '20%';
715
+ progressText.innerHTML = '<i data-lucide="cpu" class="inline-icon"></i> Connecting to AI...';
716
+ lucide.createIcons();
717
+
718
+ try {
719
+ progressBar.style.width = '50%';
720
+ progressText.innerHTML = '<i data-lucide="brain" class="inline-icon"></i> Generating agent personality...';
721
+ lucide.createIcons();
722
+
723
+ const r = await fetch('/api/generate-agent-prompt', {
724
+ method: 'POST',
725
+ headers: { 'Content-Type': 'application/json' },
726
+ body: JSON.stringify({ description: desc })
727
+ });
728
+
729
+ if (!r.ok) {
730
+ const err = await r.json();
731
+ throw new Error(err.error || 'Server error');
732
+ }
733
+
734
+ progressBar.style.width = '80%';
735
+ progressText.innerHTML = '<i data-lucide="file-text" class="inline-icon"></i> Processing response...';
736
+ lucide.createIcons();
737
+
738
+ const data = await r.json();
739
+
740
+ // Parse JSON if returned
741
+ let name = document.getElementById('agentNameInput').value.trim();
742
+ let prompt = data.system_prompt;
743
+
744
+ try {
745
+ const parsed = JSON.parse(prompt);
746
+ if (parsed.name && !name) name = parsed.name;
747
+ if (parsed.prompt) prompt = parsed.prompt;
748
+ } catch (e) {}
749
+
750
+ progressBar.style.width = '100%';
751
+ progressText.innerHTML = '<i data-lucide="check-circle" class="inline-icon"></i> Agent generated!';
752
+ lucide.createIcons();
753
+
754
+ promptInput.value = prompt;
755
+ if (name) document.getElementById('agentNameInput').value = name;
756
+
757
+ setTimeout(() => {
758
+ progressEl.style.display = 'none';
759
+ promptInput.disabled = false;
760
+ }, 1500);
761
+
762
+ } catch (e) {
763
+ progressText.innerHTML = `<i data-lucide="alert-circle" class="inline-icon"></i> Failed: ${escapeHtml(e.message)}`;
764
+ progressBar.style.background = 'var(--accent-danger)';
765
+ lucide.createIcons();
766
+ setTimeout(() => {
767
+ progressEl.style.display = 'none';
768
+ promptInput.disabled = false;
769
+ progressBar.style.background = 'var(--accent-primary)';
770
+ }, 4000);
771
+ }
772
+ }
773
+
774
+ async function createAgent() {
775
+ const name = document.getElementById('agentNameInput').value.trim();
776
+ const desc = document.getElementById('agentDescInput').value.trim();
777
+ const prompt = document.getElementById('agentPromptInput').value.trim();
778
+
779
+ if (!name) {
780
+ showToast('Agent name is required', 'error');
781
+ return;
782
+ }
783
+
784
+ try {
785
+ await fetch('/api/create-agent', {
786
+ method: 'POST',
787
+ headers: { 'Content-Type': 'application/json' },
788
+ body: JSON.stringify({ chat_id: currentChatId, name, description: desc, system_prompt: prompt })
789
+ });
790
+ showToast(`Agent "${name}" created!`, 'success');
791
+ document.getElementById('btnAgents').click();
792
+ } catch (e) {
793
+ showToast('Failed to create agent', 'error');
794
+ }
795
+ }
796
+
797
+ // Profile Modal
798
+ document.getElementById('btnProfile').onclick = () => {
799
+ document.getElementById('profileTheme').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? 'Dark' : 'Light';
800
+ openModal('profileModal');
801
+ };
802
+
803
+ // Toolbar
804
+ document.getElementById('btnNew').addEventListener('click', async () => {
805
+ const r = await fetch('/api/new-session', {
806
+ method: 'POST',
807
+ headers: { 'Content-Type': 'application/json' },
808
+ body: JSON.stringify({ chat_id: currentChatId })
809
+ });
810
+ const data = await r.json();
811
+ await switchSession(data.session_id);
812
+ showToast('New session created!', 'success');
813
+ });
814
+
815
+ document.getElementById('btnReset').addEventListener('click', async () => {
816
+ if (!activeSessionId) return;
817
+ await fetch('/api/clear', {
818
+ method: 'POST',
819
+ headers: { 'Content-Type': 'application/json' },
820
+ body: JSON.stringify({ session_id: activeSessionId })
821
+ });
822
+ chatArea.innerHTML = '';
823
+ emptyState.style.display = 'block';
824
+ showToast('Chat cleared', 'success');
825
+ });
826
+
827
+ document.getElementById('btnSummarize').addEventListener('click', async () => {
828
+ if (!activeSessionId) return;
829
+ statusText.textContent = 'Summarizing...';
830
+ const r = await fetch('/api/summarize', {
831
+ method: 'POST',
832
+ headers: { 'Content-Type': 'application/json' },
833
+ body: JSON.stringify({ session_id: activeSessionId })
834
+ });
835
+ const data = await r.json();
836
+ statusText.textContent = 'Online';
837
+ if (data.summary) {
838
+ const div = document.createElement('div');
839
+ div.className = 'msg bmo';
840
+ div.innerHTML = '<b>📋 Summary</b><br>' + data.summary.slice(0, 500);
841
+ chatArea.appendChild(div);
842
+ }
843
+ });
844
+
845
+ // Input
846
+ input.addEventListener('input', () => { sendBtn.disabled = !input.value.trim(); });
847
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !sendBtn.disabled) send(); });
848
+ sendBtn.addEventListener('click', send);
849
+
850
+ let _pollTimer = null;
851
+
852
+ function send() {
853
+ const text = input.value.trim();
854
+ if (!text) return;
855
+ socket.emit('user_message', { text, session_id: activeSessionId, chat_id: currentChatId, model: currentModel });
856
+ input.value = '';
857
+ sendBtn.disabled = true;
858
+ setStatus('typing');
859
+ _startPollFallback(activeSessionId);
860
+ }
861
+
862
+ function _startPollFallback(sid) {
863
+ if (_pollTimer) clearInterval(_pollTimer);
864
+ let attempts = 0;
865
+ _pollTimer = setInterval(async () => {
866
+ attempts++;
867
+ if (attempts > 20) { clearInterval(_pollTimer); _pollTimer = null; return; }
868
+ try {
869
+ const r = await fetch(`/api/messages?session_id=${sid}&limit=50`);
870
+ const msgs = await r.json();
871
+ let added = false;
872
+ for (const m of msgs) {
873
+ if (document.querySelector(`.msg[data-ts="${m.timestamp}"]`)) continue;
874
+ const div = document.createElement('div');
875
+ div.className = 'msg ' + (m.sender === 'user' ? 'user' : 'bmo');
876
+ div.setAttribute('data-ts', m.timestamp);
877
+ div.innerHTML = renderMessage(m.content, m.reasoning) + `<div class="time">${new Date(m.timestamp * 1000).toLocaleTimeString()}</div>`;
878
+ chatArea.appendChild(div);
879
+ added = true;
880
+ emptyState.style.display = 'none';
881
+ }
882
+ if (added) {
883
+ chatArea.scrollTop = chatArea.scrollHeight;
884
+ lucide.createIcons();
885
+ }
886
+ if (msgs.some(m => m.sender === 'bmo')) {
887
+ const lastMsg = msgs[msgs.length - 1];
888
+ if (lastMsg && lastMsg.sender === 'bmo') {
889
+ clearInterval(_pollTimer); _pollTimer = null;
890
+ setStatus('online');
891
+ }
892
+ }
893
+ } catch (e) {}
894
+ }, 1500);
895
+ }
896
+
897
+ // Helpers
898
+ function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
899
+
900
+ function setStatus(state) {
901
+ statusDot.className = 'status-dot';
902
+ if (state === 'typing') {
903
+ statusDot.classList.add('typing');
904
+ statusText.textContent = 'Typing...';
905
+ typingEl.classList.add('show');
906
+ } else if (state === 'online') {
907
+ statusText.textContent = 'Online';
908
+ typingEl.classList.remove('show');
909
+ } else {
910
+ statusDot.classList.add('offline');
911
+ statusText.textContent = 'Offline';
912
+ typingEl.classList.remove('show');
913
+ }
914
+ }
915
+
916
+ function showToast(message, type = 'success') {
917
+ const existing = document.querySelector('.toast');
918
+ if (existing) {
919
+ existing.classList.add('toast-exit');
920
+ setTimeout(() => existing.remove(), 280);
921
+ }
922
+ setTimeout(() => {
923
+ const toast = document.createElement('div');
924
+ toast.className = `toast ${type}`;
925
+ toast.innerHTML = `
926
+ <i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}"></i>
927
+ <span class="toast-text">${escapeHtml(message)}</span>
928
+ `;
929
+ document.body.appendChild(toast);
930
+ lucide.createIcons();
931
+ setTimeout(() => {
932
+ toast.classList.add('toast-exit');
933
+ setTimeout(() => toast.remove(), 280);
934
+ }, 3000);
935
+ }, 100);
936
+ }
937
+
938
+ // Theme toggle
939
+ const themeToggle = document.getElementById('themeToggle');
940
+ const html = document.documentElement;
941
+ const savedTheme = localStorage.getItem('theme') || 'dark';
942
+ html.setAttribute('data-theme', savedTheme);
943
+ updateThemeIcon(savedTheme);
944
+
945
+ themeToggle.onclick = () => {
946
+ const current = html.getAttribute('data-theme');
947
+ const next = current === 'dark' ? 'light' : 'dark';
948
+ html.setAttribute('data-theme', next);
949
+ localStorage.setItem('theme', next);
950
+ updateThemeIcon(next);
951
+ };
952
+
953
+ function updateThemeIcon(theme) {
954
+ const svg = themeToggle.querySelector('svg');
955
+ if (svg) svg.remove();
956
+ const i = document.createElement('i');
957
+ i.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
958
+ themeToggle.appendChild(i);
959
+ lucide.createIcons();
960
+ }
961
+
962
+ // Init
963
+ initFromUrlParams().then((loaded) => {
964
+ if (!loaded) initFallbackSession();
965
+ updateModelBadge();
966
+ }).catch(() => { initFallbackSession(); updateModelBadge(); });
967
+ setInterval(loadSessions, 30000);
968
+
969
+ // ── Mobile: swipe left to close sidebar ───────────────────────────
970
+ (function setupSwipeToCloseSidebar() {
971
+ let startX = 0, startY = 0, tracking = false;
972
+
973
+ function onStart(e) {
974
+ if (window.innerWidth >= 768) return;
975
+ if (sidebar.classList.contains('collapsed')) return;
976
+ if (!e.touches || e.touches.length !== 1) return;
977
+ startX = e.touches[0].clientX;
978
+ startY = e.touches[0].clientY;
979
+ tracking = true;
980
+ }
981
+
982
+ function onMove(e) {
983
+ if (!tracking) return;
984
+ const t = e.touches[0];
985
+ const dx = t.clientX - startX;
986
+ const dy = t.clientY - startY;
987
+ if (Math.abs(dy) > Math.abs(dx)) { tracking = false; return; }
988
+ if (dx < 0) e.preventDefault();
989
+ }
990
+
991
+ function onEnd(e) {
992
+ if (!tracking) return;
993
+ tracking = false;
994
+ const t = e.changedTouches[0];
995
+ const dx = t.clientX - startX;
996
+ const dy = t.clientY - startY;
997
+ if (Math.abs(dy) > Math.abs(dx)) return;
998
+ if (dx < -50) {
999
+ sidebar.classList.add('collapsed');
1000
+ sidebarOverlay.classList.remove('show');
1001
+ updateSidebarIcon();
1002
+ }
1003
+ }
1004
+
1005
+ function attach() {
1006
+ sidebar.addEventListener('touchstart', onStart, { passive: true });
1007
+ sidebar.addEventListener('touchmove', onMove, { passive: false });
1008
+ sidebar.addEventListener('touchend', onEnd, { passive: true });
1009
+ sidebar.addEventListener('touchcancel', function () { tracking = false; }, { passive: true });
1010
+ }
1011
+
1012
+ if (document.readyState === 'loading') {
1013
+ document.addEventListener('DOMContentLoaded', attach);
1014
+ } else {
1015
+ attach();
1016
+ }
1017
+ })();
1018
+
1019
+ // ── Mobile: Escape key closes sidebar ─────────────────────────────
1020
+ (function setupEscapeToCloseSidebar() {
1021
+ function onKey(e) {
1022
+ if (e.key !== 'Escape') return;
1023
+ if (window.innerWidth >= 768) return;
1024
+ if (sidebar.classList.contains('collapsed')) return;
1025
+ sidebar.classList.add('collapsed');
1026
+ sidebarOverlay.classList.remove('show');
1027
+ updateSidebarIcon();
1028
+ }
1029
+ function attach() { document.addEventListener('keydown', onKey); }
1030
+ if (document.readyState === 'loading') {
1031
+ document.addEventListener('DOMContentLoaded', attach);
1032
+ } else {
1033
+ attach();
1034
+ }
1035
+ })();
1036
+
1037
+ // ── Viewport: iOS 100vh fallback + visualViewport for keyboard ───
1038
+ (function setupViewportVars() {
1039
+ function update() {
1040
+ if (window.visualViewport) {
1041
+ document.documentElement.style.setProperty('--viewport-height', window.visualViewport.height + 'px');
1042
+ }
1043
+ document.documentElement.style.setProperty('--vh', (window.innerHeight * 0.01) + 'px');
1044
+ }
1045
+ function attach() {
1046
+ update();
1047
+ window.addEventListener('resize', update);
1048
+ window.addEventListener('orientationchange', function () { setTimeout(update, 120); });
1049
+ if (window.visualViewport) {
1050
+ window.visualViewport.addEventListener('resize', update);
1051
+ }
1052
+ }
1053
+ if (document.readyState === 'loading') {
1054
+ document.addEventListener('DOMContentLoaded', attach);
1055
+ } else {
1056
+ attach();
1057
+ }
1058
+ })();
1059
+
1060
+ // ── Mobile: orientation change resets sidebar ────────────────────
1061
+ (function setupOrientationReset() {
1062
+ function onChange() {
1063
+ setTimeout(function () {
1064
+ if (window.innerWidth < 768 && !sidebar.classList.contains('collapsed')) {
1065
+ sidebar.classList.add('collapsed');
1066
+ sidebarOverlay.classList.remove('show');
1067
+ updateSidebarIcon();
1068
+ }
1069
+ }, 200);
1070
+ }
1071
+ function attach() { window.addEventListener('orientationchange', onChange); }
1072
+ if (document.readyState === 'loading') {
1073
+ document.addEventListener('DOMContentLoaded', attach);
1074
+ } else {
1075
+ attach();
1076
+ }
1077
+ })();
1078
+
1079
+ // ── Mobile: modal focus management + inert + tab trap ────────────
1080
+ (function setupModalFocus() {
1081
+ const lastFocused = new Map();
1082
+
1083
+ function getFocusables(modal) {
1084
+ return Array.from(modal.querySelectorAll(
1085
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
1086
+ ));
1087
+ }
1088
+
1089
+ function onModalAttrChange(mutations) {
1090
+ for (const m of mutations) {
1091
+ const modal = m.target;
1092
+ const id = modal.id || '__modal__';
1093
+ const isShown = modal.classList.contains('show');
1094
+ const wasShown = (m.oldValue || '').split(/\s+/).indexOf('show') !== -1;
1095
+ if (isShown && !wasShown) {
1096
+ lastFocused.set(id, document.activeElement);
1097
+ const main = document.querySelector('main');
1098
+ if (main) main.setAttribute('inert', '');
1099
+ setTimeout(function () {
1100
+ const focusables = getFocusables(modal);
1101
+ if (focusables.length) focusables[0].focus();
1102
+ }, 0);
1103
+ } else if (!isShown && wasShown) {
1104
+ const main = document.querySelector('main');
1105
+ if (main) main.removeAttribute('inert');
1106
+ const prev = lastFocused.get(id);
1107
+ if (prev && typeof prev.focus === 'function') {
1108
+ try { prev.focus(); } catch (e) {}
1109
+ }
1110
+ lastFocused.delete(id);
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ function onTab(e) {
1116
+ if (e.key !== 'Tab') return;
1117
+ const open = document.querySelector('.modal-overlay.show, [id$="Modal"].show');
1118
+ if (!open) return;
1119
+ const focusables = getFocusables(open);
1120
+ if (focusables.length === 0) { e.preventDefault(); return; }
1121
+ const first = focusables[0];
1122
+ const last = focusables[focusables.length - 1];
1123
+ if (e.shiftKey && document.activeElement === first) {
1124
+ e.preventDefault();
1125
+ last.focus();
1126
+ } else if (!e.shiftKey && document.activeElement === last) {
1127
+ e.preventDefault();
1128
+ first.focus();
1129
+ }
1130
+ }
1131
+
1132
+ function attach() {
1133
+ document.querySelectorAll('.modal-overlay, [id$="Modal"]').forEach(function (modal) {
1134
+ const obs = new MutationObserver(onModalAttrChange);
1135
+ obs.observe(modal, { attributes: true, attributeFilter: ['class'], attributeOldValue: true });
1136
+ });
1137
+ document.addEventListener('keydown', onTab);
1138
+ }
1139
+
1140
+ if (document.readyState === 'loading') {
1141
+ document.addEventListener('DOMContentLoaded', attach);
1142
+ } else {
1143
+ attach();
1144
+ }
1145
+ })();
1146
+
1147
+ // ── Mobile: suggestion cards in empty state ──────────────────────
1148
+ (function setupSuggestionCards() {
1149
+ function onClick(e) {
1150
+ const card = e.target.closest('[data-suggestion]');
1151
+ if (!card) return;
1152
+ const text = card.getAttribute('data-suggestion') || '';
1153
+ input.value = text;
1154
+ sendBtn.disabled = !text.trim();
1155
+ if (emptyState) emptyState.style.display = 'none';
1156
+ input.focus();
1157
+ }
1158
+ function attach() {
1159
+ if (emptyState) emptyState.addEventListener('click', onClick);
1160
+ }
1161
+ if (document.readyState === 'loading') {
1162
+ document.addEventListener('DOMContentLoaded', attach);
1163
+ } else {
1164
+ attach();
1165
+ }
1166
+ })();
1167
+
1168
+ // ── Mobile: active press state via event delegation ──────────────
1169
+ (function setupPressStates() {
1170
+ const SELECTOR = '.icon-btn, .toolbar button, .btn-primary, .btn-secondary, #sendBtn, .agent-card, .model-card, .session-item, .scroll-to-bottom-pill';
1171
+ function onStart(e) {
1172
+ const btn = e.target.closest && e.target.closest(SELECTOR);
1173
+ if (btn) btn.classList.add('is-pressed');
1174
+ }
1175
+ function clearAll() {
1176
+ document.querySelectorAll('.is-pressed').forEach(function (el) { el.classList.remove('is-pressed'); });
1177
+ }
1178
+ function attach() {
1179
+ document.addEventListener('touchstart', onStart, { passive: true });
1180
+ document.addEventListener('touchend', clearAll, { passive: true });
1181
+ document.addEventListener('touchcancel', clearAll, { passive: true });
1182
+ }
1183
+ if (document.readyState === 'loading') {
1184
+ document.addEventListener('DOMContentLoaded', attach);
1185
+ } else {
1186
+ attach();
1187
+ }
1188
+ })();
1189
+
1190
+ // ── Mobile: debounce re-clicks on network-triggering buttons ─────
1191
+ (function setupClickDebounce() {
1192
+ const recentClicks = new Set();
1193
+ const DEBOUNCE_MS = 500;
1194
+
1195
+ function makeGuard(keyFn) {
1196
+ return function (e) {
1197
+ const key = keyFn(e);
1198
+ if (!key) return;
1199
+ if (recentClicks.has(key)) {
1200
+ e.stopImmediatePropagation();
1201
+ e.preventDefault();
1202
+ return;
1203
+ }
1204
+ recentClicks.add(key);
1205
+ setTimeout(function () { recentClicks.delete(key); }, DEBOUNCE_MS);
1206
+ };
1207
+ }
1208
+
1209
+ function attach() {
1210
+ document.addEventListener('click', makeGuard(function (e) {
1211
+ const el = e.target.closest('#sendBtn');
1212
+ return el ? 'sendBtn' : '';
1213
+ }), true);
1214
+ document.addEventListener('click', makeGuard(function (e) {
1215
+ const el = e.target.closest('.model-card');
1216
+ return el ? 'model:' + (el.dataset.model || '') : '';
1217
+ }), true);
1218
+ document.addEventListener('click', makeGuard(function (e) {
1219
+ const el = e.target.closest('.session-item');
1220
+ return el ? 'session:' + (el.dataset.sid || '') : '';
1221
+ }), true);
1222
+ }
1223
+
1224
+ if (document.readyState === 'loading') {
1225
+ document.addEventListener('DOMContentLoaded', attach);
1226
+ } else {
1227
+ attach();
1228
+ }
1229
+ })();
1230
+
1231
+ // ── Scroll to bottom pill (visible when scrolled up) ─────────────
1232
+ (function setupScrollToBottomPill() {
1233
+ let pill = null;
1234
+ const SHOW_THRESHOLD = 200;
1235
+
1236
+ function ensurePill() {
1237
+ if (pill) return pill;
1238
+ pill = document.createElement('button');
1239
+ pill.id = 'scrollToBottomPill';
1240
+ pill.type = 'button';
1241
+ pill.setAttribute('aria-label', 'Scroll to bottom');
1242
+ pill.className = 'scroll-to-bottom-pill';
1243
+ pill.innerHTML = '<i data-lucide="arrow-down"></i>';
1244
+ pill.style.cssText = 'position:absolute;right:16px;bottom:16px;width:42px;height:42px;border-radius:50%;border:none;background:var(--accent-primary,#c97a3a);color:#fff;display:none;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.35);z-index:20;transition:opacity .2s,transform .2s;opacity:0;transform:translateY(8px);pointer-events:none;';
1245
+ pill.addEventListener('click', function () {
1246
+ chatArea.scrollTo({ top: chatArea.scrollHeight, behavior: 'smooth' });
1247
+ });
1248
+ const parent = chatArea.parentElement;
1249
+ if (parent) {
1250
+ const cs = getComputedStyle(parent);
1251
+ if (cs.position === 'static') parent.style.position = 'relative';
1252
+ parent.appendChild(pill);
1253
+ }
1254
+ lucide.createIcons();
1255
+ return pill;
1256
+ }
1257
+
1258
+ function show(p) {
1259
+ p.style.display = 'flex';
1260
+ requestAnimationFrame(function () {
1261
+ p.style.opacity = '1';
1262
+ p.style.transform = 'translateY(0)';
1263
+ p.style.pointerEvents = 'auto';
1264
+ });
1265
+ }
1266
+
1267
+ function hide(p) {
1268
+ p.style.opacity = '0';
1269
+ p.style.transform = 'translateY(8px)';
1270
+ p.style.pointerEvents = 'none';
1271
+ setTimeout(function () { if (pill) pill.style.display = 'none'; }, 200);
1272
+ }
1273
+
1274
+ function update() {
1275
+ if (!chatArea) return;
1276
+ const distance = chatArea.scrollHeight - chatArea.scrollTop - chatArea.clientHeight;
1277
+ if (distance > SHOW_THRESHOLD) {
1278
+ show(ensurePill());
1279
+ } else if (pill) {
1280
+ hide(pill);
1281
+ }
1282
+ }
1283
+
1284
+ function attach() {
1285
+ if (chatArea) chatArea.addEventListener('scroll', update, { passive: true });
1286
+ }
1287
+
1288
+ if (document.readyState === 'loading') {
1289
+ document.addEventListener('DOMContentLoaded', attach);
1290
+ } else {
1291
+ attach();
1292
+ }
1293
+ })();