1
+/**
2
+ * Main application logic for the Netdata MCP LLM Client
3
+ */
4
+
5
+import {MessageOptimizer} from './message-optimizer.js';
6
+import * as ChatConfig from './chat-config.js';
7
+import * as TitleGenerator from './title.js';
8
+import * as SystemMsg from './system-msg.js';
9
+import {SafetyChecker, SafetyLimitError, SAFETY_LIMITS} from './safety-limits.js';
10
+
11
+class NetdataMCPChat {
12
+ constructor() {
13
+ // Log version on startup
14
+ console.log('🚀 Netdata MCP Web Client v1.0.9 - Simplified resume using sendMessage');
15
+
16
+ this.mcpServers = new Map(); // Multiple MCP servers
17
+ this.mcpConnections = new Map(); // Active MCP connections
18
+ this.llmProviders = new Map(); // Multiple LLM providers
19
+ this.chats = new Map(); // Chat sessions
20
+ this.communicationLog = []; // Universal log (not saved)
21
+ this.tokenUsageHistory = new Map(); // Track token usage per chat
22
+ this.toolInclusionStates = new Map(); // Track which tools are included/excluded per chat
23
+ this.currentContextWindow = 0; // Running total for delta calculation during rendering
24
+ this.shouldStopProcessing = false; // Flag to stop processing between requests
25
+ this.isProcessing = false; // Track if we're currently processing messages
26
+ this.modelPricing = {}; // Initialize model pricing storage
27
+ this.modelLimits = {}; // Initialize model context limits storage
28
+ this.copiedModel = null; // Track copied model for paste functionality
29
+
30
+ // Safety protections
31
+ this.safetyChecker = new SafetyChecker();
32
+
33
+ // Per-chat DOM management
34
+ this.chatContainers = new Map(); // Map of chatId -> DOM container
35
+
36
+ // Models will be loaded dynamically from the proxy server
37
+ // No hardcoded model list needed
38
+
39
+
40
+ // Default system prompt
41
+ this.defaultSystemPrompt = SystemMsg.DEFAULT_SYSTEM_PROMPT;
42
+
43
+ // Load last used system prompt from localStorage or use default
44
+ this.lastSystemPrompt = localStorage.getItem('lastSystemPrompt') || this.defaultSystemPrompt;
45
+
46
+ this.initializeUI();
47
+
48
+ // Delay resizable initialization to ensure DOM is ready
49
+ setTimeout(() => {
50
+ this.initializeResizable();
51
+ }, 0);
52
+
53
+ // Clear current chat ID to always start fresh
54
+ localStorage.removeItem('currentChatId');
55
+
56
+ // Get reference to main container
57
+ this.chatContainersEl = document.getElementById('chatContainers');
58
+ this.welcomeScreen = document.getElementById('welcomeScreen');
59
+
60
+ // Show welcome screen initially
61
+ if (this.welcomeScreen) {
62
+ this.welcomeScreen.style.display = 'flex';
63
+ }
64
+
65
+ this.loadSettings();
66
+
67
+ // Track if user has interacted with chat selection
68
+ this.userHasSelectedChat = false;
69
+
70
+ // Track if we have a pending new chat load
71
+ this.pendingNewChatLoad = false;
72
+
73
+ // Track if providers are loaded
74
+ this.providersLoaded = false;
75
+
76
+ // Initialize providers and then create default chat
77
+ // First initialize LLM provider, then MCP servers (which need the provider URL)
78
+ this.initializeDefaultLLMProvider().then(() => {
79
+ return this.initializeDefaultMCPServers();
80
+ }).then(async () => {
81
+ // Mark providers as loaded
82
+ this.providersLoaded = true;
83
+
84
+ // Update chat sessions after providers are loaded
85
+ this.updateChatSessions();
86
+
87
+ // Only create a new chat if we have both MCP servers and LLM providers
88
+ if (this.mcpServers.size > 0 && this.llmProviders.size > 0) {
89
+ // Always create a new chat on startup
90
+ const newChatId = await this.createDefaultChatIfNeeded();
91
+
92
+ // If a new chat was created AND user hasn't selected a chat, load it
93
+ if (newChatId && !this.userHasSelectedChat) {
94
+ // Mark that we have a pending new chat load
95
+ this.pendingNewChatLoad = true;
96
+ this.pendingNewChatId = newChatId;
97
+
98
+ // Give DOM time to update after chat creation
99
+ this.pendingNewChatTimeout = setTimeout(() => {
100
+ // Double-check user hasn't selected a chat in the meantime
101
+ if (!this.userHasSelectedChat && this.pendingNewChatLoad) {
102
+ this.loadChat(newChatId);
103
+ }
104
+ // Clear the pending flag
105
+ this.pendingNewChatLoad = false;
106
+ this.pendingNewChatTimeout = null;
107
+
108
+ // Clear the pending chat ID after a delay to ensure blocking works
109
+ setTimeout(() => {
110
+ this.pendingNewChatId = '';
111
+ }, 500);
112
+ }, 100);
113
+ }
114
+ } // Close the if (this.mcpServers.size > 0 && this.llmProviders.size > 0)
115
+ }).catch(error => {
116
+ console.error('Failed to initialize providers:', error);
117
+ // Still update chat sessions even if providers fail
118
+ this.updateChatSessions();
119
+ });
120
+
121
+ // Add global error handlers to catch unhandled errors
122
+ this.setupGlobalErrorHandlers();
123
+ }
124
+
125
+ setupGlobalErrorHandlers() {
126
+ // Catch unhandled JavaScript errors
127
+ window.addEventListener('error', (event) => {
128
+ console.error('Unhandled JavaScript error:', event.error);
129
+ this.showGlobalError(`JavaScript Error: ${event.error?.message || 'Unknown error'}`);
130
+ });
131
+
132
+ // Catch unhandled promise rejections
133
+ window.addEventListener('unhandledrejection', (event) => {
134
+ console.error('Unhandled promise rejection:', event.reason);
135
+ this.showGlobalError(`Promise Error: ${event.reason?.message || event.reason || 'Unknown promise rejection'}`);
136
+ // Prevent the default browser error console log
137
+ event.preventDefault();
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Extract tool calls from message content array
143
+ * @param {Array|string} content - Message content (can be array of blocks or string)
144
+ * @returns {Array} - Array of tool call objects with id, name, and arguments
145
+ */
146
+ extractToolsFromContent(content) {
147
+ if (!Array.isArray(content)) return [];
148
+ return content
149
+ .filter(block => block.type === 'tool_use')
150
+ .map(block => ({
151
+ id: block.id,
152
+ name: block.name,
153
+ arguments: block.input
154
+ }));
155
+ }
156
+
157
+ /**
158
+ * Safe message operations that automatically persist changes
159
+ * These methods ensure messages are never lost by auto-saving after each operation
160
+ *
161
+ * IMPORTANT: Always use these methods instead of direct array manipulation
162
+ * - addMessage() instead of messages.push()
163
+ * - insertMessage() instead of messages.splice(index, 0, item)
164
+ * - removeMessage() instead of messages.splice(index, count)
165
+ * - removeLastMessage() instead of messages.pop()
166
+ *
167
+ * CRITICAL ORDERING RULE: Always save messages BEFORE displaying them!
168
+ * 1. Call addMessage() to save the message
169
+ * 2. Call processRenderEvent() to display it
170
+ * This ensures users never see messages that aren't persisted
171
+ *
172
+ * ATOMIC OPERATIONS: Use batchMode for multi-step operations
173
+ * this.batchMode = true;
174
+ * try {
175
+ * // Multiple operations
176
+ * } finally {
177
+ * this.batchMode = false;
178
+ * this.autoSave(chatId);
179
+ * }
180
+ */
181
+ addMessage(chatId, message) {
182
+ if (!chatId) {
183
+ console.error('addMessage called without chatId');
184
+ return;
185
+ }
186
+
187
+ const chat = this.chats.get(chatId);
188
+ if (!chat) {
189
+ console.error('addMessage: chat not found for chatId:', chatId);
190
+ return;
191
+ }
192
+
193
+ // Calculate and add price to message if it has token usage
194
+ if (message.usage && message.model) {
195
+ const price = this.calculateMessagePrice(message.model, message.usage);
196
+ if (price !== null) {
197
+ message.price = price;
198
+ }
199
+ }
200
+
201
+ chat.messages.push(message);
202
+ chat.updatedAt = new Date().toISOString();
203
+
204
+ // Update cumulative token pricing
205
+ this.updateChatTokenPricing(chat);
206
+
207
+ // Update the cumulative token display
208
+ this.updateCumulativeTokenDisplay(chatId);
209
+
210
+ this.autoSave(chatId);
211
+ }
212
+
213
+ insertMessage(chatId, index, message) {
214
+ if (!chatId) {
215
+ console.error('insertMessage called without chatId');
216
+ return;
217
+ }
218
+
219
+ const chat = this.chats.get(chatId);
220
+ if (!chat) {
221
+ console.error('insertMessage: chat not found for chatId:', chatId);
222
+ return;
223
+ }
224
+
225
+ chat.messages.splice(index, 0, message);
226
+ chat.updatedAt = new Date().toISOString();
227
+
228
+ // Update cumulative token pricing
229
+ this.updateChatTokenPricing(chat);
230
+
231
+ // Update the cumulative token display
232
+ this.updateCumulativeTokenDisplay(chatId);
233
+
234
+ this.autoSave(chatId);
235
+ }
236
+
237
+ removeMessage(chatId, index, count = 1) {
238
+ if (!chatId) {
239
+ console.error('removeMessage called without chatId');
240
+ return;
241
+ }
242
+
243
+ const chat = this.chats.get(chatId);
244
+ if (!chat) {
245
+ console.error('removeMessage: chat not found for chatId:', chatId);
246
+ return;
247
+ }
248
+
249
+ chat.messages.splice(index, count);
250
+ chat.updatedAt = new Date().toISOString();
251
+
252
+ // Update cumulative token pricing
253
+ this.updateChatTokenPricing(chat);
254
+
255
+ // Update the cumulative token display
256
+ this.updateCumulativeTokenDisplay(chatId);
257
+
258
+ this.autoSave(chatId);
259
+ }
260
+
261
+ removeLastMessage(chatId) {
262
+ if (!chatId) {
263
+ console.error('removeLastMessage called without chatId');
264
+ return;
265
+ }
266
+
267
+ const chat = this.chats.get(chatId);
268
+ if (!chat || !this.hasUserContent(chat)) {
269
+ console.error('removeLastMessage: chat not found or no user content for chatId:', chatId);
270
+ return;
271
+ }
272
+
273
+ // Use removeMessage API instead of direct pop()
274
+ if (chat.messages.length > 0) {
275
+ this.removeMessage(chatId, chat.messages.length - 1, 1);
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Truncate messages from a specific index onwards, creating accounting records if needed
281
+ * @param {string} chatId - The chat ID
282
+ * @param {number} startIndex - Index from which to truncate (exclusive - messages from this index onwards are removed)
283
+ * @param {string} reason - Reason for truncation (e.g., 'Redo from user message')
284
+ */
285
+ truncateMessages(chatId, startIndex, reason = 'Messages truncated') {
286
+ const chat = this.chats.get(chatId);
287
+ if (!chat) {
288
+ console.error('truncateMessages: chat not found for chatId:', chatId);
289
+ return;
290
+ }
291
+
292
+ // Calculate messages to discard
293
+ const messagesToDiscard = chat.messages.length - startIndex;
294
+ if (messagesToDiscard <= 0) {
295
+ // Nothing to truncate
296
+ return;
297
+ }
298
+
299
+ // Find messages that will be discarded (from startIndex onwards)
300
+ const discardedMessages = chat.messages.slice(startIndex);
301
+
302
+ // Check if any discarded messages have non-zero tokens/costs
303
+ let hasTokens = false;
304
+ for (const message of discardedMessages) {
305
+ if (message.usage && message.model) {
306
+ const usage = message.usage;
307
+ if ((usage.promptTokens || 0) > 0 ||
308
+ (usage.completionTokens || 0) > 0 ||
309
+ (usage.cacheReadInputTokens || 0) > 0 ||
310
+ (usage.cacheCreationInputTokens || 0) > 0) {
311
+ hasTokens = true;
312
+ break;
313
+ }
314
+ }
315
+ }
316
+
317
+ // Only create accounting nodes if there are tokens to preserve
318
+ if (hasTokens) {
319
+ // Group discarded tokens by model
320
+ const tokensByModel = new Map();
321
+
322
+ for (const message of discardedMessages) {
323
+ if (message.usage && message.model) {
324
+ const model = message.model;
325
+ if (!tokensByModel.has(model)) {
326
+ tokensByModel.set(model, {
327
+ inputTokens: 0,
328
+ outputTokens: 0,
329
+ cacheReadTokens: 0,
330
+ cacheCreationTokens: 0,
331
+ messageCount: 0
332
+ });
333
+ }
334
+
335
+ const tokens = tokensByModel.get(model);
336
+ tokens.inputTokens += message.usage.promptTokens || 0;
337
+ tokens.outputTokens += message.usage.completionTokens || 0;
338
+ tokens.cacheCreationTokens += message.usage.cacheCreationInputTokens || 0;
339
+ tokens.cacheReadTokens += message.usage.cacheReadInputTokens || 0;
340
+ tokens.messageCount++;
341
+ }
342
+ }
343
+
344
+ // Create accounting nodes for each model
345
+ let insertIndex = startIndex;
346
+ for (const [model, tokens] of tokensByModel) {
347
+ // Only create accounting node if this model has non-zero tokens
348
+ if (tokens.inputTokens > 0 || tokens.outputTokens > 0 ||
349
+ tokens.cacheReadTokens > 0 || tokens.cacheCreationTokens > 0) {
350
+ const accountingNode = {
351
+ role: 'accounting',
352
+ timestamp: new Date().toISOString(),
353
+ model,
354
+ cumulativeTokens: tokens,
355
+ reason,
356
+ discardedMessages: tokens.messageCount
357
+ };
358
+ this.insertMessage(chatId, insertIndex, accountingNode);
359
+ insertIndex++;
360
+ }
361
+ }
362
+
363
+ // Remove all messages after accounting nodes
364
+ const toRemove = chat.messages.length - insertIndex;
365
+ if (toRemove > 0) {
366
+ this.removeMessage(chatId, insertIndex, toRemove);
367
+ }
368
+ } else {
369
+ // No tokens to preserve, just remove messages
370
+ this.removeMessage(chatId, startIndex, messagesToDiscard);
371
+ }
372
+
373
+ this.autoSave(chatId);
374
+ }
375
+
376
+ /**
377
+ * Check if a chat has any real user content (excluding system messages)
378
+ */
379
+ hasUserContent(chat) {
380
+ if (!chat || !chat.messages) {return false;}
381
+ return chat.messages.some(m =>
382
+ m.role !== 'system' &&
383
+ m.role !== 'system-title' &&
384
+ m.role !== 'system-summary' &&
385
+ m.role !== 'title' &&
386
+ m.role !== 'summary' &&
387
+ m.role !== 'accounting'
388
+ );
389
+ }
390
+
391
+ /**
392
+ * Check if this is the first real user message in the chat
393
+ */
394
+ isFirstUserMessage(chat) {
395
+ if (!chat || !chat.messages) {return false;}
396
+ const userMessages = chat.messages.filter(m =>
397
+ m.role === 'user'
398
+ );
399
+ return userMessages.length === 1;
400
+ }
401
+
402
+ /**
403
+ * Count real assistant messages (excluding title responses)
404
+ */
405
+ countAssistantMessages(chat) {
406
+ if (!chat || !chat.messages) {return 0;}
407
+ return chat.messages.filter(m =>
408
+ m.role === 'assistant'
409
+ ).length;
410
+ }
411
+
412
+ /**
413
+ * Auto-save with debouncing for performance
414
+ * Saves only the specific chat that was modified
415
+ */
416
+ autoSave(chatId) {
417
+ if (!chatId) {return;}
418
+
419
+ // For per-chat saves, we can be more aggressive since we're only saving one chat
420
+ // Clear any pending save for this specific chat
421
+ if (this.pendingSaveTimeouts) {
422
+ if (this.pendingSaveTimeouts[chatId]) {
423
+ clearTimeout(this.pendingSaveTimeouts[chatId]);
424
+ }
425
+ } else {
426
+ this.pendingSaveTimeouts = {};
427
+ }
428
+
429
+ // Save this specific chat after a short delay
430
+ this.pendingSaveTimeouts[chatId] = setTimeout(() => {
431
+ this.saveChatToStorage(chatId);
432
+ delete this.pendingSaveTimeouts[chatId];
433
+ }, 100); // 100ms debounce
434
+ }
435
+
436
+ /**
437
+ * Save chat configuration - only saves to chatConfig_chat_XXX for saved chats
438
+ * For unsaved chats, only updates lastChatConfig
439
+ */
440
+ saveChatConfigSmart(chatId, config) {
441
+ const chat = this.chats.get(chatId);
442
+ if (!chat) return;
443
+
444
+ // Always save as last config for new chats to inherit
445
+ ChatConfig.saveLastConfig(config);
446
+
447
+ // Only save chat-specific config if the chat is saved
448
+ if (chat.isSaved !== false && chat.messages.length > 0) {
449
+ ChatConfig.saveChatConfig(chatId, config);
450
+ }
451
+ }
452
+
453
+ // Calculate price for a single message based on its model and usage
454
+ calculateMessagePrice(model, usage) {
455
+ if (!usage || !model) {return null;}
456
+
457
+ // Extract model name from format "provider:model-name"
458
+ let modelName = model;
459
+ if (typeof model === 'string') {
460
+ modelName = ChatConfig.getModelDisplayName(model);
461
+ } else if (model?.id) {
462
+ modelName = model.id;
463
+ }
464
+
465
+ const pricing = this.modelPricing[modelName];
466
+ if (!pricing) {return null;}
467
+
468
+ let totalCost = 0;
469
+
470
+ const promptTokens = usage.promptTokens || 0;
471
+ const completionTokens = usage.completionTokens || 0;
472
+ const cacheReadTokens = usage.cacheReadInputTokens || 0;
473
+ const cacheCreationTokens = usage.cacheCreationInputTokens || 0;
474
+
475
+ // For Anthropic models with cache pricing
476
+ if (pricing.cacheWrite !== undefined && pricing.cacheRead !== undefined) {
477
+ totalCost += promptTokens / 1_000_000 * pricing.input;
478
+ totalCost += cacheReadTokens / 1_000_000 * pricing.cacheRead;
479
+ totalCost += cacheCreationTokens / 1_000_000 * pricing.cacheWrite;
480
+ totalCost += completionTokens / 1_000_000 * pricing.output;
481
+ }
482
+ // For OpenAI models with cache pricing
483
+ else if (pricing.cacheRead !== undefined) {
484
+ const cachedInputTokens = cacheReadTokens + cacheCreationTokens;
485
+ totalCost += promptTokens / 1_000_000 * pricing.input;
486
+ totalCost += cachedInputTokens / 1_000_000 * pricing.cacheRead;
487
+ totalCost += completionTokens / 1_000_000 * pricing.output;
488
+ }
489
+ // For models without cache pricing
490
+ else {
491
+ const allInputTokens = promptTokens + cacheReadTokens + cacheCreationTokens;
492
+ totalCost += allInputTokens / 1_000_000 * pricing.input;
493
+ totalCost += completionTokens / 1_000_000 * pricing.output;
494
+ }
495
+
496
+ return totalCost;
497
+ }
498
+
499
+ // Update the chat's cumulative token pricing
500
+ updateChatTokenPricing(chat) {
501
+ if (!chat) {
502
+ console.error('updateChatTokenPricing called without chat object');
503
+ return;
504
+ }
505
+
506
+ // Initialize if not present
507
+ if (!chat.totalTokensPrice) {
508
+ chat.totalTokensPrice = {
509
+ input: 0,
510
+ output: 0,
511
+ cacheRead: 0,
512
+ cacheCreation: 0,
513
+ totalCost: 0
514
+ };
515
+ }
516
+
517
+ if (!chat.perModelTokensPrice) {
518
+ chat.perModelTokensPrice = {};
519
+ }
520
+
521
+ // Reset totals
522
+ chat.totalTokensPrice = {
523
+ input: 0,
524
+ output: 0,
525
+ cacheRead: 0,
526
+ cacheCreation: 0,
527
+ totalCost: 0
528
+ };
529
+ chat.perModelTokensPrice = {};
530
+
531
+ // Calculate from all messages
532
+ for (const message of chat.messages) {
533
+ if (message.usage) {
534
+ const model = message.model || ChatConfig.getChatModelString(chat); // Fallback to chat model for old messages
535
+ if (!model) {continue;}
536
+
537
+ // Update total tokens
538
+ chat.totalTokensPrice.input += message.usage.promptTokens || 0;
539
+ chat.totalTokensPrice.output += message.usage.completionTokens || 0;
540
+ chat.totalTokensPrice.cacheRead += message.usage.cacheReadInputTokens || 0;
541
+ chat.totalTokensPrice.cacheCreation += message.usage.cacheCreationInputTokens || 0;
542
+
543
+ // Update per-model tokens
544
+ if (!chat.perModelTokensPrice[model]) {
545
+ chat.perModelTokensPrice[model] = {
546
+ input: 0,
547
+ output: 0,
548
+ cacheRead: 0,
549
+ cacheCreation: 0,
550
+ totalCost: 0
551
+ };
552
+ }
553
+
554
+ chat.perModelTokensPrice[model].input += message.usage.promptTokens || 0;
555
+ chat.perModelTokensPrice[model].output += message.usage.completionTokens || 0;
556
+ chat.perModelTokensPrice[model].cacheRead += message.usage.cacheReadInputTokens || 0;
557
+ chat.perModelTokensPrice[model].cacheCreation += message.usage.cacheCreationInputTokens || 0;
558
+
559
+ // Add price if available
560
+ if (message.price !== undefined) {
561
+ chat.totalTokensPrice.totalCost += message.price;
562
+ chat.perModelTokensPrice[model].totalCost += message.price;
563
+ }
564
+ }
565
+
566
+ // Handle accounting nodes
567
+ if (message.role === 'accounting' && message.cumulativeTokens) {
568
+ // Add the preserved tokens from accounting node
569
+ chat.totalTokensPrice.input += message.cumulativeTokens.inputTokens || 0;
570
+ chat.totalTokensPrice.output += message.cumulativeTokens.outputTokens || 0;
571
+ chat.totalTokensPrice.cacheRead += message.cumulativeTokens.cacheReadTokens || 0;
572
+ chat.totalTokensPrice.cacheCreation += message.cumulativeTokens.cacheCreationTokens || 0;
573
+
574
+ // Note: We can't attribute accounting node tokens to specific models
575
+ // They represent aggregated tokens from deleted messages
576
+ }
577
+ }
578
+ }
579
+
580
+ // Migrate old chat data to include token pricing
581
+ migrateTokenPricing(chat) {
582
+ // Initialize structures if not present
583
+ if (!chat.totalTokensPrice) {
584
+ chat.totalTokensPrice = {
585
+ input: 0,
586
+ output: 0,
587
+ cacheRead: 0,
588
+ cacheCreation: 0,
589
+ totalCost: 0
590
+ };
591
+ }
592
+
593
+ if (!chat.perModelTokensPrice) {
594
+ chat.perModelTokensPrice = {};
595
+ }
596
+
597
+ // Process all messages to calculate prices
598
+ for (const message of chat.messages) {
599
+ if (message.usage && !message.price) {
600
+ // Add model if missing (use chat's model as fallback)
601
+ if (!message.model) {
602
+ message.model = ChatConfig.getChatModelString(chat);
603
+ }
604
+
605
+ // Calculate price
606
+ if (message.model) {
607
+ const price = this.calculateMessagePrice(message.model, message.usage);
608
+ if (price !== null) {
609
+ message.price = price;
610
+ }
611
+ }
612
+ }
613
+ }
614
+
615
+ // Recalculate cumulative pricing
616
+ this.updateChatTokenPricing(chat);
617
+
618
+ // Save the migrated data
619
+ this.saveChatToStorage(chat.id);
620
+ }
621
+
622
+ initializeUI() {
623
+ // Chat sidebar
624
+ this.newChatBtn = document.getElementById('newChatBtn');
625
+ this.newChatBtn.addEventListener('click', () => this.createNewChatDirectly());
626
+ this.chatSessions = document.getElementById('chatSessions');
627
+
628
+ // Event delegation for delete buttons
629
+ this.chatSessions.addEventListener('click', (e) => {
630
+ // Cast to Element to help IDE recognize DOM methods
631
+ /** @type {Element} */
632
+ const target = e.target;
633
+ const deleteBtn = target.closest('.btn-delete-chat');
634
+ if (deleteBtn) {
635
+ e.stopPropagation();
636
+ const chatId = deleteBtn.dataset.chatId;
637
+ if (chatId) {
638
+ this.deleteChat(chatId);
639
+ }
640
+ }
641
+ });
642
+
643
+ // Sidebar footer controls
644
+ this.themeToggle = document.getElementById('themeToggle');
645
+ this.themeToggle.addEventListener('click', () => this.toggleTheme());
646
+ this.settingsBtn = document.getElementById('settingsBtn');
647
+ this.settingsBtn.addEventListener('click', () => this.showModal('settingsModal'));
648
+
649
+ // Chat area - Main containers only, not individual chat elements
650
+ this.chatContainersEl = document.getElementById('chatContainers');
651
+ this.welcomeScreen = document.getElementById('welcomeScreen');
652
+
653
+ // These will be set when switching chats for backward compatibility
654
+ this.chatTitle = null;
655
+ this.chatInput = null;
656
+ this.sendMessageBtn = null;
657
+ this.reconnectMcpBtn = null;
658
+ this.copyMetricsBtn = null;
659
+ this.summarizeBtn = null;
660
+ this.generateTitleBtn = null;
661
+ this.llmModelDropdown = null;
662
+ this.currentModelText = null;
663
+ this.mcpServerDropdown = null;
664
+ this.currentMcpText = null;
665
+
666
+ // Close dropdowns when clicking outside (global handler)
667
+ document.addEventListener('click', () => {
668
+ // Close all open dropdowns in all chat containers
669
+ this.chatContainers.forEach((container) => {
670
+ const elements = container._elements;
671
+ if (elements) {
672
+ // Check each dropdown exists before accessing style
673
+ if (elements.llmModelDropdown && elements.llmModelDropdown.style) {
674
+ elements.llmModelDropdown.style.display = 'none';
675
+ }
676
+ if (elements.mcpServerDropdown && elements.mcpServerDropdown.style) {
677
+ elements.mcpServerDropdown.style.display = 'none';
678
+ }
679
+ }
680
+ });
681
+ });
682
+
683
+ // Log panel
684
+ this.logPanel = document.getElementById('logPanel');
685
+ this.toggleLogBtn = document.getElementById('toggleLogBtn');
686
+ this.expandLogBtn = document.getElementById('expandLogBtn');
687
+ this.clearLogBtn = document.getElementById('clearLogBtn');
688
+ this.downloadLogBtn = document.getElementById('downloadLogBtn');
689
+ this.logContent = document.getElementById('logContent');
690
+
691
+ this.toggleLogBtn.addEventListener('click', () => this.toggleLog());
692
+ this.expandLogBtn.addEventListener('click', () => this.toggleLog());
693
+ this.clearLogBtn.addEventListener('click', () => this.clearLog());
694
+ this.downloadLogBtn.addEventListener('click', () => this.downloadLog());
695
+
696
+ // Sidebar management
697
+ this.chatSidebar = document.getElementById('chatSidebar');
698
+ this.toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
699
+
700
+ // Set up sidebar toggle button
701
+ this.toggleSidebarBtn.addEventListener('click', () => this.toggleChatSidebar());
702
+
703
+ // Load sidebar states from localStorage
704
+ this.loadSidebarStates();
705
+
706
+ // Temperature control - will be set when switching chats
707
+
708
+ // Settings modal
709
+ this.settingsModal = document.getElementById('settingsModal');
710
+ this.setupModal('settingsModal', 'settingsBackdrop', 'closeSettingsBtn');
711
+
712
+ // Settings lists
713
+ this.mcpServersList = document.getElementById('mcpServersList');
714
+ this.addMcpServerBtn = document.getElementById('addMcpServerBtn');
715
+
716
+ this.addMcpServerBtn.addEventListener('click', () => this.showModal('addMcpModal'));
717
+
718
+ // New chat modal - no longer used, kept for potential future use
719
+ // this.setupModal('newChatModal', 'newChatBackdrop', 'closeNewChatBtn');
720
+ // this.newChatMcpServer = document.getElementById('newChatMcpServer');
721
+ // this.newChatLlmProvider = document.getElementById('newChatLlmProvider');
722
+ // this.newChatModelGroup = document.getElementById('newChatModelGroup');
723
+ // this.newChatModel = document.getElementById('newChatModel');
724
+ // this.newChatTitle = document.getElementById('newChatTitle');
725
+ // this.createChatBtn = document.getElementById('createChatBtn');
726
+ // this.cancelNewChatBtn = document.getElementById('cancelNewChatBtn');
727
+ //
728
+ // this.newChatLlmProvider.addEventListener('change', () => this.updateNewChatModels());
729
+ // this.createChatBtn.addEventListener('click', () => this.createNewChat());
730
+ // this.cancelNewChatBtn.addEventListener('click', () => this.hideModal('newChatModal'));
731
+
732
+ // Add MCP server modal
733
+ this.setupModal('addMcpModal', 'addMcpBackdrop', 'closeAddMcpBtn');
734
+ this.mcpServerUrl = document.getElementById('mcpServerUrl');
735
+ this.mcpServerName = document.getElementById('mcpServerName');
736
+ this.saveMcpServerBtn = document.getElementById('saveMcpServerBtn');
737
+ this.cancelAddMcpBtn = document.getElementById('cancelAddMcpBtn');
738
+
739
+ this.saveMcpServerBtn.addEventListener('click', () => this.addMcpServer());
740
+ this.cancelAddMcpBtn.addEventListener('click', () => this.hideModal('addMcpModal'));
741
+
742
+ // System prompt modal controls
743
+ this.systemPromptModal = document.getElementById('systemPromptModal');
744
+ this.systemPromptTextarea = document.getElementById('systemPromptTextarea');
745
+ this.closeSystemPromptBtn = document.getElementById('closeSystemPromptBtn');
746
+ this.systemPromptBackdrop = document.getElementById('systemPromptBackdrop');
747
+ this.cancelSystemPromptBtn = document.getElementById('cancelSystemPromptBtn');
748
+ this.saveSystemPromptBtn = document.getElementById('saveSystemPromptBtn');
749
+ this.resetToDefaultPromptBtn = document.getElementById('resetToDefaultPromptBtn');
750
+
751
+ this.closeSystemPromptBtn.addEventListener('click', () => this.hideModal('systemPromptModal'));
752
+ this.systemPromptBackdrop.addEventListener('click', () => this.hideModal('systemPromptModal'));
753
+ this.cancelSystemPromptBtn.addEventListener('click', () => this.hideModal('systemPromptModal'));
754
+ this.saveSystemPromptBtn.addEventListener('click', () => {
755
+ // Get the chatId from the modal's data attribute
756
+ const chatId = this.systemPromptModal.dataset.chatId;
757
+ if (chatId) {
758
+ this.saveSystemPrompt(chatId);
759
+ }
760
+ });
761
+ this.resetToDefaultPromptBtn.addEventListener('click', () => {
762
+ this.systemPromptTextarea.value = this.defaultSystemPrompt;
763
+ });
764
+
765
+ // Auto-generate server name from URL
766
+ this.mcpServerUrl.addEventListener('input', () => {
767
+ if (!this.mcpServerName.value) {
768
+ try {
769
+ const url = new URL(this.mcpServerUrl.value);
770
+ this.mcpServerName.value = url.hostname || 'MCP Server';
771
+ } catch {
772
+ // Invalid URL, ignore
773
+ }
774
+ }
775
+ });
776
+
777
+ // Tooltips are now CSS-only, no initialization needed
778
+
779
+ // Setup no models modal
780
+ this.noModelsModal = document.getElementById('noModelsModal');
781
+ this.noModelsBackdrop = document.getElementById('noModelsBackdrop');
782
+ this.noModelsProxyUrl = document.getElementById('noModelsProxyUrl');
783
+ this.retryModelsBtn = document.getElementById('retryModelsBtn');
784
+
785
+ // Retry button handler
786
+ this.retryModelsBtn.addEventListener('click', async () => {
787
+ this.hideModal('noModelsModal');
788
+ await this.initializeDefaultLLMProvider();
789
+ });
790
+ }
791
+
792
+ setupModal(modalId, backdropId, closeId) {
793
+ const backdrop = document.getElementById(backdropId);
794
+ const closeBtn = document.getElementById(closeId);
795
+
796
+ backdrop.addEventListener('click', () => this.hideModal(modalId));
797
+ closeBtn.addEventListener('click', () => this.hideModal(modalId));
798
+ }
799
+
800
+
801
+ showModal(modalId) {
802
+ document.getElementById(modalId).classList.add('show');
803
+ }
804
+
805
+ hideModal(modalId) {
806
+ document.getElementById(modalId).classList.remove('show');
807
+ }
808
+
809
+ showNoModelsModal(proxyUrl) {
810
+ // Update the proxy URL in the modal
811
+ this.noModelsProxyUrl.textContent = proxyUrl;
812
+
813
+ // Show the modal
814
+ this.showModal('noModelsModal');
815
+
816
+ // Disable the backdrop click since we don't want users to close it
817
+ this.noModelsBackdrop.onclick = null;
818
+ }
819
+
820
+ validateChatModels() {
821
+ // Validate each chat's model
822
+ for (const [chatId, chat] of this.chats) {
823
+ // Skip if chat doesn't have proper config
824
+ if (!chat.config || !chat.config.model) {
825
+ continue;
826
+ }
827
+
828
+ if (chat.llmProviderId) {
829
+ const provider = this.llmProviders.get(chat.llmProviderId);
830
+ if (provider && provider.availableProviders) {
831
+ // Check if the model exists
832
+ let modelExists = false;
833
+ const providerType = chat.config.model.provider;
834
+ const modelName = chat.config.model.id;
835
+
836
+ if (providerType && modelName && provider.availableProviders[providerType]) {
837
+ const models = provider.availableProviders[providerType].models || [];
838
+ modelExists = models.some(m => {
839
+ const mId = typeof m === 'string' ? m : m.id;
840
+ return mId === modelName;
841
+ });
842
+ }
843
+
844
+ if (!modelExists) {
845
+ const oldModelString = ChatConfig.modelConfigToString(chat.config.model);
846
+ console.error(`Chat ${chatId} has invalid model ${oldModelString}. Model not found in available providers.`);
847
+
848
+ // Mark the chat as having an invalid model
849
+ chat.hasInvalidModel = true;
850
+
851
+ // DO NOT automatically reset or save!
852
+ // The user must manually select a valid model
853
+ }
854
+ }
855
+ }
856
+ }
857
+ }
858
+
859
+ /**
860
+ * Update the model display in the UI for a chat
861
+ */
862
+ updateModelDisplay(chat) {
863
+ const chatId = chat.id;
864
+ const container = this.getChatContainer(chatId);
865
+ if (!container || !container._elements) {return;}
866
+
867
+ const elements = container._elements;
868
+ const provider = this.llmProviders.get(chat.llmProviderId);
869
+
870
+ // Update LLM model display
871
+ if (provider && chat.config?.model?.id) {
872
+ const modelDisplay = chat.config.model.id;
873
+ if (elements.llmMeta) {
874
+ elements.llmMeta.textContent = modelDisplay;
875
+ }
876
+ if (elements.currentModelText) {
877
+ elements.currentModelText.textContent = modelDisplay;
878
+ }
879
+ } else {
880
+ if (elements.llmMeta) {
881
+ elements.llmMeta.textContent = 'Model: Not found';
882
+ }
883
+ if (elements.currentModelText) {
884
+ elements.currentModelText.textContent = 'Select Model';
885
+ }
886
+ }
887
+ }
888
+
889
+ isModelValid(model, provider) {
890
+ if (!model || !provider || !provider.availableProviders) {return false;}
891
+
892
+ // Handle both string format and config object
893
+ let providerType, modelName;
894
+ if (typeof model === 'string') {
895
+ const modelConfig = ChatConfig.modelConfigFromString(model);
896
+ providerType = modelConfig?.provider;
897
+ modelName = modelConfig?.id;
898
+ } else if (model.provider && model.id) {
899
+ providerType = model.provider;
900
+ modelName = model.id;
901
+ } else {
902
+ return false;
903
+ }
904
+
905
+ if (!providerType || !modelName || !provider.availableProviders[providerType]) {return false;}
906
+
907
+ const models = provider.availableProviders[providerType].models || [];
908
+ return models.some(m => {
909
+ const mId = typeof m === 'string' ? m : m.id;
910
+ return mId === modelName;
911
+ });
912
+ }
913
+
914
+ populateModelDropdown(chatId, dropdown = null, buttonElement = null) {
915
+ if (!chatId) {
916
+ console.error('[populateModelDropdown] Called without chatId');
917
+ return;
918
+ }
919
+ const targetChatId = chatId;
920
+ let targetDropdown = dropdown || this.llmModelDropdown;
921
+
922
+ const chat = this.chats.get(targetChatId);
923
+ if (!chat) {return;}
924
+
925
+ const provider = this.llmProviders.get(chat.llmProviderId);
926
+ if (!provider || !provider.availableProviders) {return;}
927
+
928
+ // Create a modal overlay instead of using the dropdown
929
+ const overlay = document.createElement('div');
930
+ overlay.className = 'model-selector-overlay';
931
+ overlay.style.cssText = `
932
+ position: fixed;
933
+ top: 0;
934
+ left: 0;
935
+ right: 0;
936
+ bottom: 0;
937
+ background: rgba(0, 0, 0, 0.5);
938
+ z-index: 9999;
939
+ `;
940
+
941
+ // Get button position for dropdown-like positioning
942
+ const buttonRect = buttonElement ? buttonElement.getBoundingClientRect() : null;
943
+
944
+ const modalContent = document.createElement('div');
945
+ modalContent.style.cssText = `
946
+ width: 900px !important;
947
+ min-width: 900px !important;
948
+ max-width: 900px !important;
949
+ max-height: 64vh;
950
+ position: fixed;
951
+ background: var(--background-color);
952
+ border-radius: 8px;
953
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
954
+ border: 1px solid var(--border-color);
955
+ padding: 0;
956
+ display: flex;
957
+ flex-direction: column;
958
+ `;
959
+
960
+ // Position the modal like a dropdown
961
+ if (buttonRect) {
962
+ // Position below the button
963
+ const spaceBelow = window.innerHeight - buttonRect.bottom;
964
+ const spaceAbove = buttonRect.top;
965
+
966
+ if (spaceBelow >= 400 || spaceBelow > spaceAbove) {
967
+ // Show below button
968
+ modalContent.style.top = `${buttonRect.bottom + 5}px`;
969
+ modalContent.style.bottom = 'auto';
970
+ } else {
971
+ // Show above button
972
+ modalContent.style.bottom = `${window.innerHeight - buttonRect.top + 5}px`;
973
+ modalContent.style.top = 'auto';
974
+ }
975
+
976
+ // Center horizontally relative to button
977
+ const modalWidth = 900;
978
+ const buttonCenter = buttonRect.left + (buttonRect.width / 2);
979
+ let left = buttonCenter - (modalWidth / 2);
980
+
981
+ // Keep within viewport bounds
982
+ if (left < 10) left = 10;
983
+ if (left + modalWidth > window.innerWidth - 10) {
984
+ left = window.innerWidth - modalWidth - 10;
985
+ }
986
+
987
+ modalContent.style.left = `${left}px`;
988
+ } else {
989
+ // Fallback to center if no button provided
990
+ modalContent.style.top = '50%';
991
+ modalContent.style.left = '50%';
992
+ modalContent.style.transform = 'translate(-50%, -50%)';
993
+ }
994
+
995
+ // Close when clicking overlay
996
+ overlay.addEventListener('click', (e) => {
997
+ if (e.target === overlay) {
998
+ overlay.remove();
999
+ // Update displays
1000
+ this.updateChatHeader(chatId);
1001
+ this.updateChatSessions();
1002
+ }
1003
+ });
1004
+
1005
+ // Prevent clicks inside modal from closing
1006
+ modalContent.addEventListener('click', (e) => {
1007
+ e.stopPropagation();
1008
+ });
1009
+
1010
+ overlay.appendChild(modalContent);
1011
+ document.body.appendChild(overlay);
1012
+
1013
+ // Use modalContent as our target for populating
1014
+ targetDropdown = modalContent;
1015
+
1016
+ // Get current config
1017
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
1018
+
1019
+ // Create header section (fixed)
1020
+ const headerSection = document.createElement('div');
1021
+ headerSection.style.cssText = `
1022
+ flex-shrink: 0;
1023
+ position: relative;
1024
+ padding: 12px 16px;
1025
+ border-bottom: 1px solid var(--border-color);
1026
+ background: var(--surface-color);
1027
+ `;
1028
+
1029
+ // Add title
1030
+ const headerTitle = document.createElement('h3');
1031
+ headerTitle.style.cssText = `
1032
+ margin: 0;
1033
+ font-size: 16px;
1034
+ font-weight: 600;
1035
+ color: var(--text-primary);
1036
+ `;
1037
+ headerTitle.textContent = 'Model & Optimization Settings';
1038
+ headerSection.appendChild(headerTitle);
1039
+
1040
+ // Add close button at the top
1041
+ const closeButton = document.createElement('button');
1042
+ closeButton.style.cssText = `
1043
+ position: absolute;
1044
+ top: 10px;
1045
+ right: 10px;
1046
+ background: none;
1047
+ border: none;
1048
+ font-size: 24px;
1049
+ cursor: pointer;
1050
+ color: var(--text-secondary);
1051
+ z-index: 1;
1052
+ padding: 0;
1053
+ width: 32px;
1054
+ height: 32px;
1055
+ display: flex;
1056
+ align-items: center;
1057
+ justify-content: center;
1058
+ border-radius: 4px;
1059
+ transition: background 0.2s;
1060
+ `;
1061
+ closeButton.innerHTML = '×';
1062
+ closeButton.addEventListener('mouseenter', () => {
1063
+ closeButton.style.background = 'var(--hover-color)';
1064
+ });
1065
+ closeButton.addEventListener('mouseleave', () => {
1066
+ closeButton.style.background = 'none';
1067
+ });
1068
+ closeButton.addEventListener('click', () => {
1069
+ overlay.remove();
1070
+ // Update displays
1071
+ this.updateChatHeader(chatId);
1072
+ this.updateChatSessions();
1073
+ });
1074
+ headerSection.appendChild(closeButton);
1075
+ targetDropdown.appendChild(headerSection);
1076
+
1077
+ // Create scrollable content container
1078
+ const contentContainer = document.createElement('div');
1079
+ contentContainer.style.cssText = `
1080
+ flex: 1;
1081
+ overflow-y: auto;
1082
+ min-height: 200px;
1083
+ max-height: calc(64vh - 120px); /* Account for header and footer */
1084
+ scrollbar-width: thin;
1085
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
1086
+ `;
1087
+
1088
+ // Add cost optimization settings section to content container
1089
+ this.addCostOptimizationSection(contentContainer, chatId, config);
1090
+ targetDropdown.appendChild(contentContainer);
1091
+
1092
+ // Add footer section with cost estimation
1093
+ // Footer section removed - no cost estimation needed
1094
+ }
1095
+
1096
+ addCostOptimizationSection(dropdown, chatId, config) {
1097
+ const section = document.createElement('div');
1098
+ section.style.cssText = `
1099
+ padding: 8px 12px;
1100
+ background: var(--surface-color);
1101
+ border-bottom: 1px solid var(--border-color);
1102
+ `;
1103
+
1104
+ section.innerHTML = `
1105
+ <div style="font-weight: 600; font-size: 13px; margin-bottom: 8px; color: var(--text-primary);">
1106
+ Cost Optimizations
1107
+ </div>
1108
+ `;
1109
+
1110
+ const chat = this.chats.get(chatId);
1111
+ if (!chat) {
1112
+ console.error('addCostOptimizationSection: Chat not found for ID:', chatId);
1113
+ return;
1114
+ }
1115
+
1116
+ // Get all available models - removed as unused
1117
+ // const allModels = this.getAllAvailableModels();
1118
+
1119
+ // Chat Model Selection with Max Tokens
1120
+ const chatModelDiv = document.createElement('div');
1121
+ chatModelDiv.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;';
1122
+
1123
+ const currentMaxTokens = chat.config.model.params.maxTokens;
1124
+ chatModelDiv.innerHTML = `
1125
+ <span>Chat with</span>
1126
+ <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1127
+ <button class="model-select-btn" id="chatModel_${chatId}"
1128
+ style="padding: 2px 8px; border: 1px solid var(--border-color);
1129
+ border-radius: 4px; background: var(--background-color);
1130
+ color: var(--text-primary); cursor: pointer;
1131
+ display: flex; align-items: center; gap: 4px;">
1132
+ <span class="model-name">${ChatConfig.getChatModelString(chat) || 'Select model'}</span>
1133
+ <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1134
+ </button>
1135
+ </div>
1136
+
1137
+ <div style="display: flex; align-items: center; gap: 4px; margin-left: auto;">
1138
+ <label style="font-size: 12px; color: var(--text-secondary);">max output tokens:</label>
1139
+ <select id="maxTokens_${chatId}"
1140
+ style="padding: 2px 6px; border: 1px solid var(--border-color);
1141
+ border-radius: 4px; background: var(--background-color);
1142
+ color: var(--text-primary); font-size: 12px;">
1143
+ <option value="1024" ${currentMaxTokens === 1024 ? 'selected' : ''}>1k</option>
1144
+ <option value="2048" ${currentMaxTokens === 2048 ? 'selected' : ''}>2k</option>
1145
+ <option value="4096" ${currentMaxTokens === 4096 ? 'selected' : ''}>4k</option>
1146
+ <option value="8192" ${currentMaxTokens === 8192 ? 'selected' : ''}>8k</option>
1147
+ <option value="16384" ${currentMaxTokens === 16384 ? 'selected' : ''}>16k</option>
1148
+ <option value="32768" ${currentMaxTokens === 32768 ? 'selected' : ''}>32k</option>
1149
+ <option value="65536" ${currentMaxTokens === 65536 ? 'selected' : ''}>64k</option>
1150
+ <option value="131072" ${currentMaxTokens === 131072 ? 'selected' : ''}>128k</option>
1151
+ </select>
1152
+ </div>
1153
+ `;
1154
+ section.appendChild(chatModelDiv);
1155
+
1156
+ // Tool Summarization Option (DISABLED - Not Implemented)
1157
+ const toolSumDiv = document.createElement('div');
1158
+ const _isEnabled = false; // Force disabled - not implemented
1159
+ toolSumDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; opacity: 0.4; color: var(--text-secondary);`;
1160
+
1161
+ const currentThreshold = config.optimisation.toolSummarisation.thresholdKiB || 20; // Default 20KB
1162
+ const toolSumModel = ChatConfig.modelConfigToString(config.optimisation.toolSummarisation.model) || ChatConfig.getChatModelString(chat);
1163
+
1164
+ toolSumDiv.innerHTML = `
1165
+ <label style="display: flex; align-items: center; cursor: not-allowed;">
1166
+ <input type="checkbox" id="toolSummarization_${chatId}" disabled
1167
+ style="margin-right: 6px;">
1168
+ <span style="text-decoration: line-through;">Summarize tool responses of at least</span>
1169
+ </label>
1170
+ <select id="toolThreshold_${chatId}" disabled
1171
+ style="width: 70px; padding: 2px 4px; border: 1px solid var(--border-color);
1172
+ border-radius: 4px; background: var(--background-color); color: var(--text-primary);
1173
+ cursor: not-allowed; text-decoration: line-through;">
1174
+ <option value="0">0 (all)</option>
1175
+ <option value="5">5</option>
1176
+ <option value="10">10</option>
1177
+ <option value="20" ${currentThreshold === 20 ? 'selected' : ''}>20</option>
1178
+ <option value="30">30</option>
1179
+ <option value="40">40</option>
1180
+ <option value="50">50</option>
1181
+ <option value="60">60</option>
1182
+ <option value="70">70</option>
1183
+ <option value="80">80</option>
1184
+ <option value="90">90</option>
1185
+ <option value="100">100</option>
1186
+ </select>
1187
+ <span style="text-decoration: line-through;">KiB size, with</span>
1188
+ <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1189
+ <button class="model-select-btn" id="toolSumModel_${chatId}" disabled
1190
+ style="padding: 2px 8px; border: 1px solid var(--border-color);
1191
+ border-radius: 4px; background: var(--background-color);
1192
+ color: var(--text-primary); cursor: not-allowed;
1193
+ display: flex; align-items: center; gap: 4px; text-decoration: line-through;">
1194
+ <span class="model-name">${toolSumModel || 'Select model'}</span>
1195
+ <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1196
+ </button>
1197
+ </div>
1198
+ `;
1199
+
1200
+ section.appendChild(toolSumDiv);
1201
+
1202
+ // Auto-summarization Option (DISABLED - Not Implemented)
1203
+ const autoSumDiv = document.createElement('div');
1204
+ const _autoSumEnabled = false; // Force disabled - not implemented
1205
+ autoSumDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; opacity: 0.4; color: var(--text-secondary);`;
1206
+
1207
+ const currentPercent = config.optimisation.autoSummarisation.triggerPercent || 50;
1208
+ const autoSumModel = ChatConfig.modelConfigToString(config.optimisation.autoSummarisation.model) || ChatConfig.getChatModelString(chat);
1209
+
1210
+ autoSumDiv.innerHTML = `
1211
+ <label style="display: flex; align-items: center; cursor: not-allowed;">
1212
+ <input type="checkbox" id="autoSummarization_${chatId}" disabled
1213
+ style="margin-right: 6px;">
1214
+ <span style="text-decoration: line-through;">Summarize conversation when context window above</span>
1215
+ </label>
1216
+ <select id="autoSumThreshold_${chatId}" disabled
1217
+ style="width: 70px; padding: 2px 4px; border: 1px solid var(--border-color);
1218
+ border-radius: 4px; background: var(--background-color); color: var(--text-primary);
1219
+ cursor: not-allowed; text-decoration: line-through;">
1220
+ <option value="30">30%</option>
1221
+ <option value="40">40%</option>
1222
+ <option value="50" ${currentPercent === 50 ? 'selected' : ''}>50%</option>
1223
+ <option value="60">60%</option>
1224
+ <option value="70">70%</option>
1225
+ <option value="80">80%</option>
1226
+ <option value="90">90%</option>
1227
+ </select>
1228
+ <span style="text-decoration: line-through;">with</span>
1229
+ <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1230
+ <button class="model-select-btn" id="autoSumModel_${chatId}" disabled
1231
+ style="padding: 2px 8px; border: 1px solid var(--border-color);
1232
+ border-radius: 4px; background: var(--background-color);
1233
+ color: var(--text-primary); cursor: not-allowed;
1234
+ display: flex; align-items: center; gap: 4px; text-decoration: line-through;">
1235
+ <span class="model-name">${autoSumModel || 'Select model'}</span>
1236
+ <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1237
+ </button>
1238
+ </div>
1239
+ `;
1240
+
1241
+ section.appendChild(autoSumDiv);
1242
+
1243
+ // Title Generation Option
1244
+ const titleGenDiv = document.createElement('div');
1245
+ const titleGenEnabled = config.optimisation.titleGeneration?.enabled !== false; // Default to true
1246
+ titleGenDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; ${!titleGenEnabled ? 'opacity: 0.5;' : ''}`;
1247
+
1248
+ const titleGenModel = ChatConfig.modelConfigToString(config.optimisation.titleGeneration?.model);
1249
+
1250
+ titleGenDiv.innerHTML = `
1251
+ <label style="display: flex; align-items: center; cursor: pointer;">
1252
+ <input type="checkbox" id="titleGeneration_${chatId}" ${titleGenEnabled ? 'checked' : ''}
1253
+ style="margin-right: 6px;">
1254
+ <span>Generate chat titles with</span>
1255
+ </label>
1256
+ <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1257
+ <button class="model-select-btn" id="titleGenModel_${chatId}"
1258
+ style="padding: 2px 8px; border: 1px solid var(--border-color);
1259
+ border-radius: 4px; background: var(--background-color);
1260
+ color: var(--text-primary); cursor: pointer;
1261
+ display: flex; align-items: center; gap: 4px;"
1262
+ ${!titleGenEnabled ? 'disabled' : ''}>
1263
+ <span class="model-name">${titleGenModel || 'Select model'}</span>
1264
+ <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1265
+ </button>
1266
+ </div>
1267
+ `;
1268
+
1269
+ section.appendChild(titleGenDiv);
1270
+
1271
+ // Tool Memory Option
1272
+ const toolMemoryDiv = document.createElement('div');
1273
+ const toolMemoryEnabled = config.optimisation.toolMemory.enabled;
1274
+ toolMemoryDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; ${!toolMemoryEnabled ? 'opacity: 0.5;' : ''}`;
1275
+
1276
+ const forgetAfterConclusions = config.optimisation.toolMemory.forgetAfterConclusions;
1277
+
1278
+ toolMemoryDiv.innerHTML = `
1279
+ <label style="display: flex; align-items: center; cursor: pointer;">
1280
+ <input type="checkbox" id="toolMemory_${chatId}" ${toolMemoryEnabled ? 'checked' : ''}
1281
+ style="margin-right: 6px;">
1282
+ <span>Stop sending tool responses after the assistant concludes</span>
1283
+ </label>
1284
+ <select id="toolMemoryThreshold_${chatId}"
1285
+ style="width: 50px; padding: 2px 4px; border: 1px solid var(--border-color);
1286
+ border-radius: 4px; background: var(--background-color); color: var(--text-primary);
1287
+ cursor: pointer;"
1288
+ ${!toolMemoryEnabled ? 'disabled' : ''}>
1289
+ <option value="0" ${forgetAfterConclusions === 0 ? 'selected' : ''}>0</option>
1290
+ <option value="1" ${forgetAfterConclusions === 1 ? 'selected' : ''}>1</option>
1291
+ <option value="2" ${forgetAfterConclusions === 2 ? 'selected' : ''}>2</option>
1292
+ <option value="3" ${forgetAfterConclusions === 3 ? 'selected' : ''}>3</option>
1293
+ </select>
1294
+ <span>times</span>
1295
+ `;
1296
+
1297
+ section.appendChild(toolMemoryDiv);
1298
+
1299
+ // Cache Control Option (only for Anthropic provider)
1300
+ const isAnthropicProvider = config.model && config.model.provider === 'anthropic';
1301
+ const cacheControlDiv = document.createElement('div');
1302
+ const cacheControlEnabled = config.optimisation.cacheControl.enabled;
1303
+ const cacheControlDisabled = !isAnthropicProvider || toolMemoryEnabled;
1304
+ cacheControlDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; ${cacheControlDisabled ? 'opacity: 0.5;' : ''}`;
1305
+
1306
+ cacheControlDiv.innerHTML = `
1307
+ <label style="display: flex; align-items: center; cursor: ${cacheControlDisabled ? 'default' : 'pointer'};">
1308
+ <input type="checkbox" id="cacheControl_${chatId}" ${cacheControlEnabled ? 'checked' : ''}
1309
+ style="margin-right: 6px;"
1310
+ ${cacheControlDisabled ? 'disabled' : ''}>
1311
+ <span>Enable Anthropic's cache control${toolMemoryEnabled ? ' (disabled: tool memory is on)' : ''}</span>
1312
+ </label>
1313
+ `;
1314
+
1315
+ section.appendChild(cacheControlDiv);
1316
+
1317
+ // Temperature and TopP Controls
1318
+ const paramsDiv = document.createElement('div');
1319
+ paramsDiv.style.cssText = 'margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color);';
1320
+
1321
+ const currentTemp = chat.config.model.params.temperature;
1322
+ const currentTopP = chat.config.model.params.topP;
1323
+
1324
+ paramsDiv.innerHTML = `
1325
+ <div style="display: flex; flex-direction: column; gap: 12px;">
1326
+ <!-- Temperature Control -->
1327
+ <div style="display: flex; align-items: center; gap: 12px;">
1328
+ <label style="font-size: 13px; font-weight: 600; color: var(--text-primary); min-width: 100px;">
1329
+ Temperature
1330
+ </label>
1331
+ <div style="flex: 1; display: flex; align-items: center; gap: 8px;">
1332
+ <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px;">Focused</span>
1333
+ <input type="range" id="temperature_${chatId}" min="0" max="2" step="0.1" value="${currentTemp}"
1334
+ style="flex: 1; height: 4px; accent-color: var(--primary-color);">
1335
+ <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px; text-align: right;">Creative</span>
1336
+ <span id="tempValue_${chatId}" style="font-size: 12px; font-weight: 600; color: var(--primary-color); min-width: 30px; text-align: right;">${currentTemp.toFixed(1)}</span>
1337
+ </div>
1338
+ </div>
1339
+
1340
+ <!-- TopP Control -->
1341
+ <div style="display: flex; align-items: center; gap: 12px;">
1342
+ <label style="font-size: 13px; font-weight: 600; color: var(--text-primary); min-width: 100px;">
1343
+ Top P
1344
+ </label>
1345
+ <div style="flex: 1; display: flex; align-items: center; gap: 8px;">
1346
+ <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px;">Precise</span>
1347
+ <input type="range" id="topP_${chatId}" min="0" max="1" step="0.05" value="${currentTopP}"
1348
+ style="flex: 1; height: 4px; accent-color: var(--primary-color);">
1349
+ <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px; text-align: right;">Diverse</span>
1350
+ <span id="topPValue_${chatId}" style="font-size: 12px; font-weight: 600; color: var(--primary-color); min-width: 30px; text-align: right;">${currentTopP.toFixed(2)}</span>
1351
+ </div>
1352
+ </div>
1353
+ </div>
1354
+ `;
1355
+
1356
+ section.appendChild(paramsDiv);
1357
+
1358
+ // Temperature and TopP event listeners
1359
+ const tempSlider = paramsDiv.querySelector(`#temperature_${chatId}`);
1360
+ const tempValueLabel = paramsDiv.querySelector(`#tempValue_${chatId}`);
1361
+ const topPSlider = paramsDiv.querySelector(`#topP_${chatId}`);
1362
+ const topPValueLabel = paramsDiv.querySelector(`#topPValue_${chatId}`);
1363
+
1364
+ tempSlider.addEventListener('input', (e) => {
1365
+ const value = parseFloat(e.target.value);
1366
+ tempValueLabel.textContent = value.toFixed(1);
1367
+ });
1368
+
1369
+ tempSlider.addEventListener('change', (e) => {
1370
+ chat.config.model.params.temperature = parseFloat(e.target.value);
1371
+ this.saveChatConfigSmart(chatId, chat.config);
1372
+ this.autoSave(chatId);
1373
+ });
1374
+
1375
+ topPSlider.addEventListener('input', (e) => {
1376
+ const value = parseFloat(e.target.value);
1377
+ topPValueLabel.textContent = value.toFixed(2);
1378
+ });
1379
+
1380
+ topPSlider.addEventListener('change', (e) => {
1381
+ chat.config.model.params.topP = parseFloat(e.target.value);
1382
+ this.saveChatConfigSmart(chatId, chat.config);
1383
+ this.autoSave(chatId);
1384
+ });
1385
+
1386
+ section.appendChild(document.createElement('div')); // spacer
1387
+
1388
+ // Initialize model selection buttons
1389
+ this.initializeModelSelectionButtons(section, chatId, chat, config);
1390
+
1391
+ // Add event listeners
1392
+ const toolSumCheckbox = section.querySelector(`#toolSummarization_${chatId}`);
1393
+ const thresholdSelect = section.querySelector(`#toolThreshold_${chatId}`);
1394
+ const toolModelBtn = section.querySelector(`#toolSumModel_${chatId}`);
1395
+
1396
+ toolSumCheckbox.addEventListener('change', (e) => {
1397
+ e.stopPropagation();
1398
+ const enabled = toolSumCheckbox.checked;
1399
+ thresholdSelect.disabled = !enabled;
1400
+ toolModelBtn.disabled = !enabled;
1401
+ toolSumDiv.style.opacity = enabled ? '1' : '0.5';
1402
+ this.updateOptimizationSetting(chatId, 'toolSummarization', enabled);
1403
+ });
1404
+
1405
+ thresholdSelect.addEventListener('change', (e) => {
1406
+ e.stopPropagation();
1407
+ const kbValue = parseInt(e.target.value, 10) || 20;
1408
+ const byteValue = kbValue * 1024;
1409
+ this.updateToolThreshold(chatId, byteValue);
1410
+ });
1411
+
1412
+ // Auto-summarization controls
1413
+ const autoSumCheckbox = section.querySelector(`#autoSummarization_${chatId}`);
1414
+ const autoSumSelect = section.querySelector(`#autoSumThreshold_${chatId}`);
1415
+ const autoModelBtn = section.querySelector(`#autoSumModel_${chatId}`);
1416
+
1417
+ autoSumCheckbox.addEventListener('change', (e) => {
1418
+ e.stopPropagation();
1419
+ const enabled = autoSumCheckbox.checked;
1420
+ autoSumSelect.disabled = !enabled;
1421
+ autoModelBtn.disabled = !enabled;
1422
+ autoSumDiv.style.opacity = enabled ? '1' : '0.5';
1423
+ this.updateOptimizationSetting(chatId, 'autoSummarization', enabled);
1424
+ });
1425
+
1426
+ autoSumSelect.addEventListener('change', (e) => {
1427
+ e.stopPropagation();
1428
+ const percent = parseInt(e.target.value, 10) || 50;
1429
+ this.updateAutoSumThreshold(chatId, percent);
1430
+ });
1431
+
1432
+ // Title Generation controls
1433
+ const titleGenCheckbox = section.querySelector(`#titleGeneration_${chatId}`);
1434
+ const titleModelBtn = section.querySelector(`#titleGenModel_${chatId}`);
1435
+
1436
+ titleGenCheckbox.addEventListener('change', (e) => {
1437
+ e.stopPropagation();
1438
+ const enabled = titleGenCheckbox.checked;
1439
+ titleModelBtn.disabled = !enabled;
1440
+ titleGenDiv.style.opacity = enabled ? '1' : '0.5';
1441
+ this.updateOptimizationSetting(chatId, 'titleGeneration', enabled);
1442
+ });
1443
+
1444
+ // Tool Memory controls
1445
+ const toolMemoryCheckbox = section.querySelector(`#toolMemory_${chatId}`);
1446
+ const toolMemorySelect = section.querySelector(`#toolMemoryThreshold_${chatId}`);
1447
+
1448
+ toolMemoryCheckbox.addEventListener('change', (e) => {
1449
+ e.stopPropagation();
1450
+ const enabled = toolMemoryCheckbox.checked;
1451
+ toolMemorySelect.disabled = !enabled;
1452
+ toolMemoryDiv.style.opacity = enabled ? '1' : '0.5';
1453
+
1454
+ // Update cache control state for Anthropic (mutually exclusive with tool memory)
1455
+ if (isAnthropicProvider) {
1456
+ const cacheControlCheckbox = section.querySelector(`#cacheControl_${chatId}`);
1457
+ const cacheControlLabel = cacheControlCheckbox.closest('label');
1458
+ const cacheControlSpan = cacheControlLabel.querySelector('span');
1459
+
1460
+ if (enabled) {
1461
+ // Disable cache control when tool memory is enabled
1462
+ cacheControlCheckbox.disabled = true;
1463
+ cacheControlCheckbox.closest('div').style.opacity = '0.5';
1464
+ cacheControlSpan.textContent = 'Enable Anthropic\'s cache control (disabled: tool memory is on)';
1465
+ if (cacheControlCheckbox.checked) {
1466
+ cacheControlCheckbox.checked = false;
1467
+ this.updateOptimizationSetting(chatId, 'cacheControl', false);
1468
+ }
1469
+ } else {
1470
+ // Re-enable cache control when tool memory is disabled
1471
+ cacheControlCheckbox.disabled = false;
1472
+ cacheControlCheckbox.closest('div').style.opacity = '1';
1473
+ cacheControlSpan.textContent = 'Enable Anthropic\'s cache control';
1474
+ }
1475
+ }
1476
+
1477
+ this.updateOptimizationSetting(chatId, 'toolMemory', enabled);
1478
+ });
1479
+
1480
+ toolMemorySelect.addEventListener('change', (e) => {
1481
+ e.stopPropagation();
1482
+ const newForgetAfterConclusions = parseInt(e.target.value, 10);
1483
+ this.updateToolMemoryThreshold(chatId, newForgetAfterConclusions);
1484
+ });
1485
+
1486
+ // Other checkboxes (smart filtering, cache control)
1487
+ section.querySelectorAll('input[type="checkbox"]:not(#toolSummarization_' + chatId + '):not(#autoSummarization_' + chatId + ')').forEach(checkbox => {
1488
+ checkbox.addEventListener('change', (e) => {
1489
+ e.stopPropagation();
1490
+ this.updateOptimizationSetting(chatId, checkbox.id.split('_')[0], checkbox.checked);
1491
+ });
1492
+ });
1493
+
1494
+ section.querySelectorAll('label').forEach(label => {
1495
+ label.addEventListener('click', (e) => {
1496
+ e.stopPropagation();
1497
+ });
1498
+ });
1499
+
1500
+ dropdown.appendChild(section);
1501
+ }
1502
+
1503
+ formatContextWindow(limit) {
1504
+ if (!limit) return '--';
1505
+ if (limit >= 1000000) return `${(limit / 1000000).toFixed(1)}M`;
1506
+ if (limit >= 1000) return `${(limit / 1000).toFixed(0)}k`;
1507
+ return limit.toString();
1508
+ }
1509
+
1510
+ /**
1511
+ * Create a formatted HTML tooltip for model information
1512
+ * @param {Object} chat - The chat object
1513
+ * @returns {string} HTML string for the tooltip
1514
+ */
1515
+ createModelTooltip(chat) {
1516
+ if (!chat || !chat.config || !chat.config.model) {
1517
+ return 'No model configured';
1518
+ }
1519
+
1520
+ const config = chat.config;
1521
+ const modelString = ChatConfig.modelConfigToString(config.model);
1522
+ const modelInfo = this.modelPricing[config.model.id] || {};
1523
+ const contextLimit = this.modelLimits[config.model.id] || 128000;
1524
+
1525
+ // Get MCP server name
1526
+ const mcpServer = this.mcpServers.get(config.mcpServer);
1527
+ const mcpServerName = mcpServer ? mcpServer.name : config.mcpServer;
1528
+
1529
+ // Get optimization models
1530
+ const toolSumModel = config.optimisation.toolSummarisation.model ?
1531
+ ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.toolSummarisation.model)) :
1532
+ 'Primary';
1533
+ const autoSumModel = config.optimisation.autoSummarisation.model ?
1534
+ ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.autoSummarisation.model)) :
1535
+ 'Primary';
1536
+ const titleGenModel = config.optimisation.titleGeneration.model ?
1537
+ ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.titleGeneration.model)) :
1538
+ 'Primary';
1539
+
1540
+ // Format prices more compactly with bold
1541
+ const formatPrice = (price) => {
1542
+ if (price === undefined || price === null) return 'N/A';
1543
+ return `<b>$${price.toFixed(2)}</b>`;
1544
+ };
1545
+
1546
+ // Helper to show enabled/disabled status compactly
1547
+ const status = (enabled) => enabled ?
1548
+ '<span style="color: var(--success-color);">✓</span>' :
1549
+ '<span style="color: var(--error-color);">✗</span>';
1550
+
1551
+ let tooltipHtml = `
1552
+ <div style="min-width: 300px;">
1553
+ <table style="width: 100%; font-size: 11px; border-collapse: collapse;">
1554
+ <tr style="border-bottom: 1px solid var(--border-color);">
1555
+ <td colspan="2" style="padding: 6px; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
1556
+ ${modelString}
1557
+ </td>
1558
+ </tr>`;
1559
+
1560
+ // Model parameters section (no provider, more condensed)
1561
+ tooltipHtml += `
1562
+ <tr>
1563
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Context:</td>
1564
+ <td style="padding: 4px 6px; text-align: right;">${this.formatContextWindow(contextLimit)}</td>
1565
+ </tr>
1566
+ <tr>
1567
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Params:</td>
1568
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1569
+ T=${config.model.params.temperature} P=${config.model.params.topP} Max=${config.model.params.maxTokens}${config.model.params.seed.enabled ? ` Seed=${config.model.params.seed.value}` : ''}
1570
+ </td>
1571
+ </tr>`;
1572
+
1573
+ // Pricing section (condensed with bold prices)
1574
+ if (modelInfo.input || modelInfo.output) {
1575
+ tooltipHtml += `
1576
+ <tr>
1577
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Pricing/1M:</td>
1578
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1579
+ In: ${formatPrice(modelInfo.input)} Out: ${formatPrice(modelInfo.output)}`;
1580
+
1581
+ if (modelInfo.cacheRead !== undefined) {
1582
+ tooltipHtml += ` CR: ${formatPrice(modelInfo.cacheRead)}`;
1583
+ }
1584
+ if (modelInfo.cacheWrite !== undefined) {
1585
+ tooltipHtml += ` CW: ${formatPrice(modelInfo.cacheWrite)}`;
1586
+ }
1587
+
1588
+ tooltipHtml += `</td></tr>`;
1589
+ }
1590
+
1591
+ // All optimization settings in a compact section
1592
+ tooltipHtml += `
1593
+ <tr style="border-top: 1px solid var(--border-color);">
1594
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Tool Summary:</td>
1595
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1596
+ ${status(config.optimisation.toolSummarisation.enabled)}
1597
+ ${config.optimisation.toolSummarisation.enabled ? `${config.optimisation.toolSummarisation.thresholdKiB}KiB ${toolSumModel}` : 'Disabled'}
1598
+ </td>
1599
+ </tr>
1600
+ <tr>
1601
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Auto Summary:</td>
1602
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1603
+ ${status(config.optimisation.autoSummarisation.enabled)}
1604
+ ${config.optimisation.autoSummarisation.enabled ? `${config.optimisation.autoSummarisation.triggerPercent}% ${autoSumModel}` : 'Disabled'}
1605
+ </td>
1606
+ </tr>
1607
+ <tr>
1608
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Tool Memory:</td>
1609
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1610
+ ${status(config.optimisation.toolMemory.enabled)}
1611
+ ${config.optimisation.toolMemory.enabled ?
1612
+ (config.optimisation.toolMemory.forgetAfterConclusions === 0 ? 'forget immediately' :
1613
+ config.optimisation.toolMemory.forgetAfterConclusions === 1 ? 'forget after 1 turn' :
1614
+ `forget after ${config.optimisation.toolMemory.forgetAfterConclusions} turns`) :
1615
+ 'Always remember'}
1616
+ </td>
1617
+ </tr>
1618
+ <tr>
1619
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Cache Control:</td>
1620
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1621
+ ${status(config.optimisation.cacheControl.enabled)}
1622
+ ${config.optimisation.cacheControl.enabled ? `Strategy: ${config.optimisation.cacheControl.strategy}` : 'Disabled'}
1623
+ </td>
1624
+ </tr>
1625
+ <tr>
1626
+ <td style="padding: 4px 6px; color: var(--text-secondary);">Auto Title:</td>
1627
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1628
+ ${status(config.optimisation.titleGeneration.enabled)}
1629
+ ${config.optimisation.titleGeneration.enabled ? titleGenModel : 'Disabled'}
1630
+ </td>
1631
+ </tr>`;
1632
+
1633
+ // Server info with name
1634
+ tooltipHtml += `
1635
+ <tr style="border-top: 1px solid var(--border-color);">
1636
+ <td style="padding: 4px 6px; color: var(--text-secondary);">MCP Server:</td>
1637
+ <td style="padding: 4px 6px; text-align: right; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px;">
1638
+ ${mcpServerName}
1639
+ </td>
1640
+ </tr>
1641
+ </table>
1642
+ </div>`;
1643
+
1644
+ return tooltipHtml;
1645
+ }
1646
+
1647
+ getAllAvailableModels() {
1648
+ const models = [];
1649
+
1650
+ // Iterate through all LLM providers
1651
+ this.llmProviders.forEach((provider) => {
1652
+ // Check if provider has availableProviders (the actual structure from the proxy)
1653
+ if (provider.availableProviders) {
1654
+ Object.entries(provider.availableProviders).forEach(([providerType, providerConfig]) => {
1655
+ if (providerConfig.models && Array.isArray(providerConfig.models)) {
1656
+ providerConfig.models.forEach(model => {
1657
+ const modelId = typeof model === 'string' ? model : model.id;
1658
+ const contextWindow = typeof model === 'object' ? model.contextWindow : null;
1659
+ const pricing = typeof model === 'object' ? model.pricing : null;
1660
+
1661
+ if (modelId) {
1662
+ models.push({
1663
+ id: modelId,
1664
+ providerId: providerType,
1665
+ contextWindow: contextWindow || 128000, // Default context
1666
+ pricing: pricing || null
1667
+ });
1668
+ }
1669
+ });
1670
+ }
1671
+ });
1672
+ }
1673
+ });
1674
+
1675
+ // Sort by provider and then by model name
1676
+ models.sort((a, b) => {
1677
+ if (a.providerId !== b.providerId) {
1678
+ return a.providerId.localeCompare(b.providerId);
1679
+ }
1680
+ return a.id.localeCompare(b.id);
1681
+ });
1682
+
1683
+ return models;
1684
+ }
1685
+
1686
+ initializeModelSelectionButtons(section, chatId, chat, _settings) {
1687
+ // Helper to create model dropdown with pricing table
1688
+ const createModelDropdown = (buttonId, currentModel, onSelect) => {
1689
+ const button = section.querySelector(`#${buttonId}`);
1690
+ if (!button) return;
1691
+
1692
+ // Add context menu for copy/paste
1693
+ button.addEventListener('contextmenu', (e) => {
1694
+ e.preventDefault();
1695
+ e.stopPropagation();
1696
+
1697
+ // Create context menu
1698
+ const menu = document.createElement('div');
1699
+ menu.className = 'model-context-menu';
1700
+ menu.style.cssText = `
1701
+ position: fixed;
1702
+ left: ${e.clientX}px;
1703
+ top: ${e.clientY}px;
1704
+ background: var(--background-color);
1705
+ border: 1px solid var(--border-color);
1706
+ border-radius: 4px;
1707
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
1708
+ padding: 4px 0;
1709
+ z-index: 10000;
1710
+ `;
1711
+
1712
+ const modelName = button.querySelector('.model-name').textContent;
1713
+ const hasModel = modelName && modelName !== 'Select model';
1714
+
1715
+ if (hasModel) {
1716
+ const copyItem = document.createElement('div');
1717
+ copyItem.style.cssText = `
1718
+ padding: 6px 12px;
1719
+ cursor: pointer;
1720
+ font-size: 13px;
1721
+ `;
1722
+ copyItem.textContent = `Copy "${modelName}"`;
1723
+ copyItem.addEventListener('mouseenter', () => {
1724
+ copyItem.style.background = 'var(--hover-color)';
1725
+ });
1726
+ copyItem.addEventListener('mouseleave', () => {
1727
+ copyItem.style.background = '';
1728
+ });
1729
+ copyItem.addEventListener('click', () => {
1730
+ this.copiedModel = modelName;
1731
+ document.body.removeChild(menu);
1732
+ this.showToast(`Copied model: ${modelName}`, 'success-toast');
1733
+ });
1734
+ menu.appendChild(copyItem);
1735
+ }
1736
+
1737
+ if (this.copiedModel && this.copiedModel !== modelName) {
1738
+ const pasteItem = document.createElement('div');
1739
+ pasteItem.style.cssText = `
1740
+ padding: 6px 12px;
1741
+ cursor: pointer;
1742
+ font-size: 13px;
1743
+ `;
1744
+ pasteItem.textContent = `Paste "${this.copiedModel}"`;
1745
+ pasteItem.addEventListener('mouseenter', () => {
1746
+ pasteItem.style.background = 'var(--hover-color)';
1747
+ });
1748
+ pasteItem.addEventListener('mouseleave', () => {
1749
+ pasteItem.style.background = '';
1750
+ });
1751
+ pasteItem.addEventListener('click', () => {
1752
+ button.querySelector('.model-name').textContent = this.copiedModel;
1753
+ onSelect(this.copiedModel);
1754
+ document.body.removeChild(menu);
1755
+ this.showToast(`Pasted model: ${this.copiedModel}`, 'success-toast');
1756
+ });
1757
+ menu.appendChild(pasteItem);
1758
+ }
1759
+
1760
+ if (menu.children.length === 0) {
1761
+ const emptyItem = document.createElement('div');
1762
+ emptyItem.style.cssText = `
1763
+ padding: 6px 12px;
1764
+ color: var(--text-secondary);
1765
+ font-size: 13px;
1766
+ `;
1767
+ emptyItem.textContent = 'No model to copy/paste';
1768
+ menu.appendChild(emptyItem);
1769
+ }
1770
+
1771
+ document.body.appendChild(menu);
1772
+
1773
+ // Remove menu on click outside
1774
+ const removeMenu = (evt) => {
1775
+ if (!menu.contains(evt.target)) {
1776
+ document.body.removeChild(menu);
1777
+ document.removeEventListener('click', removeMenu);
1778
+ }
1779
+ };
1780
+ setTimeout(() => {
1781
+ document.addEventListener('click', removeMenu);
1782
+ }, 0);
1783
+ });
1784
+
1785
+ // Regular click to open model selection
1786
+ button.addEventListener('click', (e) => {
1787
+ e.stopPropagation();
1788
+
1789
+ // Check if this button already has a dropdown open (toggle behavior)
1790
+ if (button.getAttribute('data-dropdown-open') === 'true') {
1791
+ const existingDropdown = document.body.querySelector('.model-selection-dropdown');
1792
+ if (existingDropdown) {
1793
+ existingDropdown.remove();
1794
+ button.removeAttribute('data-dropdown-open');
1795
+ }
1796
+ return;
1797
+ }
1798
+
1799
+ // Close any other open dropdowns
1800
+ document.querySelectorAll('.model-selection-dropdown').forEach(d => d.remove());
1801
+ document.querySelectorAll('[data-dropdown-open]').forEach(b => b.removeAttribute('data-dropdown-open'));
1802
+
1803
+ // Create model selection dropdown with pricing table
1804
+ const dropdown = document.createElement('div');
1805
+ dropdown.className = 'model-selection-dropdown';
1806
+
1807
+ // Mark button as having an open dropdown
1808
+ button.setAttribute('data-dropdown-open', 'true');
1809
+
1810
+ // Calculate button position relative to viewport
1811
+ const buttonRect = button.getBoundingClientRect();
1812
+ const viewportHeight = window.innerHeight;
1813
+ const viewportWidth = window.innerWidth;
1814
+ const dropdownHeight = 400; // Max height of dropdown
1815
+ const dropdownMinWidth = 600;
1816
+
1817
+ // Determine if dropdown should appear above or below the button
1818
+ const spaceBelow = viewportHeight - buttonRect.bottom;
1819
+ const shouldShowAbove = spaceBelow < dropdownHeight && buttonRect.top > dropdownHeight;
1820
+
1821
+ // Calculate left position - ensure dropdown doesn't go off-screen
1822
+ let leftPosition = buttonRect.left;
1823
+ if (leftPosition + dropdownMinWidth > viewportWidth) {
1824
+ leftPosition = Math.max(10, viewportWidth - dropdownMinWidth - 10);
1825
+ }
1826
+
1827
+ dropdown.style.cssText = `
1828
+ position: fixed;
1829
+ ${shouldShowAbove ? 'bottom' : 'top'}: ${shouldShowAbove ? (viewportHeight - buttonRect.top + 4) : (buttonRect.bottom + 4)}px;
1830
+ left: ${leftPosition}px;
1831
+ background: var(--background-color);
1832
+ border: 1px solid var(--border-color);
1833
+ border-radius: 4px;
1834
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1835
+ z-index: 10000;
1836
+ max-height: 400px;
1837
+ min-width: 600px;
1838
+ display: flex;
1839
+ flex-direction: column;
1840
+ `;
1841
+
1842
+ // Add search box
1843
+ const searchContainer = document.createElement('div');
1844
+ searchContainer.style.cssText = `
1845
+ padding: 8px;
1846
+ border-bottom: 1px solid var(--border-color);
1847
+ background: var(--surface-color);
1848
+ position: sticky;
1849
+ top: 0;
1850
+ z-index: 2;
1851
+ `;
1852
+
1853
+ const searchInput = document.createElement('input');
1854
+ searchInput.type = 'text';
1855
+ searchInput.placeholder = 'Search models...';
1856
+ searchInput.style.cssText = `
1857
+ width: 100%;
1858
+ padding: 6px 10px;
1859
+ border: 1px solid var(--border-color);
1860
+ border-radius: 4px;
1861
+ background: var(--background-color);
1862
+ color: var(--text-primary);
1863
+ font-size: 13px;
1864
+ `;
1865
+ searchContainer.appendChild(searchInput);
1866
+ dropdown.appendChild(searchContainer);
1867
+
1868
+ // Create scrollable content container
1869
+ const contentContainer = document.createElement('div');
1870
+ contentContainer.style.cssText = `
1871
+ flex: 1;
1872
+ overflow-y: auto;
1873
+ `;
1874
+ dropdown.appendChild(contentContainer);
1875
+
1876
+ // Focus search input when dropdown opens
1877
+ setTimeout(() => searchInput.focus(), 0);
1878
+
1879
+ // Create pricing table
1880
+ const models = this.getAllAvailableModels();
1881
+
1882
+ // Sort models by provider, then by input price desc, then by name desc
1883
+ models.sort((a, b) => {
1884
+ // First sort by provider
1885
+ if (a.providerId !== b.providerId) {
1886
+ return a.providerId.localeCompare(b.providerId);
1887
+ }
1888
+
1889
+ // Within same provider, sort by input price descending
1890
+ const aInputPrice = a.pricing?.input || 0;
1891
+ const bInputPrice = b.pricing?.input || 0;
1892
+
1893
+ if (aInputPrice !== bInputPrice) {
1894
+ return bInputPrice - aInputPrice; // Descending order (expensive first)
1895
+ }
1896
+
1897
+ // If prices are equal, sort by name descending (newer models typically have later names)
1898
+ return b.id.localeCompare(a.id);
1899
+ });
1900
+
1901
+ // Check if there are any models
1902
+ if (!models || models.length === 0) {
1903
+ contentContainer.innerHTML = `
1904
+ <div style="padding: 20px; text-align: center; color: var(--text-secondary);">
1905
+ No models available. Please check your LLM provider configuration.
1906
+ </div>
1907
+ `;
1908
+ document.body.appendChild(dropdown);
1909
+
1910
+ // Function to update dropdown position on scroll/resize
1911
+ const updateDropdownPosition = () => {
1912
+ const newButtonRect = button.getBoundingClientRect();
1913
+ const newViewportHeight = window.innerHeight;
1914
+ const newViewportWidth = window.innerWidth;
1915
+ const newSpaceBelow = newViewportHeight - newButtonRect.bottom;
1916
+ const newShouldShowAbove = newSpaceBelow < dropdownHeight && newButtonRect.top > dropdownHeight;
1917
+
1918
+ if (newShouldShowAbove) {
1919
+ dropdown.style.top = 'auto';
1920
+ dropdown.style.bottom = `${newViewportHeight - newButtonRect.top + 4}px`;
1921
+ } else {
1922
+ dropdown.style.bottom = 'auto';
1923
+ dropdown.style.top = `${newButtonRect.bottom + 4}px`;
1924
+ }
1925
+
1926
+ // Update horizontal position
1927
+ let newLeftPosition = newButtonRect.left;
1928
+ if (newLeftPosition + dropdownMinWidth > newViewportWidth) {
1929
+ newLeftPosition = Math.max(10, newViewportWidth - dropdownMinWidth - 10);
1930
+ }
1931
+ dropdown.style.left = `${newLeftPosition}px`;
1932
+ };
1933
+
1934
+ // Close dropdown on outside click
1935
+ const closeDropdown = (evt) => {
1936
+ if (!dropdown.contains(evt.target) && !button.contains(evt.target)) {
1937
+ if (dropdown.parentElement) {
1938
+ dropdown.parentElement.removeChild(dropdown);
1939
+ }
1940
+ document.removeEventListener('click', closeDropdown, true);
1941
+ document.removeEventListener('mousedown', closeDropdown, true);
1942
+ window.removeEventListener('scroll', updateDropdownPosition, true);
1943
+ window.removeEventListener('resize', updateDropdownPosition);
1944
+ }
1945
+ };
1946
+
1947
+ // Use capture phase to ensure we catch clicks before they're stopped by modal
1948
+ setTimeout(() => {
1949
+ document.addEventListener('click', closeDropdown, true);
1950
+ document.addEventListener('mousedown', closeDropdown, true);
1951
+ window.addEventListener('scroll', updateDropdownPosition, true);
1952
+ window.addEventListener('resize', updateDropdownPosition);
1953
+ }, 0);
1954
+ return;
1955
+ }
1956
+ const table = document.createElement('table');
1957
+ table.style.cssText = `
1958
+ width: 100%;
1959
+ border-collapse: collapse;
1960
+ font-size: 12px;
1961
+ `;
1962
+
1963
+ // Table header
1964
+ const thead = document.createElement('thead');
1965
+ thead.innerHTML = `
1966
+ <tr style="background: var(--surface-color); position: sticky; top: 0; z-index: 1;">
1967
+ <th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border-color);">Model</th>
1968
+ <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">Context</th>
1969
+ <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">Input $/MTok</th>
1970
+ <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">Output $/MTok</th>
1971
+ <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">CacheR $/MTok</th>
1972
+ <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">CacheW $/MTok</th>
1973
+ </tr>
1974
+ `;
1975
+ table.appendChild(thead);
1976
+
1977
+ const tbody = document.createElement('tbody');
1978
+
1979
+ // Function to rebuild table body with filtered models
1980
+ const rebuildTableBody = (filteredModels) => {
1981
+ tbody.innerHTML = '';
1982
+ let currentProvider = null;
1983
+
1984
+ filteredModels.forEach(model => {
1985
+ // Add provider header row when provider changes
1986
+ if (model.providerId !== currentProvider) {
1987
+ currentProvider = model.providerId;
1988
+ const providerRow = document.createElement('tr');
1989
+ providerRow.style.cssText = `
1990
+ background: var(--surface-color);
1991
+ font-weight: 600;
1992
+ color: var(--text-secondary);
1993
+ cursor: default;
1994
+ `;
1995
+ providerRow.innerHTML = `
1996
+ <td colspan="6" style="padding: 8px; text-transform: uppercase; font-size: 11px;">
1997
+ ${currentProvider}
1998
+ </td>
1999
+ `;
2000
+ tbody.appendChild(providerRow);
2001
+ }
2002
+
2003
+ const tr = document.createElement('tr');
2004
+
2005
+ // Capture the full model string in the closure
2006
+ const fullModelId = `${model.providerId}:${model.id}`;
2007
+
2008
+ // Check if this is the currently selected model
2009
+ const isSelected = fullModelId === currentModel;
2010
+
2011
+ tr.style.cssText = `
2012
+ cursor: pointer;
2013
+ transition: background 0.1s;
2014
+ border-bottom: 1px solid var(--border-subtle, var(--border-color));
2015
+ ${isSelected ? 'background: var(--hover-color);' : ''}
2016
+ `;
2017
+
2018
+ // Mark selected row for scrolling
2019
+ if (isSelected) {
2020
+ tr.setAttribute('data-selected', 'true');
2021
+ }
2022
+
2023
+ tr.addEventListener('mouseenter', () => {
2024
+ tr.style.background = 'var(--hover-color)';
2025
+ });
2026
+ tr.addEventListener('mouseleave', () => {
2027
+ if (!isSelected) {
2028
+ tr.style.background = '';
2029
+ }
2030
+ });
2031
+
2032
+ // Add click handler directly here
2033
+ tr.addEventListener('click', () => {
2034
+ const modelName = ChatConfig.getModelDisplayName(fullModelId);
2035
+ button.querySelector('.model-name').textContent = modelName;
2036
+ onSelect(fullModelId);
2037
+ document.body.removeChild(dropdown);
2038
+ button.removeAttribute('data-dropdown-open');
2039
+ });
2040
+
2041
+ const pricing = model.pricing || {};
2042
+ const inputPrice = pricing.input || 0;
2043
+ const outputPrice = pricing.output || 0;
2044
+ const cacheReadPrice = pricing.cacheRead !== undefined ? pricing.cacheRead : '-';
2045
+ const cacheWritePrice = pricing.cacheWrite !== undefined ? pricing.cacheWrite : '-';
2046
+
2047
+ tr.innerHTML = `
2048
+ <td style="padding: 8px; font-weight: 500;">${model.id}</td>
2049
+ <td style="padding: 8px; text-align: right; color: var(--text-secondary);">${this.formatContextWindow(model.contextWindow)}</td>
2050
+ <td style="padding: 8px; text-align: right;">$${inputPrice.toFixed(2)}</td>
2051
+ <td style="padding: 8px; text-align: right;">$${outputPrice.toFixed(2)}</td>
2052
+ <td style="padding: 8px; text-align: right;">${cacheReadPrice === '-' ? '-' : '$' + cacheReadPrice.toFixed(2)}</td>
2053
+ <td style="padding: 8px; text-align: right;">${cacheWritePrice === '-' ? '-' : '$' + cacheWritePrice.toFixed(2)}</td>
2054
+ `;
2055
+
2056
+ tbody.appendChild(tr);
2057
+ });
2058
+ };
2059
+
2060
+ // Initial build with all models
2061
+ rebuildTableBody(models);
2062
+
2063
+ // Auto-scroll to currently selected model after initial table build
2064
+ requestAnimationFrame(() => {
2065
+ const selectedRow = tbody.querySelector('tr[data-selected="true"]');
2066
+ if (selectedRow) {
2067
+ const scrollContainer = contentContainer;
2068
+ const containerRect = scrollContainer.getBoundingClientRect();
2069
+ const rowRect = selectedRow.getBoundingClientRect();
2070
+
2071
+ const rowTop = rowRect.top - containerRect.top + scrollContainer.scrollTop;
2072
+ const rowBottom = rowTop + rowRect.height;
2073
+ const containerHeight = scrollContainer.clientHeight;
2074
+
2075
+ // Check if row is outside visible area
2076
+ if (rowTop < scrollContainer.scrollTop || rowBottom > scrollContainer.scrollTop + containerHeight) {
2077
+ // Center the selected row in the viewport
2078
+ const scrollTarget = rowTop - (containerHeight / 2) + (rowRect.height / 2);
2079
+ scrollContainer.scrollTop = Math.max(0, scrollTarget);
2080
+ }
2081
+ }
2082
+ });
2083
+
2084
+ // Add search functionality
2085
+ searchInput.addEventListener('input', (event) => {
2086
+ const searchTerm = event.target.value.toLowerCase().trim();
2087
+
2088
+ if (!searchTerm) {
2089
+ rebuildTableBody(models);
2090
+ return;
2091
+ }
2092
+
2093
+ const filteredModels = models.filter(model => {
2094
+ const modelId = model.id.toLowerCase();
2095
+ const providerId = model.providerId.toLowerCase();
2096
+ const fullId = `${providerId}:${modelId}`.toLowerCase();
2097
+
2098
+ return modelId.includes(searchTerm) ||
2099
+ providerId.includes(searchTerm) ||
2100
+ fullId.includes(searchTerm);
2101
+ });
2102
+
2103
+ if (filteredModels.length === 0) {
2104
+ tbody.innerHTML = `
2105
+ <tr>
2106
+ <td colspan="6" style="padding: 20px; text-align: center; color: var(--text-secondary);">
2107
+ No models found matching "${searchTerm}"
2108
+ </td>
2109
+ </tr>
2110
+ `;
2111
+ } else {
2112
+ rebuildTableBody(filteredModels);
2113
+
2114
+ // Auto-scroll to selected model after search rebuild
2115
+ requestAnimationFrame(() => {
2116
+ const selectedRow = tbody.querySelector('tr[data-selected="true"]');
2117
+ if (selectedRow) {
2118
+ const scrollContainer = contentContainer;
2119
+ const containerRect = scrollContainer.getBoundingClientRect();
2120
+ const rowRect = selectedRow.getBoundingClientRect();
2121
+
2122
+ const rowTop = rowRect.top - containerRect.top + scrollContainer.scrollTop;
2123
+ const rowBottom = rowTop + rowRect.height;
2124
+ const containerHeight = scrollContainer.clientHeight;
2125
+
2126
+ if (rowTop < scrollContainer.scrollTop || rowBottom > scrollContainer.scrollTop + containerHeight) {
2127
+ const scrollTarget = rowTop - (containerHeight / 2) + (rowRect.height / 2);
2128
+ scrollContainer.scrollTop = Math.max(0, scrollTarget);
2129
+ }
2130
+ }
2131
+ });
2132
+ }
2133
+ });
2134
+
2135
+ // Handle keyboard navigation
2136
+ searchInput.addEventListener('keydown', (keyEvent) => {
2137
+ if (keyEvent.key === 'Escape') {
2138
+ dropdown.remove();
2139
+ button.removeAttribute('data-dropdown-open');
2140
+ } else if (keyEvent.key === 'ArrowDown') {
2141
+ keyEvent.preventDefault();
2142
+ const firstRow = tbody.querySelector('tr[style*="cursor: pointer"]');
2143
+ if (firstRow) {
2144
+ firstRow.focus();
2145
+ firstRow.style.background = 'var(--hover-color)';
2146
+ }
2147
+ }
2148
+ });
2149
+
2150
+ // Define functions before they're used
2151
+ // Function to update dropdown position on scroll/resize
2152
+ const updateDropdownPosition = () => {
2153
+ const newButtonRect = button.getBoundingClientRect();
2154
+ const newViewportHeight = window.innerHeight;
2155
+ const newViewportWidth = window.innerWidth;
2156
+ const newSpaceBelow = newViewportHeight - newButtonRect.bottom;
2157
+ const newShouldShowAbove = newSpaceBelow < dropdownHeight && newButtonRect.top > dropdownHeight;
2158
+
2159
+ if (newShouldShowAbove) {
2160
+ dropdown.style.top = 'auto';
2161
+ dropdown.style.bottom = `${newViewportHeight - newButtonRect.top + 4}px`;
2162
+ } else {
2163
+ dropdown.style.bottom = 'auto';
2164
+ dropdown.style.top = `${newButtonRect.bottom + 4}px`;
2165
+ }
2166
+
2167
+ // Update horizontal position
2168
+ let newLeftPosition = newButtonRect.left;
2169
+ if (newLeftPosition + dropdownMinWidth > newViewportWidth) {
2170
+ newLeftPosition = Math.max(10, newViewportWidth - dropdownMinWidth - 10);
2171
+ }
2172
+ dropdown.style.left = `${newLeftPosition}px`;
2173
+ };
2174
+
2175
+ // Close dropdown on outside click
2176
+ const closeDropdown = (evt) => {
2177
+ // Check if click is outside dropdown and button
2178
+ if (!dropdown.contains(evt.target) && !button.contains(evt.target)) {
2179
+ if (dropdown.parentElement) {
2180
+ dropdown.parentElement.removeChild(dropdown);
2181
+ }
2182
+ button.removeAttribute('data-dropdown-open');
2183
+ document.removeEventListener('click', closeDropdown, true);
2184
+ document.removeEventListener('mousedown', closeDropdown, true);
2185
+ window.removeEventListener('scroll', updateDropdownPosition, true);
2186
+ window.removeEventListener('resize', updateDropdownPosition);
2187
+ }
2188
+ };
2189
+
2190
+ // Click listeners are now added directly when creating rows
2191
+
2192
+ // Add event listeners
2193
+ // Use capture phase to ensure we catch clicks before they're stopped by modal
2194
+ setTimeout(() => {
2195
+ document.addEventListener('click', closeDropdown, true);
2196
+ document.addEventListener('mousedown', closeDropdown, true);
2197
+ window.addEventListener('scroll', updateDropdownPosition, true);
2198
+ window.addEventListener('resize', updateDropdownPosition);
2199
+ }, 0);
2200
+
2201
+ // Now append elements after functions are defined
2202
+ table.appendChild(tbody);
2203
+ contentContainer.appendChild(table);
2204
+
2205
+ // Append dropdown to body for proper z-index layering
2206
+ document.body.appendChild(dropdown);
2207
+ });
2208
+ };
2209
+
2210
+ // Initialize all model selection buttons
2211
+ createModelDropdown(`chatModel_${chatId}`, ChatConfig.getChatModelString(chat), (model) => {
2212
+ this.updateChatModel(chatId, model);
2213
+ });
2214
+
2215
+ createModelDropdown(`toolSumModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.toolSummarisation.model) || ChatConfig.getChatModelString(chat), (model) => {
2216
+ this.updateToolSummarizationModel(chatId, model);
2217
+ });
2218
+
2219
+ createModelDropdown(`autoSumModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.autoSummarisation.model) || ChatConfig.getChatModelString(chat), (model) => {
2220
+ this.updateAutoSummarizationModel(chatId, model);
2221
+ });
2222
+
2223
+ createModelDropdown(`titleGenModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.titleGeneration?.model), (model) => {
2224
+ this.updateTitleGenerationModel(chatId, model);
2225
+ });
2226
+ }
2227
+
2228
+ updateOptimizationSetting(chatId, settingType, enabled) {
2229
+ const chat = this.chats.get(chatId);
2230
+ if (!chat) return;
2231
+
2232
+ // Get current config or create defaults
2233
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2234
+
2235
+ // Update the specific setting
2236
+ switch (settingType) {
2237
+ case 'toolSummarization':
2238
+ config.optimisation.toolSummarisation.enabled = enabled;
2239
+ break;
2240
+ case 'toolMemory':
2241
+ config.optimisation.toolMemory.enabled = enabled;
2242
+ break;
2243
+ case 'cacheControl':
2244
+ config.optimisation.cacheControl.enabled = enabled;
2245
+ break;
2246
+ case 'autoSummarization':
2247
+ config.optimisation.autoSummarisation.enabled = enabled;
2248
+ break;
2249
+ case 'titleGeneration':
2250
+ config.optimisation.titleGeneration.enabled = enabled;
2251
+ break;
2252
+ default:
2253
+ console.warn(`Unknown setting type: ${settingType}`);
2254
+ break;
2255
+ }
2256
+
2257
+ // Update chat config
2258
+ chat.config = config;
2259
+
2260
+ // Recreate MessageOptimizer with new settings
2261
+ const optimizerSettings = {
2262
+ ...config,
2263
+ llmProviderFactory: config.optimisation.toolSummarisation.enabled ? window.createLLMProvider : undefined
2264
+ };
2265
+
2266
+ try {
2267
+ chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2268
+ } catch (error) {
2269
+ console.error('[updateOptimizationSetting] Failed to create MessageOptimizer:', error);
2270
+ }
2271
+
2272
+ // Save config
2273
+ this.saveChatConfigSmart(chatId, config);
2274
+
2275
+ // Auto-save chat
2276
+ this.autoSave(chatId);
2277
+ }
2278
+
2279
+
2280
+ updateChatModel(chatId, model) {
2281
+ const chat = this.chats.get(chatId);
2282
+ if (!chat) return;
2283
+
2284
+ // Update config
2285
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2286
+ const modelConfig = ChatConfig.modelConfigFromString(model);
2287
+ if (modelConfig) {
2288
+ // Preserve existing params
2289
+ modelConfig.params = config.model?.params || modelConfig.params;
2290
+ config.model = modelConfig;
2291
+ }
2292
+
2293
+ // Note: We intentionally do NOT auto-update optimization feature models
2294
+ // If a user explicitly selected a model for a feature, it should stay as that model
2295
+ // Only null values (which mean "use chat model") will automatically follow the chat model
2296
+
2297
+ chat.config = config;
2298
+ this.recreateMessageOptimizer(chat, config);
2299
+ this.saveChatConfigSmart(chatId, config);
2300
+ this.autoSave(chatId);
2301
+
2302
+ // Update displays
2303
+ this.updateChatHeader(chatId);
2304
+ this.updateChatSessions();
2305
+ }
2306
+
2307
+ updateToolSummarizationModel(chatId, model) {
2308
+ const chat = this.chats.get(chatId);
2309
+ if (!chat) return;
2310
+
2311
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2312
+ config.optimisation.toolSummarisation.model = ChatConfig.modelConfigFromString(model);
2313
+
2314
+ chat.config = config;
2315
+ this.recreateMessageOptimizer(chat, config);
2316
+ this.saveChatConfigSmart(chatId, config);
2317
+ this.autoSave(chatId);
2318
+ }
2319
+
2320
+ updateAutoSummarizationModel(chatId, model) {
2321
+ const chat = this.chats.get(chatId);
2322
+ if (!chat) return;
2323
+
2324
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2325
+ config.optimisation.autoSummarisation.model = ChatConfig.modelConfigFromString(model);
2326
+
2327
+ chat.config = config;
2328
+ this.recreateMessageOptimizer(chat, config);
2329
+ this.saveChatConfigSmart(chatId, config);
2330
+ this.autoSave(chatId);
2331
+ }
2332
+
2333
+ updateTitleGenerationModel(chatId, model) {
2334
+ const chat = this.chats.get(chatId);
2335
+ if (!chat) return;
2336
+
2337
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2338
+ // Use feature-specific defaults for title generation
2339
+ config.optimisation.titleGeneration.model = ChatConfig.modelConfigFromString(model, {
2340
+ temperature: 0.7,
2341
+ topP: 0.9,
2342
+ maxTokens: 100 // Title generation should use limited tokens
2343
+ });
2344
+
2345
+ chat.config = config;
2346
+ this.recreateMessageOptimizer(chat, config);
2347
+ this.saveChatConfigSmart(chatId, config);
2348
+ this.autoSave(chatId);
2349
+ }
2350
+
2351
+ updateToolThreshold(chatId, threshold) {
2352
+ const chat = this.chats.get(chatId);
2353
+ if (!chat) return;
2354
+
2355
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2356
+ config.optimisation.toolSummarisation.thresholdKiB = Math.floor(threshold / 1024);
2357
+
2358
+ chat.config = config;
2359
+ this.recreateMessageOptimizer(chat, config);
2360
+ this.saveChatConfigSmart(chatId, config);
2361
+ this.autoSave(chatId);
2362
+ }
2363
+
2364
+ updateAutoSumThreshold(chatId, percent) {
2365
+ const chat = this.chats.get(chatId);
2366
+ if (!chat) return;
2367
+
2368
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2369
+ config.optimisation.autoSummarisation.triggerPercent = percent;
2370
+
2371
+ chat.config = config;
2372
+ this.recreateMessageOptimizer(chat, config);
2373
+ this.saveChatConfigSmart(chatId, config);
2374
+ this.autoSave(chatId);
2375
+ }
2376
+
2377
+ updateToolMemoryThreshold(chatId, forgetAfterConclusions) {
2378
+ const chat = this.chats.get(chatId);
2379
+ if (!chat) return;
2380
+
2381
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2382
+ config.optimisation.toolMemory.forgetAfterConclusions = forgetAfterConclusions;
2383
+
2384
+ chat.config = config;
2385
+ this.recreateMessageOptimizer(chat, config);
2386
+ this.saveChatConfigSmart(chatId, config);
2387
+ this.autoSave(chatId);
2388
+ }
2389
+
2390
+ recreateMessageOptimizer(chat, config) {
2391
+ // Add factory for tool summarization if enabled
2392
+ const optimizerSettings = {
2393
+ ...config,
2394
+ llmProviderFactory: config.optimisation.toolSummarisation.enabled ? window.createLLMProvider : undefined
2395
+ };
2396
+
2397
+ try {
2398
+ chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2399
+ } catch (error) {
2400
+ console.error('[recreateMessageOptimizer] Failed to create MessageOptimizer:', error);
2401
+ }
2402
+ }
2403
+
2404
+
2405
+ populateMCPDropdown(chatId, dropdown = null) {
2406
+ if (!chatId) {
2407
+ console.error('[populateMCPDropdown] Called without chatId');
2408
+ return;
2409
+ }
2410
+ const targetChatId = chatId;
2411
+ const targetDropdown = dropdown || this.mcpServerDropdown;
2412
+
2413
+ targetDropdown.innerHTML = '';
2414
+
2415
+ // Sort servers by name
2416
+ const sortedServers = Array.from(this.mcpServers.entries())
2417
+ .sort(([, a], [, b]) => a.name.localeCompare(b.name));
2418
+
2419
+ for (const [id, server] of sortedServers) {
2420
+ const item = document.createElement('button');
2421
+ item.className = 'dropdown-item';
2422
+
2423
+ // Check actual connection status from mcpConnections
2424
+ const mcpConnection = this.mcpConnections.get(id);
2425
+ const isConnected = mcpConnection && mcpConnection.isReady();
2426
+
2427
+ item.innerHTML = `
2428
+ <div style="display: flex; align-items: flex-start; gap: 8px;">
2429
+ <span style="flex-shrink: 0;">${isConnected ? '🟢' : '🔴'}</span>
2430
+ <div style="flex: 1; min-width: 0;">
2431
+ <div>${server.name}</div>
2432
+ <small style="display: block; color: var(--text-tertiary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${server.url}</small>
2433
+ </div>
2434
+ </div>
2435
+ `;
2436
+
2437
+ const chat = this.chats.get(targetChatId);
2438
+ if (chat && chat.mcpServerId === id) {
2439
+ item.classList.add('active');
2440
+ }
2441
+
2442
+ item.onclick = () => {
2443
+ this.switchMcpServer(id, targetChatId).catch(error => {
2444
+ console.error('Failed to switch MCP server:', error);
2445
+ this.showError('Failed to switch MCP server', targetChatId, false);
2446
+ });
2447
+ targetDropdown.style.display = 'none';
2448
+ };
2449
+
2450
+ targetDropdown.appendChild(item);
2451
+ }
2452
+ }
2453
+
2454
+ async switchMcpServer(newServerId, chatId) {
2455
+ const chat = this.chats.get(chatId);
2456
+ if (!chat || chat.mcpServerId === newServerId) {return;}
2457
+
2458
+ try {
2459
+ // Ensure connection to new MCP server
2460
+ await this.ensureMcpConnection(newServerId);
2461
+
2462
+ chat.mcpServerId = newServerId;
2463
+ this.autoSave(chat.id);
2464
+
2465
+ // Update UI
2466
+ const server = this.mcpServers.get(newServerId);
2467
+ this.currentMcpText.textContent = server.name;
2468
+
2469
+ // Clear tool inclusion states for the new server (will be populated on next use)
2470
+ const chatToolStates = this.toolInclusionStates.get(chatId);
2471
+ if (chatToolStates) {
2472
+ chatToolStates.clear();
2473
+ }
2474
+
2475
+ // Save the updated config as default for new chats
2476
+ if (chat.config) {
2477
+ const updatedConfig = { ...chat.config };
2478
+ updatedConfig.mcpServer = newServerId;
2479
+ ChatConfig.saveLastConfig(updatedConfig);
2480
+ }
2481
+
2482
+ this.addLogEntry('SYSTEM', {
2483
+ timestamp: new Date().toISOString(),
2484
+ direction: 'info',
2485
+ message: `Switched to MCP server: ${server.name}`
2486
+ });
2487
+
2488
+ } catch (error) {
2489
+ this.showError(`Failed to switch MCP server: ${error.message}`, chatId, false);
2490
+ }
2491
+ }
2492
+
2493
+ saveSystemPrompt(chatId) {
2494
+ const chat = this.chats.get(chatId);
2495
+ if (!chat) {return;}
2496
+
2497
+ const newPrompt = this.systemPromptTextarea.value.trim();
2498
+ if (!newPrompt) {
2499
+ this.showError('System prompt cannot be empty', chatId);
2500
+ return;
2501
+ }
2502
+
2503
+ // Check if prompt actually changed
2504
+ if (newPrompt === chat.systemPrompt) {
2505
+ this.hideModal('systemPromptModal');
2506
+ return;
2507
+ }
2508
+
2509
+ // Update the chat's system prompt
2510
+ chat.systemPrompt = newPrompt;
2511
+
2512
+ // Clear messages and reset the conversation
2513
+ chat.messages = [];
2514
+ chat.updatedAt = new Date().toISOString();
2515
+
2516
+ // Save the new prompt as the last used one
2517
+ this.lastSystemPrompt = newPrompt;
2518
+ localStorage.setItem('lastSystemPrompt', newPrompt);
2519
+
2520
+ // Clear token usage history for this chat
2521
+ this.tokenUsageHistory.set(chatId, {
2522
+ requests: [],
2523
+ model: ChatConfig.getChatModelString(chat)
2524
+ });
2525
+
2526
+ // Save settings
2527
+ this.saveSettings();
2528
+
2529
+ // Reload the chat (force refresh since we cleared messages)
2530
+ this.loadChat(chatId, true);
2531
+
2532
+ // Hide modal
2533
+ this.hideModal('systemPromptModal');
2534
+
2535
+ // Show notification
2536
+ this.addSystemMessage('System prompt updated. Conversation has been reset.', chatId);
2537
+ }
2538
+
2539
+ toggleTheme() {
2540
+ const html = document.documentElement;
2541
+ const currentTheme = html.getAttribute('data-theme');
2542
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
2543
+ html.setAttribute('data-theme', newTheme);
2544
+ localStorage.setItem('theme', newTheme);
2545
+
2546
+ // Theme switching for tooltips is now handled by CSS variables
2547
+ }
2548
+ initializeResizable() {
2549
+ // Chat sidebar resize
2550
+ const chatSidebar = document.getElementById('chatSidebar');
2551
+ const chatSidebarResize = document.getElementById('chatSidebarResize');
2552
+
2553
+ // Make sure the resize handle exists
2554
+ if (!chatSidebarResize) {
2555
+ console.error('Chat sidebar resize handle not found');
2556
+ } else {
2557
+ this.setupResize(chatSidebarResize, 'horizontal', (delta) => {
2558
+ const isCollapsed = chatSidebar.classList.contains('collapsed');
2559
+ const currentWidth = chatSidebar.offsetWidth;
2560
+ const newWidth = currentWidth + delta;
2561
+
2562
+ // If collapsed and dragging to expand (delta > 0)
2563
+ if (isCollapsed && newWidth > 100) {
2564
+ // Expand the sidebar
2565
+ chatSidebar.classList.remove('collapsed');
2566
+ const icon = this.toggleSidebarBtn.querySelector('i');
2567
+ icon.className = 'fas fa-chevron-left';
2568
+ localStorage.setItem('chatSidebarCollapsed', 'false');
2569
+
2570
+ // Set the new width
2571
+ const finalExpandWidth = Math.max(200, Math.min(400, newWidth));
2572
+ chatSidebar.style.setProperty('width', finalExpandWidth + 'px', 'important');
2573
+ chatSidebar.style.setProperty('min-width', finalExpandWidth + 'px', 'important');
2574
+ chatSidebar.style.setProperty('max-width', finalExpandWidth + 'px', 'important');
2575
+ }
2576
+ // If expanded and dragging to collapse (width getting too small)
2577
+ else if (!isCollapsed && newWidth < 100) {
2578
+ // Collapse the sidebar
2579
+ chatSidebar.classList.add('collapsed');
2580
+ const icon = this.toggleSidebarBtn.querySelector('i');
2581
+ icon.className = 'fas fa-chevron-left';
2582
+ localStorage.setItem('chatSidebarCollapsed', 'true');
2583
+ chatSidebar.style.width = '';
2584
+ }
2585
+ // Normal resize when expanded
2586
+ else if (!isCollapsed) {
2587
+ const finalWidth = Math.max(200, Math.min(400, newWidth));
2588
+
2589
+ // Override all width-related CSS properties
2590
+ chatSidebar.style.setProperty('width', finalWidth + 'px', 'important');
2591
+ chatSidebar.style.setProperty('min-width', finalWidth + 'px', 'important');
2592
+ chatSidebar.style.setProperty('max-width', finalWidth + 'px', 'important');
2593
+ }
2594
+
2595
+ this.savePaneSizes();
2596
+ });
2597
+ }
2598
+
2599
+ // Log panel resize
2600
+ const logPanel = document.getElementById('logPanel');
2601
+ const logPanelResize = document.getElementById('logPanelResize');
2602
+
2603
+ this.setupResize(logPanelResize, 'horizontal', (delta) => {
2604
+ // First, ensure the panel is not collapsed
2605
+ if (logPanel.classList.contains('collapsed')) {
2606
+ // Expand it first
2607
+ logPanel.classList.remove('collapsed');
2608
+ this.toggleLogBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
2609
+ this.expandLogBtn.style.display = 'none';
2610
+ localStorage.setItem('logCollapsed', 'false');
2611
+ // Set initial width when expanding
2612
+ logPanel.style.width = '300px';
2613
+ }
2614
+
2615
+ const currentWidth = logPanel.offsetWidth;
2616
+ // For right panel, dragging left (negative delta) should increase width
2617
+ const newWidth = Math.max(200, Math.min(650, currentWidth + -delta));
2618
+ logPanel.style.width = newWidth + 'px';
2619
+ this.savePaneSizes();
2620
+ }, logPanel);
2621
+
2622
+ // Chat input resize is now handled per-chat in the createChatDOM method
2623
+ // No global chat input container exists anymore
2624
+ }
2625
+
2626
+ setupResize(handle, direction, onResize, element) {
2627
+ if (!handle) {
2628
+ console.warn('setupResize called with null handle');
2629
+ return;
2630
+ }
2631
+
2632
+ let isResizing = false;
2633
+ let startPos = 0;
2634
+
2635
+ const startResize = (e) => {
2636
+ isResizing = true;
2637
+ startPos = direction === 'horizontal' ? e.clientX : e.clientY;
2638
+ document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
2639
+ document.body.style.userSelect = 'none';
2640
+ e.preventDefault();
2641
+
2642
+ // Add active class for visual feedback
2643
+ handle.classList.add('resize-active');
2644
+
2645
+ // Add resizing class to element if provided
2646
+ if (element) {
2647
+ element.classList.add('resizing');
2648
+ }
2649
+ };
2650
+
2651
+ const doResize = (e) => {
2652
+ if (!isResizing) {return;}
2653
+
2654
+ const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
2655
+ const delta = currentPos - startPos;
2656
+ startPos = currentPos;
2657
+
2658
+ onResize(delta);
2659
+ };
2660
+
2661
+ const stopResize = () => {
2662
+ if (!isResizing) {return;}
2663
+ isResizing = false;
2664
+ document.body.style.cursor = '';
2665
+ document.body.style.userSelect = '';
2666
+
2667
+ // Remove active class
2668
+ handle.classList.remove('resize-active');
2669
+
2670
+ // Remove resizing class from element if provided
2671
+ if (element) {
2672
+ element.classList.remove('resizing');
2673
+ }
2674
+ };
2675
+
2676
+ handle.addEventListener('mousedown', startResize);
2677
+ document.addEventListener('mousemove', doResize);
2678
+ document.addEventListener('mouseup', stopResize);
2679
+
2680
+ // Also handle mouse leave to stop resize
2681
+ document.addEventListener('mouseleave', stopResize);
2682
+ }
2683
+
2684
+ makeResizable(handle, container, direction, minSize, maxSize) {
2685
+ if (!handle || !container) {
2686
+ console.warn('makeResizable called with null handle or container');
2687
+ return;
2688
+ }
2689
+
2690
+ this.setupResize(handle, direction, (delta) => {
2691
+ const isVertical = direction === 'vertical';
2692
+ const currentSize = isVertical ? container.offsetHeight : container.offsetWidth;
2693
+ const newSize = Math.max(minSize || 100, Math.min(maxSize || 1000, currentSize + (isVertical ? -delta : delta)));
2694
+
2695
+ if (isVertical) {
2696
+ container.style.height = newSize + 'px';
2697
+ } else {
2698
+ container.style.width = newSize + 'px';
2699
+ }
2700
+ }, container);
2701
+ }
2702
+
2703
+ savePaneSizes() {
2704
+ const sizes = {
2705
+ chatSidebar: this.chatSidebar ? this.chatSidebar.offsetWidth : 280,
2706
+ logPanel: this.logPanel ? this.logPanel.classList.contains('collapsed') ? 40 : this.logPanel.offsetWidth : 300,
2707
+ logPanelCollapsed: this.logPanel ? this.logPanel.classList.contains('collapsed') : false
2708
+ };
2709
+ localStorage.setItem('paneSizes', JSON.stringify(sizes));
2710
+ }
2711
+
2712
+ loadPaneSizes() {
2713
+ const savedSizes = localStorage.getItem('paneSizes');
2714
+ if (savedSizes) {
2715
+ try {
2716
+ const sizes = JSON.parse(savedSizes);
2717
+
2718
+ if (sizes.chatSidebar && this.chatSidebar && !this.chatSidebar.classList.contains('collapsed')) {
2719
+ this.chatSidebar.style.width = sizes.chatSidebar + 'px';
2720
+ }
2721
+
2722
+ if (sizes.logPanel && this.logPanel) {
2723
+ // Only set width if the panel is not currently collapsed
2724
+ if (!this.logPanel.classList.contains('collapsed')) {
2725
+ this.logPanel.style.width = sizes.logPanel + 'px';
2726
+ }
2727
+ }
2728
+
2729
+ // Chat input container sizing is now handled per-chat, skip global sizing
2730
+ } catch (e) {
2731
+ console.error('Failed to load pane sizes:', e);
2732
+ }
2733
+ }
2734
+ }
2735
+
2736
+ toggleLog() {
2737
+ const isCollapsed = this.logPanel.classList.toggle('collapsed');
2738
+ this.toggleLogBtn.innerHTML = isCollapsed ? '<i class="fas fa-chevron-left"></i>' : '<i class="fas fa-chevron-right"></i>';
2739
+ this.expandLogBtn.style.display = isCollapsed ? 'block' : 'none';
2740
+ localStorage.setItem('logCollapsed', String(isCollapsed));
2741
+ this.savePaneSizes();
2742
+ }
2743
+
2744
+ toggleChatSidebar() {
2745
+ const isCollapsed = this.chatSidebar.classList.toggle('collapsed');
2746
+
2747
+ // Update button icon - always keep as chevron-left, CSS handles rotation when collapsed
2748
+ const icon = this.toggleSidebarBtn.querySelector('i');
2749
+ icon.className = 'fas fa-chevron-left';
2750
+
2751
+ if (isCollapsed) {
2752
+ // Store current width before collapsing
2753
+ const currentWidth = this.chatSidebar.offsetWidth;
2754
+ if (currentWidth > 40) {
2755
+ localStorage.setItem('chatSidebarWidth', String(currentWidth));
2756
+ }
2757
+ // Override any inline width when collapsed
2758
+ this.chatSidebar.style.width = '';
2759
+ } else {
2760
+ // Restore previous width
2761
+ const savedWidth = localStorage.getItem('chatSidebarWidth') || '280';
2762
+ this.chatSidebar.style.width = savedWidth + 'px';
2763
+ }
2764
+
2765
+ localStorage.setItem('chatSidebarCollapsed', String(isCollapsed));
2766
+ this.savePaneSizes();
2767
+ }
2768
+
2769
+ loadSidebarStates() {
2770
+ // Load chat sidebar state
2771
+ const chatSidebarCollapsed = localStorage.getItem('chatSidebarCollapsed') === 'true';
2772
+ if (chatSidebarCollapsed) {
2773
+ this.chatSidebar.classList.add('collapsed');
2774
+ // Note: CSS rotates the icon 180deg when collapsed, so keep it as chevron-left
2775
+ const icon = this.toggleSidebarBtn.querySelector('i');
2776
+ icon.className = 'fas fa-chevron-left';
2777
+ }
2778
+
2779
+ // Load log panel state
2780
+ const logCollapsed = localStorage.getItem('logCollapsed') === 'true';
2781
+ if (logCollapsed) {
2782
+ this.logPanel.classList.add('collapsed');
2783
+ this.toggleLogBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
2784
+ this.expandLogBtn.style.display = 'block';
2785
+ }
2786
+ }
2787
+
2788
+ handleRateLimitError(chatId, retryAfterSeconds, retryCount = 0) {
2789
+ const chat = this.chats.get(chatId);
2790
+ if (!chat) return;
2791
+
2792
+ // Store retry count in chat for tracking
2793
+ chat.rateLimitRetryCount = retryCount;
2794
+
2795
+ // If we couldn't parse retry time, use exponential backoff
2796
+ let waitTime;
2797
+ if (retryAfterSeconds && retryAfterSeconds > 0) {
2798
+ // Add a small buffer to ensure we wait long enough
2799
+ waitTime = retryAfterSeconds + 1;
2800
+ } else {
2801
+ // Exponential backoff: 5s, 10s, 20s, 40s, 80s...
2802
+ waitTime = Math.min(5 * Math.pow(2, retryCount), 120); // Cap at 2 minutes
2803
+ console.log(`[Rate Limit] No retry time found, using exponential backoff: ${waitTime}s (attempt ${retryCount + 1})`);
2804
+ }
2805
+
2806
+ let remainingSeconds = Math.ceil(waitTime);
2807
+
2808
+ // Mark that we're in rate limit countdown - this prevents other operations from clearing the spinner
2809
+ chat.isInRateLimitCountdown = true;
2810
+
2811
+ // Show waiting spinner with countdown immediately
2812
+ this.showWaitingCountdown(chatId, remainingSeconds);
2813
+
2814
+ const updateCountdown = () => {
2815
+ remainingSeconds--;
2816
+ if (remainingSeconds > 0) {
2817
+ // Only update if we're still in countdown mode
2818
+ if (chat.isInRateLimitCountdown) {
2819
+ this.updateWaitingCountdown(chatId, remainingSeconds);
2820
+ setTimeout(updateCountdown, 1000);
2821
+ }
2822
+ } else {
2823
+ // Clear the flag and retry
2824
+ chat.isInRateLimitCountdown = false;
2825
+ // Don't hide the countdown - let retryLLMRequest transition to thinking spinner
2826
+ // This ensures there's no gap in the spinner display
2827
+ this.retryLLMRequest(chatId);
2828
+ }
2829
+ };
2830
+
2831
+ // Start the countdown
2832
+ setTimeout(updateCountdown, 1000);
2833
+ }
2834
+
2835
+ async retryLLMRequest(chatId) {
2836
+ const chat = this.chats.get(chatId);
2837
+ if (!chat) return;
2838
+
2839
+ try {
2840
+ const mcpConnection = this.mcpConnections.get(chat.mcpServerId);
2841
+ const proxyProvider = this.llmProviders.get(chat.llmProviderId);
2842
+
2843
+ if (!mcpConnection || !proxyProvider || !chat.config?.model) {
2844
+ this.showError('Cannot retry: MCP server or LLM provider not available', chatId);
2845
+ return;
2846
+ }
2847
+
2848
+ // Create provider instance
2849
+ const providerType = chat.config.model.provider;
2850
+ const modelName = chat.config.model.id;
2851
+ const provider = createLLMProvider(providerType, proxyProvider.proxyUrl, modelName);
2852
+ provider.onLog = (logEntry) => {
2853
+ const prefix = logEntry.direction === 'sent' ? 'llm-request' : 'llm-response';
2854
+ const providerName = providerType.charAt(0).toUpperCase() + providerType.slice(1);
2855
+ this.addLogEntry(`${prefix}: ${providerName}`, logEntry);
2856
+ };
2857
+
2858
+ // Build messages from current state
2859
+ const { messages, cacheControlIndex } = this.buildMessagesForAPI(chat, provider.prefersCachedTools, mcpConnection);
2860
+
2861
+ // Get available tools
2862
+ const tools = Array.from(mcpConnection.tools.values());
2863
+
2864
+ // Increment retry count for next attempt
2865
+ const currentRetryCount = chat.rateLimitRetryCount || 0;
2866
+ chat.rateLimitRetryCount = currentRetryCount + 1;
2867
+
2868
+ // Call assistant with proper error handling
2869
+ const temperature = this.getCurrentTemperature(chatId);
2870
+ const response = await this.callAssistant({
2871
+ chatId,
2872
+ provider,
2873
+ messages,
2874
+ tools,
2875
+ temperature,
2876
+ cacheControlIndex,
2877
+ context: `Retry (attempt ${chat.rateLimitRetryCount})`
2878
+ });
2879
+
2880
+ // Check if rate limit was handled
2881
+ if (response._rateLimitHandled) {
2882
+ return; // Rate limit retry will happen automatically with exponential backoff
2883
+ }
2884
+
2885
+ // Success - reset retry count
2886
+ chat.rateLimitRetryCount = 0;
2887
+
2888
+ // Process the response - extract the core loop logic from processMessageWithTools
2889
+ // This continues the conversation from where it left off
2890
+ await this.processLLMResponseLoop(chat, mcpConnection, provider, messages, tools, cacheControlIndex, response);
2891
+
2892
+ // Success - assistant has concluded
2893
+ this.assistantConcluded(chatId);
2894
+
2895
+ } catch (error) {
2896
+ // Clean up on error - but preserve rate limit waiting spinner
2897
+ this.assistantFailed(chatId, error);
2898
+
2899
+ // Only show error if not a handled rate limit
2900
+ if (!error._rateLimitHandled) {
2901
+ // Show error with manual retry button
2902
+ const errorMessage = `${error.context || 'Error'}: ${error.message}`;
2903
+ const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
2904
+
2905
+ // Determine error type
2906
+ let errorType = 'llm_error';
2907
+ if (error.message.includes('MCP') || error.message.includes('connection')) {
2908
+ errorType = 'mcp_error';
2909
+ } else if (error.message.includes('Tool')) {
2910
+ errorType = 'tool_error';
2911
+ }
2912
+
2913
+ // Update error state
2914
+ this.showError(chatId, errorMessage, errorType);
2915
+
2916
+ // Add error message
2917
+ this.addMessage(chatId, {
2918
+ role: 'error',
2919
+ content: errorMessage,
2920
+ errorMessageIndex: lastUserMessageIndex,
2921
+ errorType
2922
+ });
2923
+
2924
+ this.processRenderEvent({
2925
+ type: 'error-message',
2926
+ content: errorMessage,
2927
+ errorMessageIndex: lastUserMessageIndex,
2928
+ errorType
2929
+ }, chatId);
2930
+ }
2931
+ }
2932
+ }
2933
+
2934
+ async processLLMResponseLoop(chat, mcpConnection, provider, messages, tools, cacheControlIndex, initialResponse = null) {
2935
+ // If we have an initial response (from retry), process it first
2936
+ if (initialResponse) {
2937
+ await this.processSingleLLMResponse(chat, mcpConnection, provider, messages, tools, cacheControlIndex, initialResponse);
2938
+ }
2939
+
2940
+ // Continue the loop
2941
+ while (true) {
2942
+ // Check if we should stop processing
2943
+ if (this.shouldStopProcessing) {
2944
+ break;
2945
+ }
2946
+
2947
+ // Check if the last response had tool calls
2948
+ const lastMessage = messages[messages.length - 1];
2949
+ if (!lastMessage || lastMessage.role !== 'tool-results') {
2950
+ // No more tool results to process, we're done
2951
+ break;
2952
+ }
2953
+
2954
+ // Safety check: Check iteration limit before continuing
2955
+ try {
2956
+ // Only check iteration limit here, not request size
2957
+ // Request size will be checked in the provider when actual request is built
2958
+ const currentIterations = this.safetyChecker.getIterationCount(chat.id);
2959
+ if (currentIterations >= SAFETY_LIMITS.MAX_CONSECUTIVE_TOOL_ITERATIONS) {
2960
+ throw new SafetyLimitError('ITERATIONS', SAFETY_LIMITS.ERRORS.TOO_MANY_ITERATIONS(currentIterations, SAFETY_LIMITS.MAX_CONSECUTIVE_TOOL_ITERATIONS));
2961
+ }
2962
+ } catch (error) {
2963
+ if (error instanceof SafetyLimitError) {
2964
+ this.addMessage(chat.id, {
2965
+ role: 'error',
2966
+ content: error.message,
2967
+ errorType: 'safety_limit',
2968
+ isRetryable: false
2969
+ });
2970
+ this.processRenderEvent({
2971
+ type: 'error-message',
2972
+ content: error.message,
2973
+ errorType: 'safety_limit'
2974
+ }, chat.id);
2975
+ return;
2976
+ }
2977
+ throw error;
2978
+ }
2979
+
2980
+ // Send next request to LLM
2981
+ const temperature = this.getCurrentTemperature(chat.id);
2982
+ // eslint-disable-next-line no-await-in-loop
2983
+ const response = await this.callAssistant({
2984
+ chatId: chat.id,
2985
+ provider,
2986
+ messages,
2987
+ tools,
2988
+ temperature,
2989
+ cacheControlIndex,
2990
+ context: 'Processing tools'
2991
+ });
2992
+
2993
+ // Check if rate limit was handled automatically
2994
+ if (response._rateLimitHandled) {
2995
+ return { rateLimitHandled: true };
2996
+ }
2997
+
2998
+ // Process the response
2999
+ // eslint-disable-next-line no-await-in-loop
3000
+ await this.processSingleLLMResponse(chat, mcpConnection, provider, messages, tools, cacheControlIndex, response);
3001
+ }
3002
+ }
3003
+
3004
+ async processSingleLLMResponse(chat, mcpConnection, provider, messages, tools, cacheControlIndex, response) {
3005
+ const llmResponseTime = response._responseTime || 0;
3006
+
3007
+ // Track token usage
3008
+ if (response.usage) {
3009
+ this.updateTokenUsage(chat.id, response.usage, ChatConfig.getChatModelString(chat) || provider.model);
3010
+ }
3011
+
3012
+ // If no tool calls, display response and finish
3013
+ const toolsInContent = this.extractToolsFromContent(response.content);
3014
+ if (toolsInContent.length === 0) {
3015
+ // Emit metrics event first
3016
+ this.processRenderEvent({
3017
+ type: 'assistant-metrics',
3018
+ usage: response.usage,
3019
+ responseTime: llmResponseTime,
3020
+ model: ChatConfig.getChatModelString(chat) || provider.model
3021
+ }, chat.id);
3022
+
3023
+ if (response.content) {
3024
+ // Create and save the assistant message
3025
+ const assistantMsg = {
3026
+ role: 'assistant',
3027
+ content: response.content,
3028
+ usage: response.usage || null,
3029
+ responseTime: llmResponseTime || null,
3030
+ model: provider.model || ChatConfig.getChatModelString(chat),
3031
+ turn: chat.currentTurn
3032
+ };
3033
+ this.addMessage(chat.id, assistantMsg);
3034
+
3035
+ // Display it
3036
+ const messageIndex = chat.messages.length - 1;
3037
+ this.processRenderEvent({
3038
+ type: 'assistant-message',
3039
+ content: response.content,
3040
+ messageIndex
3041
+ }, chat.id);
3042
+
3043
+ // Track cumulative tokens
3044
+ if (response.usage) {
3045
+ const modelUsed = ChatConfig.getChatModelString(chat) || provider.model;
3046
+ this.addCumulativeTokens(
3047
+ chat.id,
3048
+ modelUsed,
3049
+ response.usage.promptTokens || 0,
3050
+ response.usage.completionTokens || 0,
3051
+ response.usage.cacheReadInputTokens || 0,
3052
+ response.usage.cacheCreationInputTokens || 0
3053
+ );
3054
+ }
3055
+
3056
+ // Clean and add to messages for API
3057
+ const cleanedContent = this.cleanContentForAPI(response.content);
3058
+ if (cleanedContent && cleanedContent.trim()) {
3059
+ messages.push({ role: 'assistant', content: cleanedContent });
3060
+ }
3061
+ }
3062
+ return;
3063
+ }
3064
+
3065
+ // Process response with tool calls
3066
+ let assistantMessageIndex = null;
3067
+
3068
+ // Save assistant message first
3069
+ if (response.content || toolsInContent.length > 0) {
3070
+ const assistantMessage = {
3071
+ role: 'assistant',
3072
+ content: response.content || '',
3073
+ usage: response.usage || null,
3074
+ responseTime: llmResponseTime || null,
3075
+ model: provider.model || ChatConfig.getChatModelString(chat),
3076
+ turn: chat.currentTurn,
3077
+ cacheControlIndex
3078
+ };
3079
+ this.addMessage(chat.id, assistantMessage);
3080
+ assistantMessageIndex = chat.messages.length - 1;
3081
+
3082
+ // Track cumulative tokens
3083
+ if (response.usage) {
3084
+ const modelUsed = ChatConfig.getChatModelString(chat) || provider.model;
3085
+ this.addCumulativeTokens(
3086
+ chat.id,
3087
+ modelUsed,
3088
+ response.usage.promptTokens || 0,
3089
+ response.usage.completionTokens || 0,
3090
+ response.usage.cacheReadInputTokens || 0,
3091
+ response.usage.cacheCreationInputTokens || 0
3092
+ );
3093
+ }
3094
+
3095
+ // Display metrics and content
3096
+ this.processRenderEvent({
3097
+ type: 'assistant-metrics',
3098
+ usage: response.usage,
3099
+ responseTime: llmResponseTime,
3100
+ model: ChatConfig.getChatModelString(chat) || provider.model
3101
+ }, chat.id);
3102
+
3103
+ if (response.content) {
3104
+ this.processRenderEvent({
3105
+ type: 'assistant-message',
3106
+ content: response.content,
3107
+ messageIndex: assistantMessageIndex
3108
+ }, chat.id);
3109
+ }
3110
+
3111
+ // Add to API messages - keep original content with tool calls
3112
+ const assistantMsg = {
3113
+ role: 'assistant',
3114
+ content: response.content || ''
3115
+ };
3116
+
3117
+ // Only add if there's content or tool calls
3118
+ const cleanedContent = response.content ? this.cleanContentForAPI(response.content) : '';
3119
+ if (cleanedContent || this.extractToolsFromContent(response.content).length > 0) {
3120
+ messages.push(assistantMsg);
3121
+ }
3122
+ }
3123
+
3124
+ // Execute tool calls - extract from content array
3125
+ const extractedTools = this.extractToolsFromContent(response.content);
3126
+ if (extractedTools.length > 0) {
3127
+ // Increment iteration counter when we have tool calls
3128
+ this.safetyChecker.incrementIterations(chat.id);
3129
+
3130
+ // Safety check for concurrent tools
3131
+ try {
3132
+ this.safetyChecker.checkConcurrentToolsLimit(extractedTools);
3133
+ } catch (error) {
3134
+ if (error instanceof SafetyLimitError) {
3135
+ this.addMessage(chat.id, {
3136
+ role: 'error',
3137
+ content: error.message,
3138
+ errorType: 'safety_limit',
3139
+ isRetryable: false
3140
+ });
3141
+ this.processRenderEvent({
3142
+ type: 'error-message',
3143
+ content: error.message,
3144
+ errorType: 'safety_limit'
3145
+ }, chat.id);
3146
+ return;
3147
+ }
3148
+ throw error;
3149
+ }
3150
+
3151
+ // Ensure assistant group exists
3152
+ if (!this.getCurrentAssistantGroup(chat.id)) {
3153
+ this.processRenderEvent({
3154
+ type: 'assistant-message',
3155
+ content: '',
3156
+ messageIndex: assistantMessageIndex
3157
+ }, chat.id);
3158
+ }
3159
+
3160
+ // Execute tools and collect results
3161
+ const toolResults = await this.executeToolCalls(chat, mcpConnection, extractedTools, assistantMessageIndex);
3162
+
3163
+ // Store tool results
3164
+ if (toolResults.length > 0) {
3165
+ this.addMessage(chat.id, {
3166
+ role: 'tool-results',
3167
+ toolResults,
3168
+ turn: chat.currentTurn
3169
+ });
3170
+
3171
+ // Add to messages for API
3172
+ const includedResults = toolResults.filter(tr => tr.includeInContext !== false);
3173
+ if (includedResults.length > 0) {
3174
+ const toolResultsMessage = {
3175
+ role: 'tool-results',
3176
+ toolResults: includedResults.map(tr => ({
3177
+ toolCallId: tr.toolCallId,
3178
+ toolName: tr.name,
3179
+ result: tr.result
3180
+ }))
3181
+ };
3182
+
3183
+ messages.push(toolResultsMessage);
3184
+
3185
+ // Tool summarization if enabled
3186
+ if (chat.messageOptimizer && chat.config?.optimisation?.toolSummarisation?.enabled) {
3187
+ try {
3188
+ const toolSchemas = new Map();
3189
+ for (const tool of tools) {
3190
+ toolSchemas.set(tool.name, tool);
3191
+ }
3192
+
3193
+ const summarizedMessages = await chat.messageOptimizer.performToolSummarization(
3194
+ messages,
3195
+ {
3196
+ toolSchemas,
3197
+ providerInfo: { url: provider.proxyUrl }
3198
+ }
3199
+ );
3200
+
3201
+ messages.length = 0;
3202
+ messages.push(...summarizedMessages);
3203
+ } catch (error) {
3204
+ console.error('[Tool Summarization] Failed:', error);
3205
+ }
3206
+ }
3207
+ }
3208
+
3209
+ // Reset assistant group and show thinking spinner for next iteration
3210
+ this.processRenderEvent({ type: 'reset-assistant-group' }, chat.id);
3211
+ this.showAssistantThinking(chat.id);
3212
+ }
3213
+ }
3214
+ }
3215
+
3216
+ async executeToolCalls(chat, mcpConnection, toolCalls, assistantMessageIndex) {
3217
+ const toolResults = [];
3218
+
3219
+ for (const toolCall of toolCalls) {
3220
+ if (!toolCall.id) {
3221
+ console.error('[executeToolCalls] Tool call missing required id:', toolCall);
3222
+ continue;
3223
+ }
3224
+
3225
+ try {
3226
+ const { arguments: toolArgs } = toolCall || {};
3227
+
3228
+ // Show tool call in UI
3229
+ this.processRenderEvent({
3230
+ type: 'tool-call',
3231
+ name: toolCall.name,
3232
+ arguments: toolArgs,
3233
+ id: toolCall.id,
3234
+ includeInContext: toolCall.includeInContext !== false
3235
+ }, chat.id);
3236
+
3237
+ // Show tool execution spinner
3238
+ this.showToolExecuting(chat.id, toolCall.name);
3239
+
3240
+ // Execute tool
3241
+ const toolStartTime = Date.now();
3242
+ // eslint-disable-next-line no-await-in-loop
3243
+ const rawResult = await mcpConnection.callTool(toolCall.name, toolArgs);
3244
+ const toolResponseTime = Date.now() - toolStartTime;
3245
+
3246
+ // Hide tool execution spinner
3247
+ this.hideToolExecuting(chat.id);
3248
+
3249
+ // Parse result
3250
+ const result = this.parseToolResult(rawResult);
3251
+ const responseSize = typeof result === 'string'
3252
+ ? result.length
3253
+ : JSON.stringify(result).length;
3254
+
3255
+ // Show result
3256
+ this.processRenderEvent({
3257
+ type: 'tool-result',
3258
+ name: toolCall.name,
3259
+ result,
3260
+ toolCallId: toolCall.id,
3261
+ responseTime: toolResponseTime,
3262
+ responseSize,
3263
+ messageIndex: assistantMessageIndex
3264
+ }, chat.id);
3265
+
3266
+ toolResults.push({
3267
+ toolCallId: toolCall.id,
3268
+ name: toolCall.name,
3269
+ result,
3270
+ includeInContext: true
3271
+ });
3272
+
3273
+ } catch (error) {
3274
+ const errorMsg = `Tool error (${toolCall.name}): ${error.message}`;
3275
+ this.processRenderEvent({
3276
+ type: 'tool-result',
3277
+ name: toolCall.name,
3278
+ result: { error: errorMsg },
3279
+ responseTime: 0,
3280
+ responseSize: errorMsg.length,
3281
+ messageIndex: assistantMessageIndex,
3282
+ toolCallId: toolCall.id
3283
+ }, chat.id);
3284
+
3285
+ toolResults.push({
3286
+ toolCallId: toolCall.id,
3287
+ name: toolCall.name,
3288
+ result: { error: errorMsg },
3289
+ includeInContext: true
3290
+ });
3291
+ }
3292
+ }
3293
+
3294
+ return toolResults;
3295
+ }
3296
+
3297
+ /**
3298
+ * Centralized function to call the assistant API with consistent error handling
3299
+ * @param {Object} params - Parameters for the assistant call
3300
+ * @param {string} params.chatId - Chat ID
3301
+ * @param {Object} params.provider - LLM provider instance
3302
+ * @param {Array} params.messages - Messages array
3303
+ * @param {Array} params.tools - Available tools
3304
+ * @param {number} params.temperature - Temperature setting
3305
+ * @param {number} params.cacheControlIndex - Cache control index
3306
+ * @param {string} params.context - Context for error messages (e.g., 'Redo', 'Retry', 'Send')
3307
+ * @returns {Promise<Object>} Response from the assistant
3308
+ */
3309
+ async callAssistant({ chatId, provider, messages, tools, temperature, cacheControlIndex, context = 'Request' }) {
3310
+ if (!chatId) {
3311
+ throw new Error('[callAssistant] Missing required chatId parameter');
3312
+ }
3313
+
3314
+ const chat = this.chats.get(chatId);
3315
+ if (!chat) {
3316
+ throw new Error(`[callAssistant] Chat not found: ${chatId}`);
3317
+ }
3318
+
3319
+ // Show thinking spinner
3320
+ this.showAssistantThinking(chatId);
3321
+
3322
+ try {
3323
+ // Track timing
3324
+ const llmStartTime = Date.now();
3325
+ const response = await provider.sendMessage(messages, tools, temperature, cacheControlIndex);
3326
+ const llmResponseTime = Date.now() - llmStartTime;
3327
+
3328
+ // Store response time
3329
+ response._responseTime = llmResponseTime;
3330
+
3331
+ // Hide spinner on success
3332
+ this.hideAssistantThinking(chatId);
3333
+
3334
+ return response;
3335
+
3336
+ } catch (error) {
3337
+ // Check for rate limit error FIRST before hiding spinner
3338
+ const isRateLimitError = error.message && (
3339
+ error.message.includes('Rate limit') ||
3340
+ error.message.includes('429') ||
3341
+ error.message.includes('rate_limit_exceeded')
3342
+ );
3343
+
3344
+ // Extract retry-after seconds if available (handles multiple formats)
3345
+ let retryAfterSeconds = null;
3346
+
3347
+ // Try different patterns
3348
+ const patterns = [
3349
+ /Please try again in (\d+(?:\.\d+)?)s/, // "Please try again in 4.742s"
3350
+ /Please retry after (\d+) second/, // "Please retry after 5 seconds"
3351
+ /try again in (\d+(?:\.\d+)?) second/i // Various formats
3352
+ ];
3353
+
3354
+ for (const pattern of patterns) {
3355
+ const match = error.message && error.message.match(pattern);
3356
+ if (match) {
3357
+ retryAfterSeconds = parseFloat(match[1]);
3358
+ break;
3359
+ }
3360
+ }
3361
+
3362
+ if (isRateLimitError) {
3363
+ // Always handle rate limit errors, even without retry time
3364
+ // Don't hide spinner - let handleRateLimitError manage the transition
3365
+ const retryCount = chat.rateLimitRetryCount || 0;
3366
+ this.handleRateLimitError(chatId, retryAfterSeconds, retryCount);
3367
+ // Return a special marker to indicate rate limit handling
3368
+ return { _rateLimitHandled: true };
3369
+ }
3370
+
3371
+ // Only hide spinner for non-rate-limit errors
3372
+ this.hideAssistantThinking(chatId);
3373
+
3374
+ // Add context to error
3375
+ error.context = context;
3376
+ throw error;
3377
+ }
3378
+ }
3379
+
3380
+ /**
3381
+ * Called when the assistant has finished processing and no more actions will occur
3382
+ * Ensures proper cleanup of UI state and chat processing flags
3383
+ */
3384
+ assistantConcluded(chatId) {
3385
+ if (!chatId) {
3386
+ console.error('[assistantConcluded] Called without chatId');
3387
+ return;
3388
+ }
3389
+
3390
+ const chat = this.chats.get(chatId);
3391
+ if (!chat) {
3392
+ console.error(`[assistantConcluded] Chat not found: ${chatId}`);
3393
+ return;
3394
+ }
3395
+
3396
+ // Clear any spinners
3397
+ this.clearSpinnerState(chatId);
3398
+
3399
+ // Clear processing states
3400
+ chat.isProcessing = false;
3401
+
3402
+ // Clear stop-related flags to ensure they're ready for next time
3403
+ this.shouldStopProcessing = false;
3404
+ chat.processingWasStoppedByUser = false;
3405
+
3406
+ // Clear error state on successful conclusion
3407
+ this.clearError(chatId);
3408
+
3409
+ // Clear current assistant group
3410
+ this.clearCurrentAssistantGroup(chatId);
3411
+
3412
+ // Reset safety checker iterations for next user message
3413
+ this.safetyChecker.resetIterations(chatId);
3414
+
3415
+ // Update only this chat's tile
3416
+ this.updateChatTileStatus(chatId);
3417
+
3418
+ // Save the chat state
3419
+ chat.updatedAt = new Date().toISOString();
3420
+ this.autoSave(chatId);
3421
+
3422
+ // Re-enable input if it's the active chat
3423
+ if (chatId === this.getActiveChatId()) {
3424
+ const container = this.getChatContainer(chatId);
3425
+ if (container && container._elements) {
3426
+ const input = container._elements.input;
3427
+ if (input) {
3428
+ input.disabled = false;
3429
+ input.focus();
3430
+ }
3431
+
3432
+ // Update send button state
3433
+ const sendBtn = container._elements.sendBtn;
3434
+ if (sendBtn && input) {
3435
+ sendBtn.disabled = !input.value.trim();
3436
+ }
3437
+ }
3438
+ }
3439
+ }
3440
+
3441
+ /**
3442
+ * Called when the assistant fails with an error
3443
+ * Handles cleanup differently based on error type
3444
+ */
3445
+ assistantFailed(chatId, error = null) {
3446
+ if (!chatId) {
3447
+ console.error('[assistantFailed] Called without chatId');
3448
+ return;
3449
+ }
3450
+
3451
+ const chat = this.chats.get(chatId);
3452
+ if (!chat) {
3453
+ console.error(`[assistantFailed] Chat not found: ${chatId}`);
3454
+ return;
3455
+ }
3456
+
3457
+ // Check if this is a rate limit error that's being handled
3458
+ const isRateLimitHandled = error && error._rateLimitHandled;
3459
+
3460
+ // Only clear spinners if NOT a handled rate limit error
3461
+ if (!isRateLimitHandled) {
3462
+ this.clearSpinnerState(chatId);
3463
+ }
3464
+ // If rate limit is handled, the waiting spinner should continue
3465
+
3466
+ // Clear processing states
3467
+ chat.isProcessing = false;
3468
+
3469
+ // Clear stop-related flags when failure is handled
3470
+ this.shouldStopProcessing = false;
3471
+ chat.processingWasStoppedByUser = false;
3472
+
3473
+ // Clear current assistant group
3474
+ this.clearCurrentAssistantGroup(chatId);
3475
+
3476
+ // Reset safety checker iterations for next user message
3477
+ this.safetyChecker.resetIterations(chatId);
3478
+
3479
+ // Update only this chat's tile
3480
+ this.updateChatTileStatus(chatId);
3481
+
3482
+ // Save the chat state
3483
+ chat.updatedAt = new Date().toISOString();
3484
+ this.autoSave(chatId);
3485
+
3486
+ // Re-enable input if it's the active chat and not rate limited
3487
+ if (!isRateLimitHandled && chatId === this.getActiveChatId()) {
3488
+ const container = this.getChatContainer(chatId);
3489
+ if (container && container._elements) {
3490
+ const input = container._elements.input;
3491
+ if (input) {
3492
+ input.disabled = false;
3493
+ input.focus();
3494
+ }
3495
+ }
3496
+ }
3497
+ }
3498
+
3499
+ showError(message, chatId, saveToMessages = true) {
3500
+ // Log to console
3501
+ console.error('MCP Client Error:', message, chatId ? `(Chat ID: ${chatId})` : '(Global)');
3502
+
3503
+ // Show error toast
3504
+ const toast = document.createElement('div');
3505
+ toast.className = 'error-toast';
3506
+ toast.textContent = message;
3507
+ document.getElementById('errorToastContainer').appendChild(toast);
3508
+
3509
+ // Remove after animation
3510
+ setTimeout(() => toast.remove(), 3000);
3511
+
3512
+ // Log error
3513
+ this.addLogEntry('ERROR', {
3514
+ timestamp: new Date().toISOString(),
3515
+ direction: 'error',
3516
+ message
3517
+ });
3518
+
3519
+ // Also show in chat if chatId is provided
3520
+ if (chatId) {
3521
+ const chat = this.chats.get(chatId);
3522
+
3523
+ // Save to messages if requested and chat exists
3524
+ if (saveToMessages && chat) {
3525
+ const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
3526
+ this.addMessage(chatId, {
3527
+ role: 'error',
3528
+ content: message,
3529
+ errorMessageIndex: lastUserMessageIndex,
3530
+ timestamp: new Date().toISOString()
3531
+ });
3532
+
3533
+ // Set error state so the chat list shows the warning icon
3534
+ chat.hasError = true;
3535
+ chat.lastError = message;
3536
+ this.updateChatSessions();
3537
+ }
3538
+
3539
+ const container = this.getChatContainer(chatId);
3540
+ if (container && container._elements && container._elements.messages) {
3541
+ const messageDiv = document.createElement('div');
3542
+ messageDiv.className = 'message error';
3543
+ messageDiv.innerHTML = `<i class="fas fa-times-circle"></i> ${message}`;
3544
+ container._elements.messages.appendChild(messageDiv);
3545
+
3546
+ // Scroll to bottom using the chat-specific container
3547
+ container._elements.messages.scrollTop = container._elements.messages.scrollHeight;
3548
+ }
3549
+ }
3550
+ }
3551
+
3552
+ // Global errors (not specific to any chat)
3553
+ showGlobalError(message) {
3554
+ this.showError(message, null);
3555
+ }
3556
+
3557
+ showToast(message, className = 'error-toast') {
3558
+ // Show toast notification only (no chat message)
3559
+ const toast = document.createElement('div');
3560
+ toast.className = className;
3561
+ toast.textContent = message;
3562
+ document.getElementById('errorToastContainer').appendChild(toast);
3563
+
3564
+ // Remove after animation
3565
+ setTimeout(() => toast.remove(), 3000);
3566
+ }
3567
+
3568
+ showErrorWithRetry(message, retryCallback, buttonLabel, chatId) {
3569
+ // Show error toast
3570
+ const toast = document.createElement('div');
3571
+ toast.className = 'error-toast';
3572
+ toast.textContent = message;
3573
+ document.getElementById('errorToastContainer').appendChild(toast);
3574
+
3575
+ // Remove after animation
3576
+ setTimeout(() => toast.remove(), 3000);
3577
+
3578
+ // Log error
3579
+ this.addLogEntry('ERROR', {
3580
+ timestamp: new Date().toISOString(),
3581
+ direction: 'error',
3582
+ message
3583
+ });
3584
+
3585
+ // Also show in chat with retry button if chatId is provided
3586
+ if (chatId) {
3587
+ const container = this.getChatContainer(chatId);
3588
+ if (container && container._elements && container._elements.messages) {
3589
+ const messageDiv = document.createElement('div');
3590
+ messageDiv.className = 'message error';
3591
+ const buttonIcon = buttonLabel === 'Continue' ? 'fa-play' : 'fa-redo';
3592
+ messageDiv.innerHTML = `
3593
+ <div><i class="fas fa-times-circle"></i> ${message}</div>
3594
+ <button class="btn btn-warning btn-small" style="margin-top: 8px;">
3595
+ <i class="fas ${buttonIcon}"></i> ${buttonLabel}
3596
+ </button>
3597
+ `;
3598
+
3599
+ const retryBtn = messageDiv.querySelector('button');
3600
+ retryBtn.onclick = async () => {
3601
+ retryBtn.disabled = true;
3602
+ retryBtn.textContent = 'Retrying...';
3603
+ await retryCallback();
3604
+ };
3605
+
3606
+ container._elements.messages.appendChild(messageDiv);
3607
+
3608
+ // Scroll to bottom using the chat-specific container
3609
+ container._elements.messages.scrollTop = container._elements.messages.scrollHeight;
3610
+ }
3611
+ }
3612
+ }
3613
+
3614
+ addLogEntry(source, entry) {
3615
+ const logEntry = {
3616
+ ...entry,
3617
+ source
3618
+ };
3619
+ this.communicationLog.push(logEntry);
3620
+ this.updateLogDisplay(logEntry);
3621
+ }
3622
+
3623
+ updateLogDisplay(entry) {
3624
+ const entryDiv = document.createElement('div');
3625
+ entryDiv.className = 'log-entry';
3626
+
3627
+ const directionClass = entry.direction;
3628
+ let directionSymbol;
3629
+ switch(entry.direction) {
3630
+ case 'sent': directionSymbol = '→'; break;
3631
+ case 'received': directionSymbol = '←'; break;
3632
+ case 'error': directionSymbol = '⚠'; break;
3633
+ case 'info': directionSymbol = 'ℹ'; break;
3634
+ default: directionSymbol = '•'; break;
3635
+ }
3636
+
3637
+ let metadataHtml = '';
3638
+ if (entry.metadata && Object.keys(entry.metadata).length > 0) {
3639
+ metadataHtml = `<div class="log-metadata">`;
3640
+ for (const [key, value] of Object.entries(entry.metadata)) {
3641
+ metadataHtml += `<span class="metadata-item">${key}: ${value}</span>`;
3642
+ }
3643
+ metadataHtml += `</div>`;
3644
+ }
3645
+
3646
+ // Create a unique ID for this entry
3647
+ const entryId = `log-entry-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
3648
+
3649
+ // Create header with copy button
3650
+ const headerDiv = document.createElement('div');
3651
+ headerDiv.className = 'log-entry-header';
3652
+
3653
+ const infoDiv = document.createElement('div');
3654
+ infoDiv.className = 'log-entry-info';
3655
+ infoDiv.innerHTML = `
3656
+ <span class="log-timestamp">${new Date(entry.timestamp).toLocaleTimeString()}</span>
3657
+ <span class="log-source">[${entry.source}]</span>
3658
+ <span class="log-direction ${directionClass}">${directionSymbol}</span>
3659
+ `;
3660
+
3661
+ // Create copy button using standardized method
3662
+ const copyBtn = this.createCopyButton({
3663
+ buttonClass: 'btn-copy-log',
3664
+ onCopy: () => {
3665
+ const messageElement = document.getElementById(entryId);
3666
+ return messageElement.textContent || messageElement.innerText;
3667
+ }
3668
+ });
3669
+ copyBtn.setAttribute('data-entry-id', entryId);
3670
+
3671
+ headerDiv.appendChild(infoDiv);
3672
+ headerDiv.appendChild(copyBtn);
3673
+
3674
+ entryDiv.appendChild(headerDiv);
3675
+
3676
+ // Add metadata if present
3677
+ if (metadataHtml) {
3678
+ const metadataDiv = document.createElement('div');
3679
+ metadataDiv.innerHTML = metadataHtml;
3680
+ entryDiv.appendChild(metadataDiv);
3681
+ }
3682
+
3683
+ // Add message content
3684
+ const messageDiv = document.createElement('div');
3685
+ messageDiv.className = 'log-message';
3686
+ messageDiv.id = entryId;
3687
+ messageDiv.textContent = this.formatLogMessage(entry.message);
3688
+ entryDiv.appendChild(messageDiv);
3689
+
3690
+ this.logContent.appendChild(entryDiv);
3691
+
3692
+ // Only scroll if user is already near the bottom
3693
+ const threshold = 100; // pixels from bottom to consider "at bottom"
3694
+ const isAtBottom = this.logContent.scrollHeight - this.logContent.scrollTop - this.logContent.clientHeight < threshold;
3695
+
3696
+ if (isAtBottom) {
3697
+ this.logContent.scrollTop = this.logContent.scrollHeight;
3698
+ }
3699
+ }
3700
+
3701
+ formatLogMessage(message) {
3702
+ try {
3703
+ const parsed = JSON.parse(message);
3704
+ return JSON.stringify(parsed, null, 2);
3705
+ } catch {
3706
+ return message;
3707
+ }
3708
+ }
3709
+
3710
+ // Shared clipboard utility that works in all contexts
3711
+ async writeToClipboard(text) {
3712
+ // Check if clipboard API is available
3713
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3714
+ return navigator.clipboard.writeText(text);
3715
+ } else {
3716
+ // Fallback for older browsers or insecure contexts
3717
+ const textArea = document.createElement('textarea');
3718
+ textArea.value = text;
3719
+ textArea.style.position = 'fixed';
3720
+ textArea.style.left = '-999999px';
3721
+ textArea.style.top = '-999999px';
3722
+ document.body.appendChild(textArea);
3723
+ textArea.focus();
3724
+ textArea.select();
3725
+
3726
+ try {
3727
+ const successful = document.execCommand('copy');
3728
+ if (!successful) {
3729
+ throw new Error('Copy command failed');
3730
+ }
3731
+ } finally {
3732
+ textArea.remove();
3733
+ }
3734
+ }
3735
+ }
3736
+
3737
+ // Create a standardized copy button
3738
+ createCopyButton(options = {}) {
3739
+ const {
3740
+ tooltip = 'Copy to clipboard',
3741
+ iconClass = 'fas fa-clipboard',
3742
+ buttonClass = '',
3743
+ onCopy = null
3744
+ } = options;
3745
+
3746
+ const button = document.createElement('button');
3747
+ button.className = `copy-button ${buttonClass}`.trim();
3748
+ button.setAttribute('data-tooltip', tooltip);
3749
+ button.innerHTML = `<i class="${iconClass}"></i>`;
3750
+
3751
+ button.addEventListener('click', async (e) => {
3752
+ e.stopPropagation();
3753
+ if (onCopy) {
3754
+ try {
3755
+ const textToCopy = await onCopy();
3756
+ if (textToCopy !== undefined && textToCopy !== null) {
3757
+ await this.handleCopyButtonClick(button, textToCopy);
3758
+ }
3759
+ } catch (error) {
3760
+ console.error('Copy button error:', error);
3761
+ this.showCopyButtonError(button);
3762
+ }
3763
+ }
3764
+ });
3765
+
3766
+ return button;
3767
+ }
3768
+
3769
+ // Handle copy button click with standardized feedback
3770
+ async handleCopyButtonClick(button, text) {
3771
+ const originalHTML = button.innerHTML;
3772
+
3773
+ try {
3774
+ await this.writeToClipboard(text);
3775
+
3776
+ // Show success feedback
3777
+ button.innerHTML = '<i class="fas fa-check"></i>';
3778
+ button.style.color = 'var(--success-color)';
3779
+
3780
+ setTimeout(() => {
3781
+ button.innerHTML = originalHTML;
3782
+ button.style.color = '';
3783
+ }, 1500);
3784
+ } catch (err) {
3785
+ console.error('Failed to copy to clipboard:', err);
3786
+ this.showCopyButtonError(button, originalHTML);
3787
+ }
3788
+ }
3789
+
3790
+ // Show error feedback on copy button
3791
+ showCopyButtonError(button, originalHTML = null) {
3792
+ const htmlToRestore = originalHTML || button.innerHTML;
3793
+
3794
+ button.innerHTML = '<i class="fas fa-times"></i>';
3795
+ button.style.color = 'var(--danger-color)';
3796
+
3797
+ setTimeout(() => {
3798
+ button.innerHTML = htmlToRestore;
3799
+ button.style.color = '';
3800
+ }, 1500);
3801
+ }
3802
+
3803
+ // Legacy method for backward compatibility
3804
+ async copyToClipboard(text, button) {
3805
+ await this.handleCopyButtonClick(button, text);
3806
+ }
3807
+
3808
+ // Redo from a specific point in the conversation
3809
+ async redoFromMessage(messageIndex, chatId) {
3810
+ const chat = this.chats.get(chatId);
3811
+ if (!chat) {return;}
3812
+
3813
+ // Clear broken state when starting redo operation
3814
+ chat.wasWaitingOnLoad = false;
3815
+
3816
+ // Find the message to redo from
3817
+ const message = chat.messages[messageIndex];
3818
+ if (!message) {return;}
3819
+
3820
+ // Get the MCP connection and provider
3821
+ const mcpConnection = this.mcpConnections.get(chat.mcpServerId);
3822
+ const proxyProvider = this.llmProviders.get(chat.llmProviderId);
3823
+
3824
+ if (!mcpConnection || !proxyProvider || !chat.config || !chat.config.model) {
3825
+ this.showError('Cannot redo: MCP server or LLM provider not available', chatId);
3826
+ return;
3827
+ }
3828
+
3829
+ // Get model config
3830
+ const providerType = chat.config.model.provider;
3831
+ const modelName = chat.config.model.id;
3832
+ if (!providerType || !modelName) {
3833
+ this.showError('Invalid model configuration in chat', chatId);
3834
+ return;
3835
+ }
3836
+
3837
+ // Create the LLM provider
3838
+ const provider = createLLMProvider(providerType, proxyProvider.proxyUrl, modelName);
3839
+ provider.onLog = (logEntry) => {
3840
+ const prefix = logEntry.direction === 'sent' ? 'llm-request' : 'llm-response';
3841
+ const providerName = providerType.charAt(0).toUpperCase() + providerType.slice(1);
3842
+ this.addLogEntry(`${prefix}: ${providerName}`, logEntry);
3843
+ };
3844
+
3845
+ try {
3846
+ if (message.role === 'user') {
3847
+ // Redo from user message - truncate everything AFTER this message
3848
+ // Keep all history up to and including this message
3849
+ this.truncateMessages(chatId, messageIndex + 1, 'Redo from user message');
3850
+ this.loadChat(chatId, true);
3851
+
3852
+ // Show thinking spinner AFTER loadChat to prevent it from being cleared
3853
+ this.showAssistantThinking(chatId);
3854
+
3855
+ // Get fresh chat object after loadChat
3856
+ const freshChat = this.chats.get(chatId);
3857
+ if (!freshChat) {
3858
+ throw new Error('Chat not found after reload');
3859
+ }
3860
+
3861
+ // Resend the user message with full prior context
3862
+ const result = await this.processMessageWithTools(freshChat, mcpConnection, provider, message.content);
3863
+
3864
+ // Check if rate limit was handled - don't conclude if so
3865
+ if (result && result.rateLimitHandled) {
3866
+ return;
3867
+ }
3868
+
3869
+ // Success - assistant has concluded
3870
+ this.assistantConcluded(chatId);
3871
+ } else if (message.role === 'assistant') {
3872
+ // Redo from assistant message - find the user message that triggered it
3873
+ let triggeringUserMessage = null;
3874
+
3875
+ // Find the most recent user message before this assistant message
3876
+ for (let i = messageIndex - 1; i >= 0; i--) {
3877
+ if (chat.messages[i].role === 'user') {
3878
+ triggeringUserMessage = chat.messages[i];
3879
+ break;
3880
+ }
3881
+ }
3882
+
3883
+ if (triggeringUserMessage) {
3884
+ // Truncate from THIS assistant message onwards (not from the user message)
3885
+ // This handles cases where assistant sent multiple messages (e.g., with tool calls)
3886
+ this.truncateMessages(chatId, messageIndex, 'Redo from assistant message');
3887
+ this.loadChat(chatId, true);
3888
+
3889
+ // Show thinking spinner AFTER loadChat to prevent it from being cleared
3890
+ this.showAssistantThinking(chatId);
3891
+
3892
+ // Get fresh chat object after loadChat
3893
+ const freshChat = this.chats.get(chatId);
3894
+ if (!freshChat) {
3895
+ throw new Error('Chat not found after reload');
3896
+ }
3897
+
3898
+ // Resend the triggering user message with full prior context
3899
+ const result = await this.processMessageWithTools(freshChat, mcpConnection, provider, triggeringUserMessage.content);
3900
+
3901
+ // Check if rate limit was handled - don't conclude if so
3902
+ if (result && result.rateLimitHandled) {
3903
+ return;
3904
+ }
3905
+
3906
+ // Success - assistant has concluded
3907
+ this.assistantConcluded(chatId);
3908
+ } else {
3909
+ this.showError('Cannot find the user message that triggered this response', chatId);
3910
+ }
3911
+ } else {
3912
+ // For any other message type, show error (shouldn't happen with our button logic)
3913
+ this.showError('Redo is only available for user and assistant messages', chatId);
3914
+ }
3915
+ } catch (error) {
3916
+ // Check for rate limit error
3917
+ const isRateLimitError = error.message.includes('Rate limit') || error.message.includes('429');
3918
+ const retryMatch = error.message.match(/Please try again in (\d+(?:\.\d+)?)s/);
3919
+ const retryAfterSeconds = retryMatch ? parseFloat(retryMatch[1]) : null;
3920
+
3921
+ if (isRateLimitError) {
3922
+ // Handle rate limit with automatic retry (even without retry time)
3923
+ const retryCount = chat.rateLimitRetryCount || 0;
3924
+ this.handleRateLimitError(chatId, retryAfterSeconds, retryCount);
3925
+ // Mark error as handled to prevent spinner clearing
3926
+ error._rateLimitHandled = true;
3927
+ // Don't show error UI for rate limits
3928
+ this.assistantFailed(chatId, error);
3929
+ return;
3930
+ } else {
3931
+ // Show error with retry button
3932
+ const errorMessage = `Redo failed: ${error.message}`;
3933
+ const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
3934
+
3935
+ // Determine error type
3936
+ let errorType = 'llm_error';
3937
+ if (error.message.includes('MCP') || error.message.includes('connection')) {
3938
+ errorType = 'mcp_error';
3939
+ } else if (error.message.includes('Tool')) {
3940
+ errorType = 'tool_error';
3941
+ }
3942
+
3943
+ this.addMessage(chatId, {
3944
+ role: 'error',
3945
+ content: errorMessage,
3946
+ errorMessageIndex: lastUserMessageIndex,
3947
+ errorType
3948
+ });
3949
+
3950
+ this.processRenderEvent({
3951
+ type: 'error-message',
3952
+ content: errorMessage,
3953
+ errorMessageIndex: lastUserMessageIndex,
3954
+ errorType
3955
+ }, chatId);
3956
+ }
3957
+
3958
+ // Clean up on error - but preserve rate limit waiting spinner
3959
+ this.assistantFailed(chatId, error);
3960
+ }
3961
+ }
3962
+
3963
+ clearLog() {
3964
+ if (confirm('Clear all communication logs?')) {
3965
+ this.communicationLog = [];
3966
+ this.logContent.innerHTML = '';
3967
+ }
3968
+ }
3969
+
3970
+ downloadLog() {
3971
+ const logText = this.communicationLog.map(entry => {
3972
+ return `[${entry.timestamp}] [${entry.source}] ${entry.direction}: ${entry.message}`;
3973
+ }).join('\n\n');
3974
+
3975
+ const blob = new Blob([logText], { type: 'text/plain' });
3976
+ const url = URL.createObjectURL(blob);
3977
+ const a = document.createElement('a');
3978
+ a.href = url;
3979
+ a.download = `mcp-communication-log-${new Date().toISOString()}.txt`;
3980
+ a.click();
3981
+ URL.revokeObjectURL(url);
3982
+ }
3983
+
3984
+ loadSettings() {
3985
+ // Note: file:// protocol is now supported thanks to the proxy server
3986
+ // The proxy handles CORS issues that would normally prevent direct API access
3987
+
3988
+ // Load theme
3989
+ const savedTheme = localStorage.getItem('theme') || 'dark';
3990
+ document.documentElement.setAttribute('data-theme', savedTheme);
3991
+
3992
+ // Load log collapsed state (default to collapsed)
3993
+ const logCollapsed = localStorage.getItem('logCollapsed') !== 'false';
3994
+ if (logCollapsed) {
3995
+ this.logPanel.classList.add('collapsed');
3996
+ this.toggleLogBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
3997
+ this.expandLogBtn.style.display = 'block';
3998
+ }
3999
+
4000
+ // Load pane sizes
4001
+ this.loadPaneSizes();
4002
+
4003
+ // Load MCP servers from localStorage (but these will be merged with proxy servers later)
4004
+ const savedMcpServers = localStorage.getItem('mcpServers');
4005
+ if (savedMcpServers) {
4006
+ try {
4007
+ const servers = JSON.parse(savedMcpServers);
4008
+ servers.forEach(server => {
4009
+ this.mcpServers.set(server.id, server);
4010
+ });
4011
+ // Don't update the UI yet - wait for proxy servers to be loaded too
4012
+ } catch (e) {
4013
+ console.error('Failed to load MCP servers:', e);
4014
+ }
4015
+ }
4016
+
4017
+ // LLM providers will be fetched from proxy on demand
4018
+
4019
+ // Load chats - first try split storage, then fall back to legacy
4020
+ this.loadChatsFromStorage();
4021
+ }
4022
+
4023
+ loadChatsFromStorage() {
4024
+ // Load chats with pattern chat_TIMESTAMP
4025
+ const chatKeyPrefix = 'chat_';
4026
+
4027
+ // Scan localStorage for individual chat keys
4028
+ for (let i = 0; i < localStorage.length; i++) {
4029
+ const key = localStorage.key(i);
4030
+ if (key && key.startsWith(chatKeyPrefix)) {
4031
+ try {
4032
+ const chatData = JSON.parse(localStorage.getItem(key));
4033
+ if (chatData && chatData.id) {
4034
+ // Ensure no chat is marked as active on load
4035
+ chatData.isActive = false;
4036
+ // Clean up empty draft messages
4037
+ if (chatData.draftMessage === '' || (chatData.draftMessage && chatData.draftMessage.trim().length === 0)) {
4038
+ chatData.draftMessage = null;
4039
+ }
4040
+ this.validateAndAddChat(chatData);
4041
+ }
4042
+ } catch (e) {
4043
+ console.error(`Failed to load chat from key ${key}:`, e);
4044
+ }
4045
+ }
4046
+ }
4047
+
4048
+ // Don't update chat sessions yet - wait until after default chat is created
4049
+ // this.updateChatSessions();
4050
+ }
4051
+
4052
+
4053
+ validateAndAddChat(chat) {
4054
+ // IMMEDIATE MIGRATION - Delete ALL old properties
4055
+ delete chat.model;
4056
+ delete chat.temperature;
4057
+ delete chat.optimizerSettings;
4058
+ delete chat.primaryModel;
4059
+ delete chat.secondaryModel;
4060
+ delete chat.toolSummarization;
4061
+ delete chat.autoSummarization;
4062
+ delete chat.toolMemory;
4063
+ delete chat.cacheControl;
4064
+
4065
+ // Migrate old chat format to new config format if needed
4066
+ if (!chat.config) {
4067
+ // No config - create default
4068
+ chat.config = ChatConfig.createDefaultConfig();
4069
+ // If we had an mcpServerId, preserve it
4070
+ if (chat.mcpServerId) {
4071
+ chat.config.mcpServer = chat.mcpServerId;
4072
+ }
4073
+ }
4074
+
4075
+ // Ensure config is valid
4076
+ chat.config = ChatConfig.validateConfig(chat.config);
4077
+
4078
+ // Migrate old tool-results format to new format
4079
+ if (chat.messages && Array.isArray(chat.messages)) {
4080
+ chat.messages = chat.messages.map(msg => {
4081
+ // Convert old tool-results format with results field
4082
+ if (msg.type === 'tool-results' && msg.results) {
4083
+ // Convert to new format
4084
+ return {
4085
+ role: 'tool-results',
4086
+ toolResults: msg.results.map(result => ({
4087
+ toolCallId: result.toolCallId,
4088
+ toolName: result.toolName || 'unknown',
4089
+ result: result.content || result.result || ''
4090
+ })),
4091
+ timestamp: msg.timestamp
4092
+ };
4093
+ }
4094
+
4095
+ // Convert tool-results that have type but no role
4096
+ if (msg.type === 'tool-results' && !msg.role && msg.toolResults) {
4097
+ const cleanMsg = { ...msg };
4098
+ delete cleanMsg.type;
4099
+ cleanMsg.role = 'tool-results';
4100
+ return cleanMsg;
4101
+ }
4102
+
4103
+ // Clean up messages that have both type and role - remove type
4104
+ if (msg.type && msg.role) {
4105
+ const cleanMsg = { ...msg };
4106
+ delete cleanMsg.type;
4107
+ return cleanMsg;
4108
+ }
4109
+
4110
+ // Convert any remaining messages with type but no role
4111
+ if (msg.type && !msg.role) {
4112
+ const cleanMsg = { ...msg };
4113
+ cleanMsg.role = cleanMsg.type;
4114
+ delete cleanMsg.type;
4115
+ return cleanMsg;
4116
+ }
4117
+
4118
+ return msg;
4119
+ });
4120
+ }
4121
+
4122
+ // Validate that the chat's model still exists
4123
+ if (chat.config && chat.config.model && chat.llmProviderId) {
4124
+ const provider = this.llmProviders.get(chat.llmProviderId);
4125
+ if (provider && provider.availableProviders) {
4126
+ // Check if the model exists in the provider's available models
4127
+ let modelExists = false;
4128
+ const providerType = chat.config.model.provider;
4129
+ const modelName = chat.config.model.id;
4130
+
4131
+ if (providerType && modelName && provider.availableProviders[providerType]) {
4132
+ const models = provider.availableProviders[providerType].models || [];
4133
+ modelExists = models.some(m => {
4134
+ const mId = typeof m === 'string' ? m : m.id;
4135
+ return mId === modelName;
4136
+ });
4137
+ }
4138
+
4139
+ if (!modelExists) {
4140
+ const oldModelString = ChatConfig.modelConfigToString(chat.config.model);
4141
+ console.error(`Chat ${chat.id} has invalid model ${oldModelString}. Model not found in available providers.`);
4142
+
4143
+ // Mark the chat as having an invalid model
4144
+ chat.hasInvalidModel = true;
4145
+
4146
+ // DO NOT automatically reset or save!
4147
+ // The user must manually select a valid model
4148
+ }
4149
+ }
4150
+ }
4151
+
4152
+ // Validate MCP server ID - set to null if it doesn't exist
4153
+ if (chat.config && chat.config.mcpServer) {
4154
+ if (!this.mcpServers.has(chat.config.mcpServer)) {
4155
+ console.error(`Chat ${chat.id} has invalid MCP server: ${chat.config.mcpServer} - setting to null`);
4156
+ chat.config.mcpServer = null;
4157
+ }
4158
+ // Sync mcpServerId with config
4159
+ chat.mcpServerId = chat.config.mcpServer;
4160
+ } else if (chat.mcpServerId && !this.mcpServers.has(chat.mcpServerId)) {
4161
+ console.error(`Chat ${chat.id} has invalid mcpServerId: ${chat.mcpServerId} - setting to null`);
4162
+ chat.mcpServerId = null;
4163
+ if (chat.config) {
4164
+ chat.config.mcpServer = null;
4165
+ }
4166
+ }
4167
+
4168
+ // Ensure currentAssistantGroup exists for loaded chats
4169
+ if (!Object.prototype.hasOwnProperty.call(chat, 'currentAssistantGroup')) {
4170
+ chat.currentAssistantGroup = null;
4171
+ }
4172
+
4173
+ // Ensure pendingToolCalls is a Map (it gets serialized as {} in localStorage)
4174
+ if (!chat.pendingToolCalls || !(chat.pendingToolCalls instanceof Map)) {
4175
+ chat.pendingToolCalls = new Map();
4176
+ }
4177
+
4178
+ // Check if the chat was saved while waiting for a response (broken state)
4179
+ if (chat.spinnerState || chat.isProcessing) {
4180
+ chat.wasWaitingOnLoad = true;
4181
+ // Clear the spinner state since we're not actually waiting anymore
4182
+ chat.spinnerState = null;
4183
+ chat.isProcessing = false;
4184
+ }
4185
+
4186
+ // Reconstruct MessageOptimizer instance for loaded chats
4187
+ if (!chat.messageOptimizer || !(chat.messageOptimizer instanceof MessageOptimizer)) {
4188
+ // Config should already be migrated by this point
4189
+ if (!chat.config) {
4190
+ throw new Error(`Chat ${chat.id} has no config after migration`);
4191
+ }
4192
+
4193
+ // Get optimizer settings from config
4194
+ const optimizerSettings = ChatConfig.getOptimizerSettings(chat.config, window.createLLMProvider);
4195
+
4196
+ // Create MessageOptimizer instance
4197
+ chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
4198
+ }
4199
+
4200
+ // Ensure pendingToolCalls map exists
4201
+ if (!chat.pendingToolCalls) {
4202
+ chat.pendingToolCalls = new Map();
4203
+ }
4204
+
4205
+ // Add to memory - DO NOT SAVE!
4206
+ // The chat is now in the correct format in memory
4207
+ // It will only be saved when the user actually modifies it
4208
+ this.chats.set(chat.id, chat);
4209
+ }
4210
+
4211
+ async initializeDefaultLLMProvider() {
4212
+ // Always fetch models even if we have providers (to get fresh model list)
4213
+
4214
+ // Auto-detect the proxy URL from the current origin
4215
+ const proxyUrl = window.location.origin;
4216
+
4217
+ // console.log('Fetching models from:', `${proxyUrl}/models`);
4218
+
4219
+ try {
4220
+ // Fetch available models from the same origin
4221
+ const response = await fetch(`${proxyUrl}/models`);
4222
+ if (!response.ok) {
4223
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4224
+ }
4225
+
4226
+ const data = await response.json();
4227
+ const providers = data.providers || {};
4228
+
4229
+ // console.log('Received providers data from proxy:', providers);
4230
+
4231
+ if (Object.keys(providers).length === 0) {
4232
+ console.warn('No LLM providers configured in proxy');
4233
+ this.showNoModelsModal(proxyUrl);
4234
+ return;
4235
+ }
4236
+
4237
+ // Update availableProviders for all existing providers with the same proxyUrl
4238
+ let updated = false;
4239
+ for (const [, provider] of this.llmProviders) {
4240
+ if (provider.proxyUrl === proxyUrl) {
4241
+ provider.availableProviders = providers;
4242
+ updated = true;
4243
+ }
4244
+ }
4245
+
4246
+ // If no existing provider, create a new one
4247
+ if (!updated) {
4248
+ const providerId = 'default_llm_provider';
4249
+ const provider = {
4250
+ id: providerId,
4251
+ name: 'LLM Provider',
4252
+ proxyUrl,
4253
+ availableProviders: providers,
4254
+ onLog: (logEntry) => {
4255
+ const prefix = logEntry.direction === 'sent' ? 'llm-request' : 'llm-response';
4256
+ this.addLogEntry(`${prefix}: LLM Provider`, logEntry);
4257
+ }
4258
+ };
4259
+ this.llmProviders.set(providerId, provider);
4260
+ }
4261
+
4262
+ // Always populate modelLimits and pricing from fresh data
4263
+ this.modelPricing = {};
4264
+ Object.entries(providers).forEach(([_providerType, config]) => {
4265
+ if (config.models) {
4266
+ config.models.forEach(model => {
4267
+ if (typeof model === 'object' && model.id) {
4268
+ if (model.contextWindow) {
4269
+ this.modelLimits[model.id] = model.contextWindow;
4270
+ }
4271
+ if (model.pricing) {
4272
+ this.modelPricing[model.id] = model.pricing;
4273
+ }
4274
+ }
4275
+ });
4276
+ }
4277
+ });
4278
+
4279
+ this.saveSettings();
4280
+ this.updateLlmProvidersList();
4281
+
4282
+ // Validate all existing chats have valid models
4283
+ this.validateChatModels();
4284
+
4285
+ // Update token displays for the current chat now that pricing is loaded
4286
+ const activeChatId = this.getActiveChatId();
4287
+ if (activeChatId) {
4288
+ this.updateAllTokenDisplays(activeChatId);
4289
+ }
4290
+
4291
+ this.addLogEntry('SYSTEM', {
4292
+ timestamp: new Date().toISOString(),
4293
+ direction: 'info',
4294
+ message: `Auto-configured LLM proxy from ${proxyUrl}`
4295
+ });
4296
+
4297
+ } catch (error) {
4298
+ console.error('Failed to auto-configure LLM provider:', error);
4299
+ this.addLogEntry('SYSTEM', {
4300
+ timestamp: new Date().toISOString(),
4301
+ direction: 'error',
4302
+ message: `Failed to auto-configure LLM provider: ${error.message}`
4303
+ });
4304
+ this.showNoModelsModal(proxyUrl);
4305
+ }
4306
+ }
4307
+
4308
+ async createDefaultChatIfNeeded() {
4309
+ // Make sure we have at least one MCP server and LLM provider
4310
+ if (this.mcpServers.size === 0 || this.llmProviders.size === 0) {return;}
4311
+
4312
+ // Get the last used configuration
4313
+ const config = ChatConfig.getLastConfig();
4314
+ let mcpServerId = config.mcpServer;
4315
+ const llmProviderId = this.llmProviders.keys().next().value;
4316
+
4317
+ // Use defaults if needed
4318
+ if (!mcpServerId || !this.mcpServers.has(mcpServerId)) {
4319
+ // Get first server from sorted list
4320
+ const sortedServers = Array.from(this.mcpServers.entries())
4321
+ .sort(([, a], [, b]) => a.name.localeCompare(b.name));
4322
+ mcpServerId = sortedServers[0]?.[0];
4323
+ }
4324
+
4325
+
4326
+ // Check if the config has a valid model, otherwise get first available
4327
+ let model = null;
4328
+ if (!config || !config.model || !config.model.provider || !config.model.id) {
4329
+ const provider = this.llmProviders.get(llmProviderId);
4330
+ if (provider && provider.availableProviders) {
4331
+ const firstProvider = Object.keys(provider.availableProviders)[0];
4332
+ const firstModel = provider.availableProviders[firstProvider]?.models?.[0];
4333
+ if (firstModel) {
4334
+ const modelId = typeof firstModel === 'string' ? firstModel : firstModel.id;
4335
+ model = `${firstProvider}:${modelId}`;
4336
+ }
4337
+ }
4338
+
4339
+ if (!model) {return;}
4340
+ }
4341
+
4342
+ // Create an unsaved chat
4343
+ const createOptions = {
4344
+ mcpServerId,
4345
+ llmProviderId,
4346
+ title: 'New Chat',
4347
+ isSaved: false,
4348
+ config // Pass the config
4349
+ };
4350
+
4351
+ // Only pass model if we had to find one
4352
+ if (model) {
4353
+ createOptions.model = model;
4354
+ }
4355
+
4356
+ return this.createNewChat(createOptions);
4357
+ }
4358
+
4359
+ async initializeDefaultMCPServers() {
4360
+ try {
4361
+ // Get the proxy URL - try to get from LLM provider or use current origin
4362
+ let proxyUrl;
4363
+
4364
+ const defaultProvider = [...this.llmProviders.values()].find(p => p.url);
4365
+ if (defaultProvider && defaultProvider.url) {
4366
+ proxyUrl = defaultProvider.url;
4367
+ } else {
4368
+ // Use the same origin as the current page (since we're being served by the proxy)
4369
+ proxyUrl = window.location.origin;
4370
+ }
4371
+
4372
+ const response = await fetch(`${proxyUrl}/mcp-servers`);
4373
+ if (!response.ok) {
4374
+ console.warn('Failed to fetch default MCP servers from proxy:', response.status);
4375
+ return;
4376
+ }
4377
+
4378
+ const data = await response.json();
4379
+ const defaultServers = data.servers || [];
4380
+
4381
+ if (defaultServers.length === 0) {
4382
+ return;
4383
+ }
4384
+
4385
+ // Handle migration of old default_mcp_server if it exists
4386
+ const oldDefaultServer = this.mcpServers.get('default_mcp_server');
4387
+ if (oldDefaultServer && oldDefaultServer.url === 'ws://localhost:19999/mcp') {
4388
+ // Remove the old default server as it's being replaced by Costa-Desktop
4389
+ this.mcpServers.delete('default_mcp_server');
4390
+ }
4391
+
4392
+ // Create a map of existing servers by URL for easy lookup
4393
+ const existingServersByUrl = new Map();
4394
+ for (const [id, server] of this.mcpServers) {
4395
+ existingServersByUrl.set(server.url, { id, server });
4396
+ }
4397
+
4398
+ // Add or update default servers
4399
+ for (const defaultServer of defaultServers) {
4400
+ const existing = existingServersByUrl.get(defaultServer.url);
4401
+
4402
+ if (existing) {
4403
+ // Server with this URL exists, update it with default info if it's one of our defaults
4404
+ // But keep user-defined servers untouched
4405
+ if (existing.id.startsWith('costa_') || existing.id.startsWith('prod_') ||
4406
+ existing.id.startsWith('demos_') || existing.id === 'agent_events' ||
4407
+ existing.id === 'default_mcp_server') {
4408
+ // It's one of our default servers, update it
4409
+ existing.server.id = defaultServer.id;
4410
+ existing.server.name = defaultServer.name;
4411
+ this.mcpServers.delete(existing.id);
4412
+ this.mcpServers.set(defaultServer.id, existing.server);
4413
+ }
4414
+ // Otherwise it's a user-defined server, leave it alone
4415
+ } else {
4416
+ // Server doesn't exist, add it
4417
+ const server = {
4418
+ id: defaultServer.id,
4419
+ name: defaultServer.name,
4420
+ url: defaultServer.url,
4421
+ connected: false
4422
+ };
4423
+ this.mcpServers.set(defaultServer.id, server);
4424
+ }
4425
+ }
4426
+
4427
+ // Save the updated server list
4428
+ this.saveSettings();
4429
+ this.updateMcpServersList();
4430
+
4431
+ // Log the initialization
4432
+ this.addLogEntry('SYSTEM', {
4433
+ timestamp: new Date().toISOString(),
4434
+ direction: 'info',
4435
+ message: 'Initialized default MCP servers'
4436
+ });
4437
+ } catch (error) {
4438
+ console.error('Failed to initialize default MCP servers:', error);
4439
+ // Continue without default servers - user can add them manually
4440
+ }
4441
+ }
4442
+
4443
+ saveSettings() {
4444
+ // Save MCP servers
4445
+ const serversToSave = Array.from(this.mcpServers.values());
4446
+ localStorage.setItem('mcpServers', JSON.stringify(serversToSave));
4447
+
4448
+ // Don't save LLM providers - always fetch fresh from proxy
4449
+
4450
+ // Note: Chats are now saved individually via saveChatToStorage()
4451
+ // when they are modified, not all at once
4452
+
4453
+ // Save current chat ID
4454
+ const activeChatId = this.getActiveChatId();
4455
+ if (activeChatId) {
4456
+ localStorage.setItem('currentChatId', activeChatId);
4457
+ }
4458
+ }
4459
+
4460
+ saveChatToStorage(chatId) {
4461
+ const chat = this.chats.get(chatId);
4462
+ if (!chat || chat.isSaved === false) {return;}
4463
+
4464
+ try {
4465
+ localStorage.setItem(chatId, JSON.stringify(chat));
4466
+ } catch (e) {
4467
+ console.error(`Failed to save chat ${chatId}:`, e);
4468
+ if (e.name === 'QuotaExceededError') {
4469
+ this.showGlobalError('Storage quota exceeded. Consider deleting old chats.');
4470
+ }
4471
+ }
4472
+ }
4473
+
4474
+ // MCP Server Management
4475
+ async addMcpServer() {
4476
+ const url = this.mcpServerUrl.value.trim();
4477
+ const name = this.mcpServerName.value.trim();
4478
+
4479
+ if (!url || !name) {
4480
+ this.showGlobalError('Please fill in all fields');
4481
+ return;
4482
+ }
4483
+
4484
+ // Test connection
4485
+ try {
4486
+ const testClient = new MCPClient();
4487
+ testClient.onLog = (logEntry) => {
4488
+ const prefix = logEntry.direction === 'sent' ? 'mcp-request' : 'mcp-response';
4489
+ this.addLogEntry(`${prefix}: ${name}`, logEntry);
4490
+ };
4491
+ await testClient.connect(url);
4492
+
4493
+ // Connection successful, save server
4494
+ const serverId = `mcp_${Date.now()}`;
4495
+ const server = {
4496
+ id: serverId,
4497
+ name,
4498
+ url,
4499
+ connected: true
4500
+ };
4501
+
4502
+ this.mcpServers.set(serverId, server);
4503
+ this.mcpConnections.set(serverId, testClient);
4504
+
4505
+ this.saveSettings();
4506
+ this.updateMcpServersList();
4507
+
4508
+ // Clear form
4509
+ this.mcpServerUrl.value = '';
4510
+ this.mcpServerName.value = '';
4511
+ this.hideModal('addMcpModal');
4512
+
4513
+ this.addLogEntry('SYSTEM', {
4514
+ timestamp: new Date().toISOString(),
4515
+ direction: 'info',
4516
+ message: `MCP server "${name}" added successfully`
4517
+ });
4518
+
4519
+ } catch (error) {
4520
+ this.showError(`Failed to connect to MCP server: ${error.message}`, this.getActiveChatId());
4521
+ }
4522
+ }
4523
+
4524
+ updateMcpServersList() {
4525
+ this.mcpServersList.innerHTML = '';
4526
+
4527
+ if (this.mcpServers.size === 0) {
4528
+ this.mcpServersList.innerHTML = '<div class="text-center text-muted">No MCP servers configured</div>';
4529
+ return;
4530
+ }
4531
+
4532
+ // Sort servers by name
4533
+ const sortedServers = Array.from(this.mcpServers.entries())
4534
+ .sort(([, a], [, b]) => a.name.localeCompare(b.name));
4535
+
4536
+ for (const [id, server] of sortedServers) {
4537
+ const connection = this.mcpConnections.get(id);
4538
+ const isConnected = connection && connection.isReady();
4539
+
4540
+ const serverDiv = document.createElement('div');
4541
+ serverDiv.className = 'config-item';
4542
+ serverDiv.innerHTML = `
4543
+ <div class="config-item-info">
4544
+ <div class="config-item-name">${server.name}</div>
4545
+ <div class="config-item-details">${server.url}</div>
4546
+ </div>
4547
+ <div class="config-item-actions">
4548
+ <div class="config-item-status">
4549
+ <span class="status-dot ${isConnected ? 'connected' : 'disconnected'}"></span>
4550
+ <span>${isConnected ? 'Connected' : 'Disconnected'}</span>
4551
+ </div>
4552
+ <button class="btn btn-small btn-danger" onclick="app.removeMcpServer('${id}')">Remove</button>
4553
+ </div>
4554
+ `;
4555
+ this.mcpServersList.appendChild(serverDiv);
4556
+ }
4557
+ }
4558
+
4559
+ async removeMcpServer(serverId) {
4560
+ if (confirm('Remove this MCP server?')) {
4561
+ // Disconnect if connected
4562
+ const connection = this.mcpConnections.get(serverId);
4563
+ if (connection) {
4564
+ connection.disconnect();
4565
+ this.mcpConnections.delete(serverId);
4566
+ }
4567
+
4568
+ this.mcpServers.delete(serverId);
4569
+ this.saveSettings();
4570
+ this.updateMcpServersList();
4571
+
4572
+ // Check if any chats use this server
4573
+ for (const chat of this.chats.values()) {
4574
+ if (chat.mcpServerId === serverId) {
4575
+ chat.mcpServerId = null;
4576
+ // Note: Chat becomes unusable without MCP server
4577
+ }
4578
+ }
4579
+ this.saveSettings();
4580
+ }
4581
+ }
4582
+
4583
+ // LLM Provider Management
4584
+ updateLlmProvidersList() {
4585
+ // This function is no longer needed since LLM providers are auto-configured
4586
+ // but we'll keep it for backward compatibility
4587
+ }
4588
+
4589
+ // Chat Management
4590
+ async createNewChatDirectly() {
4591
+ // Check if providers are still loading
4592
+ if (!this.providersLoaded) {
4593
+ this.showGlobalError('Please wait, loading providers...');
4594
+ return;
4595
+ }
4596
+
4597
+ // Check if there's an unsaved chat
4598
+ const unsavedChat = Array.from(this.chats.values()).find(chat => chat.isSaved === false);
4599
+ if (unsavedChat) {
4600
+ const activeChatId = this.getActiveChatId();
4601
+ console.log('[createNewChatDirectly] Found unsaved chat:', unsavedChat.id, 'Active chat:', activeChatId);
4602
+
4603
+ // Check if we're already in the unsaved chat
4604
+ if (activeChatId === unsavedChat.id) {
4605
+ // Already in the unsaved chat, just show toast
4606
+ this.showToast('Please use the current chat or save it by sending a message before creating a new one.');
4607
+ } else {
4608
+ // Switch to the unsaved chat instead of creating a new one
4609
+ console.log('[createNewChatDirectly] Switching to unsaved chat:', unsavedChat.id);
4610
+ this.loadChat(unsavedChat.id);
4611
+ }
4612
+ return;
4613
+ }
4614
+
4615
+ // Make sure we have at least one MCP server and LLM provider
4616
+ if (this.mcpServers.size === 0 || this.llmProviders.size === 0) {
4617
+ this.showGlobalError('Please configure at least one MCP server and LLM provider');
4618
+ return;
4619
+ }
4620
+
4621
+ // Get the last used configuration
4622
+ const config = ChatConfig.getLastConfig();
4623
+ let mcpServerId = config.mcpServer;
4624
+ const llmProviderId = this.llmProviders.keys().next().value; // Always use first available
4625
+
4626
+ // Use defaults if needed
4627
+ if (!mcpServerId || !this.mcpServers.has(mcpServerId)) {
4628
+ // Get first server from sorted list
4629
+ const sortedServers = Array.from(this.mcpServers.entries())
4630
+ .sort(([, a], [, b]) => a.name.localeCompare(b.name));
4631
+ mcpServerId = sortedServers[0]?.[0];
4632
+ }
4633
+
4634
+ // Check if the config has a valid model, otherwise get first available
4635
+ let model = null;
4636
+ if (!config || !config.model || !config.model.provider || !config.model.id) {
4637
+ const provider = this.llmProviders.get(llmProviderId);
4638
+ if (provider && provider.availableProviders) {
4639
+ const firstProvider = Object.keys(provider.availableProviders)[0];
4640
+ const firstModel = provider.availableProviders[firstProvider]?.models?.[0];
4641
+ if (firstModel) {
4642
+ const modelId = typeof firstModel === 'string' ? firstModel : firstModel.id;
4643
+ model = `${firstProvider}:${modelId}`;
4644
+ }
4645
+ }
4646
+
4647
+ if (!model) {
4648
+ this.showGlobalError('No models available');
4649
+ return;
4650
+ }
4651
+ }
4652
+
4653
+ // Create an unsaved chat
4654
+ const createOptions = {
4655
+ mcpServerId,
4656
+ llmProviderId,
4657
+ title: 'New Chat',
4658
+ isSaved: false,
4659
+ config // Pass the full config
4660
+ };
4661
+
4662
+ // Only pass model if we had to find one
4663
+ if (model) {
4664
+ createOptions.model = model;
4665
+ }
4666
+
4667
+ const chatId = await this.createNewChat(createOptions);
4668
+
4669
+ // Load the chat immediately when created via button click
4670
+ if (chatId) {
4671
+ this.loadChat(chatId);
4672
+ }
4673
+ }
4674
+
4675
+ // Per-chat DOM management
4676
+ getChatContainer(chatId) {
4677
+ if (!this.chatContainers.has(chatId)) {
4678
+ const container = this.createChatDOM(chatId);
4679
+ if (container) {
4680
+ this.chatContainersEl.appendChild(container);
4681
+ this.chatContainers.set(chatId, container);
4682
+
4683
+ // Apply any pending connection state
4684
+ const chat = this.chats.get(chatId);
4685
+ if (chat && chat.pendingConnectionState) {
4686
+ this.updateChatConnectionUI(chatId, chat.pendingConnectionState.state, chat.pendingConnectionState.details);
4687
+ delete chat.pendingConnectionState;
4688
+ }
4689
+ }
4690
+ }
4691
+ return this.chatContainers.get(chatId);
4692
+ }
4693
+
4694
+ createChatDOM(chatId) {
4695
+ const chat = this.chats.get(chatId);
4696
+ if (!chat) {return null;}
4697
+
4698
+ const container = document.createElement('div');
4699
+ container.className = 'chat-container';
4700
+ container.dataset.chatId = chatId;
4701
+
4702
+ // Create the complete chat UI structure
4703
+ container.innerHTML = `
4704
+ <div class="chat-header">
4705
+ <div class="chat-info">
4706
+ <div>
4707
+ <h3 class="chat-title">${chat.title}</h3>
4708
+ <div class="chat-meta">
4709
+ <span class="chat-mcp"></span>
4710
+ <span class="chat-llm">
4711
+ <span class="model-name"></span>
4712
+ </span>
4713
+ </div>
4714
+ </div>
4715
+ <div class="chat-controls">
4716
+ <div class="metrics-dashboard">
4717
+ <!-- Context Window Indicator -->
4718
+ <div class="context-window-section" data-tooltip="Shows how much of the model's context is being used">
4719
+ <div class="context-window-header">
4720
+ <span class="context-window-label">CONTEXT WINDOW</span>
4721
+ </div>
4722
+ <div class="context-window-bar-container">
4723
+ <div class="context-window-bar">
4724
+ <div class="context-window-fill" style="width: 0"></div>
4725
+ <span class="context-window-stats">0 / 4k</span>
4726
+ </div>
4727
+ </div>
4728
+ </div>
4729
+
4730
+ <!-- Cumulative Token Counters -->
4731
+ <div class="token-counters-section">
4732
+ <div class="token-counters-headers">
4733
+ <span class="token-header-primary">TOKENS</span>
4734
+ <span class="token-header-item"><i class="fas fa-file-alt"></i> INPUT</span>
4735
+ <span class="token-header-item"><i class="fas fa-memory"></i> CACHE R</span>
4736
+ <span class="token-header-item"><i class="fas fa-save"></i> CACHE W</span>
4737
+ <span class="token-header-item"><i class="fas fa-upload"></i> OUTPUT</span>
4738
+ <span class="token-header-item"><i class="fas fa-dollar-sign"></i> COST</span>
4739
+ </div>
4740
+ <div class="token-counters-values">
4741
+ <span class="token-value-primary">PRIMARY</span>
4742
+ <span class="cumulative-input-tokens token-value-item">0</span>
4743
+ <span class="cumulative-cache-read-tokens token-value-item">0</span>
4744
+ <span class="cumulative-cache-creation-tokens token-value-item">0</span>
4745
+ <span class="cumulative-output-tokens token-value-item">0</span>
4746
+ <span class="cumulative-cost token-value-item" style="color: #4CAF50;">$0.00</span>
4747
+ </div>
4748
+ </div>
4749
+ </div>
4750
+ </div>
4751
+ </div>
4752
+ </div>
4753
+ <div class="chat-content">
4754
+ <div class="chat-messages"></div>
4755
+ <div class="chat-controls-bar" style="display: flex; align-items: center; justify-content: center; gap: 4px; margin: 5px auto; flex-wrap: wrap; padding: 0 10px; max-width: 900px;">
4756
+
4757
+ <!-- Model and MCP Server Selection -->
4758
+ <div class="dropdown" style="position: relative;">
4759
+ <button class="llm-model-btn btn btn-secondary dropdown-toggle">
4760
+ <span><i class="fas fa-robot"></i></span>
4761
+ <span class="current-model-text">Model</span>
4762
+ <span style="margin-left: 5px;"><i class="fas fa-chevron-down"></i></span>
4763
+ </button>
4764
+ <div class="llm-model-dropdown dropdown-menu" style="display: none; position: absolute; bottom: 100%; left: 0; margin-bottom: 5px; max-height: 300px; overflow-y: auto;"></div>
4765
+ </div>
4766
+
4767
+ <div class="dropdown" style="position: relative;">
4768
+ <button class="mcp-server-btn btn btn-secondary dropdown-toggle" data-tooltip="Switch MCP server">
4769
+ <span><i class="fas fa-plug"></i></span>
4770
+ <span class="current-mcp-text">MCP Server</span>
4771
+ <span style="margin-left: 5px;"><i class="fas fa-chevron-down"></i></span>
4772
+ </button>
4773
+ <div class="mcp-server-dropdown dropdown-menu" style="display: none; position: absolute; bottom: 100%; left: 0; margin-bottom: 5px; max-height: 300px; overflow-y: auto;"></div>
4774
+ </div>
4775
+
4776
+ <!-- Other buttons -->
4777
+ <button class="copy-metrics-btn btn btn-secondary" data-tooltip="Copy all message metadata including tokens and timing">
4778
+ <span><i class="fas fa-copy"></i></span>
4779
+ <span>Log</span>
4780
+ </button>
4781
+ <button class="summarize-btn btn btn-secondary" data-tooltip="Summarize conversation to reduce context size">
4782
+ <span><i class="fas fa-compress-alt"></i></span>
4783
+ <span>Summarize</span>
4784
+ </button>
4785
+ <button class="generate-title-btn btn btn-secondary" data-tooltip="Generate or update chat title using AI">
4786
+ <span><i class="fas fa-edit"></i></span>
4787
+ <span>Title</span>
4788
+ </button>
4789
+ </div>
4790
+ <div class="resize-handle resize-handle-horizontal"></div>
4791
+ <div class="chat-input-container">
4792
+ <button class="reconnect-mcp-btn btn btn-primary" style="display: none;">Reconnect MCP Server</button>
4793
+ <div class="chat-input-wrapper">
4794
+ <textarea
4795
+ class="chat-input"
4796
+ placeholder="Ask about your Netdata metrics..."
4797
+ rows="3"
4798
+ ></textarea>
4799
+ <button class="send-message-btn btn btn-send">Send</button>
4800
+ </div>
4801
+ </div>
4802
+ </div>
4803
+ `;
4804
+
4805
+ // Store element references for easy access
4806
+ container._elements = {
4807
+ header: container.querySelector('.chat-header'),
4808
+ title: container.querySelector('.chat-title'),
4809
+ mcpMeta: container.querySelector('.chat-mcp'),
4810
+ llmMeta: container.querySelector('.chat-llm'),
4811
+ messages: container.querySelector('.chat-messages'),
4812
+ input: container.querySelector('.chat-input'),
4813
+ sendBtn: container.querySelector('.send-message-btn'),
4814
+ reconnectBtn: container.querySelector('.reconnect-mcp-btn'),
4815
+
4816
+ // Context window elements
4817
+ contextFill: container.querySelector('.context-window-fill'),
4818
+ contextStats: container.querySelector('.context-window-stats'),
4819
+
4820
+ // Token counter elements
4821
+ cumulativeInputTokens: container.querySelector('.cumulative-input-tokens'),
4822
+ cumulativeCacheReadTokens: container.querySelector('.cumulative-cache-read-tokens'),
4823
+ cumulativeCacheCreationTokens: container.querySelector('.cumulative-cache-creation-tokens'),
4824
+ cumulativeOutputTokens: container.querySelector('.cumulative-output-tokens'),
4825
+ cumulativeCost: container.querySelector('.cumulative-cost'),
4826
+
4827
+ // Control buttons
4828
+
4829
+ llmModelBtn: container.querySelector('.llm-model-btn'),
4830
+ llmModelDropdown: container.querySelector('.llm-model-dropdown'),
4831
+ currentModelText: container.querySelector('.current-model-text'),
4832
+
4833
+ mcpServerBtn: container.querySelector('.mcp-server-btn'),
4834
+ mcpServerDropdown: container.querySelector('.mcp-server-dropdown'),
4835
+ currentMcpText: container.querySelector('.current-mcp-text'),
4836
+
4837
+ copyMetricsBtn: container.querySelector('.copy-metrics-btn'),
4838
+ summarizeBtn: container.querySelector('.summarize-btn'),
4839
+ generateTitleBtn: container.querySelector('.generate-title-btn'),
4840
+
4841
+ // Resize handle
4842
+ inputResizeHandle: container.querySelector('.resize-handle-horizontal')
4843
+ };
4844
+
4845
+ // Attach event listeners
4846
+ this.attachChatEventListeners(container, chatId);
4847
+
4848
+ return container;
4849
+ }
4850
+
4851
+ attachChatEventListeners(container, chatId) {
4852
+ const elements = container._elements;
4853
+ const chat = this.chats.get(chatId);
4854
+ if (!chat) {return;}
4855
+
4856
+ // Send button
4857
+ elements.sendBtn.addEventListener('click', () => {
4858
+ if (this.isProcessing) {
4859
+ // Stop processing
4860
+ console.log('[Stop Button] Setting shouldStopProcessing = true');
4861
+ this.shouldStopProcessing = true;
4862
+ this.isProcessing = false;
4863
+ this.updateSendButton();
4864
+ this.chatInput.disabled = false;
4865
+ // Don't add a system message as it breaks message sequencing
4866
+ // The assistantFailed handler will take care of the UI feedback
4867
+ } else {
4868
+ // Send message
4869
+ this.sendMessage(chatId).catch(error => {
4870
+ console.error('Failed to send message:', error);
4871
+ this.showError('Failed to send message', chatId);
4872
+ });
4873
+ }
4874
+ });
4875
+
4876
+ // Input field
4877
+ elements.input.addEventListener('input', (e) => {
4878
+ // Save draft in memory only - don't update UI or save to storage on every keystroke
4879
+ chat.draftMessage = e.target.value;
4880
+
4881
+ // Update send button state
4882
+ elements.sendBtn.disabled = !e.target.value.trim();
4883
+
4884
+ // Debounce saving to storage - save after 2 seconds of no typing
4885
+ if (this.draftSaveTimeout) {
4886
+ clearTimeout(this.draftSaveTimeout);
4887
+ }
4888
+ this.draftSaveTimeout = setTimeout(() => {
4889
+ this.autoSave(chatId);
4890
+ // Still don't update UI here - just save to storage
4891
+ }, 2000);
4892
+ });
4893
+
4894
+ // Enter to send
4895
+ elements.input.addEventListener('keydown', (e) => {
4896
+ if (e.key === 'Enter' && !e.shiftKey) {
4897
+ e.preventDefault();
4898
+ this.sendMessage(chatId).catch(error => {
4899
+ console.error('Failed to send message:', error);
4900
+ this.showError('Failed to send message', chatId);
4901
+ });
4902
+ }
4903
+ });
4904
+
4905
+ // Model selector
4906
+ elements.llmModelBtn.addEventListener('click', (e) => {
4907
+ e.stopPropagation();
4908
+ // Close any existing model selector overlays
4909
+ document.querySelectorAll('.model-selector-overlay').forEach(el => el.remove());
4910
+ this.populateModelDropdown(chatId, elements.llmModelDropdown, elements.llmModelBtn);
4911
+ });
4912
+
4913
+ // MCP server selector
4914
+ elements.mcpServerBtn.addEventListener('click', (e) => {
4915
+ e.stopPropagation();
4916
+ this.populateMCPDropdown(chatId, elements.mcpServerDropdown);
4917
+ this.toggleChatDropdown(elements.mcpServerDropdown);
4918
+ });
4919
+
4920
+ // Other buttons
4921
+ elements.copyMetricsBtn.addEventListener('click', () => {
4922
+ this.copyConversationMetrics(chatId).catch(error => {
4923
+ console.error('Failed to copy metrics:', error);
4924
+ this.showError('Failed to copy metrics', chatId);
4925
+ });
4926
+ });
4927
+
4928
+ elements.summarizeBtn.addEventListener('click', () => {
4929
+ this.summarizeConversation(chatId).catch(error => {
4930
+ console.error('Failed to summarize conversation:', error);
4931
+ this.showError('Failed to summarize conversation', chatId);
4932
+ });
4933
+ });
4934
+
4935
+ elements.generateTitleBtn.addEventListener('click', () => {
4936
+ this.handleGenerateTitleClick(chatId).catch(error => {
4937
+ console.error('Failed to generate title:', error);
4938
+ this.showError('Failed to generate title', chatId);
4939
+ });
4940
+ });
4941
+
4942
+ elements.reconnectBtn.addEventListener('click', () => {
4943
+ const mcpServerId = this.reconnectMcpBtn ? this.reconnectMcpBtn.dataset.mcpServerId : null;
4944
+ if (mcpServerId) {
4945
+ this.reconnectMcpServer(mcpServerId).catch(error => {
4946
+ console.error('Failed to reconnect MCP server:', error);
4947
+ this.showError('Failed to reconnect MCP server', chatId);
4948
+ });
4949
+ }
4950
+ });
4951
+
4952
+ // Resize handle for input - delay to ensure DOM is ready
4953
+ if (elements.inputResizeHandle) {
4954
+ requestAnimationFrame(() => {
4955
+ this.makeResizable(elements.inputResizeHandle, elements.input.parentElement.parentElement, 'vertical', 150, 400);
4956
+ elements.inputResizeHandle._resizeInitialized = true;
4957
+ });
4958
+ }
4959
+
4960
+ // Note: Global dropdown close handler is already set up in initializeUI()
4961
+ }
4962
+
4963
+ toggleChatDropdown(dropdownEl) {
4964
+ // Close all other dropdowns in all chats
4965
+ this.chatContainers.forEach(container => {
4966
+ const elements = container._elements;
4967
+ if (elements) {
4968
+ if (elements.llmModelDropdown !== dropdownEl) {
4969
+ elements.llmModelDropdown.style.display = 'none';
4970
+ }
4971
+ if (elements.mcpServerDropdown !== dropdownEl) {
4972
+ elements.mcpServerDropdown.style.display = 'none';
4973
+ }
4974
+ }
4975
+ });
4976
+
4977
+ // Toggle the requested dropdown
4978
+ dropdownEl.style.display = dropdownEl.style.display === 'none' ? 'block' : 'none';
4979
+ }
4980
+
4981
+ switchChatDOM(chatId) {
4982
+ // If this is the pending new chat trying to switch while user has selected another chat, block it
4983
+ if (this.pendingNewChatId === chatId && this.userHasSelectedChat) {
4984
+ const activeChatId = this.getActiveChatId();
4985
+ if (activeChatId && activeChatId !== chatId) {
4986
+ console.log('Blocking DOM switch to new chat - user already selected:', activeChatId);
4987
+ return;
4988
+ }
4989
+ }
4990
+
4991
+ // Hide welcome screen
4992
+ if (this.welcomeScreen) {
4993
+ this.welcomeScreen.style.display = 'none';
4994
+ }
4995
+
4996
+ // Hide all chat containers
4997
+ this.chatContainers.forEach((container, id) => {
4998
+ container.classList.remove('active');
4999
+ const chat = this.chats.get(id);
This file is too large to show in full.