create-byan-agent 2.8.1 → 2.9.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.
@@ -0,0 +1,1512 @@
1
+ /**
2
+ * BYAN Chat — AI Agent Interface
3
+ * Vanilla JS chat application. No frameworks, no build step.
4
+ */
5
+
6
+ class ByanChat {
7
+ constructor() {
8
+ this.ws = null;
9
+ this.wsRetryDelay = 1000;
10
+ this.sessionId = null;
11
+ this.currentAgent = null;
12
+ this.currentCLI = null;
13
+ this.currentModel = null;
14
+ this.messages = [];
15
+ this.agents = [];
16
+ this.clis = [];
17
+ this.sessions = [];
18
+ this.pinnedMessages = new Set();
19
+ this.splitView = false;
20
+ this.voiceActive = false;
21
+ this.attachedFiles = [];
22
+ this.isStreaming = false;
23
+ this.streamingMessageEl = null;
24
+ this.streamingContent = '';
25
+ this.pendingToolApproval = null;
26
+ this.mediaRecorder = null;
27
+ this.audioChunks = [];
28
+ this.recognition = null;
29
+
30
+ this.init();
31
+ }
32
+
33
+ async init() {
34
+ this.cacheDOM();
35
+ this.connectWebSocket();
36
+ await Promise.all([
37
+ this.detectCLIs(),
38
+ this.loadAgents(),
39
+ this.loadSessions()
40
+ ]);
41
+ this.setupEventListeners();
42
+ this.autoGrowTextarea();
43
+ this.restoreState();
44
+ }
45
+
46
+ cacheDOM() {
47
+ this.dom = {
48
+ messages: document.getElementById('messages'),
49
+ welcomeScreen: document.getElementById('welcome-screen'),
50
+ quickAgents: document.getElementById('quick-agents'),
51
+ messageInput: document.getElementById('message-input'),
52
+ btnSend: document.getElementById('btn-send'),
53
+ btnVoice: document.getElementById('btn-voice'),
54
+ btnAttach: document.getElementById('btn-attach'),
55
+ btnSplit: document.getElementById('btn-split'),
56
+ btnExport: document.getElementById('btn-export'),
57
+ btnSettings: document.getElementById('btn-settings'),
58
+ btnSidebar: document.getElementById('btn-sidebar'),
59
+ btnImportAgent: document.getElementById('btn-import-agent'),
60
+ sidebar: document.getElementById('sidebar'),
61
+ sidebarOverlay: document.getElementById('sidebar-overlay'),
62
+ agentList: document.getElementById('agent-list'),
63
+ sessionList: document.getElementById('session-list'),
64
+ cliStatus: document.getElementById('cli-status'),
65
+ modelSelect: document.getElementById('model-select'),
66
+ agentIndicator: document.getElementById('agent-indicator'),
67
+ activeAgentIcon: document.getElementById('active-agent-icon'),
68
+ activeAgentName: document.getElementById('active-agent-name'),
69
+ typingIndicator: document.getElementById('typing-indicator'),
70
+ cliLabel: document.getElementById('cli-label'),
71
+ charCount: document.getElementById('char-count'),
72
+ splitPanel: document.getElementById('split-panel'),
73
+ splitContent: document.getElementById('split-content'),
74
+ toolApproval: document.getElementById('tool-approval'),
75
+ toolName: document.getElementById('tool-name'),
76
+ toolCommand: document.getElementById('tool-command'),
77
+ attachPreview: document.getElementById('attach-preview'),
78
+ attachInput: document.getElementById('attach-input')
79
+ };
80
+ }
81
+
82
+ // ============================================================
83
+ // WebSocket
84
+ // ============================================================
85
+
86
+ connectWebSocket() {
87
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
88
+ const url = `${protocol}//${window.location.host}`;
89
+
90
+ try {
91
+ this.ws = new WebSocket(url);
92
+ } catch {
93
+ this.scheduleReconnect();
94
+ return;
95
+ }
96
+
97
+ this.ws.onopen = () => {
98
+ this.wsRetryDelay = 1000;
99
+ if (this.sessionId) {
100
+ this.wsSend({ type: 'join', sessionId: this.sessionId });
101
+ }
102
+ };
103
+
104
+ this.ws.onmessage = (event) => {
105
+ try {
106
+ this.handleWSMessage(JSON.parse(event.data));
107
+ } catch { /* ignore malformed messages */ }
108
+ };
109
+
110
+ this.ws.onclose = () => this.scheduleReconnect();
111
+ this.ws.onerror = () => {};
112
+ }
113
+
114
+ scheduleReconnect() {
115
+ setTimeout(() => {
116
+ this.wsRetryDelay = Math.min(this.wsRetryDelay * 1.5, 15000);
117
+ this.connectWebSocket();
118
+ }, this.wsRetryDelay);
119
+ }
120
+
121
+ wsSend(data) {
122
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
123
+ this.ws.send(JSON.stringify(data));
124
+ }
125
+ }
126
+
127
+ handleWSMessage(data) {
128
+ switch (data.type) {
129
+ case 'chat':
130
+ this.appendChunk(data.chunk);
131
+ if (data.raw) this.appendRawOutput(data.raw);
132
+ break;
133
+ case 'chat-complete':
134
+ this.finishStreaming(data.fullResponse || this.streamingContent);
135
+ break;
136
+ case 'tool-approval':
137
+ this.showToolApproval(data.tool, data.command);
138
+ break;
139
+ case 'error':
140
+ this.showToast(data.message || 'An error occurred', 'error');
141
+ this.finishStreaming(this.streamingContent);
142
+ break;
143
+ case 'raw-output':
144
+ this.appendRawOutput(data.content);
145
+ break;
146
+ default:
147
+ break;
148
+ }
149
+ }
150
+
151
+ // ============================================================
152
+ // CLI Detection
153
+ // ============================================================
154
+
155
+ async detectCLIs() {
156
+ const knownCLIs = [
157
+ { id: 'claude', name: 'Claude Code', cmd: 'claude' },
158
+ { id: 'copilot', name: 'GitHub Copilot', cmd: 'gh copilot' },
159
+ { id: 'codex', name: 'OpenCode', cmd: 'codex' }
160
+ ];
161
+
162
+ try {
163
+ const res = await fetch('/api/cli/detect');
164
+ if (res.ok) {
165
+ const data = await res.json();
166
+ this.clis = (data.clis || []).map(cli => ({
167
+ ...cli,
168
+ available: cli.available || cli.detected || false
169
+ }));
170
+ }
171
+ } catch {
172
+ // API not available, use defaults with unknown status
173
+ this.clis = knownCLIs.map(c => ({ ...c, available: false }));
174
+ }
175
+
176
+ if (this.clis.length === 0) {
177
+ this.clis = knownCLIs.map(c => ({ ...c, available: false }));
178
+ }
179
+
180
+ this.renderCLIStatus();
181
+ this.pickDefaultCLI();
182
+ }
183
+
184
+ renderCLIStatus() {
185
+ this.dom.cliStatus.innerHTML = '';
186
+ for (const cli of this.clis) {
187
+ const el = document.createElement('div');
188
+ el.className = 'cli-item' + (cli.available ? ' available' : '') + (this.currentCLI === cli.id ? ' active' : '');
189
+ el.setAttribute('role', 'button');
190
+ el.setAttribute('tabindex', '0');
191
+ el.setAttribute('aria-label', `${cli.name}: ${cli.available ? 'available' : 'not detected'}`);
192
+ el.innerHTML = `<span class="cli-dot"></span>${this.escapeHtml(cli.name)}`;
193
+ el.addEventListener('click', () => this.selectCLI(cli.id));
194
+ el.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.selectCLI(cli.id); });
195
+ this.dom.cliStatus.appendChild(el);
196
+ }
197
+ }
198
+
199
+ pickDefaultCLI() {
200
+ const available = this.clis.filter(c => c.available);
201
+ if (available.length > 0) {
202
+ this.selectCLI(available[0].id);
203
+ } else if (this.clis.length > 0) {
204
+ this.selectCLI(this.clis[0].id);
205
+ }
206
+ }
207
+
208
+ selectCLI(id) {
209
+ this.currentCLI = id;
210
+ const cli = this.clis.find(c => c.id === id);
211
+ this.dom.cliLabel.textContent = cli ? cli.name : id;
212
+ this.renderCLIStatus();
213
+ this.saveState();
214
+ }
215
+
216
+ // ============================================================
217
+ // Agent Management
218
+ // ============================================================
219
+
220
+ async loadAgents() {
221
+ const defaultAgents = [
222
+ { name: 'byan', title: 'BYAN', module: 'bmb', icon: '\u{1F916}', description: 'Agent builder' },
223
+ { name: 'bmm-analyst', title: 'Analyst', module: 'bmm', icon: '\u{1F50D}', description: 'Requirements analysis' },
224
+ { name: 'bmm-pm', title: 'PM', module: 'bmm', icon: '\u{1F4CB}', description: 'Product management' },
225
+ { name: 'bmm-architect', title: 'Architect', module: 'bmm', icon: '\u{1F3D7}', description: 'System architecture' },
226
+ { name: 'bmm-dev', title: 'Dev', module: 'bmm', icon: '\u{1F4BB}', description: 'Implementation' },
227
+ { name: 'bmm-sm', title: 'SM', module: 'bmm', icon: '\u{1F3AF}', description: 'Scrum master' },
228
+ { name: 'bmm-quinn', title: 'Quinn', module: 'bmm', icon: '\u{1F9EA}', description: 'Quality assurance' },
229
+ { name: 'bmm-ux-designer', title: 'UX Designer', module: 'bmm', icon: '\u{1F3A8}', description: 'User experience' },
230
+ { name: 'brainstorming-coach', title: 'Carson', module: 'cis', icon: '\u{1F4A1}', description: 'Creative brainstorming' },
231
+ { name: 'tea-testarch', title: 'TEA', module: 'tea', icon: '\u{1F9F0}', description: 'Test architecture' },
232
+ { name: 'bmad-master', title: 'BMAD Master', module: 'core', icon: '\u{2699}', description: 'Platform orchestrator' }
233
+ ];
234
+
235
+ try {
236
+ const res = await fetch('/api/agents');
237
+ if (res.ok) {
238
+ const data = await res.json();
239
+ if (data.agents && data.agents.length > 0) {
240
+ this.agents = data.agents;
241
+ } else {
242
+ this.agents = defaultAgents;
243
+ }
244
+ } else {
245
+ this.agents = defaultAgents;
246
+ }
247
+ } catch {
248
+ this.agents = defaultAgents;
249
+ }
250
+
251
+ this.renderAgentList();
252
+ this.renderQuickAgents();
253
+ }
254
+
255
+ renderAgentList() {
256
+ this.dom.agentList.innerHTML = '';
257
+
258
+ const grouped = {};
259
+ for (const agent of this.agents) {
260
+ const mod = agent.module || 'other';
261
+ if (!grouped[mod]) grouped[mod] = [];
262
+ grouped[mod].push(agent);
263
+ }
264
+
265
+ const moduleOrder = ['core', 'bmm', 'bmb', 'tea', 'cis', 'other'];
266
+ const sortedModules = Object.keys(grouped).sort((a, b) => {
267
+ const ia = moduleOrder.indexOf(a);
268
+ const ib = moduleOrder.indexOf(b);
269
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
270
+ });
271
+
272
+ for (const mod of sortedModules) {
273
+ const group = document.createElement('div');
274
+ group.className = 'agent-group';
275
+
276
+ const title = document.createElement('div');
277
+ title.className = 'agent-group-title';
278
+ title.textContent = mod.toUpperCase();
279
+ group.appendChild(title);
280
+
281
+ for (const agent of grouped[mod]) {
282
+ const card = document.createElement('div');
283
+ card.className = 'agent-card' + (this.currentAgent && this.currentAgent.name === agent.name ? ' active' : '');
284
+ card.setAttribute('role', 'button');
285
+ card.setAttribute('tabindex', '0');
286
+ card.setAttribute('aria-label', `Select agent ${agent.title || agent.name}`);
287
+ card.dataset.agent = agent.name;
288
+
289
+ card.innerHTML = `
290
+ <div class="agent-card-icon">${agent.icon || '\u{1F916}'}</div>
291
+ <div class="agent-card-info">
292
+ <div class="agent-card-name">${this.escapeHtml(agent.title || agent.name)}</div>
293
+ <div class="agent-card-desc">${this.escapeHtml(agent.description || '')}</div>
294
+ </div>
295
+ <span class="agent-card-badge">${this.escapeHtml(mod)}</span>
296
+ `;
297
+
298
+ card.addEventListener('click', () => this.selectAgent(agent.name));
299
+ card.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.selectAgent(agent.name); });
300
+ group.appendChild(card);
301
+ }
302
+
303
+ this.dom.agentList.appendChild(group);
304
+ }
305
+ }
306
+
307
+ renderQuickAgents() {
308
+ const quickList = this.agents.slice(0, 4);
309
+ this.dom.quickAgents.innerHTML = '';
310
+
311
+ for (const agent of quickList) {
312
+ const card = document.createElement('div');
313
+ card.className = 'quick-agent-card';
314
+ card.setAttribute('role', 'button');
315
+ card.setAttribute('tabindex', '0');
316
+ card.setAttribute('aria-label', `Start chat with ${agent.title || agent.name}`);
317
+ card.innerHTML = `
318
+ <span class="qa-icon">${agent.icon || '\u{1F916}'}</span>
319
+ <span class="qa-name">${this.escapeHtml(agent.title || agent.name)}</span>
320
+ <span class="qa-desc">${this.escapeHtml(agent.description || '')}</span>
321
+ `;
322
+ card.addEventListener('click', () => this.selectAgent(agent.name));
323
+ card.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.selectAgent(agent.name); });
324
+ this.dom.quickAgents.appendChild(card);
325
+ }
326
+ }
327
+
328
+ selectAgent(name) {
329
+ const agent = this.agents.find(a => a.name === name);
330
+ if (!agent) return;
331
+
332
+ this.currentAgent = agent;
333
+ this.dom.activeAgentIcon.textContent = agent.icon || '\u{1F916}';
334
+ this.dom.activeAgentName.textContent = agent.title || agent.name;
335
+ this.dom.agentIndicator.classList.add('active');
336
+
337
+ // Highlight in sidebar
338
+ this.dom.agentList.querySelectorAll('.agent-card').forEach(el => {
339
+ el.classList.toggle('active', el.dataset.agent === name);
340
+ });
341
+
342
+ this.startSession();
343
+ this.closeSidebar();
344
+ this.dom.messageInput.focus();
345
+ this.saveState();
346
+ }
347
+
348
+ // ============================================================
349
+ // Session Management
350
+ // ============================================================
351
+
352
+ async loadSessions() {
353
+ try {
354
+ const res = await fetch('/api/chat/sessions');
355
+ if (res.ok) {
356
+ const data = await res.json();
357
+ this.sessions = data.sessions || [];
358
+ }
359
+ } catch {
360
+ this.sessions = this.loadSessionsFromStorage();
361
+ }
362
+ this.renderSessions();
363
+ }
364
+
365
+ loadSessionsFromStorage() {
366
+ try {
367
+ return JSON.parse(localStorage.getItem('byan-chat-sessions') || '[]');
368
+ } catch { return []; }
369
+ }
370
+
371
+ saveSessionsToStorage() {
372
+ try {
373
+ localStorage.setItem('byan-chat-sessions', JSON.stringify(this.sessions));
374
+ } catch { /* storage full or unavailable */ }
375
+ }
376
+
377
+ renderSessions() {
378
+ this.dom.sessionList.innerHTML = '';
379
+
380
+ if (this.sessions.length === 0) {
381
+ const empty = document.createElement('div');
382
+ empty.className = 'session-item';
383
+ empty.innerHTML = '<span class="session-item-title" style="color:var(--text-dim)">No conversations yet</span>';
384
+ this.dom.sessionList.appendChild(empty);
385
+ return;
386
+ }
387
+
388
+ const sorted = [...this.sessions].sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
389
+
390
+ for (const session of sorted.slice(0, 20)) {
391
+ const el = document.createElement('div');
392
+ el.className = 'session-item' + (this.sessionId === session.id ? ' active' : '');
393
+ el.setAttribute('role', 'button');
394
+ el.setAttribute('tabindex', '0');
395
+
396
+ const title = session.title || session.agent || 'Untitled';
397
+ const time = session.updatedAt ? this.formatRelativeTime(session.updatedAt) : '';
398
+
399
+ el.innerHTML = `
400
+ <span class="session-item-title">${this.escapeHtml(title)}</span>
401
+ <span class="session-item-time">${this.escapeHtml(time)}</span>
402
+ <button class="session-item-delete" title="Delete" aria-label="Delete conversation">&times;</button>
403
+ `;
404
+
405
+ el.addEventListener('click', (e) => {
406
+ if (e.target.classList.contains('session-item-delete')) {
407
+ this.deleteSession(session.id);
408
+ } else {
409
+ this.loadSession(session.id);
410
+ }
411
+ });
412
+ el.addEventListener('keydown', (e) => { if (e.key === 'Enter') this.loadSession(session.id); });
413
+
414
+ this.dom.sessionList.appendChild(el);
415
+ }
416
+ }
417
+
418
+ async startSession() {
419
+ const agentName = this.currentAgent ? this.currentAgent.name : null;
420
+
421
+ try {
422
+ const res = await fetch('/api/chat/start', {
423
+ method: 'POST',
424
+ headers: { 'Content-Type': 'application/json' },
425
+ body: JSON.stringify({ agent: agentName, cli: this.currentCLI, model: this.currentModel })
426
+ });
427
+ if (res.ok) {
428
+ const data = await res.json();
429
+ this.sessionId = data.sessionId || data.id || this.generateId();
430
+ } else {
431
+ this.sessionId = this.generateId();
432
+ }
433
+ } catch {
434
+ this.sessionId = this.generateId();
435
+ }
436
+
437
+ this.messages = [];
438
+ this.clearMessages();
439
+
440
+ const newSession = {
441
+ id: this.sessionId,
442
+ agent: agentName,
443
+ title: this.currentAgent ? this.currentAgent.title : 'New chat',
444
+ createdAt: Date.now(),
445
+ updatedAt: Date.now()
446
+ };
447
+
448
+ this.sessions.unshift(newSession);
449
+ this.saveSessionsToStorage();
450
+ this.renderSessions();
451
+
452
+ if (this.currentAgent) {
453
+ this.addMessage('system', `Session started with ${this.currentAgent.title || this.currentAgent.name}`);
454
+ }
455
+
456
+ this.wsSend({ type: 'join', sessionId: this.sessionId });
457
+ this.saveState();
458
+ }
459
+
460
+ async loadSession(id) {
461
+ try {
462
+ const res = await fetch(`/api/chat/session/${encodeURIComponent(id)}`);
463
+ if (res.ok) {
464
+ const data = await res.json();
465
+ this.sessionId = id;
466
+ this.messages = data.messages || [];
467
+ this.clearMessages();
468
+ for (const msg of this.messages) {
469
+ this.addMessage(msg.role, msg.content, msg.metadata, true);
470
+ }
471
+ this.wsSend({ type: 'join', sessionId: this.sessionId });
472
+ }
473
+ } catch {
474
+ // Try loading from stored messages
475
+ this.sessionId = id;
476
+ this.clearMessages();
477
+ }
478
+
479
+ this.renderSessions();
480
+ this.scrollToBottom();
481
+ this.closeSidebar();
482
+ this.saveState();
483
+ }
484
+
485
+ async deleteSession(id) {
486
+ if (!confirm('Delete this conversation?')) return;
487
+
488
+ try {
489
+ await fetch(`/api/chat/session/${encodeURIComponent(id)}`, { method: 'DELETE' });
490
+ } catch { /* best effort */ }
491
+
492
+ this.sessions = this.sessions.filter(s => s.id !== id);
493
+ this.saveSessionsToStorage();
494
+
495
+ if (this.sessionId === id) {
496
+ this.sessionId = null;
497
+ this.messages = [];
498
+ this.clearMessages();
499
+ }
500
+
501
+ this.renderSessions();
502
+ }
503
+
504
+ // ============================================================
505
+ // Messaging
506
+ // ============================================================
507
+
508
+ async send() {
509
+ const text = this.dom.messageInput.value.trim();
510
+ if (!text && this.attachedFiles.length === 0) return;
511
+ if (this.isStreaming) return;
512
+
513
+ if (!this.sessionId) {
514
+ await this.startSession();
515
+ }
516
+
517
+ let fullMessage = text;
518
+ if (this.attachedFiles.length > 0) {
519
+ const fileHeaders = this.attachedFiles.map(f => `@${f.name}\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n');
520
+ fullMessage = fileHeaders + (text ? '\n\n' + text : '');
521
+ this.attachedFiles = [];
522
+ }
523
+
524
+ this.addMessage('user', fullMessage);
525
+ this.dom.messageInput.value = '';
526
+ this.dom.messageInput.style.height = 'auto';
527
+ this.dom.charCount.textContent = '';
528
+
529
+ this.startStreaming();
530
+
531
+ try {
532
+ const res = await fetch('/api/chat/send', {
533
+ method: 'POST',
534
+ headers: { 'Content-Type': 'application/json' },
535
+ body: JSON.stringify({
536
+ sessionId: this.sessionId,
537
+ message: fullMessage,
538
+ agent: this.currentAgent ? this.currentAgent.name : null,
539
+ cli: this.currentCLI,
540
+ model: this.currentModel
541
+ })
542
+ });
543
+
544
+ if (!res.ok) {
545
+ const err = await res.json().catch(() => ({ error: 'Failed to send message' }));
546
+ this.showToast(err.error || 'Failed to send message', 'error');
547
+ this.finishStreaming('');
548
+ return;
549
+ }
550
+
551
+ const data = await res.json();
552
+
553
+ // If the API returns a direct response (non-streaming), display it
554
+ if (data.response) {
555
+ this.finishStreaming(data.response);
556
+ }
557
+ // Otherwise, response will come via WebSocket
558
+ } catch (err) {
559
+ this.showToast('Network error. Check your connection.', 'error');
560
+ this.finishStreaming('');
561
+ }
562
+ }
563
+
564
+ // ============================================================
565
+ // Message Rendering
566
+ // ============================================================
567
+
568
+ addMessage(role, content, metadata, skipPush) {
569
+ if (!skipPush) {
570
+ this.messages.push({ role, content, metadata, timestamp: Date.now() });
571
+ this.updateSessionTitle(content, role);
572
+ }
573
+
574
+ // Hide welcome screen once we have messages
575
+ if (this.dom.welcomeScreen && this.messages.length > 0) {
576
+ this.dom.welcomeScreen.classList.add('hidden');
577
+ }
578
+
579
+ const index = this.messages.length - 1;
580
+
581
+ const el = document.createElement('div');
582
+ el.className = `message ${role}`;
583
+ el.dataset.index = index;
584
+
585
+ const avatarLabel = role === 'user' ? 'Y' : role === 'system' ? '!' : (this.currentAgent ? this.currentAgent.icon || 'A' : 'A');
586
+
587
+ const isPinned = this.pinnedMessages.has(index);
588
+ const pinClass = isPinned ? ' pinned' : '';
589
+ const pinIcon = isPinned ? '\u{1F4CC}' : '\u{1F4CC}';
590
+
591
+ el.innerHTML = `
592
+ <div class="message-avatar" aria-hidden="true">${avatarLabel}</div>
593
+ <div class="message-body">
594
+ <div class="content">${role === 'user' ? this.escapeHtml(content) : this.renderMarkdown(content)}</div>
595
+ <div class="meta">
596
+ <span>${this.escapeHtml((metadata && metadata.agent) || this.getAgentLabel(role))}</span>
597
+ <span>${this.formatTime(metadata && metadata.timestamp ? metadata.timestamp : Date.now())}</span>
598
+ ${isPinned ? '<span class="pin-indicator">\u{1F4CC} Pinned</span>' : ''}
599
+ </div>
600
+ ${role !== 'system' ? `
601
+ <div class="actions">
602
+ <button title="Copy" aria-label="Copy message" onclick="chat.copyMessage(${index})">&#x2398;</button>
603
+ <button title="Pin" aria-label="Pin message" class="${pinClass}" onclick="chat.pinMessage(${index})">${pinIcon}</button>
604
+ <button title="Fork" aria-label="Fork from here" onclick="chat.forkFromMessage(${index})">&#x2442;</button>
605
+ </div>` : ''}
606
+ </div>
607
+ `;
608
+
609
+ this.dom.messages.appendChild(el);
610
+ this.scrollToBottom();
611
+
612
+ return el;
613
+ }
614
+
615
+ clearMessages() {
616
+ this.dom.messages.innerHTML = '';
617
+ if (this.messages.length === 0 && this.dom.welcomeScreen) {
618
+ this.dom.messages.appendChild(this.dom.welcomeScreen);
619
+ this.dom.welcomeScreen.classList.remove('hidden');
620
+ }
621
+ }
622
+
623
+ updateSessionTitle(content, role) {
624
+ if (role !== 'user' || !this.sessionId) return;
625
+ const session = this.sessions.find(s => s.id === this.sessionId);
626
+ if (!session) return;
627
+
628
+ // Use first user message as title
629
+ const userMessages = this.messages.filter(m => m.role === 'user');
630
+ if (userMessages.length === 1) {
631
+ session.title = content.substring(0, 60) + (content.length > 60 ? '...' : '');
632
+ }
633
+ session.updatedAt = Date.now();
634
+ this.saveSessionsToStorage();
635
+ this.renderSessions();
636
+ }
637
+
638
+ getAgentLabel(role) {
639
+ if (role === 'user') return 'You';
640
+ if (role === 'system') return 'System';
641
+ return this.currentAgent ? (this.currentAgent.title || this.currentAgent.name) : 'Assistant';
642
+ }
643
+
644
+ // ============================================================
645
+ // Markdown Renderer
646
+ // ============================================================
647
+
648
+ renderMarkdown(text) {
649
+ if (!text) return '';
650
+
651
+ let html = this.escapeHtml(text);
652
+
653
+ // Code blocks with optional language
654
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
655
+ const langLabel = lang ? `<div class="code-block-header"><span class="code-lang">${lang}</span><button class="code-copy-btn" onclick="chat.copyCodeBlock(this)">Copy</button></div>` : '';
656
+ return `${langLabel}<pre><code>${code}</code></pre>`;
657
+ });
658
+
659
+ // Inline code (must come after code blocks)
660
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
661
+
662
+ // Headers
663
+ html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
664
+ html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
665
+ html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
666
+ html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
667
+
668
+ // Bold and italic
669
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
670
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
671
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
672
+ html = html.replace(/_(.+?)_/g, '<em>$1</em>');
673
+
674
+ // Strikethrough
675
+ html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
676
+
677
+ // Blockquote
678
+ html = html.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
679
+
680
+ // Horizontal rule
681
+ html = html.replace(/^---$/gm, '<hr>');
682
+
683
+ // Links: [text](url)
684
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
685
+
686
+ // Unordered lists
687
+ html = html.replace(/^(\s*)[-*] (.+)$/gm, (_, indent, item) => {
688
+ const level = Math.floor(indent.length / 2);
689
+ return `<li style="margin-left:${level}em">${item}</li>`;
690
+ });
691
+ html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
692
+
693
+ // Ordered lists
694
+ html = html.replace(/^(\s*)\d+\. (.+)$/gm, (_, indent, item) => {
695
+ const level = Math.floor(indent.length / 2);
696
+ return `<oli style="margin-left:${level}em">${item}</oli>`;
697
+ });
698
+ html = html.replace(/((?:<oli[^>]*>.*<\/oli>\n?)+)/g, (match) => {
699
+ return '<ol>' + match.replace(/<\/?oli/g, (m) => m.replace('oli', 'li')) + '</ol>';
700
+ });
701
+
702
+ // Paragraphs (double newlines)
703
+ html = html.replace(/\n\n+/g, '</p><p>');
704
+ html = '<p>' + html + '</p>';
705
+
706
+ // Clean up empty paragraphs
707
+ html = html.replace(/<p>\s*<\/p>/g, '');
708
+ html = html.replace(/<p>(<h[1-6]>)/g, '$1');
709
+ html = html.replace(/(<\/h[1-6]>)<\/p>/g, '$1');
710
+ html = html.replace(/<p>(<pre>)/g, '$1');
711
+ html = html.replace(/(<\/pre>)<\/p>/g, '$1');
712
+ html = html.replace(/<p>(<ul>)/g, '$1');
713
+ html = html.replace(/(<\/ul>)<\/p>/g, '$1');
714
+ html = html.replace(/<p>(<ol>)/g, '$1');
715
+ html = html.replace(/(<\/ol>)<\/p>/g, '$1');
716
+ html = html.replace(/<p>(<blockquote>)/g, '$1');
717
+ html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
718
+ html = html.replace(/<p>(<hr>)/g, '$1');
719
+ html = html.replace(/(<hr>)<\/p>/g, '$1');
720
+ html = html.replace(/<p>(<div class="code-block-header">)/g, '$1');
721
+
722
+ // Single newlines to <br>
723
+ html = html.replace(/\n/g, '<br>');
724
+
725
+ return html;
726
+ }
727
+
728
+ copyCodeBlock(btnEl) {
729
+ const pre = btnEl.closest('.code-block-header')
730
+ ? btnEl.closest('.code-block-header').nextElementSibling
731
+ : btnEl.closest('pre');
732
+ if (!pre) return;
733
+ const code = pre.querySelector('code') || pre;
734
+ navigator.clipboard.writeText(code.textContent).then(() => {
735
+ const orig = btnEl.textContent;
736
+ btnEl.textContent = 'Copied!';
737
+ setTimeout(() => { btnEl.textContent = orig; }, 1500);
738
+ });
739
+ }
740
+
741
+ // ============================================================
742
+ // Streaming
743
+ // ============================================================
744
+
745
+ startStreaming() {
746
+ this.isStreaming = true;
747
+ this.streamingContent = '';
748
+ this.dom.typingIndicator.classList.remove('hidden');
749
+ this.dom.btnSend.disabled = true;
750
+
751
+ // Create empty assistant message
752
+ const el = document.createElement('div');
753
+ el.className = 'message assistant';
754
+ el.dataset.index = this.messages.length;
755
+
756
+ const avatarLabel = this.currentAgent ? (this.currentAgent.icon || 'A') : 'A';
757
+ el.innerHTML = `
758
+ <div class="message-avatar" aria-hidden="true">${avatarLabel}</div>
759
+ <div class="message-body">
760
+ <div class="content"><span class="streaming-cursor"></span></div>
761
+ <div class="meta">
762
+ <span>${this.escapeHtml(this.getAgentLabel('assistant'))}</span>
763
+ </div>
764
+ </div>
765
+ `;
766
+
767
+ this.dom.messages.appendChild(el);
768
+ this.streamingMessageEl = el;
769
+ this.scrollToBottom();
770
+ }
771
+
772
+ appendChunk(chunk) {
773
+ if (!chunk || !this.streamingMessageEl) return;
774
+ this.streamingContent += chunk;
775
+
776
+ const contentEl = this.streamingMessageEl.querySelector('.content');
777
+ if (contentEl) {
778
+ contentEl.innerHTML = this.renderMarkdown(this.streamingContent) + '<span class="streaming-cursor"></span>';
779
+ }
780
+ this.scrollToBottom();
781
+ }
782
+
783
+ finishStreaming(fullResponse) {
784
+ this.isStreaming = false;
785
+ this.dom.typingIndicator.classList.add('hidden');
786
+ this.dom.btnSend.disabled = false;
787
+
788
+ const content = fullResponse || this.streamingContent;
789
+
790
+ if (this.streamingMessageEl) {
791
+ // Remove streaming message, add final rendered message
792
+ this.streamingMessageEl.remove();
793
+ this.streamingMessageEl = null;
794
+ }
795
+
796
+ if (content) {
797
+ this.addMessage('assistant', content);
798
+ }
799
+
800
+ this.streamingContent = '';
801
+ this.dom.messageInput.focus();
802
+ }
803
+
804
+ // ============================================================
805
+ // Tool Approval
806
+ // ============================================================
807
+
808
+ showToolApproval(tool, command) {
809
+ this.pendingToolApproval = { tool, command };
810
+ this.dom.toolName.textContent = tool || 'Unknown tool';
811
+ this.dom.toolCommand.textContent = command || '';
812
+ this.dom.toolApproval.classList.remove('hidden');
813
+ }
814
+
815
+ approveTool() {
816
+ this.wsSend({
817
+ type: 'tool-response',
818
+ sessionId: this.sessionId,
819
+ approved: true,
820
+ tool: this.pendingToolApproval ? this.pendingToolApproval.tool : null
821
+ });
822
+ this.dom.toolApproval.classList.add('hidden');
823
+ this.pendingToolApproval = null;
824
+ }
825
+
826
+ denyTool() {
827
+ this.wsSend({
828
+ type: 'tool-response',
829
+ sessionId: this.sessionId,
830
+ approved: false,
831
+ tool: this.pendingToolApproval ? this.pendingToolApproval.tool : null
832
+ });
833
+ this.dom.toolApproval.classList.add('hidden');
834
+ this.pendingToolApproval = null;
835
+ this.addMessage('system', 'Tool request denied.');
836
+ }
837
+
838
+ // ============================================================
839
+ // Split View (Raw CLI Output)
840
+ // ============================================================
841
+
842
+ toggleSplit() {
843
+ this.splitView = !this.splitView;
844
+ this.dom.splitPanel.classList.toggle('hidden', !this.splitView);
845
+ }
846
+
847
+ appendRawOutput(text) {
848
+ if (!text) return;
849
+ this.dom.splitContent.textContent += text;
850
+ this.dom.splitContent.scrollTop = this.dom.splitContent.scrollHeight;
851
+ }
852
+
853
+ closeSplit() {
854
+ this.splitView = false;
855
+ this.dom.splitPanel.classList.add('hidden');
856
+ }
857
+
858
+ // ============================================================
859
+ // Voice Input
860
+ // ============================================================
861
+
862
+ async toggleVoice() {
863
+ if (this.voiceActive) {
864
+ this.stopVoice();
865
+ return;
866
+ }
867
+
868
+ // Try server-side STT first
869
+ try {
870
+ const res = await fetch('/api/stt/status');
871
+ if (res.ok) {
872
+ const data = await res.json();
873
+ if (data.available) {
874
+ this.startServerVoice();
875
+ return;
876
+ }
877
+ }
878
+ } catch { /* fallback to browser */ }
879
+
880
+ // Browser SpeechRecognition fallback
881
+ this.startBrowserVoice();
882
+ }
883
+
884
+ startServerVoice() {
885
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
886
+ this.showToast('Microphone access not available', 'warning');
887
+ return;
888
+ }
889
+
890
+ this.voiceActive = true;
891
+ this.dom.btnVoice.classList.add('recording');
892
+ this.audioChunks = [];
893
+
894
+ navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
895
+ this.mediaRecorder = new MediaRecorder(stream);
896
+
897
+ this.mediaRecorder.ondataavailable = (e) => {
898
+ if (e.data.size > 0) this.audioChunks.push(e.data);
899
+ };
900
+
901
+ this.mediaRecorder.onstop = async () => {
902
+ stream.getTracks().forEach(t => t.stop());
903
+ const blob = new Blob(this.audioChunks, { type: 'audio/webm' });
904
+
905
+ try {
906
+ const formData = new FormData();
907
+ formData.append('audio', blob, 'voice.webm');
908
+
909
+ const res = await fetch('/api/stt/transcribe', { method: 'POST', body: formData });
910
+ if (res.ok) {
911
+ const data = await res.json();
912
+ if (data.text) {
913
+ this.dom.messageInput.value += data.text;
914
+ this.dom.messageInput.dispatchEvent(new Event('input'));
915
+ }
916
+ } else {
917
+ this.showToast('Transcription failed', 'error');
918
+ }
919
+ } catch {
920
+ this.showToast('Transcription failed', 'error');
921
+ }
922
+ };
923
+
924
+ this.mediaRecorder.start();
925
+ }).catch(() => {
926
+ this.voiceActive = false;
927
+ this.dom.btnVoice.classList.remove('recording');
928
+ this.showToast('Microphone access denied', 'error');
929
+ });
930
+ }
931
+
932
+ startBrowserVoice() {
933
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
934
+ if (!SpeechRecognition) {
935
+ this.showToast('Speech recognition not supported in this browser', 'warning');
936
+ return;
937
+ }
938
+
939
+ this.voiceActive = true;
940
+ this.dom.btnVoice.classList.add('recording');
941
+
942
+ this.recognition = new SpeechRecognition();
943
+ this.recognition.lang = 'en-US';
944
+ this.recognition.interimResults = true;
945
+ this.recognition.continuous = false;
946
+
947
+ let finalTranscript = '';
948
+
949
+ this.recognition.onresult = (event) => {
950
+ let interim = '';
951
+ for (let i = event.resultIndex; i < event.results.length; i++) {
952
+ if (event.results[i].isFinal) {
953
+ finalTranscript += event.results[i][0].transcript;
954
+ } else {
955
+ interim += event.results[i][0].transcript;
956
+ }
957
+ }
958
+ this.dom.messageInput.value = finalTranscript + interim;
959
+ this.dom.messageInput.dispatchEvent(new Event('input'));
960
+ };
961
+
962
+ this.recognition.onend = () => {
963
+ this.voiceActive = false;
964
+ this.dom.btnVoice.classList.remove('recording');
965
+ this.recognition = null;
966
+ };
967
+
968
+ this.recognition.onerror = () => {
969
+ this.voiceActive = false;
970
+ this.dom.btnVoice.classList.remove('recording');
971
+ this.recognition = null;
972
+ };
973
+
974
+ this.recognition.start();
975
+ }
976
+
977
+ stopVoice() {
978
+ this.voiceActive = false;
979
+ this.dom.btnVoice.classList.remove('recording');
980
+
981
+ if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
982
+ this.mediaRecorder.stop();
983
+ }
984
+ if (this.recognition) {
985
+ this.recognition.stop();
986
+ }
987
+ }
988
+
989
+ // ============================================================
990
+ // File Attachment
991
+ // ============================================================
992
+
993
+ showAttachModal() {
994
+ this.openModal('modal-attach');
995
+ this.dom.attachPreview.innerHTML = '';
996
+ if (this.dom.attachInput) this.dom.attachInput.value = '';
997
+ }
998
+
999
+ handleAttachFiles(files) {
1000
+ this.dom.attachPreview.innerHTML = '';
1001
+
1002
+ for (const file of files) {
1003
+ const item = document.createElement('div');
1004
+ item.className = 'attach-file-item';
1005
+ item.innerHTML = `
1006
+ <span class="attach-file-name">${this.escapeHtml(file.name)}</span>
1007
+ <span class="attach-file-size">${this.formatFileSize(file.size)}</span>
1008
+ `;
1009
+ this.dom.attachPreview.appendChild(item);
1010
+ }
1011
+ }
1012
+
1013
+ async confirmAttach() {
1014
+ const input = this.dom.attachInput;
1015
+ if (!input || !input.files || input.files.length === 0) {
1016
+ this.closeModal('modal-attach');
1017
+ return;
1018
+ }
1019
+
1020
+ const files = Array.from(input.files);
1021
+ for (const file of files) {
1022
+ try {
1023
+ const text = await this.readFileAsText(file);
1024
+ this.attachedFiles.push({ name: file.name, content: text, size: file.size });
1025
+ } catch {
1026
+ this.showToast(`Could not read ${file.name}`, 'warning');
1027
+ }
1028
+ }
1029
+
1030
+ this.closeModal('modal-attach');
1031
+
1032
+ if (this.attachedFiles.length > 0) {
1033
+ const names = this.attachedFiles.map(f => f.name).join(', ');
1034
+ this.dom.messageInput.placeholder = `Attached: ${names} — Type your message...`;
1035
+ this.dom.messageInput.focus();
1036
+ }
1037
+ }
1038
+
1039
+ readFileAsText(file) {
1040
+ return new Promise((resolve, reject) => {
1041
+ const reader = new FileReader();
1042
+ reader.onload = () => resolve(reader.result);
1043
+ reader.onerror = reject;
1044
+ reader.readAsText(file);
1045
+ });
1046
+ }
1047
+
1048
+ // ============================================================
1049
+ // Import / Export
1050
+ // ============================================================
1051
+
1052
+ async exportAs(format) {
1053
+ if (this.messages.length === 0) {
1054
+ this.showToast('No messages to export', 'warning');
1055
+ this.closeModal('modal-export');
1056
+ return;
1057
+ }
1058
+
1059
+ let content, filename, mime;
1060
+ const agent = this.currentAgent ? this.currentAgent.name : 'unknown';
1061
+ const timestamp = new Date().toISOString().slice(0, 10);
1062
+
1063
+ switch (format) {
1064
+ case 'json': {
1065
+ const data = {
1066
+ version: '1.0',
1067
+ agent,
1068
+ sessionId: this.sessionId,
1069
+ exportedAt: new Date().toISOString(),
1070
+ messages: this.messages
1071
+ };
1072
+ content = JSON.stringify(data, null, 2);
1073
+ filename = `byan-chat-${agent}-${timestamp}.json`;
1074
+ mime = 'application/json';
1075
+ break;
1076
+ }
1077
+ case 'markdown': {
1078
+ const lines = [`# BYAN Chat — ${agent}`, `*Exported: ${new Date().toLocaleString()}*`, ''];
1079
+ for (const msg of this.messages) {
1080
+ const label = msg.role === 'user' ? '**You**' : msg.role === 'system' ? '*System*' : `**${agent}**`;
1081
+ lines.push(`### ${label}`, '', msg.content, '');
1082
+ }
1083
+ content = lines.join('\n');
1084
+ filename = `byan-chat-${agent}-${timestamp}.md`;
1085
+ mime = 'text/markdown';
1086
+ break;
1087
+ }
1088
+ case 'template': {
1089
+ const prompts = this.messages.filter(m => m.role === 'user').map(m => m.content);
1090
+ content = JSON.stringify({ version: '1.0', agent, prompts }, null, 2);
1091
+ filename = `byan-template-${agent}-${timestamp}.json`;
1092
+ mime = 'application/json';
1093
+ break;
1094
+ }
1095
+ default:
1096
+ return;
1097
+ }
1098
+
1099
+ this.downloadFile(content, filename, mime);
1100
+ this.closeModal('modal-export');
1101
+ this.showToast(`Exported as ${format}`, 'success');
1102
+ }
1103
+
1104
+ downloadFile(content, filename, mime) {
1105
+ const blob = new Blob([content], { type: mime });
1106
+ const url = URL.createObjectURL(blob);
1107
+ const a = document.createElement('a');
1108
+ a.href = url;
1109
+ a.download = filename;
1110
+ document.body.appendChild(a);
1111
+ a.click();
1112
+ document.body.removeChild(a);
1113
+ URL.revokeObjectURL(url);
1114
+ }
1115
+
1116
+ showImportModal() {
1117
+ this.openModal('modal-import');
1118
+ }
1119
+
1120
+ async importFromFile(file) {
1121
+ try {
1122
+ const formData = new FormData();
1123
+ formData.append('file', file);
1124
+ const res = await fetch('/api/agents/import', { method: 'POST', body: formData });
1125
+ if (res.ok) {
1126
+ this.showToast('Agent imported successfully', 'success');
1127
+ await this.loadAgents();
1128
+ } else {
1129
+ const err = await res.json().catch(() => ({}));
1130
+ this.showToast(err.error || 'Import failed', 'error');
1131
+ }
1132
+ } catch {
1133
+ this.showToast('Import failed', 'error');
1134
+ }
1135
+ this.closeModal('modal-import');
1136
+ }
1137
+
1138
+ async importFromURL() {
1139
+ const urlInput = document.getElementById('import-url');
1140
+ const url = urlInput ? urlInput.value.trim() : '';
1141
+ if (!url) {
1142
+ this.showToast('Please enter a URL', 'warning');
1143
+ return;
1144
+ }
1145
+
1146
+ try {
1147
+ const res = await fetch('/api/agents/import-url', {
1148
+ method: 'POST',
1149
+ headers: { 'Content-Type': 'application/json' },
1150
+ body: JSON.stringify({ url })
1151
+ });
1152
+ if (res.ok) {
1153
+ this.showToast('Agent imported successfully', 'success');
1154
+ await this.loadAgents();
1155
+ } else {
1156
+ const err = await res.json().catch(() => ({}));
1157
+ this.showToast(err.error || 'Import failed', 'error');
1158
+ }
1159
+ } catch {
1160
+ this.showToast('Import failed', 'error');
1161
+ }
1162
+ this.closeModal('modal-import');
1163
+ }
1164
+
1165
+ // ============================================================
1166
+ // Message Actions
1167
+ // ============================================================
1168
+
1169
+ copyMessage(index) {
1170
+ const msg = this.messages[index];
1171
+ if (!msg) return;
1172
+ navigator.clipboard.writeText(msg.content).then(() => {
1173
+ this.showToast('Copied to clipboard', 'success');
1174
+ }).catch(() => {
1175
+ this.showToast('Failed to copy', 'error');
1176
+ });
1177
+ }
1178
+
1179
+ pinMessage(index) {
1180
+ if (this.pinnedMessages.has(index)) {
1181
+ this.pinnedMessages.delete(index);
1182
+ } else {
1183
+ this.pinnedMessages.add(index);
1184
+ }
1185
+
1186
+ // Re-render the specific message
1187
+ const el = this.dom.messages.querySelector(`.message[data-index="${index}"]`);
1188
+ if (el) {
1189
+ const pinBtn = el.querySelector('.actions button[title="Pin"]');
1190
+ if (pinBtn) {
1191
+ pinBtn.classList.toggle('pinned', this.pinnedMessages.has(index));
1192
+ }
1193
+ const meta = el.querySelector('.meta');
1194
+ if (meta) {
1195
+ const existing = meta.querySelector('.pin-indicator');
1196
+ if (this.pinnedMessages.has(index) && !existing) {
1197
+ const pin = document.createElement('span');
1198
+ pin.className = 'pin-indicator';
1199
+ pin.textContent = '\u{1F4CC} Pinned';
1200
+ meta.appendChild(pin);
1201
+ } else if (!this.pinnedMessages.has(index) && existing) {
1202
+ existing.remove();
1203
+ }
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ async forkFromMessage(index) {
1209
+ const messagesUpTo = this.messages.slice(0, index + 1);
1210
+ const oldSessionId = this.sessionId;
1211
+
1212
+ await this.startSession();
1213
+
1214
+ for (const msg of messagesUpTo) {
1215
+ this.addMessage(msg.role, msg.content, msg.metadata, true);
1216
+ }
1217
+
1218
+ this.messages = [...messagesUpTo];
1219
+ this.addMessage('system', `Forked from message ${index + 1} of previous session.`);
1220
+ this.showToast('Conversation forked', 'success');
1221
+ }
1222
+
1223
+ // ============================================================
1224
+ // Model Switching
1225
+ // ============================================================
1226
+
1227
+ switchModel(model) {
1228
+ this.currentModel = model || null;
1229
+ this.saveState();
1230
+
1231
+ if (this.sessionId) {
1232
+ this.wsSend({
1233
+ type: 'config',
1234
+ sessionId: this.sessionId,
1235
+ model: this.currentModel
1236
+ });
1237
+ }
1238
+ }
1239
+
1240
+ // ============================================================
1241
+ // UI Helpers
1242
+ // ============================================================
1243
+
1244
+ autoGrowTextarea() {
1245
+ const textarea = this.dom.messageInput;
1246
+ textarea.addEventListener('input', () => {
1247
+ textarea.style.height = 'auto';
1248
+ textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
1249
+ this.dom.charCount.textContent = textarea.value.length > 0 ? `${textarea.value.length}` : '';
1250
+ });
1251
+ }
1252
+
1253
+ scrollToBottom() {
1254
+ requestAnimationFrame(() => {
1255
+ this.dom.messages.scrollTop = this.dom.messages.scrollHeight;
1256
+ });
1257
+ }
1258
+
1259
+ openModal(id) {
1260
+ const modal = document.getElementById(id);
1261
+ if (modal) {
1262
+ modal.classList.remove('hidden');
1263
+ const firstInput = modal.querySelector('input, button, select, textarea');
1264
+ if (firstInput) firstInput.focus();
1265
+ }
1266
+ }
1267
+
1268
+ closeModal(id) {
1269
+ const modal = document.getElementById(id);
1270
+ if (modal) modal.classList.add('hidden');
1271
+ }
1272
+
1273
+ closeAllModals() {
1274
+ document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
1275
+ }
1276
+
1277
+ toggleSidebar() {
1278
+ this.dom.sidebar.classList.toggle('open');
1279
+ this.dom.sidebarOverlay.classList.toggle('hidden', !this.dom.sidebar.classList.contains('open'));
1280
+ }
1281
+
1282
+ closeSidebar() {
1283
+ this.dom.sidebar.classList.remove('open');
1284
+ this.dom.sidebarOverlay.classList.add('hidden');
1285
+ }
1286
+
1287
+ showToast(message, type) {
1288
+ const toast = document.createElement('div');
1289
+ toast.className = `toast ${type || ''}`;
1290
+ toast.textContent = message;
1291
+ document.body.appendChild(toast);
1292
+ setTimeout(() => {
1293
+ if (toast.parentNode) toast.parentNode.removeChild(toast);
1294
+ }, 4000);
1295
+ }
1296
+
1297
+ // ============================================================
1298
+ // Event Listeners
1299
+ // ============================================================
1300
+
1301
+ setupEventListeners() {
1302
+ // Send button
1303
+ this.dom.btnSend.addEventListener('click', () => this.send());
1304
+
1305
+ // Textarea keyboard
1306
+ this.dom.messageInput.addEventListener('keydown', (e) => {
1307
+ if (e.key === 'Enter' && !e.shiftKey) {
1308
+ e.preventDefault();
1309
+ this.send();
1310
+ }
1311
+ });
1312
+
1313
+ // Topbar buttons
1314
+ this.dom.btnSplit.addEventListener('click', () => this.toggleSplit());
1315
+ this.dom.btnExport.addEventListener('click', () => this.openModal('modal-export'));
1316
+ this.dom.btnSettings.addEventListener('click', () => this.openModal('modal-settings'));
1317
+ this.dom.btnSidebar.addEventListener('click', () => this.toggleSidebar());
1318
+ this.dom.btnImportAgent.addEventListener('click', () => this.showImportModal());
1319
+
1320
+ // Voice & attach
1321
+ this.dom.btnVoice.addEventListener('click', () => this.toggleVoice());
1322
+ this.dom.btnAttach.addEventListener('click', () => this.showAttachModal());
1323
+
1324
+ // Model select
1325
+ this.dom.modelSelect.addEventListener('change', (e) => this.switchModel(e.target.value));
1326
+
1327
+ // Sidebar overlay (close on click)
1328
+ this.dom.sidebarOverlay.addEventListener('click', () => this.closeSidebar());
1329
+
1330
+ // Attach input change
1331
+ if (this.dom.attachInput) {
1332
+ this.dom.attachInput.addEventListener('change', (e) => this.handleAttachFiles(e.target.files));
1333
+ }
1334
+
1335
+ // File import via file-input in import modal
1336
+ const fileInput = document.getElementById('file-input');
1337
+ if (fileInput) {
1338
+ fileInput.addEventListener('change', (e) => {
1339
+ if (e.target.files.length > 0) this.importFromFile(e.target.files[0]);
1340
+ });
1341
+ }
1342
+
1343
+ // Drag and drop on import modal
1344
+ const dropZone = document.getElementById('drop-zone');
1345
+ if (dropZone) {
1346
+ dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
1347
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
1348
+ dropZone.addEventListener('drop', (e) => {
1349
+ e.preventDefault();
1350
+ dropZone.classList.remove('drag-over');
1351
+ if (e.dataTransfer.files.length > 0) this.importFromFile(e.dataTransfer.files[0]);
1352
+ });
1353
+ }
1354
+
1355
+ // Global drag-drop for file attachment
1356
+ document.body.addEventListener('dragover', (e) => e.preventDefault());
1357
+ document.body.addEventListener('drop', (e) => {
1358
+ e.preventDefault();
1359
+ if (e.dataTransfer.files.length > 0) {
1360
+ const files = Array.from(e.dataTransfer.files);
1361
+ this.attachDroppedFiles(files);
1362
+ }
1363
+ });
1364
+
1365
+ // Keyboard shortcuts
1366
+ document.addEventListener('keydown', (e) => {
1367
+ // Escape closes modals
1368
+ if (e.key === 'Escape') {
1369
+ this.closeAllModals();
1370
+ this.closeSidebar();
1371
+ }
1372
+
1373
+ // Ctrl+/ toggles sidebar
1374
+ if (e.ctrlKey && e.key === '/') {
1375
+ e.preventDefault();
1376
+ this.toggleSidebar();
1377
+ }
1378
+
1379
+ // Ctrl+Shift+E export
1380
+ if (e.ctrlKey && e.shiftKey && e.key === 'E') {
1381
+ e.preventDefault();
1382
+ this.openModal('modal-export');
1383
+ }
1384
+
1385
+ // Ctrl+N new chat
1386
+ if (e.ctrlKey && e.key === 'n') {
1387
+ e.preventDefault();
1388
+ this.startSession();
1389
+ }
1390
+ });
1391
+
1392
+ // Close modals on backdrop click
1393
+ document.querySelectorAll('.modal').forEach(modal => {
1394
+ modal.addEventListener('click', (e) => {
1395
+ if (e.target === modal) {
1396
+ modal.classList.add('hidden');
1397
+ }
1398
+ });
1399
+ });
1400
+ }
1401
+
1402
+ async attachDroppedFiles(files) {
1403
+ for (const file of files) {
1404
+ try {
1405
+ const text = await this.readFileAsText(file);
1406
+ this.attachedFiles.push({ name: file.name, content: text, size: file.size });
1407
+ } catch {
1408
+ this.showToast(`Could not read ${file.name}`, 'warning');
1409
+ }
1410
+ }
1411
+
1412
+ if (this.attachedFiles.length > 0) {
1413
+ const names = this.attachedFiles.map(f => f.name).join(', ');
1414
+ this.dom.messageInput.placeholder = `Attached: ${names} — Type your message...`;
1415
+ this.dom.messageInput.focus();
1416
+ this.showToast(`${files.length} file(s) attached`, 'success');
1417
+ }
1418
+ }
1419
+
1420
+ // ============================================================
1421
+ // Conversation Fork
1422
+ // ============================================================
1423
+
1424
+ async forkConversation(atMessageIndex) {
1425
+ return this.forkFromMessage(atMessageIndex);
1426
+ }
1427
+
1428
+ // ============================================================
1429
+ // State Persistence
1430
+ // ============================================================
1431
+
1432
+ saveState() {
1433
+ try {
1434
+ localStorage.setItem('byan-chat-state', JSON.stringify({
1435
+ currentCLI: this.currentCLI,
1436
+ currentModel: this.currentModel,
1437
+ currentAgent: this.currentAgent ? this.currentAgent.name : null,
1438
+ sessionId: this.sessionId
1439
+ }));
1440
+ } catch { /* ignore */ }
1441
+ }
1442
+
1443
+ restoreState() {
1444
+ try {
1445
+ const state = JSON.parse(localStorage.getItem('byan-chat-state') || '{}');
1446
+
1447
+ if (state.currentCLI) this.selectCLI(state.currentCLI);
1448
+ if (state.currentModel) {
1449
+ this.currentModel = state.currentModel;
1450
+ this.dom.modelSelect.value = state.currentModel;
1451
+ }
1452
+ if (state.currentAgent) {
1453
+ const agent = this.agents.find(a => a.name === state.currentAgent);
1454
+ if (agent) {
1455
+ this.currentAgent = agent;
1456
+ this.dom.activeAgentIcon.textContent = agent.icon || '\u{1F916}';
1457
+ this.dom.activeAgentName.textContent = agent.title || agent.name;
1458
+ this.dom.agentIndicator.classList.add('active');
1459
+ this.dom.agentList.querySelectorAll('.agent-card').forEach(el => {
1460
+ el.classList.toggle('active', el.dataset.agent === agent.name);
1461
+ });
1462
+ }
1463
+ }
1464
+ if (state.sessionId) {
1465
+ this.loadSession(state.sessionId);
1466
+ }
1467
+ } catch { /* ignore */ }
1468
+ }
1469
+
1470
+ // ============================================================
1471
+ // Utility
1472
+ // ============================================================
1473
+
1474
+ escapeHtml(str) {
1475
+ if (!str) return '';
1476
+ const div = document.createElement('div');
1477
+ div.textContent = str;
1478
+ return div.innerHTML;
1479
+ }
1480
+
1481
+ generateId() {
1482
+ return 'byan-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8);
1483
+ }
1484
+
1485
+ formatTime(ts) {
1486
+ const d = new Date(ts);
1487
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1488
+ }
1489
+
1490
+ formatRelativeTime(ts) {
1491
+ const diff = Date.now() - ts;
1492
+ const seconds = Math.floor(diff / 1000);
1493
+ const minutes = Math.floor(seconds / 60);
1494
+ const hours = Math.floor(minutes / 60);
1495
+ const days = Math.floor(hours / 24);
1496
+
1497
+ if (days > 7) return new Date(ts).toLocaleDateString();
1498
+ if (days > 0) return `${days}d ago`;
1499
+ if (hours > 0) return `${hours}h ago`;
1500
+ if (minutes > 0) return `${minutes}m ago`;
1501
+ return 'just now';
1502
+ }
1503
+
1504
+ formatFileSize(bytes) {
1505
+ if (bytes < 1024) return bytes + ' B';
1506
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
1507
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1508
+ }
1509
+ }
1510
+
1511
+ // Initialize
1512
+ const chat = new ByanChat();