create-byan-agent 2.8.1 → 2.9.1

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