2
* Main application logic for the Netdata MCP LLM Client
3
*/
4
5
-import {MessageOptimizer} from './message-optimizer.js';
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';
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.67 - Multi-Chat Input Management Fixes');
15
-
14
+ console.log('🚀 Netdata MCP Web Client v1.0.99');
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
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
-
29
+
30
// Safety protections
31
this.safetyChecker = new SafetyChecker();
32
-
32
+
33
// Per-chat DOM management
34
this.chatContainers = new Map(); // Map of chatId -> DOM container (includes both main and sub-chats)
35
36
// Models will be loaded dynamically from the proxy server
37
// No hardcoded model list needed
38
-
39
-
38
+
39
+
40
// Default system prompt
41
this.defaultSystemPrompt = SystemMsg.DEFAULT_SYSTEM_PROMPT;
42
-
42
+
43
// Load last used system prompt from localStorage or use default
44
this.lastSystemPrompt = localStorage.getItem('lastSystemPrompt') || this.defaultSystemPrompt;
45
-
45
+
46
this.initializeUI();
47
-
47
+
48
// Delay resizable initialization to ensure DOM is ready
49
setTimeout(() => {
50
this.initializeResizable();
51
}, 0);
52
-
52
+
53
// Clear current chat ID to always start fresh
54
localStorage.removeItem('currentChatId');
55
-
55
+
56
// Get reference to main container
57
this.chatContainersEl = document.getElementById('chatContainers');
58
this.welcomeScreen = document.getElementById('welcomeScreen');
59
-
59
+
60
// Show welcome screen initially
61
if (this.welcomeScreen) {
62
this.welcomeScreen.style.display = 'flex';
63
}
64
-
64
+
65
this.loadSettings();
66
-
66
+
67
// Track if user has interacted with chat selection
68
this.userHasSelectedChat = false;
69
-
69
+
70
// Track if we have a pending new chat load
71
this.pendingNewChatLoad = false;
72
-
72
+
73
// Track if providers are loaded
74
this.providersLoaded = false;
75
-
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(() => {
80
}).then(async () => {
81
// Mark providers as loaded
82
this.providersLoaded = true;
83
-
83
+
84
// Update chat sessions after providers are loaded
85
this.updateChatSessions();
86
-
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
-
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(this.pendingNewChatId);
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
- }
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(this.pendingNewChatId);
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
-
120
+
121
// Add global error handlers to catch unhandled errors
122
this.setupGlobalErrorHandlers();
123
}
124
-
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
-
131
+
132
// Catch unhandled promise rejections
133
window.addEventListener('unhandledrejection', (event) => {
134
console.error('Unhandled promise rejection:', event.reason);
137
event.preventDefault();
138
});
139
}
140
-
140
+
141
/**
142
* Extract tool calls from message content array
143
* @param {Array|string} content - Message content (can be array of blocks or string)
153
arguments: block.input
154
}));
155
}
156
-
156
+
157
/**
158
* Safe message operations that automatically persist changes
159
* These methods ensure messages are never lost by auto-saving after each operation
183
console.error('addMessage called without chatId');
184
return;
185
}
186
-
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
-
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);
197
message.price = price;
198
}
199
}
200
-
200
+
201
chat.messages.push(message);
202
chat.updatedAt = new Date().toISOString();
203
-
203
+
204
// Update cumulative token pricing
205
this.updateChatTokenPricing(chat);
206
-
206
+
207
// NOTE: Sub-chat cost accumulation is now handled in processSingleLLMResponse
208
// after tool-results are added to the parent chat, ensuring proper timing
209
-
209
+
210
// Update the cumulative token display
211
this.updateCumulativeTokenDisplay(chatId);
212
-
212
+
213
this.autoSave(chatId);
214
}
215
-
215
+
216
insertMessage(chatId, index, message) {
217
if (!chatId) {
218
console.error('insertMessage called without chatId');
219
return;
220
}
221
-
221
+
222
const chat = this.chats.get(chatId);
223
if (!chat) {
224
console.error('insertMessage: chat not found for chatId:', chatId);
225
return;
226
}
227
-
227
+
228
chat.messages.splice(index, 0, message);
229
chat.updatedAt = new Date().toISOString();
230
-
230
+
231
// Update cumulative token pricing
232
this.updateChatTokenPricing(chat);
233
-
233
+
234
// Update the cumulative token display
235
this.updateCumulativeTokenDisplay(chatId);
236
-
236
+
237
this.autoSave(chatId);
238
}
239
-
239
+
240
removeMessage(chatId, index, count = 1) {
241
if (!chatId) {
242
console.error('removeMessage called without chatId');
243
return;
244
}
245
-
245
+
246
const chat = this.chats.get(chatId);
247
if (!chat) {
248
console.error('removeMessage: chat not found for chatId:', chatId);
249
return;
250
}
251
-
251
+
252
chat.messages.splice(index, count);
253
chat.updatedAt = new Date().toISOString();
254
-
254
+
255
// Update cumulative token pricing
256
this.updateChatTokenPricing(chat);
257
-
257
+
258
// Update the cumulative token display
259
this.updateCumulativeTokenDisplay(chatId);
260
-
260
+
261
this.autoSave(chatId);
262
}
263
-
263
+
264
removeLastMessage(chatId) {
265
if (!chatId) {
266
console.error('removeLastMessage called without chatId');
267
return;
268
}
269
-
269
+
270
const chat = this.chats.get(chatId);
271
if (!chat || !this.hasUserContent(chat)) {
272
console.error('removeLastMessage: chat not found or no user content for chatId:', chatId);
273
return;
274
}
275
-
275
+
276
// Use removeMessage API instead of direct pop()
277
if (chat.messages.length > 0) {
278
this.removeMessage(chatId, chat.messages.length - 1, 1);
279
}
280
}
281
-
281
+
282
/**
283
* Truncate messages from a specific index onwards, creating accounting records if needed
284
* @param {string} chatId - The chat ID
291
console.error('truncateMessages: chat not found for chatId:', chatId);
292
return;
293
}
294
-
294
+
295
// Calculate messages to discard
296
const messagesToDiscard = chat.messages.length - startIndex;
297
if (messagesToDiscard <= 0) {
298
// Nothing to truncate
299
return;
300
}
301
-
301
+
302
// Find messages that will be discarded (from startIndex onwards)
303
const discardedMessages = chat.messages.slice(startIndex);
304
-
304
+
305
// Check if any discarded messages have non-zero tokens/costs
306
let hasTokens = false;
307
for (const message of discardedMessages) {
308
if (message.usage && message.model) {
309
const usage = message.usage;
310
- if ((usage.promptTokens || 0) > 0 ||
311
- (usage.completionTokens || 0) > 0 ||
312
- (usage.cacheReadInputTokens || 0) > 0 ||
310
+ if ((usage.promptTokens || 0) > 0 ||
311
+ (usage.completionTokens || 0) > 0 ||
312
+ (usage.cacheReadInputTokens || 0) > 0 ||
313
(usage.cacheCreationInputTokens || 0) > 0) {
314
hasTokens = true;
315
break;
317
} else if (message.role === 'accounting' && message.cumulativeTokens) {
318
// Also check accounting nodes for tokens
319
const tokens = message.cumulativeTokens;
320
- if ((tokens.inputTokens || 0) > 0 ||
321
- (tokens.outputTokens || 0) > 0 ||
322
- (tokens.cacheReadTokens || 0) > 0 ||
320
+ if ((tokens.inputTokens || 0) > 0 ||
321
+ (tokens.outputTokens || 0) > 0 ||
322
+ (tokens.cacheReadTokens || 0) > 0 ||
323
(tokens.cacheCreationTokens || 0) > 0) {
324
hasTokens = true;
325
break;
326
}
327
}
328
}
329
-
329
+
330
// Only create accounting nodes if there are tokens to preserve
331
if (hasTokens) {
332
// Group discarded tokens by model
333
const tokensByModel = new Map();
334
-
334
+
335
for (const message of discardedMessages) {
336
if (message.usage && message.model) {
337
const model = message.model;
344
messageCount: 0
345
});
346
}
347
-
347
+
348
const tokens = tokensByModel.get(model);
349
tokens.inputTokens += message.usage.promptTokens || 0;
350
tokens.outputTokens += message.usage.completionTokens || 0;
363
messageCount: 0
364
});
365
}
366
-
366
+
367
const tokens = tokensByModel.get(model);
368
const cumTokens = message.cumulativeTokens;
369
tokens.inputTokens += cumTokens.inputTokens || 0;
373
tokens.messageCount += message.discardedMessages || 0;
374
}
375
}
376
-
376
+
377
// Create accounting nodes for each model
378
let insertIndex = startIndex;
379
for (const [model, tokens] of tokensByModel) {
380
// Only create accounting node if this model has non-zero tokens
381
- if (tokens.inputTokens > 0 || tokens.outputTokens > 0 ||
381
+ if (tokens.inputTokens > 0 || tokens.outputTokens > 0 ||
382
tokens.cacheReadTokens > 0 || tokens.cacheCreationTokens > 0) {
383
const accountingNode = {
384
role: 'accounting',
392
insertIndex++;
393
}
394
}
395
-
395
+
396
// Remove all messages after accounting nodes
397
const toRemove = chat.messages.length - insertIndex;
398
if (toRemove > 0) {
402
// No tokens to preserve, just remove messages
403
this.removeMessage(chatId, startIndex, messagesToDiscard);
404
}
405
-
405
+
406
this.autoSave(chatId);
407
}
408
-
408
+
409
/**
410
* Check if a chat has any real user content (excluding system messages)
411
*/
412
hasUserContent(chat) {
413
- if (!chat || !chat.messages) {return false;}
414
- return chat.messages.some(m =>
415
- m.role !== 'system' &&
416
- m.role !== 'system-title' &&
413
+ if (!chat || !chat.messages) { return false; }
414
+ return chat.messages.some(m =>
415
+ m.role !== 'system' &&
416
+ m.role !== 'system-title' &&
417
m.role !== 'system-summary' &&
418
m.role !== 'title' &&
419
m.role !== 'summary' &&
420
m.role !== 'accounting'
421
);
422
}
423
-
423
+
424
/**
425
* Check if this is the first real user message in the chat
426
*/
427
isFirstUserMessage(chat) {
428
- if (!chat || !chat.messages) {return false;}
429
- const userMessages = chat.messages.filter(m =>
428
+ if (!chat || !chat.messages) { return false; }
429
+ const userMessages = chat.messages.filter(m =>
430
m.role === 'user'
431
);
432
return userMessages.length === 1;
433
}
434
-
434
+
435
/**
436
* Count real assistant messages (excluding title responses)
437
*/
438
countAssistantMessages(chat) {
439
- if (!chat || !chat.messages) {return 0;}
440
- return chat.messages.filter(m =>
439
+ if (!chat || !chat.messages) { return 0; }
440
+ return chat.messages.filter(m =>
441
m.role === 'assistant'
442
).length;
443
}
444
-
444
+
445
/**
446
* Check if a string contains markdown formatting
447
*/
449
if (typeof content !== 'string' || !content.trim()) {
450
return false;
451
}
452
-
452
+
453
// Common markdown patterns
454
const markdownPatterns = [
455
/^#+\s/m, // Headers: # ## ###
466
/^---+$/m, // Horizontal rules: ---
467
/~~.*~~/, // Strikethrough: ~~text~~
468
];
469
-
469
+
470
return markdownPatterns.some(pattern => pattern.test(content));
471
}
472
-
472
+
473
/**
474
* Auto-save with debouncing for performance
475
* Saves only the specific chat that was modified
476
*/
477
autoSave(chatId) {
478
- if (!chatId) {return;}
479
-
478
+ if (!chatId) { return; }
479
+
480
// For per-chat saves, we can be more aggressive since we're only saving one chat
481
// Clear any pending save for this specific chat
482
if (this.pendingSaveTimeouts) {
486
} else {
487
this.pendingSaveTimeouts = {};
488
}
489
-
489
+
490
// Save this specific chat after a short delay
491
this.pendingSaveTimeouts[chatId] = setTimeout(() => {
492
this.saveChatToStorage(chatId);
493
delete this.pendingSaveTimeouts[chatId];
494
}, 100); // 100ms debounce
495
}
496
-
496
+
497
/**
498
* Save chat configuration - only saves to chatConfig_chat_XXX for saved chats
499
* For unsaved chats, only updates lastChatConfig
504
console.error(`[saveChatConfigSmart] Chat not found for chatId: ${chatId}`);
505
return;
506
}
507
-
507
+
508
// Always save as last config for new chats to inherit
509
ChatConfig.saveLastConfig(config);
510
-
510
+
511
// Only save chat-specific config if the chat is saved
512
if (chat.isSaved !== false && chat.messages.length > 0) {
513
ChatConfig.saveChatConfig(chatId, config);
514
}
515
}
516
-
516
+
517
// Calculate price for a single message based on its model and usage
518
calculateMessagePrice(model, usage) {
519
- if (!usage || !model) {return null;}
520
-
519
+ if (!usage || !model) { return null; }
520
+
521
// Extract model name from format "provider:model-name"
522
let modelName = model;
523
if (typeof model === 'string') {
525
} else if (model?.id) {
526
modelName = model.id;
527
}
528
-
528
+
529
const pricing = this.modelPricing[modelName];
530
- if (!pricing) {return null;}
531
-
530
+ if (!pricing) { return null; }
531
+
532
let totalCost = 0;
533
-
533
+
534
const promptTokens = usage.promptTokens || 0;
535
const completionTokens = usage.completionTokens || 0;
536
const cacheReadTokens = usage.cacheReadInputTokens || 0;
537
const cacheCreationTokens = usage.cacheCreationInputTokens || 0;
538
-
538
+
539
// For Anthropic models with cache pricing
540
if (pricing.cacheWrite !== undefined && pricing.cacheRead !== undefined) {
541
totalCost += promptTokens / 1_000_000 * pricing.input;
556
totalCost += allInputTokens / 1_000_000 * pricing.input;
557
totalCost += completionTokens / 1_000_000 * pricing.output;
558
}
559
-
559
+
560
return totalCost;
561
}
562
566
console.error('updateChatTokenPricing called without chat object');
567
return;
568
}
569
-
569
+
570
// Initialize if not present
571
if (!chat.totalTokensPrice) {
572
chat.totalTokensPrice = {
577
totalCost: 0
578
};
579
}
580
-
580
+
581
if (!chat.perModelTokensPrice) {
582
chat.perModelTokensPrice = {};
583
}
591
totalCost: 0
592
};
593
chat.perModelTokensPrice = {};
594
-
594
+
595
// Calculate from all messages
596
for (const message of chat.messages) {
597
if (message.usage) {
598
const model = message.model || ChatConfig.getChatModelString(chat); // Fallback to chat model for old messages
599
- if (!model) {continue;}
600
-
599
+ if (!model) { continue; }
600
+
601
// Update total tokens
602
chat.totalTokensPrice.input += message.usage.promptTokens || 0;
603
chat.totalTokensPrice.output += message.usage.completionTokens || 0;
604
chat.totalTokensPrice.cacheRead += message.usage.cacheReadInputTokens || 0;
605
chat.totalTokensPrice.cacheCreation += message.usage.cacheCreationInputTokens || 0;
606
-
606
+
607
// Update per-model tokens
608
if (!chat.perModelTokensPrice[model]) {
609
chat.perModelTokensPrice[model] = {
614
totalCost: 0
615
};
616
}
617
-
617
+
618
chat.perModelTokensPrice[model].input += message.usage.promptTokens || 0;
619
chat.perModelTokensPrice[model].output += message.usage.completionTokens || 0;
620
chat.perModelTokensPrice[model].cacheRead += message.usage.cacheReadInputTokens || 0;
621
chat.perModelTokensPrice[model].cacheCreation += message.usage.cacheCreationInputTokens || 0;
622
-
622
+
623
// Add price if available
624
if (message.price !== undefined) {
625
chat.totalTokensPrice.totalCost += message.price;
626
chat.perModelTokensPrice[model].totalCost += message.price;
627
}
628
}
629
-
629
+
630
// Handle accounting nodes
631
if (message.role === 'accounting' && message.cumulativeTokens) {
632
// Add the preserved tokens from accounting node
634
chat.totalTokensPrice.output += message.cumulativeTokens.outputTokens || 0;
635
chat.totalTokensPrice.cacheRead += message.cumulativeTokens.cacheReadTokens || 0;
636
chat.totalTokensPrice.cacheCreation += message.cumulativeTokens.cacheCreationTokens || 0;
637
-
637
+
638
// Note: We can't attribute accounting node tokens to specific models
639
// They represent aggregated tokens from deleted messages
640
}
641
}
642
-
642
+
643
// Add sub-chat costs from tool-results
644
this.aggregateSubChatCostsFromToolResults(chat);
645
}
653
console.error(`[updateParentToolResultCosts] Parent chat ${parentChatId} not found`);
654
return;
655
}
656
-
657
-
656
+
657
+
658
// Find the tool-result in parent messages
659
for (const message of parentChat.messages) {
660
if (message.role === 'tool-results' && message.toolResults) {
665
totalTokens: { ...subChat.totalTokensPrice },
666
perModel: {}
667
};
668
-
668
+
669
// Deep copy per-model costs
670
for (const [model, costs] of Object.entries(subChat.perModelTokensPrice || {})) {
671
toolResult.subChatCosts.perModel[model] = { ...costs };
672
}
673
-
673
+
674
// Save parent chat to persist the updated costs
675
this.autoSave(parentChatId);
676
return;
677
}
678
}
679
}
680
-
680
+
681
console.error(`[updateParentToolResultCosts] Tool result ${toolCallId} not found in parent messages`);
682
}
683
722
}
723
}
724
725
+
726
// Migrate old chat data to include token pricing
727
migrateTokenPricing(chat) {
728
// Initialize structures if not present
735
totalCost: 0
736
};
737
}
737
-
738
+
739
if (!chat.perModelTokensPrice) {
740
chat.perModelTokensPrice = {};
741
}
741
-
742
+
743
// Process all messages to calculate prices
744
for (const message of chat.messages) {
745
if (message.usage && !message.price) {
747
if (!message.model) {
748
message.model = ChatConfig.getChatModelString(chat);
749
}
749
-
750
+
751
// Calculate price
752
if (message.model) {
753
const price = this.calculateMessagePrice(message.model, message.usage);
757
}
758
}
759
}
759
-
760
+
761
// Recalculate cumulative pricing
762
this.updateChatTokenPricing(chat);
762
-
763
+
764
// Save the migrated data
765
this.saveChatToStorage(chat.id);
766
}
770
this.newChatBtn = document.getElementById('newChatBtn');
771
this.newChatBtn.addEventListener('click', () => this.createNewChatDirectly());
772
this.chatSessions = document.getElementById('chatSessions');
772
-
773
+
774
// Event delegation for delete buttons
775
this.chatSessions.addEventListener('click', (e) => {
776
// Cast to Element to help IDE recognize DOM methods
785
}
786
}
787
});
787
-
788
+
789
// Sidebar footer controls
790
this.themeToggle = document.getElementById('themeToggle');
791
this.themeToggle.addEventListener('click', () => this.toggleTheme());
792
this.settingsBtn = document.getElementById('settingsBtn');
793
this.settingsBtn.addEventListener('click', () => this.showModal('settingsModal'));
793
-
794
+
795
// Chat area - Main containers only, not individual chat elements
796
this.chatContainersEl = document.getElementById('chatContainers');
797
this.welcomeScreen = document.getElementById('welcomeScreen');
797
-
798
+
799
// These will be set when switching chats for backward compatibility
800
this.chatTitle = null;
801
this.sendMessageBtn = null;
807
this.currentModelText = null;
808
this.mcpServerDropdown = null;
809
this.currentMcpText = null;
809
-
810
+
811
// Close dropdowns when clicking outside (global handler)
812
document.addEventListener('click', () => {
813
// Close all open dropdowns in all chat containers
824
}
825
});
826
});
826
-
827
+
828
// Log panel
829
this.logPanel = document.getElementById('logPanel');
830
this.toggleLogBtn = document.getElementById('toggleLogBtn');
832
this.clearLogBtn = document.getElementById('clearLogBtn');
833
this.downloadLogBtn = document.getElementById('downloadLogBtn');
834
this.logContent = document.getElementById('logContent');
834
-
835
+
836
this.toggleLogBtn.addEventListener('click', () => this.toggleLog());
837
this.expandLogBtn.addEventListener('click', () => this.toggleLog());
838
this.clearLogBtn.addEventListener('click', () => this.clearLog());
839
this.downloadLogBtn.addEventListener('click', () => this.downloadLog());
839
-
840
+
841
// Sidebar management
842
this.chatSidebar = document.getElementById('chatSidebar');
843
this.toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
843
-
844
+
845
// Set up sidebar toggle button
846
this.toggleSidebarBtn.addEventListener('click', () => this.toggleChatSidebar());
846
-
847
+
848
// Load sidebar states from localStorage
849
this.loadSidebarStates();
849
-
850
+
851
// Temperature control - will be set when switching chats
851
-
852
+
853
// Settings modal
854
this.settingsModal = document.getElementById('settingsModal');
855
this.setupModal('settingsModal', 'settingsBackdrop', 'closeSettingsBtn');
855
-
856
+
857
// Settings lists
858
this.mcpServersList = document.getElementById('mcpServersList');
859
this.addMcpServerBtn = document.getElementById('addMcpServerBtn');
859
-
860
+
861
this.addMcpServerBtn.addEventListener('click', () => this.showModal('addMcpModal'));
861
-
862
+
863
// New chat modal - no longer used, kept for potential future use
864
// this.setupModal('newChatModal', 'newChatBackdrop', 'closeNewChatBtn');
865
// this.newChatMcpServer = document.getElementById('newChatMcpServer');
873
// this.newChatLlmProvider.addEventListener('change', () => this.updateNewChatModels());
874
// this.createChatBtn.addEventListener('click', () => this.createNewChat());
875
// this.cancelNewChatBtn.addEventListener('click', () => this.hideModal('newChatModal'));
875
-
876
+
877
// Add MCP server modal
878
this.setupModal('addMcpModal', 'addMcpBackdrop', 'closeAddMcpBtn');
879
this.mcpServerUrl = document.getElementById('mcpServerUrl');
880
this.mcpServerName = document.getElementById('mcpServerName');
881
this.saveMcpServerBtn = document.getElementById('saveMcpServerBtn');
882
this.cancelAddMcpBtn = document.getElementById('cancelAddMcpBtn');
882
-
883
+
884
this.saveMcpServerBtn.addEventListener('click', () => this.addMcpServer());
885
this.cancelAddMcpBtn.addEventListener('click', () => this.hideModal('addMcpModal'));
885
-
886
+
887
// System prompt modal controls
888
this.systemPromptModal = document.getElementById('systemPromptModal');
889
this.systemPromptTextarea = document.getElementById('systemPromptTextarea');
892
this.cancelSystemPromptBtn = document.getElementById('cancelSystemPromptBtn');
893
this.saveSystemPromptBtn = document.getElementById('saveSystemPromptBtn');
894
this.resetToDefaultPromptBtn = document.getElementById('resetToDefaultPromptBtn');
894
-
895
+
896
this.closeSystemPromptBtn.addEventListener('click', () => this.hideModal('systemPromptModal'));
897
this.systemPromptBackdrop.addEventListener('click', () => this.hideModal('systemPromptModal'));
898
this.cancelSystemPromptBtn.addEventListener('click', () => this.hideModal('systemPromptModal'));
905
});
906
this.resetToDefaultPromptBtn.addEventListener('click', () => {
907
this.systemPromptTextarea.value = this.defaultSystemPrompt;
908
+ // Also reset the lastSystemPrompt so new chats get the default
909
+ this.lastSystemPrompt = this.defaultSystemPrompt;
910
+ localStorage.removeItem('lastSystemPrompt');
911
});
908
-
912
+
913
// Auto-generate server name from URL
914
this.mcpServerUrl.addEventListener('input', () => {
915
if (!this.mcpServerName.value) {
921
}
922
}
923
});
920
-
924
+
925
// Tooltips are now CSS-only, no initialization needed
922
-
926
+
927
// Setup no models modal
928
this.noModelsModal = document.getElementById('noModelsModal');
929
this.noModelsBackdrop = document.getElementById('noModelsBackdrop');
930
this.noModelsProxyUrl = document.getElementById('noModelsProxyUrl');
931
this.retryModelsBtn = document.getElementById('retryModelsBtn');
928
-
932
+
933
// Retry button handler
934
this.retryModelsBtn.addEventListener('click', async () => {
935
this.hideModal('noModelsModal');
940
setupModal(modalId, backdropId, closeId) {
941
const backdrop = document.getElementById(backdropId);
942
const closeBtn = document.getElementById(closeId);
939
-
943
+
944
backdrop.addEventListener('click', () => this.hideModal(modalId));
945
closeBtn.addEventListener('click', () => this.hideModal(modalId));
946
}
953
hideModal(modalId) {
954
document.getElementById(modalId).classList.remove('show');
955
}
952
-
956
+
957
showNoModelsModal(proxyUrl) {
958
// Update the proxy URL in the modal
959
this.noModelsProxyUrl.textContent = proxyUrl;
956
-
960
+
961
// Show the modal
962
this.showModal('noModelsModal');
959
-
963
+
964
// Disable the backdrop click since we don't want users to close it
965
this.noModelsBackdrop.onclick = null;
966
}
963
-
967
+
968
validateChatModels() {
969
// Validate each chat's model
970
for (const [chatId, chat] of this.chats) {
972
if (!chat.config || !chat.config.model) {
973
continue;
974
}
971
-
975
+
976
if (chat.llmProviderId) {
977
const provider = this.llmProviders.get(chat.llmProviderId);
978
if (provider && provider.availableProviders) {
980
let modelExists = false;
981
const providerType = chat.config.model.provider;
982
const modelName = chat.config.model.id;
979
-
983
+
984
if (providerType && modelName && provider.availableProviders[providerType]) {
985
const models = provider.availableProviders[providerType].models || [];
986
modelExists = models.some(m => {
988
return mId === modelName;
989
});
990
}
987
-
991
+
992
if (!modelExists) {
993
const oldModelString = ChatConfig.modelConfigToString(chat.config.model);
994
console.error(`Chat ${chatId} has invalid model ${oldModelString}. Model not found in available providers.`);
991
-
995
+
996
// Mark the chat as having an invalid model
997
chat.hasInvalidModel = true;
994
-
998
+
999
// DO NOT automatically reset or save!
1000
// The user must manually select a valid model
1001
}
1003
}
1004
}
1005
}
1002
-
1006
+
1007
/**
1008
* Update the model display in the UI for a chat
1009
*/
1010
updateModelDisplay(chat) {
1011
const chatId = chat.id;
1012
const container = this.getChatContainer(chatId);
1009
- if (!container || !container._elements) {return;}
1010
-
1013
+ if (!container || !container._elements) { return; }
1014
+
1015
const elements = container._elements;
1016
const provider = this.llmProviders.get(chat.llmProviderId);
1013
-
1017
+
1018
// Update LLM model display
1019
if (provider && chat.config?.model?.id) {
1020
const modelDisplay = chat.config.model.id;
1033
}
1034
}
1035
}
1032
-
1036
+
1037
isModelValid(model, provider) {
1034
- if (!model || !provider || !provider.availableProviders) {return false;}
1035
-
1038
+ if (!model || !provider || !provider.availableProviders) { return false; }
1039
+
1040
// Handle both string format and config object
1041
let providerType, modelName;
1042
if (typeof model === 'string') {
1049
} else {
1050
return false;
1051
}
1048
-
1049
- if (!providerType || !modelName || !provider.availableProviders[providerType]) {return false;}
1050
-
1052
+
1053
+ if (!providerType || !modelName || !provider.availableProviders[providerType]) { return false; }
1054
+
1055
const models = provider.availableProviders[providerType].models || [];
1056
return models.some(m => {
1057
const mId = typeof m === 'string' ? m : m.id;
1058
return mId === modelName;
1059
});
1060
}
1057
-
1061
+
1062
populateModelDropdown(chatId, dropdown = null, buttonElement = null) {
1063
if (!chatId) {
1064
console.error('[populateModelDropdown] Called without chatId');
1066
}
1067
const targetChatId = chatId;
1068
let targetDropdown = dropdown || this.llmModelDropdown;
1065
-
1069
+
1070
const chat = this.chats.get(targetChatId);
1071
if (!chat) {
1072
console.error(`[showModelSelector] Chat not found for chatId: ${targetChatId}`);
1073
return;
1074
}
1071
-
1075
+
1076
const provider = this.llmProviders.get(chat.llmProviderId);
1077
if (!provider || !provider.availableProviders) {
1078
console.error(`[showModelSelector] Provider not found or has no available providers for providerId: ${chat.llmProviderId}`, { provider, hasAvailableProviders: provider?.availableProviders });
1079
return;
1080
}
1077
-
1081
+
1082
// Create a modal overlay instead of using the dropdown
1083
const overlay = document.createElement('div');
1084
overlay.className = 'model-selector-overlay';
1091
background: rgba(0, 0, 0, 0.5);
1092
z-index: 9999;
1093
`;
1090
-
1094
+
1095
// Get button position for dropdown-like positioning
1096
const buttonRect = buttonElement ? buttonElement.getBoundingClientRect() : null;
1093
-
1097
+
1098
const modalContent = document.createElement('div');
1099
modalContent.style.cssText = `
1100
width: 900px !important;
1110
display: flex;
1111
flex-direction: column;
1112
`;
1109
-
1113
+
1114
// Position the modal like a dropdown
1115
if (buttonRect) {
1116
// Position below the button
1117
const spaceBelow = window.innerHeight - buttonRect.bottom;
1118
const spaceAbove = buttonRect.top;
1115
-
1119
+
1120
if (spaceBelow >= 400 || spaceBelow > spaceAbove) {
1121
// Show below button
1122
modalContent.style.top = `${buttonRect.bottom + 5}px`;
1126
modalContent.style.bottom = `${window.innerHeight - buttonRect.top + 5}px`;
1127
modalContent.style.top = 'auto';
1128
}
1125
-
1129
+
1130
// Center horizontally relative to button
1131
const modalWidth = 900;
1132
const buttonCenter = buttonRect.left + (buttonRect.width / 2);
1133
let left = buttonCenter - (modalWidth / 2);
1130
-
1134
+
1135
// Keep within viewport bounds
1136
if (left < 10) left = 10;
1137
if (left + modalWidth > window.innerWidth - 10) {
1138
left = window.innerWidth - modalWidth - 10;
1139
}
1136
-
1140
+
1141
modalContent.style.left = `${left}px`;
1142
} else {
1143
// Fallback to center if no button provided
1145
modalContent.style.left = '50%';
1146
modalContent.style.transform = 'translate(-50%, -50%)';
1147
}
1144
-
1148
+
1149
// Close when clicking overlay
1150
overlay.addEventListener('click', (e) => {
1151
if (e.target === overlay) {
1155
this.updateChatSessions();
1156
}
1157
});
1154
-
1158
+
1159
// Prevent clicks inside modal from closing
1160
modalContent.addEventListener('click', (e) => {
1161
e.stopPropagation();
1162
});
1159
-
1163
+
1164
overlay.appendChild(modalContent);
1165
document.body.appendChild(overlay);
1162
-
1166
+
1167
// Use modalContent as our target for populating
1168
targetDropdown = modalContent;
1165
-
1169
+
1170
// Get current config
1171
const config = chat.config || ChatConfig.loadChatConfig(chatId);
1168
-
1172
+
1173
// Ensure the config is assigned to the chat object
1174
if (!chat.config) {
1175
chat.config = config;
1176
}
1173
-
1177
+
1178
// Create header section (fixed)
1179
const headerSection = document.createElement('div');
1180
headerSection.style.cssText = `
1184
border-bottom: 1px solid var(--border-color);
1185
background: var(--surface-color);
1186
`;
1183
-
1187
+
1188
// Add title
1189
const headerTitle = document.createElement('h3');
1190
headerTitle.style.cssText = `
1195
`;
1196
headerTitle.textContent = 'Model & Optimization Settings';
1197
headerSection.appendChild(headerTitle);
1194
-
1198
+
1199
// Add close button at the top
1200
const closeButton = document.createElement('button');
1201
closeButton.style.cssText = `
1232
});
1233
headerSection.appendChild(closeButton);
1234
targetDropdown.appendChild(headerSection);
1231
-
1235
+
1236
// Create scrollable content container
1237
const contentContainer = document.createElement('div');
1238
contentContainer.style.cssText = `
1243
scrollbar-width: thin;
1244
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
1245
`;
1242
-
1246
+
1247
// Add cost optimization settings section to content container
1248
this.addCostOptimizationSection(contentContainer, chatId, config);
1249
targetDropdown.appendChild(contentContainer);
1246
-
1250
+
1251
// Add footer section with cost estimation
1252
// Footer section removed - no cost estimation needed
1253
}
1259
background: var(--surface-color);
1260
border-bottom: 1px solid var(--border-color);
1261
`;
1258
-
1262
+
1263
section.innerHTML = `
1264
<div style="font-weight: 600; font-size: 13px; margin-bottom: 8px; color: var(--text-primary);">
1265
Cost Optimizations
1266
</div>
1267
`;
1264
-
1268
+
1269
const chat = this.chats.get(chatId);
1270
if (!chat) {
1271
console.error('addCostOptimizationSection: Chat not found for ID:', chatId);
1272
return;
1273
}
1270
-
1274
+
1275
// Get all available models - removed as unused
1276
// const allModels = this.getAllAvailableModels();
1273
-
1277
+
1278
// Chat Model Selection with Max Tokens
1279
const chatModelDiv = document.createElement('div');
1280
chatModelDiv.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;';
1277
-
1281
+
1282
const currentMaxTokens = chat.config.model.params.maxTokens;
1283
chatModelDiv.innerHTML = `
1284
<span>Chat with</span>
1293
</button>
1294
</div>
1295
1292
- <div style="display: flex; align-items: center; gap: 4px; margin-left: auto;">
1293
- <label style="font-size: 12px; color: var(--text-secondary);">max output tokens:</label>
1294
- <select id="maxTokens_${chatId}"
1295
- style="padding: 2px 6px; border: 1px solid var(--border-color);
1296
- border-radius: 4px; background: var(--background-color);
1297
- color: var(--text-primary); font-size: 12px;">
1298
- <option value="1024" ${currentMaxTokens === 1024 ? 'selected' : ''}>1k</option>
1299
- <option value="2048" ${currentMaxTokens === 2048 ? 'selected' : ''}>2k</option>
1300
- <option value="4096" ${currentMaxTokens === 4096 ? 'selected' : ''}>4k</option>
1301
- <option value="8192" ${currentMaxTokens === 8192 ? 'selected' : ''}>8k</option>
1302
- <option value="16384" ${currentMaxTokens === 16384 ? 'selected' : ''}>16k</option>
1303
- <option value="32768" ${currentMaxTokens === 32768 ? 'selected' : ''}>32k</option>
1304
- <option value="65536" ${currentMaxTokens === 65536 ? 'selected' : ''}>64k</option>
1305
- <option value="131072" ${currentMaxTokens === 131072 ? 'selected' : ''}>128k</option>
1306
- </select>
1296
+ <div style="display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap;">
1297
+ <div style="display: flex; align-items: center; gap: 4px;">
1298
+ <label style="font-size: 12px; color: var(--text-secondary);">max output tokens:</label>
1299
+ <input type="text" id="maxTokens_${chatId}"
1300
+ list="maxTokensList_${chatId}"
1301
+ value="${currentMaxTokens}"
1302
+ style="width: 70px; padding: 2px 6px; border: 1px solid var(--border-color);
1303
+ border-radius: 4px; background: var(--background-color);
1304
+ color: var(--text-primary); font-size: 12px;">
1305
+ <datalist id="maxTokensList_${chatId}">
1306
+ <option value="1024"></option>
1307
+ <option value="2048"></option>
1308
+ <option value="4096"></option>
1309
+ <option value="8192"></option>
1310
+ <option value="16384"></option>
1311
+ <option value="32768"></option>
1312
+ <option value="65536"></option>
1313
+ <option value="131072"></option>
1314
+ </datalist>
1315
+ </div>
1316
+ <div id="contextWindowControl_${chatId}" style="display: ${this.shouldShowContextWindowControl(chat) ? 'flex' : 'none'}; align-items: center; gap: 4px;">
1317
+ <label style="font-size: 12px; color: var(--text-secondary);">context window:</label>
1318
+ <input type="text" id="contextWindow_${chatId}"
1319
+ list="contextWindowList_${chatId}"
1320
+ value="${chat.config?.model?.params?.contextWindow || this.getDefaultContextWindow(chat)}"
1321
+ style="width: 70px; padding: 2px 6px; border: 1px solid var(--border-color);
1322
+ border-radius: 4px; background: var(--background-color);
1323
+ color: var(--text-primary); font-size: 12px;">
1324
+ <datalist id="contextWindowList_${chatId}">
1325
+ ${this.getContextWindowDatalistOptions(chat)}
1326
+ </datalist>
1327
+ </div>
1328
</div>
1329
`;
1330
section.appendChild(chatModelDiv);
1310
-
1331
+
1332
// Tool Summarization Option
1333
const toolSumDiv = document.createElement('div');
1334
const _isEnabled = true; // Feature is now implemented
1335
toolSumDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px;`;
1315
-
1336
+
1337
const currentThreshold = chat.config.optimisation.toolSummarisation.thresholdKiB ?? 20; // Default 20KB, allow 0
1338
const toolSumModel = ChatConfig.modelConfigToString(chat.config.optimisation.toolSummarisation.model) || ChatConfig.getChatModelString(chat);
1318
-
1339
+
1340
toolSumDiv.innerHTML = `
1341
<label style="display: flex; align-items: center; cursor: pointer;">
1342
<input type="checkbox" id="toolSummarization_${chatId}" ${_isEnabled ? '' : 'disabled'}
1373
</button>
1374
</div>
1375
`;
1355
-
1376
+
1377
section.appendChild(toolSumDiv);
1357
-
1378
+
1379
// Auto-summarization Option
1380
const autoSumDiv = document.createElement('div');
1381
const _autoSumEnabled = true; // Auto-summarization is now implemented
1382
autoSumDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px;`;
1362
-
1383
+
1384
const currentPercent = chat.config.optimisation.autoSummarisation.triggerPercent || 50;
1385
const autoSumModel = ChatConfig.modelConfigToString(chat.config.optimisation.autoSummarisation.model) || ChatConfig.getChatModelString(chat);
1365
-
1386
+
1387
autoSumDiv.innerHTML = `
1388
<label style="display: flex; align-items: center; cursor: pointer;">
1389
<input type="checkbox" id="autoSummarization_${chatId}" ${chat.config.optimisation.autoSummarisation.enabled ? 'checked' : ''}
1414
</button>
1415
</div>
1416
`;
1396
-
1417
+
1418
section.appendChild(autoSumDiv);
1398
-
1419
+
1420
// Title Generation Option
1421
const titleGenDiv = document.createElement('div');
1422
const titleGenEnabled = chat.config.optimisation.titleGeneration?.enabled !== false; // Default to true
1423
titleGenDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; ${!titleGenEnabled ? 'opacity: 0.5;' : ''}`;
1403
-
1424
+
1425
const titleGenModel = ChatConfig.modelConfigToString(chat.config.optimisation.titleGeneration?.model);
1405
-
1426
+
1427
titleGenDiv.innerHTML = `
1428
<label style="display: flex; align-items: center; cursor: pointer;">
1429
<input type="checkbox" id="titleGeneration_${chatId}" ${titleGenEnabled ? 'checked' : ''}
1442
</button>
1443
</div>
1444
`;
1424
-
1445
+
1446
section.appendChild(titleGenDiv);
1426
-
1447
+
1448
// Tool Memory Option
1449
const toolMemoryDiv = document.createElement('div');
1450
const toolMemoryEnabled = chat.config.optimisation.toolMemory.enabled;
1451
toolMemoryDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; ${!toolMemoryEnabled ? 'opacity: 0.5;' : ''}`;
1431
-
1452
+
1453
const forgetAfterConclusions = chat.config.optimisation.toolMemory.forgetAfterConclusions;
1433
-
1454
+
1455
toolMemoryDiv.innerHTML = `
1456
<label style="display: flex; align-items: center; cursor: pointer;">
1457
<input type="checkbox" id="toolMemory_${chatId}" ${toolMemoryEnabled ? 'checked' : ''}
1470
</select>
1471
<span>times</span>
1472
`;
1452
-
1473
+
1474
section.appendChild(toolMemoryDiv);
1454
-
1455
- // Cache Control Option (for Anthropic provider)
1456
- const isAnthropicProvider = chat.config.model && chat.config.model.provider === 'anthropic';
1475
+
1476
+ // Cache Control Option (for providers that support it)
1477
+ // Get the provider API type to determine cache support
1478
+ const providerType = chat.config.model?.provider;
1479
+ const provider = this.llmProviders.get(chat.llmProviderId);
1480
+ const providerApiType = provider?.availableProviders?.[providerType]?.type || providerType;
1481
+ const supportsCacheControl = providerApiType === 'anthropic';
1482
+
1483
const cacheControlDiv = document.createElement('div');
1484
const cacheControlMode = chat.config.optimisation.cacheControl;
1459
- const cacheControlDisabled = !isAnthropicProvider;
1485
+ const cacheControlDisabled = !supportsCacheControl;
1486
cacheControlDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; ${cacheControlDisabled ? 'opacity: 0.5;' : ''}`;
1461
-
1487
+
1488
cacheControlDiv.innerHTML = `
1489
<label style="display: flex; align-items: center;">
1490
<span>Cache control:</span>
1498
<option value="system" ${cacheControlMode === 'system' ? 'selected' : ''}>System</option>
1499
<option value="cached" ${cacheControlMode === 'cached' ? 'selected' : ''}>Cached</option>
1500
</select>
1475
- ${!isAnthropicProvider ? '<span style="color: var(--text-secondary); font-size: 12px;">Anthropic only</span>' : ''}
1501
+ ${!supportsCacheControl ? '<span style="color: var(--text-secondary); font-size: 12px;">Anthropic only</span>' : ''}
1502
`;
1477
-
1503
+
1504
section.appendChild(cacheControlDiv);
1479
-
1505
+
1506
// Temperature and TopP Controls
1507
const paramsDiv = document.createElement('div');
1508
paramsDiv.style.cssText = 'margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-color);';
1483
-
1509
+
1510
const currentTemp = chat.config.model.params.temperature;
1511
const currentTopP = chat.config.model.params.topP;
1486
-
1512
+
1513
paramsDiv.innerHTML = `
1514
<div style="display: flex; flex-direction: column; gap: 12px;">
1515
<!-- Temperature Control -->
1541
</div>
1542
</div>
1543
`;
1518
-
1544
+
1545
section.appendChild(paramsDiv);
1520
-
1546
+
1547
// Temperature and TopP event listeners
1548
const tempSlider = paramsDiv.querySelector(`#temperature_${chatId}`);
1549
const tempValueLabel = paramsDiv.querySelector(`#tempValue_${chatId}`);
1550
const topPSlider = paramsDiv.querySelector(`#topP_${chatId}`);
1551
const topPValueLabel = paramsDiv.querySelector(`#topPValue_${chatId}`);
1526
-
1552
+
1553
tempSlider.addEventListener('input', (e) => {
1554
const value = parseFloat(e.target.value);
1555
tempValueLabel.textContent = value.toFixed(1);
1556
});
1531
-
1557
+
1558
tempSlider.addEventListener('change', (e) => {
1559
chat.config.model.params.temperature = parseFloat(e.target.value);
1560
this.saveChatConfigSmart(chatId, chat.config);
1561
this.autoSave(chatId);
1562
});
1537
-
1563
+
1564
topPSlider.addEventListener('input', (e) => {
1565
const value = parseFloat(e.target.value);
1566
topPValueLabel.textContent = value.toFixed(2);
1567
});
1542
-
1568
+
1569
topPSlider.addEventListener('change', (e) => {
1570
chat.config.model.params.topP = parseFloat(e.target.value);
1571
this.saveChatConfigSmart(chatId, chat.config);
1572
this.autoSave(chatId);
1573
});
1548
-
1574
+
1575
section.appendChild(document.createElement('div')); // spacer
1550
-
1576
+
1577
// Initialize model selection buttons
1578
this.initializeModelSelectionButtons(section, chatId, chat, config);
1553
-
1579
+
1580
// Add event listeners
1581
const toolSumCheckbox = section.querySelector(`#toolSummarization_${chatId}`);
1582
const thresholdSelect = section.querySelector(`#toolThreshold_${chatId}`);
1583
const toolModelBtn = section.querySelector(`#toolSumModel_${chatId}`);
1558
-
1584
+
1585
toolSumCheckbox.addEventListener('change', (e) => {
1586
e.stopPropagation();
1587
const enabled = toolSumCheckbox.checked;
1590
toolSumDiv.style.opacity = enabled ? '1' : '0.5';
1591
this.updateOptimizationSetting(chatId, 'toolSummarization', enabled);
1592
});
1567
-
1593
+
1594
thresholdSelect.addEventListener('change', (e) => {
1595
e.stopPropagation();
1596
const kbValue = parseInt(e.target.value, 10);
1598
const byteValue = validKbValue * 1024;
1599
this.updateToolThreshold(chatId, byteValue);
1600
});
1575
-
1601
+
1602
// Auto-summarization controls
1603
const autoSumCheckbox = section.querySelector(`#autoSummarization_${chatId}`);
1604
const autoSumSelect = section.querySelector(`#autoSumThreshold_${chatId}`);
1605
const autoModelBtn = section.querySelector(`#autoSumModel_${chatId}`);
1580
-
1606
+
1607
autoSumCheckbox.addEventListener('change', (e) => {
1608
e.stopPropagation();
1609
const enabled = autoSumCheckbox.checked;
1612
autoSumDiv.style.opacity = enabled ? '1' : '0.5';
1613
this.updateOptimizationSetting(chatId, 'autoSummarization', enabled);
1614
});
1589
-
1615
+
1616
autoSumSelect.addEventListener('change', (e) => {
1617
e.stopPropagation();
1618
const percent = parseInt(e.target.value, 10) || 50;
1619
this.updateAutoSumThreshold(chatId, percent);
1620
});
1595
-
1621
+
1622
// Title Generation controls
1623
const titleGenCheckbox = section.querySelector(`#titleGeneration_${chatId}`);
1624
const titleModelBtn = section.querySelector(`#titleGenModel_${chatId}`);
1599
-
1625
+
1626
titleGenCheckbox.addEventListener('change', (e) => {
1627
e.stopPropagation();
1628
const enabled = titleGenCheckbox.checked;
1630
titleGenDiv.style.opacity = enabled ? '1' : '0.5';
1631
this.updateOptimizationSetting(chatId, 'titleGeneration', enabled);
1632
});
1607
-
1633
+
1634
// Tool Memory controls
1635
const toolMemoryCheckbox = section.querySelector(`#toolMemory_${chatId}`);
1636
const toolMemorySelect = section.querySelector(`#toolMemoryThreshold_${chatId}`);
1611
-
1637
+
1638
toolMemoryCheckbox.addEventListener('change', (e) => {
1639
e.stopPropagation();
1640
const enabled = toolMemoryCheckbox.checked;
1641
toolMemorySelect.disabled = !enabled;
1642
toolMemoryDiv.style.opacity = enabled ? '1' : '0.5';
1617
-
1643
+
1644
// Cache control is no longer mutually exclusive with tool memory
1619
-
1645
+
1646
this.updateOptimizationSetting(chatId, 'toolMemory', enabled);
1647
});
1622
-
1648
+
1649
toolMemorySelect.addEventListener('change', (e) => {
1650
e.stopPropagation();
1651
const newForgetAfterConclusions = parseInt(e.target.value, 10);
1652
this.updateToolMemoryThreshold(chatId, newForgetAfterConclusions);
1653
});
1628
-
1654
+
1655
+ // Max tokens input event listener
1656
+ const maxTokensInput = section.querySelector(`#maxTokens_${chatId}`);
1657
+ if (maxTokensInput) {
1658
+ // Handle both manual input and datalist selection
1659
+ maxTokensInput.addEventListener('change', (e) => {
1660
+ e.stopPropagation();
1661
+ const newMaxTokens = parseInt(e.target.value, 10);
1662
+ if (!isNaN(newMaxTokens) && newMaxTokens > 0) {
1663
+ chat.config.model.params.maxTokens = newMaxTokens;
1664
+ this.saveChatConfigSmart(chatId, chat.config);
1665
+ this.autoSave(chatId);
1666
+ }
1667
+ });
1668
+ // Also handle when user presses Enter
1669
+ maxTokensInput.addEventListener('keypress', (e) => {
1670
+ if (e.key === 'Enter') {
1671
+ e.stopPropagation();
1672
+ e.preventDefault();
1673
+ const newMaxTokens = parseInt(e.target.value, 10);
1674
+ if (!isNaN(newMaxTokens) && newMaxTokens > 0) {
1675
+ chat.config.model.params.maxTokens = newMaxTokens;
1676
+ this.saveChatConfigSmart(chatId, chat.config);
1677
+ this.autoSave(chatId);
1678
+ }
1679
+ }
1680
+ });
1681
+ }
1682
+
1683
+ // Context window input event listener (Ollama only)
1684
+ const contextWindowInput = section.querySelector(`#contextWindow_${chatId}`);
1685
+ if (contextWindowInput) {
1686
+ // Handle both manual input and datalist selection
1687
+ contextWindowInput.addEventListener('change', (e) => {
1688
+ e.stopPropagation();
1689
+ const newContextWindow = parseInt(e.target.value, 10);
1690
+ if (!isNaN(newContextWindow) && newContextWindow > 0) {
1691
+ chat.config.model.params.contextWindow = newContextWindow;
1692
+ this.saveChatConfigSmart(chatId, chat.config);
1693
+ this.autoSave(chatId);
1694
+ // Update the header immediately
1695
+ this.updateContextWindowIndicator(chatId);
1696
+ }
1697
+ });
1698
+ // Also handle when user presses Enter
1699
+ contextWindowInput.addEventListener('keypress', (e) => {
1700
+ if (e.key === 'Enter') {
1701
+ e.stopPropagation();
1702
+ e.preventDefault();
1703
+ const newContextWindow = parseInt(e.target.value, 10);
1704
+ if (!isNaN(newContextWindow) && newContextWindow > 0) {
1705
+ chat.config.model.params.contextWindow = newContextWindow;
1706
+ this.saveChatConfigSmart(chatId, chat.config);
1707
+ this.autoSave(chatId);
1708
+ // Update the header immediately
1709
+ this.updateContextWindowIndicator(chatId);
1710
+ }
1711
+ }
1712
+ });
1713
+ }
1714
+
1715
// Cache control dropdown event listener
1716
const cacheControlSelect = section.querySelector(`#cacheControl_${chatId}`);
1717
if (cacheControlSelect) {
1721
this.updateCacheControlMode(chatId, newCacheMode);
1722
});
1723
}
1638
-
1724
+
1725
// Other checkboxes (smart filtering, cache control)
1726
section.querySelectorAll('input[type="checkbox"]:not(#toolSummarization_' + chatId + '):not(#autoSummarization_' + chatId + ')').forEach(checkbox => {
1727
checkbox.addEventListener('change', (e) => {
1729
this.updateOptimizationSetting(chatId, checkbox.id.split('_')[0], checkbox.checked);
1730
});
1731
});
1646
-
1732
+
1733
section.querySelectorAll('label').forEach(label => {
1734
label.addEventListener('click', (e) => {
1735
e.stopPropagation();
1736
});
1737
});
1652
-
1738
+
1739
dropdown.appendChild(section);
1740
}
1741
1745
if (limit >= 1000) return `${(limit / 1000).toFixed(0)}k`;
1746
return limit.toString();
1747
}
1662
-
1748
+
1749
+ /**
1750
+ * Check if context window control should be shown for this chat
1751
+ * @param {Object} chat - The chat object
1752
+ * @returns {boolean} True if context window control should be shown
1753
+ */
1754
+ shouldShowContextWindowControl(chat) {
1755
+ if (!chat || !chat.config || !chat.config.model) {
1756
+ return false;
1757
+ }
1758
+ // Show context window control only for Ollama provider
1759
+ const provider = chat.config.model.provider;
1760
+ return provider === 'ollama';
1761
+ }
1762
+
1763
+ /**
1764
+ * Update context window visibility dynamically
1765
+ * @param {string} chatId - Chat ID
1766
+ * @param {Object} chat - The chat object
1767
+ */
1768
+ updateContextWindowVisibility(chatId, chat) {
1769
+ const contextWindowControl = document.querySelector(`#contextWindowControl_${chatId}`);
1770
+ if (contextWindowControl) {
1771
+ const shouldShow = this.shouldShowContextWindowControl(chat);
1772
+ contextWindowControl.style.display = shouldShow ? 'flex' : 'none';
1773
+
1774
+ // If showing, update the datalist options for the new model
1775
+ if (shouldShow) {
1776
+ const datalist = document.querySelector(`#contextWindowList_${chatId}`);
1777
+ if (datalist) {
1778
+ datalist.innerHTML = this.getContextWindowDatalistOptions(chat);
1779
+ }
1780
+ // Set default value if not already set
1781
+ const input = document.querySelector(`#contextWindow_${chatId}`);
1782
+ if (input && !chat.config?.model?.params?.contextWindow) {
1783
+ input.value = this.getDefaultContextWindow(chat);
1784
+ }
1785
+ }
1786
+ }
1787
+ }
1788
+
1789
+ /**
1790
+ * Get default context window for a chat
1791
+ * @param {Object} chat - The chat object
1792
+ * @returns {number} Default context window size
1793
+ */
1794
+ getDefaultContextWindow(chat) {
1795
+ if (!chat || !chat.config || !chat.config.model) {
1796
+ return 128000;
1797
+ }
1798
+ const modelId = chat.config.model.id;
1799
+ return this.modelLimits[modelId] || 128000;
1800
+ }
1801
+
1802
+ /**
1803
+ * Get the effective context window for a chat
1804
+ * @param {Object} chat - The chat object
1805
+ * @returns {number} The effective context window size (user-configured or model limit)
1806
+ */
1807
+ getEffectiveContextWindow(chat) {
1808
+ // If chat has an explicitly configured context window, use it
1809
+ if (chat?.config?.model?.params?.contextWindow) {
1810
+ return chat.config.model.params.contextWindow;
1811
+ }
1812
+ // Otherwise use the model's limit
1813
+ return this.getDefaultContextWindow(chat);
1814
+ }
1815
+
1816
+ /**
1817
+ * Generate context window datalist options
1818
+ * @param {Object} chat - The chat object
1819
+ * @returns {string} HTML string with option elements for datalist
1820
+ */
1821
+ getContextWindowDatalistOptions(chat) {
1822
+ if (!chat || !chat.config || !chat.config.model) {
1823
+ return '<option value="128000"></option>';
1824
+ }
1825
+
1826
+ const modelId = chat.config.model.id;
1827
+ const maxContext = this.modelLimits[modelId] || 128000;
1828
+
1829
+ // Generate options based on max context window
1830
+ const options = [];
1831
+ const values = [2048, 4096, 8192, 16384, 32768, 65536, 128000, 256000, 512000, 1000000, 2000000];
1832
+
1833
+ for (const value of values) {
1834
+ if (value <= maxContext) {
1835
+ options.push(`<option value="${value}"></option>`);
1836
+ }
1837
+ }
1838
+
1839
+ // If max context is not in our list, add it
1840
+ if (!values.includes(maxContext)) {
1841
+ options.push(`<option value="${maxContext}"></option>`);
1842
+ }
1843
+
1844
+ return options.join('');
1845
+ }
1846
+
1847
+ /**
1848
+ * Generate context window dropdown options
1849
+ * @param {Object} chat - The chat object
1850
+ * @returns {string} HTML string with option elements
1851
+ */
1852
+ getContextWindowOptions(chat) {
1853
+ if (!chat || !chat.config || !chat.config.model) {
1854
+ return '<option value="128000">128k</option>';
1855
+ }
1856
+
1857
+ const modelId = chat.config.model.id;
1858
+ const maxContext = this.modelLimits[modelId] || 128000;
1859
+ const currentContextWindow = chat.config.model.params.contextWindow || maxContext;
1860
+
1861
+ // Generate options based on max context window
1862
+ const options = [];
1863
+ const values = [2048, 4096, 8192, 16384, 32768, 65536, 128000, 256000, 512000, 1000000, 2000000];
1864
+
1865
+ for (const value of values) {
1866
+ if (value <= maxContext) {
1867
+ const label = this.formatContextWindow(value);
1868
+ const selected = value === currentContextWindow ? 'selected' : '';
1869
+ options.push(`<option value="${value}" ${selected}>${label}</option>`);
1870
+ }
1871
+ }
1872
+
1873
+ // If max context is not in our list, add it
1874
+ if (!values.includes(maxContext)) {
1875
+ const label = this.formatContextWindow(maxContext);
1876
+ const selected = maxContext === currentContextWindow ? 'selected' : '';
1877
+ options.push(`<option value="${maxContext}" ${selected}>${label} (max)</option>`);
1878
+ }
1879
+
1880
+ return options.join('');
1881
+ }
1882
+
1883
/**
1884
* Create a formatted HTML tooltip for model information
1885
* @param {Object} chat - The chat object
1889
if (!chat || !chat.config || !chat.config.model) {
1890
return 'No model configured';
1891
}
1672
-
1892
+
1893
const config = chat.config;
1894
const modelString = ChatConfig.modelConfigToString(config.model);
1895
const modelInfo = this.modelPricing[config.model.id] || {};
1896
const contextLimit = this.modelLimits[config.model.id] || 128000;
1677
-
1897
+
1898
// Get MCP server name
1899
const mcpServer = this.mcpServers.get(config.mcpServer);
1900
const mcpServerName = mcpServer ? mcpServer.name : config.mcpServer;
1681
-
1901
+
1902
// Get optimization models
1683
- const toolSumModel = config.optimisation.toolSummarisation.model ?
1684
- ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.toolSummarisation.model)) :
1903
+ const toolSumModel = config.optimisation.toolSummarisation.model ?
1904
+ ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.toolSummarisation.model)) :
1905
'Primary';
1686
- const autoSumModel = config.optimisation.autoSummarisation.model ?
1687
- ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.autoSummarisation.model)) :
1906
+ const autoSumModel = config.optimisation.autoSummarisation.model ?
1907
+ ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.autoSummarisation.model)) :
1908
'Primary';
1689
- const titleGenModel = config.optimisation.titleGeneration.model ?
1690
- ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.titleGeneration.model)) :
1909
+ const titleGenModel = config.optimisation.titleGeneration.model ?
1910
+ ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.titleGeneration.model)) :
1911
'Primary';
1692
-
1912
+
1913
// Format prices more compactly with bold
1914
const formatPrice = (price) => {
1915
if (price === undefined || price === null) return 'N/A';
1916
return `<b>$${price.toFixed(2)}</b>`;
1917
};
1698
-
1918
+
1919
// Helper to show enabled/disabled status compactly
1700
- const status = (enabled) => enabled ?
1701
- '<span style="color: var(--success-color);">✓</span>' :
1920
+ const status = (enabled) => enabled ?
1921
+ '<span style="color: var(--success-color);">✓</span>' :
1922
'<span style="color: var(--error-color);">✗</span>';
1703
-
1923
+
1924
let tooltipHtml = `
1925
<div style="min-width: 300px;">
1926
<table style="width: 100%; font-size: 11px; border-collapse: collapse;">
1929
${modelString}
1930
</td>
1931
</tr>`;
1712
-
1932
+
1933
// Model parameters section (no provider, more condensed)
1934
tooltipHtml += `
1935
<tr>
1942
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}` : ''}
1943
</td>
1944
</tr>`;
1725
-
1945
+
1946
// Pricing section (condensed with bold prices)
1947
if (modelInfo.input || modelInfo.output) {
1948
tooltipHtml += `
1950
<td style="padding: 4px 6px; color: var(--text-secondary);">Pricing/1M:</td>
1951
<td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1952
In: ${formatPrice(modelInfo.input)} Out: ${formatPrice(modelInfo.output)}`;
1733
-
1953
+
1954
if (modelInfo.cacheRead !== undefined) {
1955
tooltipHtml += ` CR: ${formatPrice(modelInfo.cacheRead)}`;
1956
}
1957
if (modelInfo.cacheWrite !== undefined) {
1958
tooltipHtml += ` CW: ${formatPrice(modelInfo.cacheWrite)}`;
1959
}
1740
-
1960
+
1961
tooltipHtml += `</td></tr>`;
1962
}
1743
-
1963
+
1964
// All optimization settings in a compact section
1965
tooltipHtml += `
1966
<tr style="border-top: 1px solid var(--border-color);">
1981
<td style="padding: 4px 6px; color: var(--text-secondary);">Tool Memory:</td>
1982
<td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1983
${status(config.optimisation.toolMemory.enabled)}
1764
- ${config.optimisation.toolMemory.enabled ?
1765
- (config.optimisation.toolMemory.forgetAfterConclusions === 0 ? 'forget immediately' :
1766
- config.optimisation.toolMemory.forgetAfterConclusions === 1 ? 'forget after 1 turn' :
1767
- `forget after ${config.optimisation.toolMemory.forgetAfterConclusions} turns`) :
1768
- 'Always remember'}
1984
+ ${config.optimisation.toolMemory.enabled ?
1985
+ (config.optimisation.toolMemory.forgetAfterConclusions === 0 ? 'forget immediately' :
1986
+ config.optimisation.toolMemory.forgetAfterConclusions === 1 ? 'forget after 1 turn' :
1987
+ `forget after ${config.optimisation.toolMemory.forgetAfterConclusions} turns`) :
1988
+ 'Always remember'}
1989
</td>
1990
</tr>
1991
<tr>
1992
<td style="padding: 4px 6px; color: var(--text-secondary);">Cache Control:</td>
1993
<td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1774
- ${config.optimisation.cacheControl === 'all-off' ? 'Off' :
1775
- config.optimisation.cacheControl === 'system' ? 'System only' :
1776
- config.optimisation.cacheControl === 'cached' ? 'Cached' : config.optimisation.cacheControl}
1994
+ ${config.optimisation.cacheControl === 'all-off' ? 'Off' :
1995
+ config.optimisation.cacheControl === 'system' ? 'System only' :
1996
+ config.optimisation.cacheControl === 'cached' ? 'Cached' : config.optimisation.cacheControl}
1997
</td>
1998
</tr>
1999
<tr>
2003
${config.optimisation.titleGeneration.enabled ? titleGenModel : 'Disabled'}
2004
</td>
2005
</tr>`;
1786
-
2006
+
2007
// Server info with name
2008
tooltipHtml += `
2009
<tr style="border-top: 1px solid var(--border-color);">
2014
</tr>
2015
</table>
2016
</div>`;
1797
-
2017
+
2018
return tooltipHtml;
2019
}
1800
-
2020
+
2021
getAllAvailableModels() {
2022
const models = [];
1803
-
2023
+
2024
// Iterate through all LLM providers
2025
this.llmProviders.forEach((provider) => {
2026
// Check if provider has availableProviders (the actual structure from the proxy)
2031
const modelId = typeof model === 'string' ? model : model.id;
2032
const contextWindow = typeof model === 'object' ? model.contextWindow : null;
2033
const pricing = typeof model === 'object' ? model.pricing : null;
1814
-
2034
+
2035
if (modelId) {
2036
models.push({
2037
id: modelId,
2038
providerId: providerType,
2039
+ providerApiType: providerConfig.type || providerType, // API type for this provider
2040
contextWindow: contextWindow || 128000, // Default context
1820
- pricing: pricing || null
2041
+ pricing: pricing || null,
2042
+ endpoint: typeof model === 'object' ? model.endpoint : undefined,
2043
+ supportsTools: typeof model === 'object' ? model.supportsTools : undefined
2044
});
2045
}
2046
});
2048
});
2049
}
2050
});
1828
-
2051
+
2052
// Sort by provider and then by model name
2053
models.sort((a, b) => {
2054
if (a.providerId !== b.providerId) {
2056
}
2057
return a.id.localeCompare(b.id);
2058
});
1836
-
2059
+
2060
return models;
2061
}
1839
-
2062
+
2063
initializeModelSelectionButtons(section, chatId, chat, _settings) {
2064
// Helper to create model dropdown with pricing table
2065
const createModelDropdown = (buttonId, currentModel, onSelect) => {
2066
const button = section.querySelector(`#${buttonId}`);
2067
if (!button) return;
1845
-
2068
+
2069
+ // Store the full model string with the button for copy/paste operations
2070
+ button.dataset.fullModelString = currentModel || '';
2071
+
2072
// Add context menu for copy/paste
2073
button.addEventListener('contextmenu', (e) => {
2074
e.preventDefault();
2075
e.stopPropagation();
1850
-
2076
+
2077
// Create context menu
2078
const menu = document.createElement('div');
2079
menu.className = 'model-context-menu';
2088
padding: 4px 0;
2089
z-index: 10000;
2090
`;
1865
-
2091
+
2092
const modelName = button.querySelector('.model-name').textContent;
1867
- const hasModel = modelName && modelName !== 'Select model';
1868
-
2093
+ const fullModelString = button.dataset.fullModelString;
2094
+ const hasModel = fullModelString && modelName && modelName !== 'Select model';
2095
+
2096
if (hasModel) {
2097
const copyItem = document.createElement('div');
2098
copyItem.style.cssText = `
2108
copyItem.style.background = '';
2109
});
2110
copyItem.addEventListener('click', () => {
1884
- this.copiedModel = modelName;
1885
- document.body.removeChild(menu);
2111
+ // Store the full model string (provider:model format)
2112
+ this.copiedModel = fullModelString;
2113
+ this.copiedModelDisplayName = modelName;
2114
+ if (menu.parentNode) {
2115
+ document.body.removeChild(menu);
2116
+ }
2117
this.showToast(`Copied model: ${modelName}`, 'success-toast');
2118
});
2119
menu.appendChild(copyItem);
2120
}
1890
-
1891
- if (this.copiedModel && this.copiedModel !== modelName) {
2121
+
2122
+ if (this.copiedModel) {
2123
const pasteItem = document.createElement('div');
2124
pasteItem.style.cssText = `
2125
padding: 6px 12px;
2126
cursor: pointer;
2127
font-size: 13px;
2128
`;
1898
- pasteItem.textContent = `Paste "${this.copiedModel}"`;
2129
+ const displayName = this.copiedModelDisplayName || this.copiedModel;
2130
+ pasteItem.textContent = `Paste "${displayName}"`;
2131
pasteItem.addEventListener('mouseenter', () => {
2132
pasteItem.style.background = 'var(--hover-color)';
2133
});
2135
pasteItem.style.background = '';
2136
});
2137
pasteItem.addEventListener('click', () => {
1906
- button.querySelector('.model-name').textContent = this.copiedModel;
2138
+ // Update button display and data
2139
+ button.querySelector('.model-name').textContent = this.copiedModelDisplayName || this.copiedModel;
2140
+ button.dataset.fullModelString = this.copiedModel;
2141
+ // Pass the full model string to the callback
2142
onSelect(this.copiedModel);
1908
- document.body.removeChild(menu);
1909
- this.showToast(`Pasted model: ${this.copiedModel}`, 'success-toast');
2143
+ if (menu.parentNode) {
2144
+ document.body.removeChild(menu);
2145
+ }
2146
+ this.showToast(`Pasted model: ${this.copiedModelDisplayName || this.copiedModel}`, 'success-toast');
2147
});
2148
menu.appendChild(pasteItem);
2149
}
1913
-
2150
+
2151
if (menu.children.length === 0) {
2152
const emptyItem = document.createElement('div');
2153
emptyItem.style.cssText = `
2158
emptyItem.textContent = 'No model to copy/paste';
2159
menu.appendChild(emptyItem);
2160
}
1924
-
2161
+
2162
document.body.appendChild(menu);
1926
-
2163
+
2164
// Remove menu on click outside
2165
const removeMenu = (evt) => {
2166
+ // Don't close if clicking inside the menu
2167
if (!menu.contains(evt.target)) {
1930
- document.body.removeChild(menu);
1931
- document.removeEventListener('click', removeMenu);
2168
+ // Check if menu is still in DOM before removing
2169
+ if (menu.parentNode) {
2170
+ document.body.removeChild(menu);
2171
+ }
2172
+ // Remove all event listeners
2173
+ document.removeEventListener('click', removeMenu, true);
2174
+ document.removeEventListener('mousedown', removeMenu, true);
2175
+ document.removeEventListener('contextmenu', removeMenu, true);
2176
}
2177
};
2178
+
2179
+ // Use setTimeout to avoid immediate closure
2180
setTimeout(() => {
1935
- document.addEventListener('click', removeMenu);
2181
+ // Add listeners in capture phase to ensure we catch all clicks
2182
+ document.addEventListener('click', removeMenu, true);
2183
+ document.addEventListener('mousedown', removeMenu, true);
2184
+ document.addEventListener('contextmenu', removeMenu, true);
2185
}, 0);
2186
});
1938
-
2187
+
2188
// Regular click to open model selection
2189
button.addEventListener('click', (e) => {
2190
e.stopPropagation();
1942
-
2191
+
2192
// Check if this button already has a dropdown open (toggle behavior)
2193
if (button.getAttribute('data-dropdown-open') === 'true') {
2194
const existingDropdown = document.body.querySelector('.model-selection-dropdown');
2198
}
2199
return;
2200
}
1952
-
2201
+
2202
// Close any other open dropdowns
2203
document.querySelectorAll('.model-selection-dropdown').forEach(d => d.remove());
2204
document.querySelectorAll('[data-dropdown-open]').forEach(b => b.removeAttribute('data-dropdown-open'));
1956
-
2205
+
2206
// Create model selection dropdown with pricing table
2207
const dropdown = document.createElement('div');
2208
dropdown.className = 'model-selection-dropdown';
1960
-
2209
+
2210
// Mark button as having an open dropdown
2211
button.setAttribute('data-dropdown-open', 'true');
1963
-
2212
+
2213
// Calculate button position relative to viewport
2214
const buttonRect = button.getBoundingClientRect();
2215
const viewportHeight = window.innerHeight;
2216
const viewportWidth = window.innerWidth;
2217
const dropdownHeight = 400; // Max height of dropdown
2218
const dropdownMinWidth = 600;
1970
-
2219
+
2220
// Determine if dropdown should appear above or below the button
2221
const spaceBelow = viewportHeight - buttonRect.bottom;
2222
const shouldShowAbove = spaceBelow < dropdownHeight && buttonRect.top > dropdownHeight;
1974
-
2223
+
2224
// Calculate left position - ensure dropdown doesn't go off-screen
2225
let leftPosition = buttonRect.left;
2226
if (leftPosition + dropdownMinWidth > viewportWidth) {
2227
leftPosition = Math.max(10, viewportWidth - dropdownMinWidth - 10);
2228
}
1980
-
2229
+
2230
dropdown.style.cssText = `
2231
position: fixed;
2232
${shouldShowAbove ? 'bottom' : 'top'}: ${shouldShowAbove ? (viewportHeight - buttonRect.top + 4) : (buttonRect.bottom + 4)}px;
2241
display: flex;
2242
flex-direction: column;
2243
`;
1995
-
2244
+
2245
// Add search box
2246
const searchContainer = document.createElement('div');
2247
searchContainer.style.cssText = `
2252
top: 0;
2253
z-index: 2;
2254
`;
2006
-
2255
+
2256
const searchInput = document.createElement('input');
2257
searchInput.type = 'text';
2258
searchInput.placeholder = 'Search models...';
2267
`;
2268
searchContainer.appendChild(searchInput);
2269
dropdown.appendChild(searchContainer);
2021
-
2270
+
2271
// Create scrollable content container
2272
const contentContainer = document.createElement('div');
2273
contentContainer.style.cssText = `
2275
overflow-y: auto;
2276
`;
2277
dropdown.appendChild(contentContainer);
2029
-
2278
+
2279
// Focus search input when dropdown opens
2280
setTimeout(() => searchInput.focus(), 0);
2032
-
2281
+
2282
// Function to update dropdown position on scroll/resize
2283
const updateDropdownPosition = () => {
2284
const newButtonRect = button.getBoundingClientRect();
2286
const newViewportWidth = window.innerWidth;
2287
const newSpaceBelow = newViewportHeight - newButtonRect.bottom;
2288
const newShouldShowAbove = newSpaceBelow < dropdownHeight && newButtonRect.top > dropdownHeight;
2040
-
2289
+
2290
if (newShouldShowAbove) {
2291
dropdown.style.top = 'auto';
2292
dropdown.style.bottom = `${newViewportHeight - newButtonRect.top + 4}px`;
2294
dropdown.style.bottom = 'auto';
2295
dropdown.style.top = `${newButtonRect.bottom + 4}px`;
2296
}
2048
-
2297
+
2298
// Update horizontal position
2299
let newLeftPosition = newButtonRect.left;
2300
if (newLeftPosition + dropdownMinWidth > newViewportWidth) {
2302
}
2303
dropdown.style.left = `${newLeftPosition}px`;
2304
};
2056
-
2305
+
2306
// Create pricing table
2307
const models = this.getAllAvailableModels();
2059
-
2308
+
2309
// Sort models by provider, then by input price desc, then by name desc
2310
models.sort((a, b) => {
2311
// First sort by provider
2312
if (a.providerId !== b.providerId) {
2313
return a.providerId.localeCompare(b.providerId);
2314
}
2066
-
2315
+
2316
// Within same provider, sort by input price descending
2317
const aInputPrice = a.pricing?.input || 0;
2318
const bInputPrice = b.pricing?.input || 0;
2070
-
2319
+
2320
if (aInputPrice !== bInputPrice) {
2321
return bInputPrice - aInputPrice; // Descending order (expensive first)
2322
}
2074
-
2323
+
2324
// If prices are equal, sort by name descending (newer models typically have later names)
2325
return b.id.localeCompare(a.id);
2326
});
2078
-
2327
+
2328
// Check if there are any models
2329
if (!models || models.length === 0) {
2330
contentContainer.innerHTML = `
2333
</div>
2334
`;
2335
document.body.appendChild(dropdown);
2087
-
2336
+
2337
// Close dropdown on outside click
2338
const closeDropdown = (evt) => {
2339
if (!dropdown.contains(evt.target) && !button.contains(evt.target)) {
2346
window.removeEventListener('resize', updateDropdownPosition);
2347
}
2348
};
2100
-
2349
+
2350
// Use capture phase to ensure we catch clicks before they're stopped by modal
2351
setTimeout(() => {
2352
document.addEventListener('click', closeDropdown, true);
2362
border-collapse: collapse;
2363
font-size: 12px;
2364
`;
2116
-
2365
+
2366
// Table header
2367
const thead = document.createElement('thead');
2368
thead.innerHTML = `
2376
</tr>
2377
`;
2378
table.appendChild(thead);
2130
-
2379
+
2380
const tbody = document.createElement('tbody');
2132
-
2381
+
2382
// Function to rebuild table body with filtered models
2383
const rebuildTableBody = (filteredModels) => {
2384
tbody.innerHTML = '';
2385
let currentProvider = null;
2137
-
2386
+
2387
filteredModels.forEach(model => {
2139
- // Add provider header row when provider changes
2140
- if (model.providerId !== currentProvider) {
2141
- currentProvider = model.providerId;
2142
- const providerRow = document.createElement('tr');
2143
- providerRow.style.cssText = `
2388
+ // Add provider header row when provider changes
2389
+ if (model.providerId !== currentProvider) {
2390
+ currentProvider = model.providerId;
2391
+ const providerRow = document.createElement('tr');
2392
+ providerRow.style.cssText = `
2393
background: var(--surface-color);
2394
font-weight: 600;
2395
color: var(--text-secondary);
2396
cursor: default;
2397
`;
2149
- providerRow.innerHTML = `
2398
+ providerRow.innerHTML = `
2399
<td colspan="6" style="padding: 8px; text-transform: uppercase; font-size: 11px;">
2400
${currentProvider}
2401
</td>
2402
`;
2154
- tbody.appendChild(providerRow);
2155
- }
2156
-
2157
- const tr = document.createElement('tr');
2158
-
2159
- // Capture the full model string in the closure
2160
- const fullModelId = `${model.providerId}:${model.id}`;
2161
-
2162
- // Check if this is the currently selected model
2163
- const isSelected = fullModelId === currentModel;
2164
-
2165
- tr.style.cssText = `
2403
+ tbody.appendChild(providerRow);
2404
+ }
2405
+
2406
+ const tr = document.createElement('tr');
2407
+
2408
+ // Capture the full model string in the closure
2409
+ const fullModelId = `${model.providerId}:${model.id}`;
2410
+
2411
+ // Check if this is the currently selected model
2412
+ const isSelected = fullModelId === currentModel;
2413
+
2414
+ tr.style.cssText = `
2415
cursor: pointer;
2416
transition: background 0.1s;
2417
border-bottom: 1px solid var(--border-subtle, var(--border-color));
2418
${isSelected ? 'background: var(--hover-color);' : ''}
2419
`;
2171
-
2172
- // Mark selected row for scrolling
2173
- if (isSelected) {
2174
- tr.setAttribute('data-selected', 'true');
2175
- }
2176
-
2177
- tr.addEventListener('mouseenter', () => {
2178
- tr.style.background = 'var(--hover-color)';
2179
- });
2180
- tr.addEventListener('mouseleave', () => {
2181
- if (!isSelected) {
2182
- tr.style.background = '';
2420
+
2421
+ // Mark selected row for scrolling
2422
+ if (isSelected) {
2423
+ tr.setAttribute('data-selected', 'true');
2424
}
2184
- });
2185
-
2186
- // Add click handler directly here
2187
- tr.addEventListener('click', () => {
2188
- const modelName = ChatConfig.getModelDisplayName(fullModelId);
2189
- button.querySelector('.model-name').textContent = modelName;
2190
- onSelect(fullModelId);
2191
- document.body.removeChild(dropdown);
2192
- button.removeAttribute('data-dropdown-open');
2193
- });
2194
-
2195
- const pricing = model.pricing || {};
2196
- const inputPrice = pricing.input || 0;
2197
- const outputPrice = pricing.output || 0;
2198
- const cacheReadPrice = pricing.cacheRead !== undefined ? pricing.cacheRead : '-';
2199
- const cacheWritePrice = pricing.cacheWrite !== undefined ? pricing.cacheWrite : '-';
2200
-
2201
- tr.innerHTML = `
2425
+
2426
+ tr.addEventListener('mouseenter', () => {
2427
+ tr.style.background = 'var(--hover-color)';
2428
+ });
2429
+ tr.addEventListener('mouseleave', () => {
2430
+ if (!isSelected) {
2431
+ tr.style.background = '';
2432
+ }
2433
+ });
2434
+
2435
+ // Add click handler directly here
2436
+ tr.addEventListener('click', () => {
2437
+ const modelName = ChatConfig.getModelDisplayName(fullModelId);
2438
+ button.querySelector('.model-name').textContent = modelName;
2439
+ button.dataset.fullModelString = fullModelId; // Update button's stored model string
2440
+ onSelect(fullModelId);
2441
+ document.body.removeChild(dropdown);
2442
+ button.removeAttribute('data-dropdown-open');
2443
+ });
2444
+
2445
+ const pricing = model.pricing || {};
2446
+ const inputPrice = pricing.input || 0;
2447
+ const outputPrice = pricing.output || 0;
2448
+ const cacheReadPrice = pricing.cacheRead !== undefined ? pricing.cacheRead : '-';
2449
+ const cacheWritePrice = pricing.cacheWrite !== undefined ? pricing.cacheWrite : '-';
2450
+
2451
+ tr.innerHTML = `
2452
<td style="padding: 8px; font-weight: 500;">${model.id}</td>
2453
<td style="padding: 8px; text-align: right; color: var(--text-secondary);">${this.formatContextWindow(model.contextWindow)}</td>
2454
<td style="padding: 8px; text-align: right;">$${inputPrice.toFixed(2)}</td>
2456
<td style="padding: 8px; text-align: right;">${cacheReadPrice === '-' ? '-' : '$' + cacheReadPrice.toFixed(2)}</td>
2457
<td style="padding: 8px; text-align: right;">${cacheWritePrice === '-' ? '-' : '$' + cacheWritePrice.toFixed(2)}</td>
2458
`;
2209
-
2210
- tbody.appendChild(tr);
2459
+
2460
+ tbody.appendChild(tr);
2461
});
2462
};
2213
-
2463
+
2464
// Initial build with all models
2465
rebuildTableBody(models);
2216
-
2466
+
2467
// Auto-scroll to currently selected model after initial table build
2468
requestAnimationFrame(() => {
2469
const selectedRow = tbody.querySelector('tr[data-selected="true"]');
2471
const scrollContainer = contentContainer;
2472
const containerRect = scrollContainer.getBoundingClientRect();
2473
const rowRect = selectedRow.getBoundingClientRect();
2224
-
2474
+
2475
const rowTop = rowRect.top - containerRect.top + scrollContainer.scrollTop;
2476
const rowBottom = rowTop + rowRect.height;
2477
const containerHeight = scrollContainer.clientHeight;
2228
-
2478
+
2479
// Check if row is outside visible area
2480
if (rowTop < scrollContainer.scrollTop || rowBottom > scrollContainer.scrollTop + containerHeight) {
2481
// Center the selected row in the viewport
2484
}
2485
}
2486
});
2237
-
2487
+
2488
// Add search functionality
2489
searchInput.addEventListener('input', (event) => {
2490
const searchTerm = event.target.value.toLowerCase().trim();
2241
-
2491
+
2492
if (!searchTerm) {
2493
rebuildTableBody(models);
2494
return;
2495
}
2246
-
2496
+
2497
const filteredModels = models.filter(model => {
2498
const modelId = model.id.toLowerCase();
2499
const providerId = model.providerId.toLowerCase();
2500
const fullId = `${providerId}:${modelId}`.toLowerCase();
2251
-
2252
- return modelId.includes(searchTerm) ||
2253
- providerId.includes(searchTerm) ||
2254
- fullId.includes(searchTerm);
2501
+
2502
+ return modelId.includes(searchTerm) ||
2503
+ providerId.includes(searchTerm) ||
2504
+ fullId.includes(searchTerm);
2505
});
2256
-
2506
+
2507
if (filteredModels.length === 0) {
2508
tbody.innerHTML = `
2509
<tr>
2514
`;
2515
} else {
2516
rebuildTableBody(filteredModels);
2267
-
2517
+
2518
// Auto-scroll to selected model after search rebuild
2519
requestAnimationFrame(() => {
2520
const selectedRow = tbody.querySelector('tr[data-selected="true"]');
2522
const scrollContainer = contentContainer;
2523
const containerRect = scrollContainer.getBoundingClientRect();
2524
const rowRect = selectedRow.getBoundingClientRect();
2275
-
2525
+
2526
const rowTop = rowRect.top - containerRect.top + scrollContainer.scrollTop;
2527
const rowBottom = rowTop + rowRect.height;
2528
const containerHeight = scrollContainer.clientHeight;
2279
-
2529
+
2530
if (rowTop < scrollContainer.scrollTop || rowBottom > scrollContainer.scrollTop + containerHeight) {
2531
const scrollTarget = rowTop - (containerHeight / 2) + (rowRect.height / 2);
2532
scrollContainer.scrollTop = Math.max(0, scrollTarget);
2535
});
2536
}
2537
});
2288
-
2538
+
2539
// Handle keyboard navigation
2540
searchInput.addEventListener('keydown', (keyEvent) => {
2541
if (keyEvent.key === 'Escape') {
2550
}
2551
}
2552
});
2303
-
2553
+
2554
// Close dropdown on outside click
2555
const closeDropdown = (evt) => {
2556
// Check if click is outside dropdown and button
2565
window.removeEventListener('resize', updateDropdownPosition);
2566
}
2567
};
2318
-
2568
+
2569
// Click listeners are now added directly when creating rows
2320
-
2570
+
2571
// Add event listeners
2572
// Use capture phase to ensure we catch clicks before they're stopped by modal
2573
setTimeout(() => {
2576
window.addEventListener('scroll', updateDropdownPosition, true);
2577
window.addEventListener('resize', updateDropdownPosition);
2578
}, 0);
2329
-
2579
+
2580
// Now append elements after functions are defined
2581
table.appendChild(tbody);
2582
contentContainer.appendChild(table);
2333
-
2583
+
2584
// Append dropdown to body for proper z-index layering
2585
document.body.appendChild(dropdown);
2586
});
2587
};
2338
-
2588
+
2589
// Initialize all model selection buttons
2590
createModelDropdown(`chatModel_${chatId}`, ChatConfig.getChatModelString(chat), (model) => {
2591
this.updateChatModel(chatId, model);
2592
});
2343
-
2593
+
2594
createModelDropdown(`toolSumModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.toolSummarisation.model) || ChatConfig.getChatModelString(chat), (model) => {
2595
this.updateToolSummarizationModel(chatId, model);
2596
});
2347
-
2597
+
2598
createModelDropdown(`autoSumModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.autoSummarisation.model) || ChatConfig.getChatModelString(chat), (model) => {
2599
this.updateAutoSummarizationModel(chatId, model);
2600
});
2351
-
2601
+
2602
createModelDropdown(`titleGenModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.titleGeneration?.model), (model) => {
2603
this.updateTitleGenerationModel(chatId, model);
2604
});
2613
2614
// Get current config or create defaults
2615
const config = chat.config || ChatConfig.loadChatConfig(chatId);
2366
-
2616
+
2617
// Update the specific setting
2618
switch (settingType) {
2619
case 'toolSummarization':
2641
...config,
2642
llmProviderFactory: config.optimisation.toolSummarisation.enabled ? window.createLLMProvider : undefined
2643
};
2394
-
2644
+
2645
try {
2646
chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2647
} catch (error) {
2650
2651
// Save config
2652
this.saveChatConfigSmart(chatId, config);
2403
-
2653
+
2654
// Auto-save chat
2655
this.autoSave(chatId);
2656
}
2664
2665
// Get current config or create defaults
2666
const config = chat.config || ChatConfig.loadChatConfig(chatId);
2417
-
2667
+
2668
// Update cache control mode
2669
config.optimisation.cacheControl = cacheMode;
2670
2676
...config,
2677
llmProviderFactory: config.optimisation.toolSummarisation.enabled ? window.createLLMProvider : undefined
2678
};
2429
-
2679
+
2680
try {
2681
chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2682
} catch (error) {
2685
2686
// Save config
2687
this.saveChatConfigSmart(chatId, config);
2438
-
2688
+
2689
// Auto-save chat
2690
this.autoSave(chatId);
2691
}
2442
-
2692
+
2693
+ updateCacheControlUI(chatId) {
2694
+ const chat = this.chats.get(chatId);
2695
+ if (!chat) {
2696
+ console.error(`[updateCacheControlUI] Chat not found for chatId: ${chatId}`);
2697
+ return;
2698
+ }
2699
+
2700
+ // Get the cache control select element
2701
+ const cacheControlSelect = document.querySelector(`#cacheControl_${chatId}`);
2702
+ if (!cacheControlSelect) {
2703
+ return; // UI not available
2704
+ }
2705
+
2706
+ // Get the parent div for opacity control
2707
+ const cacheControlDiv = cacheControlSelect.closest('div');
2708
+ if (!cacheControlDiv) {
2709
+ return;
2710
+ }
2711
+
2712
+ // Determine if cache control is supported for the current model
2713
+ const providerType = chat.config.model?.provider;
2714
+ const provider = this.llmProviders.get(chat.llmProviderId);
2715
+ const providerApiType = provider?.availableProviders?.[providerType]?.type || providerType;
2716
+ const supportsCacheControl = providerApiType === 'anthropic';
2717
+
2718
+ // Update UI state
2719
+ cacheControlSelect.disabled = !supportsCacheControl;
2720
+ cacheControlDiv.style.opacity = supportsCacheControl ? '1' : '0.5';
2721
+ }
2722
+
2723
updateChatModel(chatId, model) {
2724
const chat = this.chats.get(chatId);
2725
if (!chat) {
2726
console.error(`[updateChatModel] Chat not found for chatId: ${chatId}, model: ${model}`);
2727
return;
2728
}
2449
-
2729
+
2730
// Update config
2731
const config = chat.config || ChatConfig.loadChatConfig(chatId);
2732
const modelConfig = ChatConfig.modelConfigFromString(model);
2733
if (modelConfig) {
2734
// Preserve existing params
2735
modelConfig.params = config.model?.params || modelConfig.params;
2736
+
2737
+ // ALWAYS reset context window to the model's maximum
2738
+ // This ensures correct calculations and predictable behavior
2739
+ modelConfig.params.contextWindow = this.getDefaultContextWindow({ config: { model: modelConfig } });
2740
+
2741
config.model = modelConfig;
2742
}
2458
-
2743
+
2744
// Note: We intentionally do NOT auto-update optimization feature models
2745
// If a user explicitly selected a model for a feature, it should stay as that model
2746
// Only null values (which mean "use chat model") will automatically follow the chat model
2462
-
2747
+
2748
chat.config = config;
2749
this.recreateMessageOptimizer(chat, config);
2750
this.saveChatConfigSmart(chatId, config);
2751
this.autoSave(chatId);
2467
-
2752
+
2753
+ // Update context window visibility dynamically
2754
+ this.updateContextWindowVisibility(chatId, chat);
2755
+
2756
+ // Update the context window input field if it exists (for Ollama models)
2757
+ const contextWindowInput = document.querySelector(`#contextWindow_${chatId}`);
2758
+ if (contextWindowInput && modelConfig) {
2759
+ contextWindowInput.value = modelConfig.params.contextWindow;
2760
+ }
2761
+
2762
// Update displays
2763
this.updateChatHeader(chatId);
2764
+ this.updateContextWindowIndicator(chatId); // Update context window immediately
2765
this.updateChatSessions();
2766
+
2767
+ // Update cache control UI based on new model's provider
2768
+ this.updateCacheControlUI(chatId);
2769
}
2472
-
2770
+
2771
updateToolSummarizationModel(chatId, model) {
2772
const chat = this.chats.get(chatId);
2773
if (!chat) {
2774
console.error(`[updateToolSummarizationModel] Chat not found for chatId: ${chatId}, model: ${model}`);
2775
return;
2776
}
2479
-
2480
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2481
- config.optimisation.toolSummarisation.model = ChatConfig.modelConfigFromString(model);
2482
-
2483
- chat.config = config;
2484
- this.recreateMessageOptimizer(chat, config);
2485
- this.saveChatConfigSmart(chatId, config);
2486
- this.autoSave(chatId);
2777
+
2778
+ try {
2779
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2780
+ const modelConfig = ChatConfig.modelConfigFromString(model);
2781
+
2782
+ // Set context window for secondary model (inherit from main, capped at model max)
2783
+ if (modelConfig && modelConfig.provider === 'ollama') {
2784
+ const mainContextWindow = config.model?.params?.contextWindow || this.getDefaultContextWindow(chat);
2785
+ const modelMaxContextWindow = this.getDefaultContextWindow({ config: { model: modelConfig } });
2786
+ modelConfig.params.contextWindow = Math.min(mainContextWindow, modelMaxContextWindow);
2787
+ }
2788
+
2789
+ // Propagate max output tokens from main chat to tool summarization model
2790
+ if (config.model?.params?.maxTokens) {
2791
+ modelConfig.params.maxTokens = config.model.params.maxTokens;
2792
+ }
2793
+
2794
+ config.optimisation.toolSummarisation.model = modelConfig;
2795
+
2796
+ chat.config = config;
2797
+ this.recreateMessageOptimizer(chat, config);
2798
+ this.saveChatConfigSmart(chatId, config);
2799
+ this.autoSave(chatId);
2800
+ } catch (error) {
2801
+ console.error(`[updateToolSummarizationModel] Invalid model format for chatId: ${chatId}, model: ${model}`, error);
2802
+ this.showToast(`Invalid model format: ${model}. Expected format: provider:model`, 'error-toast');
2803
+ }
2804
}
2488
-
2805
+
2806
updateAutoSummarizationModel(chatId, model) {
2807
const chat = this.chats.get(chatId);
2808
if (!chat) {
2809
console.error(`[updateAutoSummarizationModel] Chat not found for chatId: ${chatId}, model: ${model}`);
2810
return;
2811
}
2495
-
2496
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2497
- config.optimisation.autoSummarisation.model = ChatConfig.modelConfigFromString(model);
2498
-
2499
- chat.config = config;
2500
- this.recreateMessageOptimizer(chat, config);
2501
- this.saveChatConfigSmart(chatId, config);
2502
- this.autoSave(chatId);
2812
+
2813
+ try {
2814
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2815
+ const modelConfig = ChatConfig.modelConfigFromString(model);
2816
+
2817
+ // Set context window for secondary model (inherit from main, capped at model max)
2818
+ if (modelConfig && modelConfig.provider === 'ollama') {
2819
+ const mainContextWindow = config.model?.params?.contextWindow || this.getDefaultContextWindow(chat);
2820
+ const modelMaxContextWindow = this.getDefaultContextWindow({ config: { model: modelConfig } });
2821
+ modelConfig.params.contextWindow = Math.min(mainContextWindow, modelMaxContextWindow);
2822
+ }
2823
+
2824
+ // Propagate max output tokens from main chat to auto summarization model
2825
+ if (config.model?.params?.maxTokens) {
2826
+ modelConfig.params.maxTokens = config.model.params.maxTokens;
2827
+ }
2828
+
2829
+ config.optimisation.autoSummarisation.model = modelConfig;
2830
+
2831
+ chat.config = config;
2832
+ this.recreateMessageOptimizer(chat, config);
2833
+ this.saveChatConfigSmart(chatId, config);
2834
+ this.autoSave(chatId);
2835
+ } catch (error) {
2836
+ console.error(`[updateAutoSummarizationModel] Invalid model format for chatId: ${chatId}, model: ${model}`, error);
2837
+ this.showToast(`Invalid model format: ${model}. Expected format: provider:model`, 'error-toast');
2838
+ }
2839
}
2504
-
2840
+
2841
updateTitleGenerationModel(chatId, model) {
2842
const chat = this.chats.get(chatId);
2843
if (!chat) {
2844
console.error(`[updateTitleGenerationModel] Chat not found for chatId: ${chatId}, model: ${model}`);
2845
return;
2846
}
2511
-
2512
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2513
- // Use feature-specific defaults for title generation
2514
- config.optimisation.titleGeneration.model = ChatConfig.modelConfigFromString(model, {
2515
- temperature: 0.7,
2516
- topP: 0.9,
2517
- maxTokens: 100 // Title generation should use limited tokens
2518
- });
2519
-
2520
- chat.config = config;
2521
- this.recreateMessageOptimizer(chat, config);
2522
- this.saveChatConfigSmart(chatId, config);
2523
- this.autoSave(chatId);
2847
+
2848
+ try {
2849
+ const config = chat.config || ChatConfig.loadChatConfig(chatId);
2850
+ // Use feature-specific defaults for title generation
2851
+ const modelConfig = ChatConfig.modelConfigFromString(model, {
2852
+ temperature: 0.7,
2853
+ topP: 0.9,
2854
+ maxTokens: 100 // Title generation should use limited tokens
2855
+ });
2856
+
2857
+ // Set context window for secondary model (inherit from main, capped at model max)
2858
+ if (modelConfig && modelConfig.provider === 'ollama') {
2859
+ const mainContextWindow = config.model?.params?.contextWindow || this.getDefaultContextWindow(chat);
2860
+ const modelMaxContextWindow = this.getDefaultContextWindow({ config: { model: modelConfig } });
2861
+ modelConfig.params.contextWindow = Math.min(mainContextWindow, modelMaxContextWindow);
2862
+ }
2863
+
2864
+ // Title generation intentionally uses a small maxTokens (100); do not override from main chat
2865
+
2866
+ config.optimisation.titleGeneration.model = modelConfig;
2867
+
2868
+ chat.config = config;
2869
+ this.recreateMessageOptimizer(chat, config);
2870
+ this.saveChatConfigSmart(chatId, config);
2871
+ this.autoSave(chatId);
2872
+ } catch (error) {
2873
+ console.error(`[updateTitleGenerationModel] Invalid model format for chatId: ${chatId}, model: ${model}`, error);
2874
+ this.showToast(`Invalid model format: ${model}. Expected format: provider:model`, 'error-toast');
2875
+ }
2876
}
2525
-
2877
+
2878
updateToolThreshold(chatId, threshold) {
2879
const chat = this.chats.get(chatId);
2880
if (!chat) return;
2529
-
2881
+
2882
const config = chat.config || ChatConfig.loadChatConfig(chatId);
2883
config.optimisation.toolSummarisation.thresholdKiB = Math.floor(threshold / 1024);
2532
-
2884
+
2885
chat.config = config;
2886
this.recreateMessageOptimizer(chat, config);
2887
this.saveChatConfigSmart(chatId, config);
2888
this.autoSave(chatId);
2889
}
2538
-
2890
+
2891
updateAutoSumThreshold(chatId, percent) {
2892
const chat = this.chats.get(chatId);
2893
if (!chat) return;
2542
-
2894
+
2895
const config = chat.config || ChatConfig.loadChatConfig(chatId);
2896
config.optimisation.autoSummarisation.triggerPercent = percent;
2545
-
2897
+
2898
chat.config = config;
2899
this.recreateMessageOptimizer(chat, config);
2900
this.saveChatConfigSmart(chatId, config);
2901
this.autoSave(chatId);
2902
}
2551
-
2903
+
2904
updateToolMemoryThreshold(chatId, forgetAfterConclusions) {
2905
const chat = this.chats.get(chatId);
2906
if (!chat) return;
2555
-
2907
+
2908
const config = chat.config || ChatConfig.loadChatConfig(chatId);
2909
config.optimisation.toolMemory.forgetAfterConclusions = forgetAfterConclusions;
2558
-
2910
+
2911
chat.config = config;
2912
this.recreateMessageOptimizer(chat, config);
2913
this.saveChatConfigSmart(chatId, config);
2914
this.autoSave(chatId);
2915
}
2564
-
2916
+
2917
recreateMessageOptimizer(chat, config) {
2918
// Add factory for tool summarization if enabled
2919
const optimizerSettings = {
2920
...config,
2921
llmProviderFactory: config.optimisation.toolSummarisation.enabled ? window.createLLMProvider : undefined
2922
};
2571
-
2923
+
2924
try {
2925
chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2926
} catch (error) {
2928
}
2929
}
2930
2579
-
2931
+
2932
populateMCPDropdown(chatId, dropdown = null) {
2933
if (!chatId) {
2934
console.error('[populateMCPDropdown] Called without chatId');
2936
}
2937
const targetChatId = chatId;
2938
const targetDropdown = dropdown || this.mcpServerDropdown;
2587
-
2939
+
2940
targetDropdown.innerHTML = '';
2589
-
2941
+
2942
// Sort servers by name
2943
const sortedServers = Array.from(this.mcpServers.entries())
2944
.sort(([, a], [, b]) => a.name.localeCompare(b.name));
2593
-
2945
+
2946
for (const [id, server] of sortedServers) {
2947
const item = document.createElement('button');
2948
item.className = 'dropdown-item';
2597
-
2949
+
2950
// Check actual connection status from mcpConnections
2951
const mcpConnection = this.mcpConnections.get(id);
2952
const isConnected = mcpConnection && mcpConnection.isReady();
2601
-
2953
+
2954
item.innerHTML = `
2955
<div style="display: flex; align-items: flex-start; gap: 8px;">
2956
<span style="flex-shrink: 0;">${isConnected ? '🟢' : '🔴'}</span>
2960
</div>
2961
</div>
2962
`;
2611
-
2963
+
2964
const chat = this.chats.get(targetChatId);
2965
if (chat && chat.mcpServerId === id) {
2966
item.classList.add('active');
2967
}
2616
-
2968
+
2969
item.onclick = () => {
2970
this.switchMcpServer(id, targetChatId).catch(error => {
2971
console.error('Failed to switch MCP server:', error);
2973
});
2974
targetDropdown.style.display = 'none';
2975
};
2624
-
2976
+
2977
targetDropdown.appendChild(item);
2978
}
2979
}
2628
-
2980
+
2981
async switchMcpServer(newServerId, chatId) {
2982
const chat = this.chats.get(chatId);
2631
- if (!chat || chat.mcpServerId === newServerId) {return;}
2632
-
2983
+ if (!chat || chat.mcpServerId === newServerId) { return; }
2984
+
2985
try {
2986
// Ensure connection to new MCP server
2987
await this.ensureMcpConnection(newServerId);
2636
-
2988
+
2989
chat.mcpServerId = newServerId;
2990
this.autoSave(chat.id);
2639
-
2991
+
2992
// Update UI
2993
const server = this.mcpServers.get(newServerId);
2994
this.currentMcpText.textContent = server.name;
2643
-
2995
+
2996
// Clear tool inclusion states for the new server (will be populated on next use)
2997
const chatToolStates = this.toolInclusionStates.get(chatId);
2998
if (chatToolStates) {
2999
chatToolStates.clear();
3000
}
2649
-
3001
+
3002
// Save the updated config as default for new chats
3003
if (chat.config) {
3004
const updatedConfig = { ...chat.config };
3005
updatedConfig.mcpServer = newServerId;
3006
ChatConfig.saveLastConfig(updatedConfig);
3007
}
2656
-
3008
+
3009
this.addLogEntry('SYSTEM', {
3010
timestamp: new Date().toISOString(),
3011
direction: 'info',
3012
message: `Switched to MCP server: ${server.name}`
3013
});
2662
-
3014
+
3015
} catch (error) {
3016
this.showError(`Failed to switch MCP server: ${error.message}`, chatId, false);
3017
}
3019
3020
saveSystemPrompt(chatId) {
3021
const chat = this.chats.get(chatId);
2670
- if (!chat) {return;}
2671
-
3022
+ if (!chat) { return; }
3023
+
3024
const newPrompt = this.systemPromptTextarea.value.trim();
3025
if (!newPrompt) {
3026
this.showError('System prompt cannot be empty', chatId);
3027
return;
3028
}
2677
-
3029
+
3030
// Check if prompt actually changed
3031
if (newPrompt === chat.systemPrompt) {
3032
this.hideModal('systemPromptModal');
3033
return;
3034
}
2683
-
3035
+
3036
// Update the chat's system prompt
3037
chat.systemPrompt = newPrompt;
2686
-
3038
+
3039
// Clear messages and reset the conversation
3040
chat.messages = [];
3041
chat.updatedAt = new Date().toISOString();
2690
-
2691
- // Save the new prompt as the last used one
2692
- this.lastSystemPrompt = newPrompt;
2693
- localStorage.setItem('lastSystemPrompt', newPrompt);
2694
-
3042
+
3043
+ // Save the new prompt as the last used one, or remove if it's the default
3044
+ if (newPrompt === this.defaultSystemPrompt) {
3045
+ // If it's the default, remove from localStorage so we always use the current default
3046
+ this.lastSystemPrompt = this.defaultSystemPrompt;
3047
+ localStorage.removeItem('lastSystemPrompt');
3048
+ } else {
3049
+ // Only save to localStorage if it's a custom prompt
3050
+ this.lastSystemPrompt = newPrompt;
3051
+ localStorage.setItem('lastSystemPrompt', newPrompt);
3052
+ }
3053
+
3054
// Clear token usage history for this chat
3055
this.tokenUsageHistory.set(chatId, {
3056
requests: [],
3057
model: ChatConfig.getChatModelString(chat)
3058
});
2700
-
3059
+
3060
// Save settings
3061
this.saveSettings();
2703
-
3062
+
3063
// Reload the chat (force refresh since we cleared messages)
3064
this.loadChat(chatId, true);
2706
-
3065
+
3066
// Hide modal
3067
this.hideModal('systemPromptModal');
2709
-
3068
+
3069
// Show notification
3070
this.addSystemMessage('System prompt updated. Conversation has been reset.', chatId);
3071
}
3076
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
3077
html.setAttribute('data-theme', newTheme);
3078
localStorage.setItem('theme', newTheme);
2720
-
3079
+
3080
// Theme switching for tooltips is now handled by CSS variables
3081
}
3082
initializeResizable() {
3083
// Chat sidebar resize
3084
const chatSidebar = document.getElementById('chatSidebar');
3085
const chatSidebarResize = document.getElementById('chatSidebarResize');
2727
-
3086
+
3087
// Make sure the resize handle exists
3088
if (!chatSidebarResize) {
3089
console.error('Chat sidebar resize handle not found');
3092
const isCollapsed = chatSidebar.classList.contains('collapsed');
3093
const currentWidth = chatSidebar.offsetWidth;
3094
const newWidth = currentWidth + delta;
2736
-
3095
+
3096
// If collapsed and dragging to expand (delta > 0)
2738
- if (isCollapsed && newWidth > 100) {
2739
- // Expand the sidebar
2740
- chatSidebar.classList.remove('collapsed');
2741
- const icon = this.toggleSidebarBtn.querySelector('i');
2742
- icon.className = 'fas fa-chevron-left';
2743
- localStorage.setItem('chatSidebarCollapsed', 'false');
2744
-
2745
- // Set the new width
2746
- const finalExpandWidth = Math.max(200, Math.min(400, newWidth));
2747
- chatSidebar.style.setProperty('width', finalExpandWidth + 'px', 'important');
2748
- chatSidebar.style.setProperty('min-width', finalExpandWidth + 'px', 'important');
2749
- chatSidebar.style.setProperty('max-width', finalExpandWidth + 'px', 'important');
2750
- }
2751
- // If expanded and dragging to collapse (width getting too small)
2752
- else if (!isCollapsed && newWidth < 100) {
2753
- // Collapse the sidebar
2754
- chatSidebar.classList.add('collapsed');
2755
- const icon = this.toggleSidebarBtn.querySelector('i');
2756
- icon.className = 'fas fa-chevron-left';
2757
- localStorage.setItem('chatSidebarCollapsed', 'true');
2758
- chatSidebar.style.width = '';
2759
- }
2760
- // Normal resize when expanded
2761
- else if (!isCollapsed) {
2762
- const finalWidth = Math.max(200, Math.min(400, newWidth));
2763
-
2764
- // Override all width-related CSS properties
2765
- chatSidebar.style.setProperty('width', finalWidth + 'px', 'important');
2766
- chatSidebar.style.setProperty('min-width', finalWidth + 'px', 'important');
2767
- chatSidebar.style.setProperty('max-width', finalWidth + 'px', 'important');
2768
- }
2769
-
2770
- this.savePaneSizes();
2771
- });
3097
+ if (isCollapsed && newWidth > 100) {
3098
+ // Expand the sidebar
3099
+ chatSidebar.classList.remove('collapsed');
3100
+ const icon = this.toggleSidebarBtn.querySelector('i');
3101
+ icon.className = 'fas fa-chevron-left';
3102
+ localStorage.setItem('chatSidebarCollapsed', 'false');
3103
+
3104
+ // Set the new width
3105
+ const finalExpandWidth = Math.max(200, Math.min(400, newWidth));
3106
+ chatSidebar.style.setProperty('width', finalExpandWidth + 'px', 'important');
3107
+ chatSidebar.style.setProperty('min-width', finalExpandWidth + 'px', 'important');
3108
+ chatSidebar.style.setProperty('max-width', finalExpandWidth + 'px', 'important');
3109
+ }
3110
+ // If expanded and dragging to collapse (width getting too small)
3111
+ else if (!isCollapsed && newWidth < 100) {
3112
+ // Collapse the sidebar
3113
+ chatSidebar.classList.add('collapsed');
3114
+ const icon = this.toggleSidebarBtn.querySelector('i');
3115
+ icon.className = 'fas fa-chevron-left';
3116
+ localStorage.setItem('chatSidebarCollapsed', 'true');
3117
+ chatSidebar.style.width = '';
3118
+ }
3119
+ // Normal resize when expanded
3120
+ else if (!isCollapsed) {
3121
+ const finalWidth = Math.max(200, Math.min(400, newWidth));
3122
+
3123
+ // Override all width-related CSS properties
3124
+ chatSidebar.style.setProperty('width', finalWidth + 'px', 'important');
3125
+ chatSidebar.style.setProperty('min-width', finalWidth + 'px', 'important');
3126
+ chatSidebar.style.setProperty('max-width', finalWidth + 'px', 'important');
3127
+ }
3128
+
3129
+ this.savePaneSizes();
3130
+ });
3131
}
3132
3133
// Log panel resize
3134
const logPanel = document.getElementById('logPanel');
3135
const logPanelResize = document.getElementById('logPanelResize');
2777
-
3136
+
3137
this.setupResize(logPanelResize, 'horizontal', (delta) => {
3138
// First, ensure the panel is not collapsed
3139
if (logPanel.classList.contains('collapsed')) {
3145
// Set initial width when expanding
3146
logPanel.style.width = '300px';
3147
}
2789
-
3148
+
3149
const currentWidth = logPanel.offsetWidth;
3150
// For right panel, dragging left (negative delta) should increase width
3151
const newWidth = Math.max(200, Math.min(650, currentWidth + -delta));
3162
console.warn('setupResize called with null handle');
3163
return;
3164
}
2806
-
3165
+
3166
let isResizing = false;
3167
let startPos = 0;
2809
-
3168
+
3169
const startResize = (e) => {
3170
isResizing = true;
3171
startPos = direction === 'horizontal' ? e.clientX : e.clientY;
3172
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
3173
document.body.style.userSelect = 'none';
3174
e.preventDefault();
2816
-
3175
+
3176
// Add active class for visual feedback
3177
handle.classList.add('resize-active');
2819
-
3178
+
3179
// Add resizing class to element if provided
3180
if (element) {
3181
element.classList.add('resizing');
3182
}
3183
};
2825
-
3184
+
3185
const doResize = (e) => {
2827
- if (!isResizing) {return;}
2828
-
3186
+ if (!isResizing) { return; }
3187
+
3188
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
3189
const delta = currentPos - startPos;
3190
startPos = currentPos;
2832
-
3191
+
3192
onResize(delta);
3193
};
2835
-
3194
+
3195
const stopResize = () => {
2837
- if (!isResizing) {return;}
3196
+ if (!isResizing) { return; }
3197
isResizing = false;
3198
document.body.style.cursor = '';
3199
document.body.style.userSelect = '';
2841
-
3200
+
3201
// Remove active class
3202
handle.classList.remove('resize-active');
2844
-
3203
+
3204
// Remove resizing class from element if provided
3205
if (element) {
3206
element.classList.remove('resizing');
3207
}
3208
};
2850
-
3209
+
3210
handle.addEventListener('mousedown', startResize);
3211
document.addEventListener('mousemove', doResize);
3212
document.addEventListener('mouseup', stopResize);
2854
-
3213
+
3214
// Also handle mouse leave to stop resize
3215
document.addEventListener('mouseleave', stopResize);
3216
}
2858
-
3217
+
3218
makeResizable(handle, container, direction, minSize, maxSize) {
3219
if (!handle || !container) {
3220
console.warn('makeResizable called with null handle or container');
3221
return;
3222
}
2864
-
3223
+
3224
this.setupResize(handle, direction, (delta) => {
3225
const isVertical = direction === 'vertical';
3226
const currentSize = isVertical ? container.offsetHeight : container.offsetWidth;
3227
const newSize = Math.max(minSize || 100, Math.min(maxSize || 1000, currentSize + (isVertical ? -delta : delta)));
2869
-
3228
+
3229
if (isVertical) {
3230
container.style.height = newSize + 'px';
3231
} else {
3248
if (savedSizes) {
3249
try {
3250
const sizes = JSON.parse(savedSizes);
2892
-
3251
+
3252
if (sizes.chatSidebar && this.chatSidebar && !this.chatSidebar.classList.contains('collapsed')) {
3253
this.chatSidebar.style.width = sizes.chatSidebar + 'px';
3254
}
2896
-
3255
+
3256
if (sizes.logPanel && this.logPanel) {
3257
// Only set width if the panel is not currently collapsed
3258
if (!this.logPanel.classList.contains('collapsed')) {
3259
this.logPanel.style.width = sizes.logPanel + 'px';
3260
}
3261
}
2903
-
3262
+
3263
// Chat input container sizing is now handled per-chat, skip global sizing
3264
} catch (e) {
3265
console.error('Failed to load pane sizes:', e);
3274
localStorage.setItem('logCollapsed', String(isCollapsed));
3275
this.savePaneSizes();
3276
}
2918
-
3277
+
3278
toggleChatSidebar() {
3279
const isCollapsed = this.chatSidebar.classList.toggle('collapsed');
2921
-
3280
+
3281
// Update button icon - always keep as chevron-left, CSS handles rotation when collapsed
3282
const icon = this.toggleSidebarBtn.querySelector('i');
3283
icon.className = 'fas fa-chevron-left';
2925
-
3284
+
3285
if (isCollapsed) {
3286
// Store current width before collapsing
3287
const currentWidth = this.chatSidebar.offsetWidth;
3295
const savedWidth = localStorage.getItem('chatSidebarWidth') || '280';
3296
this.chatSidebar.style.width = savedWidth + 'px';
3297
}
2939
-
3298
+
3299
localStorage.setItem('chatSidebarCollapsed', String(isCollapsed));
3300
this.savePaneSizes();
3301
}
2943
-
3302
+
3303
loadSidebarStates() {
3304
// Load chat sidebar state
3305
const chatSidebarCollapsed = localStorage.getItem('chatSidebarCollapsed') === 'true';
3309
const icon = this.toggleSidebarBtn.querySelector('i');
3310
icon.className = 'fas fa-chevron-left';
3311
}
2953
-
3312
+
3313
// Load log panel state
3314
const logCollapsed = localStorage.getItem('logCollapsed') === 'true';
3315
if (logCollapsed) {
3322
handleRateLimitError(chatId, retryAfterSeconds, retryCount = 0) {
3323
const chat = this.chats.get(chatId);
3324
if (!chat) return;
2966
-
3325
+
3326
// Store retry count in chat for tracking
3327
chat.rateLimitRetryCount = retryCount;
2969
-
3328
+
3329
// If we couldn't parse retry time, use exponential backoff
3330
let waitTime;
3331
if (retryAfterSeconds && retryAfterSeconds > 0) {
3336
waitTime = Math.min(5 * Math.pow(2, retryCount), 120); // Cap at 2 minutes
3337
console.log(`[Rate Limit] No retry time found, using exponential backoff: ${waitTime}s (attempt ${retryCount + 1})`);
3338
}
2980
-
3339
+
3340
let remainingSeconds = Math.ceil(waitTime);
2982
-
3341
+
3342
// Mark that we're in rate limit countdown - this prevents other operations from clearing the spinner
3343
chat.isInRateLimitCountdown = true;
2985
-
3344
+
3345
// Show waiting spinner with countdown immediately
3346
this.showWaitingCountdown(chatId, remainingSeconds);
2988
-
3347
+
3348
const updateCountdown = () => {
3349
remainingSeconds--;
3350
if (remainingSeconds > 0) {
3361
this.retryLLMRequest(chatId);
3362
}
3363
};
3005
-
3364
+
3365
// Start the countdown
3366
setTimeout(updateCountdown, 1000);
3367
}
3009
-
3368
+
3369
async retryLLMRequest(chatId) {
3370
const chat = this.chats.get(chatId);
3371
if (!chat) return;
3013
-
3372
+
3373
try {
3374
const mcpConnection = this.mcpConnections.get(chat.mcpServerId);
3375
const proxyProvider = this.llmProviders.get(chat.llmProviderId);
3017
-
3376
+
3377
if (!mcpConnection || !proxyProvider || !chat.config?.model) {
3378
this.showError('Cannot retry: MCP server or LLM provider not available', chatId);
3379
return;
3380
}
3022
-
3381
+
3382
// Create provider instance
3383
const providerType = chat.config.model.provider;
3384
const modelName = chat.config.model.id;
3026
- const provider = createLLMProvider(providerType, proxyProvider.proxyUrl, modelName);
3385
+
3386
+ // Get the API type from the provider configuration
3387
+ const providerApiType = proxyProvider.availableProviders?.[providerType]?.type || providerType;
3388
+
3389
+ const provider = createLLMProvider(providerApiType, proxyProvider.proxyUrl, modelName, proxyProvider.availableProviders?.[providerType], providerType);
3390
provider.onLog = (logEntry) => {
3391
const prefix = logEntry.direction === 'sent' ? 'llm-request' : 'llm-response';
3392
const providerName = providerType.charAt(0).toUpperCase() + providerType.slice(1);
3393
this.addLogEntry(`${prefix}: ${providerName}`, logEntry);
3394
};
3032
-
3395
+
3396
// Build messages from current state
3397
const { messages, cacheControlIndex } = this.buildMessagesForAPI(chat, provider.prefersCachedTools, mcpConnection);
3035
-
3398
+
3399
// Get available tools
3400
const tools = Array.from(mcpConnection.tools.values());
3038
-
3401
+
3402
// Increment retry count for next attempt
3403
const currentRetryCount = chat.rateLimitRetryCount || 0;
3404
chat.rateLimitRetryCount = currentRetryCount + 1;
3042
-
3405
+
3406
// Call assistant with proper error handling
3407
const temperature = this.getCurrentTemperature(chatId);
3408
const response = await this.callAssistant({
3414
cacheControlIndex,
3415
context: `Retry (attempt ${chat.rateLimitRetryCount})`
3416
});
3054
-
3417
+
3418
// Check if rate limit was handled
3419
if (response._rateLimitHandled) {
3420
return; // Rate limit retry will happen automatically with exponential backoff
3421
}
3059
-
3422
+
3423
// Success - reset retry count
3424
chat.rateLimitRetryCount = 0;
3062
-
3425
+
3426
// Process the response - extract the core loop logic from processMessageWithTools
3427
// This continues the conversation from where it left off
3428
await this.processLLMResponseLoop(chat, mcpConnection, provider, messages, tools, cacheControlIndex, response);
3066
-
3429
+
3430
// Success - assistant has concluded
3431
this.assistantConcluded(chatId);
3069
-
3432
+
3433
} catch (error) {
3434
// Clean up on error - but preserve rate limit waiting spinner
3435
this.assistantFailed(chatId, error);
3073
-
3436
+
3437
// Only show error if not a handled rate limit
3438
if (!error._rateLimitHandled) {
3439
// Show error with manual retry button
3440
const errorMessage = `${error.context || 'Error'}: ${error.message}`;
3441
const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
3079
-
3442
+
3443
// Determine error type
3444
let errorType = 'llm_error';
3445
if (error.message.includes('MCP') || error.message.includes('connection')) {
3447
} else if (error.message.includes('Tool')) {
3448
errorType = 'tool_error';
3449
}
3087
-
3450
+
3451
// Update error state
3452
this.showError(errorMessage, chatId, false, errorType);
3090
-
3453
+
3454
// Add error message
3092
- this.addMessage(chatId, {
3093
- role: 'error',
3094
- content: errorMessage,
3455
+ this.addMessage(chatId, {
3456
+ role: 'error',
3457
+ content: errorMessage,
3458
errorMessageIndex: lastUserMessageIndex,
3459
errorType
3460
});
3098
-
3099
- this.processRenderEvent({
3100
- type: 'error-message',
3101
- content: errorMessage,
3461
+
3462
+ this.processRenderEvent({
3463
+ type: 'error-message',
3464
+ content: errorMessage,
3465
errorMessageIndex: lastUserMessageIndex,
3466
errorType
3467
}, chatId);
3468
}
3469
}
3470
}
3108
-
3471
+
3472
async processLLMResponseLoop(chat, mcpConnection, provider, messages, tools, cacheControlIndex, initialResponse = null) {
3473
// If we have an initial response (from retry), process it first
3474
if (initialResponse) {
3475
await this.processSingleLLMResponse(chat, mcpConnection, provider, messages, tools, cacheControlIndex, initialResponse);
3476
}
3114
-
3477
+
3478
// Continue the loop
3479
while (true) {
3480
// Check if we should stop processing for this chat
3481
if (chat.shouldStopProcessing) {
3482
break;
3483
}
3121
-
3484
+
3485
// Check if the last response had tool calls
3486
const lastMessage = messages[messages.length - 1];
3487
if (!lastMessage || lastMessage.role !== 'tool-results') {
3488
// No more tool results to process, we're done
3489
break;
3490
}
3128
-
3491
+
3492
// Safety check: Check iteration limit before continuing
3493
try {
3494
// Only check iteration limit here, not request size
3499
}
3500
} catch (error) {
3501
if (error instanceof SafetyLimitError) {
3139
- this.addMessage(chat.id, {
3140
- role: 'error',
3502
+ this.addMessage(chat.id, {
3503
+ role: 'error',
3504
content: error.message,
3505
errorType: 'safety_limit',
3506
isRetryable: false
3507
});
3145
- this.processRenderEvent({
3146
- type: 'error-message',
3147
- content: error.message,
3508
+ this.processRenderEvent({
3509
+ type: 'error-message',
3510
+ content: error.message,
3511
errorType: 'safety_limit'
3512
}, chat.id);
3513
return;
3514
}
3515
throw error;
3516
}
3154
-
3517
+
3518
// Send next request to LLM
3519
const temperature = this.getCurrentTemperature(chat.id);
3520
// eslint-disable-next-line no-await-in-loop
3527
cacheControlIndex,
3528
context: 'Processing tools'
3529
});
3167
-
3530
+
3531
// Check if rate limit was handled automatically
3532
if (response._rateLimitHandled) {
3533
return { rateLimitHandled: true };
3534
}
3172
-
3535
+
3536
// Process the response
3537
// eslint-disable-next-line no-await-in-loop
3538
await this.processSingleLLMResponse(chat, mcpConnection, provider, messages, tools, cacheControlIndex, response);
3539
}
3540
}
3178
-
3541
+
3542
async processSingleLLMResponse(chat, mcpConnection, provider, messages, tools, cacheControlIndex, response) {
3543
const llmResponseTime = response._responseTime || 0;
3181
-
3544
+
3545
// Track token usage
3546
if (response.usage) {
3184
- this.updateTokenUsage(chat.id, response.usage, ChatConfig.getChatModelString(chat) || provider.model);
3547
+ this.updateTokenUsage(chat.id, response.usage, ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`);
3548
}
3186
-
3549
+
3550
// If no tool calls, display response and finish
3551
const toolsInContent = this.extractToolsFromContent(response.content);
3552
if (toolsInContent.length === 0) {
3553
// Emit metrics event first
3191
- this.processRenderEvent({
3192
- type: 'assistant-metrics',
3193
- usage: response.usage,
3194
- responseTime: llmResponseTime,
3195
- model: ChatConfig.getChatModelString(chat) || provider.model
3554
+ this.processRenderEvent({
3555
+ type: 'assistant-metrics',
3556
+ usage: response.usage,
3557
+ responseTime: llmResponseTime,
3558
+ model: ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`
3559
}, chat.id);
3197
-
3560
+
3561
if (response.content) {
3562
// Create and save the assistant message
3200
- const assistantMsg = {
3201
- role: 'assistant',
3563
+ const assistantMsg = {
3564
+ role: 'assistant',
3565
content: response.content,
3566
usage: response.usage || null,
3567
responseTime: llmResponseTime || null,
3205
- model: provider.model || ChatConfig.getChatModelString(chat),
3568
+ model: ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`,
3569
turn: chat.currentTurn
3570
};
3571
this.addMessage(chat.id, assistantMsg);
3209
-
3572
+
3573
// Display it
3574
const messageIndex = chat.messages.length - 1;
3212
- this.processRenderEvent({
3213
- type: 'assistant-message',
3575
+ this.processRenderEvent({
3576
+ type: 'assistant-message',
3577
content: response.content,
3578
messageIndex
3579
}, chat.id);
3217
-
3580
+
3581
// Track cumulative tokens
3582
if (response.usage) {
3220
- const modelUsed = ChatConfig.getChatModelString(chat) || provider.model;
3583
+ const modelUsed = ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`;
3584
this.addCumulativeTokens(
3585
chat.id,
3586
modelUsed,
3590
response.usage.cacheCreationInputTokens || 0
3591
);
3592
}
3230
-
3593
+
3594
// Clean and add to messages for API
3595
const cleanedContent = this.cleanContentForAPI(response.content);
3596
// Always add assistant message when no tool calls, even if content is empty
3599
}
3600
return;
3601
}
3239
-
3602
+
3603
// Process response with tool calls
3604
let assistantMessageIndex = null;
3242
-
3605
+
3606
// Save assistant message first
3607
if (response.content || toolsInContent.length > 0) {
3608
const assistantMessage = {
3610
content: response.content || '',
3611
usage: response.usage || null,
3612
responseTime: llmResponseTime || null,
3250
- model: provider.model || ChatConfig.getChatModelString(chat),
3613
+ model: ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`,
3614
turn: chat.currentTurn,
3615
cacheControlIndex
3616
};
3617
this.addMessage(chat.id, assistantMessage);
3618
assistantMessageIndex = chat.messages.length - 1;
3256
-
3619
+
3620
// Track cumulative tokens
3621
if (response.usage) {
3259
- const modelUsed = ChatConfig.getChatModelString(chat) || provider.model;
3622
+ const modelUsed = ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`;
3623
this.addCumulativeTokens(
3624
chat.id,
3625
modelUsed,
3629
response.usage.cacheCreationInputTokens || 0
3630
);
3631
}
3269
-
3632
+
3633
// Display metrics and content
3271
- this.processRenderEvent({
3272
- type: 'assistant-metrics',
3273
- usage: response.usage,
3274
- responseTime: llmResponseTime,
3275
- model: ChatConfig.getChatModelString(chat) || provider.model
3634
+ this.processRenderEvent({
3635
+ type: 'assistant-metrics',
3636
+ usage: response.usage,
3637
+ responseTime: llmResponseTime,
3638
+ model: ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`
3639
}, chat.id);
3277
-
3640
+
3641
if (response.content) {
3279
- this.processRenderEvent({
3280
- type: 'assistant-message',
3642
+ this.processRenderEvent({
3643
+ type: 'assistant-message',
3644
content: response.content,
3645
messageIndex: assistantMessageIndex
3646
}, chat.id);
3647
}
3285
-
3648
+
3649
// Add to API messages - keep original content with tool calls
3650
const assistantMsg = {
3651
role: 'assistant',
3652
content: response.content || ''
3653
};
3291
-
3654
+
3655
// Only add if there's content or tool calls
3656
const cleanedContent = response.content ? this.cleanContentForAPI(response.content) : '';
3657
if (cleanedContent || this.extractToolsFromContent(response.content).length > 0) {
3658
messages.push(assistantMsg);
3659
}
3660
}
3298
-
3661
+
3662
// Execute tool calls - extract from content array
3663
const extractedTools = this.extractToolsFromContent(response.content);
3664
if (extractedTools.length > 0) {
3665
// Increment iteration counter when we have tool calls
3666
this.safetyChecker.incrementIterations(chat.id);
3304
-
3667
+
3668
// Safety check for concurrent tools
3669
try {
3670
this.safetyChecker.checkConcurrentToolsLimit(extractedTools);
3671
} catch (error) {
3672
if (error instanceof SafetyLimitError) {
3310
- this.addMessage(chat.id, {
3311
- role: 'error',
3673
+ this.addMessage(chat.id, {
3674
+ role: 'error',
3675
content: error.message,
3676
errorType: 'safety_limit',
3677
isRetryable: false
3678
});
3316
- this.processRenderEvent({
3317
- type: 'error-message',
3318
- content: error.message,
3679
+ this.processRenderEvent({
3680
+ type: 'error-message',
3681
+ content: error.message,
3682
errorType: 'safety_limit'
3683
}, chat.id);
3684
return;
3685
}
3686
throw error;
3687
}
3325
-
3688
+
3689
// Ensure assistant group exists
3690
if (!this.getCurrentAssistantGroup(chat.id)) {
3328
- this.processRenderEvent({
3329
- type: 'assistant-message',
3691
+ this.processRenderEvent({
3692
+ type: 'assistant-message',
3693
content: '',
3694
messageIndex: assistantMessageIndex
3695
}, chat.id);
3696
}
3334
-
3697
+
3698
// Execute tools and collect results
3699
const toolResults = await this.executeToolCalls(chat, mcpConnection, extractedTools, assistantMessageIndex);
3337
-
3700
+
3701
// Store tool results
3702
if (toolResults.length > 0) {
3703
this.addMessage(chat.id, {
3705
toolResults,
3706
turn: chat.currentTurn
3707
});
3345
-
3708
+
3709
// CRITICAL: Now that tool-results are added, update sub-chat costs
3710
for (const toolResult of toolResults) {
3711
if (toolResult.subChatId && toolResult.wasProcessedBySubChat) {
3720
}
3721
}
3722
}
3360
-
3723
+
3724
// CRITICAL FIX: Update parent chat token pricing to include all sub-chat costs
3725
this.updateChatTokenPricing(chat);
3726
this.updateAllTokenDisplays(chat.id);
3364
-
3727
+
3728
// Add to messages for API
3729
const includedResults = toolResults.filter(tr => tr.includeInContext !== false);
3730
if (includedResults.length > 0) {
3736
result: tr.result
3737
}))
3738
};
3376
-
3739
+
3740
messages.push(toolResultsMessage);
3378
-
3741
+
3742
// Sub-chats are now processed immediately during tool execution (interleaved)
3743
}
3381
-
3744
+
3745
// Reset assistant group and show thinking spinner for next iteration
3746
this.processRenderEvent({ type: 'reset-assistant-group' }, chat.id);
3747
this.showAssistantThinking(chat.id);
3748
}
3749
}
3750
}
3388
-
3751
+
3752
async executeToolCalls(chat, mcpConnection, toolCalls, assistantMessageIndex) {
3753
const toolResults = [];
3391
-
3754
+
3755
for (const toolCall of toolCalls) {
3756
if (!toolCall.id) {
3757
console.error('[executeToolCalls] Tool call missing required id:', toolCall);
3758
continue;
3759
}
3397
-
3760
+
3761
try {
3762
const { arguments: toolArgs } = toolCall || {};
3400
-
3763
+
3764
// Show tool call in UI
3402
- this.processRenderEvent({
3403
- type: 'tool-call',
3404
- name: toolCall.name,
3765
+ this.processRenderEvent({
3766
+ type: 'tool-call',
3767
+ name: toolCall.name,
3768
arguments: toolArgs,
3769
id: toolCall.id,
3407
- includeInContext: toolCall.includeInContext !== false
3770
+ includeInContext: toolCall.includeInContext !== false
3771
}, chat.id);
3409
-
3772
+
3773
// Show tool execution spinner
3774
this.showToolExecuting(chat.id, toolCall.name);
3412
-
3775
+
3776
// Execute tool
3777
const toolStartTime = Date.now();
3778
// eslint-disable-next-line no-await-in-loop
3779
const rawResult = await mcpConnection.callTool(toolCall.name, toolArgs);
3780
const toolResponseTime = Date.now() - toolStartTime;
3418
-
3781
+
3782
// Hide tool execution spinner
3783
this.hideToolExecuting(chat.id);
3421
-
3784
+
3785
// Parse result
3786
const result = this.parseToolResult(rawResult);
3424
- const responseSize = typeof result === 'string'
3425
- ? result.length
3787
+ const responseSize = typeof result === 'string'
3788
+ ? result.length
3789
: JSON.stringify(result).length;
3427
-
3790
+
3791
// Check if we should create a sub-chat for this tool response
3792
// eslint-disable-next-line no-await-in-loop
3793
const shouldCreateSubChat = await this.shouldCreateSubChat(chat, responseSize, toolCall);
3794
3795
// Show result only if we're not creating a sub-chat
3796
if (!shouldCreateSubChat) {
3434
- this.processRenderEvent({
3435
- type: 'tool-result',
3436
- name: toolCall.name,
3437
- result,
3797
+ this.processRenderEvent({
3798
+ type: 'tool-result',
3799
+ name: toolCall.name,
3800
+ result,
3801
toolCallId: toolCall.id,
3439
- responseTime: toolResponseTime,
3440
- responseSize,
3441
- messageIndex: assistantMessageIndex
3802
+ responseTime: toolResponseTime,
3803
+ responseSize,
3804
+ messageIndex: assistantMessageIndex
3805
}, chat.id);
3806
}
3444
-
3807
+
3808
if (shouldCreateSubChat) {
3446
- console.log(`[executeToolCalls] Creating sub-chat for tool ${toolCall.id} (${toolCall.name})`);
3447
-
3809
// Create sub-chat for processing this tool response
3810
// eslint-disable-next-line no-await-in-loop
3811
const subChatId = await this.createSubChatForTool(chat, toolCall, result);
3451
-
3452
- console.log(`[executeToolCalls] Created sub-chat ${subChatId} for tool ${toolCall.id}`);
3453
-
3812
+
3813
// Show secondary assistant waiting spinner for main chat
3814
this.showSecondaryAssistantWaiting(chat.id);
3456
-
3815
+
3816
// Render sub-chat DOM BEFORE processing starts so users can see it populate
3458
- this.renderSubChatAsItem(chat.id, subChatId, toolCall.id, 'processing');
3459
-
3817
+ // Pass the original response size
3818
+ this.renderSubChatAsItem(chat.id, subChatId, toolCall.id, 'processing', responseSize, null);
3819
+
3820
// CRITICAL: Ensure the sub-chat container is available before processing
3821
// The container should have been created by renderSubChatAsItem
3822
const subChatContainer = this.chatContainers.get(subChatId);
3826
this.updateSubChatStatus(chat.id, toolCall.id, 'failed');
3827
continue;
3828
}
3469
-
3829
+
3830
// Process the sub-chat immediately (interleaved execution)
3831
let summarizedResult = null;
3832
try {
3833
// eslint-disable-next-line no-await-in-loop
3834
summarizedResult = await this.processSubChat(subChatId, chat.id, toolCall.id);
3475
-
3835
+
3836
if (!summarizedResult) {
3837
console.warn('[Sub-chat Processing] No summarized result returned, keeping original');
3838
+ } else if (summarizedResult.startsWith('ERROR: Tool request succeeded')) {
3839
+ console.warn(`[Sub-chat Processing] Sub-chat failed with error: ${summarizedResult}`);
3840
} else {
3841
console.log(`[Sub-chat Processing] Replacing tool result for ${toolCall.id} with summarized content (${summarizedResult.length} chars)`);
3842
}
3481
-
3843
+
3844
// Update sub-chat status to final state
3483
- this.updateSubChatStatus(chat.id, toolCall.id, summarizedResult ? 'success' : 'failed');
3845
+ // Check if the result contains an error message
3846
+ const isError = summarizedResult && summarizedResult.startsWith('ERROR: Tool request succeeded');
3847
+ // Calculate the size of the summarized result in bytes
3848
+ const summarizedSize = summarizedResult ? new Blob([summarizedResult]).size : 0;
3849
+ this.updateSubChatStatus(chat.id, toolCall.id, isError ? 'failed' : (summarizedResult ? 'success' : 'failed'), responseSize, summarizedSize);
3850
} catch (error) {
3851
console.error('[Sub-chat Processing] Failed:', error);
3852
// Update sub-chat status to failed
3853
this.updateSubChatStatus(chat.id, toolCall.id, 'failed');
3854
}
3489
-
3855
+
3856
// Hide secondary assistant waiting spinner
3857
this.hideSecondaryAssistantWaiting(chat.id);
3492
-
3493
- this.processRenderEvent({
3494
- type: 'tool-result',
3495
- name: toolCall.name,
3858
+
3859
+ this.processRenderEvent({
3860
+ type: 'tool-result',
3861
+ name: toolCall.name,
3862
result: summarizedResult || result, // Use summarized result if available
3863
toolCallId: toolCall.id,
3498
- responseTime: toolResponseTime,
3499
- responseSize,
3864
+ responseTime: toolResponseTime,
3865
+ responseSize,
3866
messageIndex: assistantMessageIndex,
3867
subChatId,
3868
wasProcessedBySubChat: !!summarizedResult
3869
}, chat.id);
3504
-
3870
+
3871
// Add the processed tool result
3872
+ // Check if the result contains an error message
3873
+ const resultIsError = summarizedResult && summarizedResult.startsWith('ERROR: Tool request succeeded');
3874
+
3875
const processedToolResult = {
3876
toolCallId: toolCall.id,
3877
name: toolCall.name,
3879
includeInContext: true,
3880
subChatId, // Keep for tracking
3881
wasProcessedBySubChat: !!summarizedResult,
3513
- subChatFailed: !summarizedResult
3882
+ subChatFailed: !summarizedResult || resultIsError,
3883
+ is_error: resultIsError, // Mark as error if sub-chat failed
3884
+ originalResponseSize: responseSize // Store original size for loaded chats
3885
};
3886
toolResults.push(processedToolResult);
3516
-
3887
+
3888
// Update the DOM display to show final state
3889
this.updateToolResultDisplay(chat.id, toolCall.id, processedToolResult);
3519
-
3890
+
3891
// CRITICAL: Save the updated parent chat with summarized results
3892
this.autoSave(chat.id);
3893
} else {
3898
includeInContext: true
3899
});
3900
}
3530
-
3901
+
3902
} catch (error) {
3903
const errorMsg = `Tool error (${toolCall.name}): ${error.message}`;
3533
- this.processRenderEvent({
3534
- type: 'tool-result',
3535
- name: toolCall.name,
3536
- result: { error: errorMsg },
3537
- responseTime: 0,
3538
- responseSize: errorMsg.length,
3539
- messageIndex: assistantMessageIndex,
3540
- toolCallId: toolCall.id
3904
+ this.processRenderEvent({
3905
+ type: 'tool-result',
3906
+ name: toolCall.name,
3907
+ result: { error: errorMsg },
3908
+ responseTime: 0,
3909
+ responseSize: errorMsg.length,
3910
+ messageIndex: assistantMessageIndex,
3911
+ toolCallId: toolCall.id
3912
}, chat.id);
3542
-
3913
+
3914
toolResults.push({
3915
toolCallId: toolCall.id,
3916
name: toolCall.name,
3919
});
3920
}
3921
}
3551
-
3922
+
3923
return toolResults;
3924
}
3554
-
3925
+
3926
/**
3927
* Centralized function to call the assistant API with consistent error handling
3928
* @param {Object} params - Parameters for the assistant call
3939
if (!chatId) {
3940
throw new Error('[callAssistant] Missing required chatId parameter');
3941
}
3571
-
3942
+
3943
const chat = this.chats.get(chatId);
3944
if (!chat) {
3945
throw new Error(`[callAssistant] Chat not found: ${chatId}`);
3946
}
3576
-
3947
+
3948
// Show thinking spinner
3949
this.showAssistantThinking(chatId);
3579
-
3950
+
3951
try {
3952
// Track timing
3953
const llmStartTime = Date.now();
3954
const response = await provider.sendMessage(messages, tools, temperature, chat.config.optimisation.cacheControl || 'all-off', cacheControlIndex, chat);
3955
const llmResponseTime = Date.now() - llmStartTime;
3585
-
3956
+
3957
// Store response time
3958
response._responseTime = llmResponseTime;
3588
-
3959
+
3960
// Hide spinner on success
3961
this.hideAssistantThinking(chatId);
3591
-
3962
+
3963
return response;
3593
-
3964
+
3965
} catch (error) {
3966
// Check for rate limit error FIRST before hiding spinner
3967
const isRateLimitError = error.message && (
3597
- error.message.includes('Rate limit') ||
3968
+ error.message.includes('Rate limit') ||
3969
error.message.includes('429') ||
3599
- error.message.includes('rate_limit_exceeded')
3970
+ error.message.includes('rate_limit_exceeded') ||
3971
+ error.message.includes('529') ||
3972
+ error.message.includes('overloaded_error') ||
3973
+ error.message.includes('Overloaded')
3974
);
3601
-
3975
+
3976
// Extract retry-after seconds if available (handles multiple formats)
3977
let retryAfterSeconds = null;
3604
-
3978
+
3979
// Try different patterns
3980
const patterns = [
3981
/Please try again in (\d+(?:\.\d+)?)s/, // "Please try again in 4.742s"
3982
/Please retry after (\d+) second/, // "Please retry after 5 seconds"
3983
/try again in (\d+(?:\.\d+)?) second/i // Various formats
3984
];
3611
-
3985
+
3986
for (const pattern of patterns) {
3987
const match = error.message && error.message.match(pattern);
3988
if (match) {
3990
break;
3991
}
3992
}
3619
-
3993
+
3994
if (isRateLimitError) {
3995
// Always handle rate limit errors, even without retry time
3996
// Don't hide spinner - let handleRateLimitError manage the transition
3999
// Return a special marker to indicate rate limit handling
4000
return { _rateLimitHandled: true };
4001
}
3628
-
4002
+
4003
// Only hide spinner for non-rate-limit errors
4004
this.hideAssistantThinking(chatId);
3631
-
4005
+
4006
// Add context to error
4007
error.context = context;
4008
throw error;
4009
}
4010
}
3637
-
4011
+
4012
/**
4013
* Called when the assistant has finished processing and no more actions will occur
4014
* Ensures proper cleanup of UI state and chat processing flags
4018
console.error('[assistantConcluded] Called without chatId');
4019
return;
4020
}
3647
-
4021
+
4022
const chat = this.chats.get(chatId);
4023
if (!chat) {
4024
console.error(`[assistantConcluded] Chat not found: ${chatId}`);
4025
return;
4026
}
3653
-
4027
+
4028
// Clear any spinners
4029
this.clearSpinnerState(chatId);
3656
-
4030
+
4031
// Clear processing states
4032
chat.isProcessing = false;
3659
-
4033
+
4034
// Clear stop-related flags to ensure they're ready for next time
4035
chat.shouldStopProcessing = false;
4036
chat.processingWasStoppedByUser = false;
3663
-
4037
+
4038
// Clear error state on successful conclusion
4039
this.clearError(chatId);
3666
-
4040
+
4041
// Clear current assistant group
4042
this.clearCurrentAssistantGroup(chatId);
3669
-
4043
+
4044
// Reset safety checker iterations for next user message
4045
this.safetyChecker.resetIterations(chatId);
3672
-
4046
+
4047
// Update only this chat's tile
4048
this.updateChatTileStatus(chatId);
3675
-
4049
+
4050
// Save the chat state
4051
chat.updatedAt = new Date().toISOString();
4052
this.autoSave(chatId);
3679
-
4053
+
4054
// Always re-enable input for the chat that concluded
4055
const container = this.getChatContainer(chatId);
4056
if (container && container._elements) {
4058
if (input) {
4059
// Always re-enable contentEditable
4060
input.contentEditable = true;
3687
-
4061
+
4062
// Only focus if this is the active chat
4063
if (chatId === this.getActiveChatId()) {
4064
input.focus();
4065
}
4066
}
3693
-
4067
+
4068
// Update send button state
4069
const sendBtn = container._elements.sendBtn;
4070
if (sendBtn && input) {
4078
}
4079
}
4080
}
3707
-
4081
+
4082
/**
4083
* Called when the assistant fails with an error
4084
* Handles cleanup differently based on error type
4088
console.error('[assistantFailed] Called without chatId');
4089
return;
4090
}
3717
-
4091
+
4092
const chat = this.chats.get(chatId);
4093
if (!chat) {
4094
console.error(`[assistantFailed] Chat not found: ${chatId}`);
4095
return;
4096
}
3723
-
4097
+
4098
// Check if this is a rate limit error that's being handled
4099
const isRateLimitHandled = error && error._rateLimitHandled;
3726
-
4100
+
4101
// Only clear spinners if NOT a handled rate limit error
4102
if (!isRateLimitHandled) {
4103
this.clearSpinnerState(chatId);
4104
}
4105
// If rate limit is handled, the waiting spinner should continue
3732
-
4106
+
4107
// Clear processing states
4108
chat.isProcessing = false;
3735
-
4109
+
4110
// Clear stop-related flags when failure is handled
4111
chat.shouldStopProcessing = false;
4112
chat.processingWasStoppedByUser = false;
3739
-
4113
+
4114
// Clear current assistant group
4115
this.clearCurrentAssistantGroup(chatId);
3742
-
4116
+
4117
// Reset safety checker iterations for next user message
4118
this.safetyChecker.resetIterations(chatId);
3745
-
4119
+
4120
// Update only this chat's tile
4121
this.updateChatTileStatus(chatId);
3748
-
4122
+
4123
// Save the chat state
4124
chat.updatedAt = new Date().toISOString();
4125
this.autoSave(chatId);
3752
-
4126
+
4127
// Re-enable input if not rate limited (always for the chat that failed)
4128
if (!isRateLimitHandled) {
4129
const container = this.getChatContainer(chatId);
4132
if (input) {
4133
// Always re-enable contentEditable
4134
input.contentEditable = true;
3761
-
4135
+
4136
// Only focus if this is the active chat
4137
if (chatId === this.getActiveChatId()) {
4138
input.focus();
4141
}
4142
}
4143
}
3770
-
4144
+
4145
/**
4146
* Shows a professional confirmation dialog modal
4147
* @param {string} title - Dialog title
4171
</div>
4172
</div>
4173
`;
3800
-
4174
+
4175
document.body.appendChild(modal);
3802
-
4176
+
4177
// Focus the confirm button
4178
const confirmBtn = modal.querySelector('.confirm-ok');
4179
const cancelBtn = modal.querySelector('.confirm-cancel');
4180
confirmBtn.focus();
3807
-
4181
+
4182
// Define all functions using function declarations to avoid hoisting issues
4183
function cleanup() {
4184
modal.remove();
4185
document.removeEventListener('keydown', handleKeydown);
4186
}
3813
-
4187
+
4188
function handleConfirm() {
4189
cleanup();
4190
resolve(true);
4191
}
3818
-
4192
+
4193
function handleCancel() {
4194
cleanup();
4195
resolve(false);
4196
}
3823
-
4197
+
4198
function handleKeydown(e) {
4199
if (e.key === 'Enter') {
4200
e.preventDefault();
4204
handleCancel();
4205
}
4206
}
3833
-
4207
+
4208
// Add event listeners
4209
confirmBtn.addEventListener('click', handleConfirm);
4210
cancelBtn.addEventListener('click', handleCancel);
4212
document.addEventListener('keydown', handleKeydown);
4213
});
4214
}
3841
-
4215
+
4216
/**
4217
* Escapes HTML to prevent XSS
4218
*/
4221
div.textContent = text;
4222
return div.innerHTML;
4223
}
3850
-
4224
+
4225
showError(message, chatId, saveToMessages = true, errorType = 'general') {
4226
// Log to console
4227
console.error('MCP Client Error:', message, chatId ? `(Chat ID: ${chatId})` : '(Global)');
3854
-
4228
+
4229
// Show error toast
4230
const toast = document.createElement('div');
4231
toast.className = 'error-toast';
4232
toast.textContent = message;
4233
document.getElementById('errorToastContainer').appendChild(toast);
3860
-
4234
+
4235
// Remove after animation
4236
setTimeout(() => toast.remove(), 3000);
3863
-
4237
+
4238
// Log error
4239
this.addLogEntry('ERROR', {
4240
timestamp: new Date().toISOString(),
4241
direction: 'error',
4242
message
4243
});
3870
-
4244
+
4245
// Also show in chat if chatId is provided
4246
if (chatId) {
4247
const chat = this.chats.get(chatId);
3874
-
4248
+
4249
if (chat) {
4250
// Clear any spinners (from second method)
4251
this.clearSpinnerState(chatId);
3878
-
4252
+
4253
// Save to messages if requested
4254
if (saveToMessages) {
4255
const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
3882
- this.addMessage(chatId, {
3883
- role: 'error',
3884
- content: message,
4256
+ this.addMessage(chatId, {
4257
+ role: 'error',
4258
+ content: message,
4259
errorMessageIndex: lastUserMessageIndex,
4260
timestamp: new Date().toISOString()
4261
});
4262
}
3889
-
4263
+
4264
// Set error state with structured error object (enhanced from second method)
4265
chat.hasError = true;
4266
chat.lastError = {
4268
type: errorType,
4269
timestamp: Date.now()
4270
};
3897
-
4271
+
4272
// Update UI
4273
this.updateChatSessions();
4274
this.updateChatTileStatus(chatId);
4275
}
3902
-
4276
+
4277
const container = this.getChatContainer(chatId);
4278
if (container && container._elements && container._elements.messages) {
4279
const messageDiv = document.createElement('div');
4280
messageDiv.className = 'message error';
4281
messageDiv.innerHTML = `<i class="fas fa-times-circle"></i> ${message}`;
4282
container._elements.messages.appendChild(messageDiv);
3909
-
4283
+
4284
// Scroll to bottom using the chat-specific container
4285
container._elements.messages.scrollTop = container._elements.messages.scrollHeight;
4286
}
4287
}
4288
}
3915
-
4289
+
4290
// Global errors (not specific to any chat)
4291
showGlobalError(message) {
4292
this.showError(message, null);
4293
}
3920
-
4294
+
4295
showToast(message, className = 'error-toast') {
4296
// Show toast notification only (no chat message)
4297
const toast = document.createElement('div');
4298
toast.className = className;
4299
toast.textContent = message;
4300
document.getElementById('errorToastContainer').appendChild(toast);
3927
-
4301
+
4302
// Remove after animation
4303
setTimeout(() => toast.remove(), 3000);
4304
}
3931
-
4305
+
4306
showErrorWithRetry(message, retryCallback, buttonLabel, chatId) {
4307
// Show error toast
4308
const toast = document.createElement('div');
4309
toast.className = 'error-toast';
4310
toast.textContent = message;
4311
document.getElementById('errorToastContainer').appendChild(toast);
3938
-
4312
+
4313
// Remove after animation
4314
setTimeout(() => toast.remove(), 3000);
3941
-
4315
+
4316
// Log error
4317
this.addLogEntry('ERROR', {
4318
timestamp: new Date().toISOString(),
4319
direction: 'error',
4320
message
4321
});
3948
-
4322
+
4323
// Also show in chat with retry button if chatId is provided
4324
if (chatId) {
4325
const container = this.getChatContainer(chatId);
4333
<i class="fas ${buttonIcon}"></i> ${buttonLabel}
4334
</button>
4335
`;
3962
-
4336
+
4337
const retryBtn = messageDiv.querySelector('button');
4338
retryBtn.onclick = async () => {
4339
retryBtn.disabled = true;
4340
retryBtn.textContent = 'Retrying...';
4341
await retryCallback();
4342
};
3969
-
4343
+
4344
container._elements.messages.appendChild(messageDiv);
3971
-
4345
+
4346
// Scroll to bottom using the chat-specific container
4347
container._elements.messages.scrollTop = container._elements.messages.scrollHeight;
4348
}
4361
updateLogDisplay(entry) {
4362
const entryDiv = document.createElement('div');
4363
entryDiv.className = 'log-entry';
3990
-
4364
+
4365
const directionClass = entry.direction;
4366
let directionSymbol;
3993
- switch(entry.direction) {
4367
+ switch (entry.direction) {
4368
case 'sent': directionSymbol = '→'; break;
4369
case 'received': directionSymbol = '←'; break;
4370
case 'error': directionSymbol = '⚠'; break;
4371
case 'info': directionSymbol = 'ℹ'; break;
4372
default: directionSymbol = '•'; break;
4373
}
4000
-
4374
+
4375
let metadataHtml = '';
4376
if (entry.metadata && Object.keys(entry.metadata).length > 0) {
4377
metadataHtml = `<div class="log-metadata">`;
4380
}
4381
metadataHtml += `</div>`;
4382
}
4009
-
4383
+
4384
// Create a unique ID for this entry
4385
const entryId = `log-entry-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
4012
-
4386
+
4387
// Create header with copy button
4388
const headerDiv = document.createElement('div');
4389
headerDiv.className = 'log-entry-header';
4016
-
4390
+
4391
const infoDiv = document.createElement('div');
4392
infoDiv.className = 'log-entry-info';
4393
infoDiv.innerHTML = `
4395
<span class="log-source">[${entry.source}]</span>
4396
<span class="log-direction ${directionClass}">${directionSymbol}</span>
4397
`;
4024
-
4398
+
4399
// Create copy button using standardized method
4400
const copyBtn = this.createCopyButton({
4401
buttonClass: 'btn-copy-log',
4405
}
4406
});
4407
copyBtn.setAttribute('data-entry-id', entryId);
4034
-
4408
+
4409
headerDiv.appendChild(infoDiv);
4410
headerDiv.appendChild(copyBtn);
4037
-
4411
+
4412
entryDiv.appendChild(headerDiv);
4039
-
4413
+
4414
// Add metadata if present
4415
if (metadataHtml) {
4416
const metadataDiv = document.createElement('div');
4417
metadataDiv.innerHTML = metadataHtml;
4418
entryDiv.appendChild(metadataDiv);
4419
}
4046
-
4420
+
4421
// Add message content
4422
const messageDiv = document.createElement('div');
4423
messageDiv.className = 'log-message';
4424
messageDiv.id = entryId;
4425
messageDiv.textContent = this.formatLogMessage(entry.message);
4426
entryDiv.appendChild(messageDiv);
4053
-
4427
+
4428
this.logContent.appendChild(entryDiv);
4055
-
4429
+
4430
// Only scroll if user is already near the bottom
4431
const threshold = 100; // pixels from bottom to consider "at bottom"
4432
const isAtBottom = this.logContent.scrollHeight - this.logContent.scrollTop - this.logContent.clientHeight < threshold;
4059
-
4433
+
4434
if (isAtBottom) {
4435
this.logContent.scrollTop = this.logContent.scrollHeight;
4436
}
4460
document.body.appendChild(textArea);
4461
textArea.focus();
4462
textArea.select();
4089
-
4463
+
4464
try {
4465
const successful = document.execCommand('copy');
4466
if (!successful) {
4485
button.className = `copy-button ${buttonClass}`.trim();
4486
button.setAttribute('data-tooltip', tooltip);
4487
button.innerHTML = `<i class="${iconClass}"></i>`;
4114
-
4488
+
4489
button.addEventListener('click', async (e) => {
4490
e.stopPropagation();
4491
if (onCopy) {
4500
}
4501
}
4502
});
4129
-
4503
+
4504
return button;
4505
}
4506
4507
// Handle copy button click with standardized feedback
4508
async handleCopyButtonClick(button, text) {
4509
const originalHTML = button.innerHTML;
4136
-
4510
+
4511
try {
4512
await this.writeToClipboard(text);
4139
-
4513
+
4514
// Show success feedback
4515
button.innerHTML = '<i class="fas fa-check"></i>';
4516
button.style.color = 'var(--success-color)';
4143
-
4517
+
4518
setTimeout(() => {
4519
button.innerHTML = originalHTML;
4520
button.style.color = '';
4528
// Show error feedback on copy button
4529
showCopyButtonError(button, originalHTML = null) {
4530
const htmlToRestore = originalHTML || button.innerHTML;
4157
-
4531
+
4532
button.innerHTML = '<i class="fas fa-times"></i>';
4533
button.style.color = 'var(--danger-color)';
4160
-
4534
+
4535
setTimeout(() => {
4536
button.innerHTML = htmlToRestore;
4537
button.style.color = '';
4542
async copyToClipboard(text, button) {
4543
await this.handleCopyButtonClick(button, text);
4544
}
4171
-
4545
+
4546
// Redo from a specific point in the conversation
4547
async redoFromMessage(messageIndex, chatId) {
4548
const chat = this.chats.get(chatId);
4175
- if (!chat) {return;}
4176
-
4549
+ if (!chat) { return; }
4550
+
4551
// Clear broken state when starting redo operation
4552
chat.wasWaitingOnLoad = false;
4179
-
4553
+
4554
// Find the message to redo from
4555
const message = chat.messages[messageIndex];
4182
- if (!message) {return;}
4183
-
4556
+ if (!message) { return; }
4557
+
4558
// Get the MCP connection and provider
4559
const mcpConnection = this.mcpConnections.get(chat.mcpServerId);
4560
const proxyProvider = this.llmProviders.get(chat.llmProviderId);
4187
-
4561
+
4562
if (!mcpConnection || !proxyProvider || !chat.config || !chat.config.model) {
4563
this.showError('Cannot redo: MCP server or LLM provider not available', chatId);
4564
return;
4565
}
4192
-
4566
+
4567
// Get model config
4568
const providerType = chat.config.model.provider;
4569
const modelName = chat.config.model.id;
4571
this.showError('Invalid model configuration in chat', chatId);
4572
return;
4573
}
4200
-
4574
+
4575
+ // Get the API type from the provider configuration
4576
+ const providerApiType = proxyProvider.availableProviders?.[providerType]?.type || providerType;
4577
+
4578
// Create the LLM provider
4202
- const provider = createLLMProvider(providerType, proxyProvider.proxyUrl, modelName);
4579
+ const provider = createLLMProvider(providerApiType, proxyProvider.proxyUrl, modelName, proxyProvider.availableProviders?.[providerType], providerType);
4580
provider.onLog = (logEntry) => {
4581
const prefix = logEntry.direction === 'sent' ? 'llm-request' : 'llm-response';
4582
const providerName = providerType.charAt(0).toUpperCase() + providerType.slice(1);
4583
this.addLogEntry(`${prefix}: ${providerName}`, logEntry);
4584
};
4208
-
4585
+
4586
try {
4587
if (message.role === 'user') {
4588
// Redo from user message - truncate everything AFTER this message
4589
// Keep all history up to and including this message
4590
this.truncateMessages(chatId, messageIndex + 1, 'Redo from user message');
4591
this.loadChat(chatId, true);
4215
-
4592
+
4593
// Show thinking spinner AFTER loadChat to prevent it from being cleared
4594
this.showAssistantThinking(chatId);
4218
-
4595
+
4596
// Get fresh chat object after loadChat
4597
const freshChat = this.chats.get(chatId);
4598
if (!freshChat) {
4599
throw new Error('Chat not found after reload');
4600
}
4224
-
4601
+
4602
// Resend the user message with full prior context
4603
const result = await this.processMessageWithTools(freshChat, mcpConnection, provider, message.content);
4227
-
4604
+
4605
// Check if rate limit was handled - don't conclude if so
4606
if (result && result.rateLimitHandled) {
4607
return;
4608
}
4232
-
4609
+
4610
// Success - assistant has concluded
4611
this.assistantConcluded(chatId);
4612
} else if (message.role === 'assistant') {
4613
// Redo from assistant message - find the user message that triggered it
4614
let triggeringUserMessage = null;
4238
-
4615
+
4616
// Find the most recent user message before this assistant message
4617
for (let i = messageIndex - 1; i >= 0; i--) {
4618
if (chat.messages[i].role === 'user') {
4620
break;
4621
}
4622
}
4246
-
4623
+
4624
if (triggeringUserMessage) {
4625
// Truncate from THIS assistant message onwards (not from the user message)
4626
// This handles cases where assistant sent multiple messages (e.g., with tool calls)
4627
this.truncateMessages(chatId, messageIndex, 'Redo from assistant message');
4628
this.loadChat(chatId, true);
4252
-
4629
+
4630
// Show thinking spinner AFTER loadChat to prevent it from being cleared
4631
this.showAssistantThinking(chatId);
4255
-
4632
+
4633
// Get fresh chat object after loadChat
4634
const freshChat = this.chats.get(chatId);
4635
if (!freshChat) {
4636
throw new Error('Chat not found after reload');
4637
}
4261
-
4638
+
4639
// Resend the triggering user message with full prior context
4640
const result = await this.processMessageWithTools(freshChat, mcpConnection, provider, triggeringUserMessage.content);
4264
-
4641
+
4642
// Check if rate limit was handled - don't conclude if so
4643
if (result && result.rateLimitHandled) {
4644
return;
4645
}
4269
-
4646
+
4647
// Success - assistant has concluded
4648
this.assistantConcluded(chatId);
4649
} else {
4655
}
4656
} catch (error) {
4657
// Check for rate limit error
4281
- const isRateLimitError = error.message.includes('Rate limit') || error.message.includes('429');
4658
+ const isRateLimitError = error.message && (
4659
+ error.message.includes('Rate limit') ||
4660
+ error.message.includes('429') ||
4661
+ error.message.includes('rate_limit_exceeded') ||
4662
+ error.message.includes('529') ||
4663
+ error.message.includes('overloaded_error') ||
4664
+ error.message.includes('Overloaded')
4665
+ );
4666
const retryMatch = error.message.match(/Please try again in (\d+(?:\.\d+)?)s/);
4667
const retryAfterSeconds = retryMatch ? parseFloat(retryMatch[1]) : null;
4284
-
4668
+
4669
if (isRateLimitError) {
4670
// Handle rate limit with automatic retry (even without retry time)
4671
const retryCount = chat.rateLimitRetryCount || 0;
4679
// Show error with retry button
4680
const errorMessage = `Redo failed: ${error.message}`;
4681
const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
4298
-
4682
+
4683
// Determine error type
4684
let errorType = 'llm_error';
4685
if (error.message.includes('MCP') || error.message.includes('connection')) {
4687
} else if (error.message.includes('Tool')) {
4688
errorType = 'tool_error';
4689
}
4306
-
4307
- this.addMessage(chatId, {
4308
- role: 'error',
4309
- content: errorMessage,
4690
+
4691
+ this.addMessage(chatId, {
4692
+ role: 'error',
4693
+ content: errorMessage,
4694
errorMessageIndex: lastUserMessageIndex,
4311
- errorType
4695
+ errorType
4696
});
4313
-
4314
- this.processRenderEvent({
4315
- type: 'error-message',
4316
- content: errorMessage,
4697
+
4698
+ this.processRenderEvent({
4699
+ type: 'error-message',
4700
+ content: errorMessage,
4701
errorMessageIndex: lastUserMessageIndex,
4702
errorType
4703
}, chatId);
4704
}
4321
-
4705
+
4706
// Clean up on error - but preserve rate limit waiting spinner
4707
this.assistantFailed(chatId, error);
4708
}
4726
const logText = this.communicationLog.map(entry => {
4727
return `[${entry.timestamp}] [${entry.source}] ${entry.direction}: ${entry.message}`;
4728
}).join('\n\n');
4345
-
4729
+
4730
const blob = new Blob([logText], { type: 'text/plain' });
4731
const url = URL.createObjectURL(blob);
4732
const a = document.createElement('a');
4739
loadSettings() {
4740
// Note: file:// protocol is now supported thanks to the proxy server
4741
// The proxy handles CORS issues that would normally prevent direct API access
4358
-
4742
+
4743
// Load theme
4744
const savedTheme = localStorage.getItem('theme') || 'dark';
4745
document.documentElement.setAttribute('data-theme', savedTheme);
4362
-
4746
+
4747
// Load log collapsed state (default to collapsed)
4748
const logCollapsed = localStorage.getItem('logCollapsed') !== 'false';
4749
if (logCollapsed) {
4751
this.toggleLogBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
4752
this.expandLogBtn.style.display = 'block';
4753
}
4370
-
4754
+
4755
// Load pane sizes
4756
this.loadPaneSizes();
4373
-
4757
+
4758
// Load MCP servers from localStorage (but these will be merged with proxy servers later)
4759
const savedMcpServers = localStorage.getItem('mcpServers');
4760
if (savedMcpServers) {
4768
console.error('Failed to load MCP servers:', e);
4769
}
4770
}
4387
-
4771
+
4772
// LLM providers will be fetched from proxy on demand
4389
-
4773
+
4774
// Load chats - first try split storage, then fall back to legacy
4775
this.loadChatsFromStorage();
4776
}
4778
loadChatsFromStorage() {
4779
// Load chats with pattern chat_TIMESTAMP
4780
const chatKeyPrefix = 'chat_';
4397
-
4781
+
4782
// Scan localStorage for individual chat keys
4783
for (let i = 0; i < localStorage.length; i++) {
4784
const key = localStorage.key(i);
4799
}
4800
}
4801
}
4418
-
4802
+
4803
// Don't update chat sessions yet - wait until after default chat is created
4804
// this.updateChatSessions();
4805
}
4422
-
4423
-
4806
+
4807
+
4808
validateAndAddChat(chat) {
4809
// IMMEDIATE MIGRATION - Delete ALL old properties
4810
delete chat.model;
4816
delete chat.autoSummarization;
4817
delete chat.toolMemory;
4818
delete chat.cacheControl;
4435
-
4819
+
4820
// Migrate old chat format to new config format if needed
4821
if (!chat.config) {
4822
// No config - create default
4826
chat.config.mcpServer = chat.mcpServerId;
4827
}
4828
}
4445
-
4829
+
4830
// Ensure config is valid
4831
chat.config = ChatConfig.validateConfig(chat.config);
4448
-
4832
+
4833
// Migrate old tool-results format to new format
4834
if (chat.messages && Array.isArray(chat.messages)) {
4835
chat.messages = chat.messages.map(msg => {
4846
timestamp: msg.timestamp
4847
};
4848
}
4465
-
4849
+
4850
// Convert tool-results that have type but no role
4851
if (msg.type === 'tool-results' && !msg.role && msg.toolResults) {
4852
const cleanMsg = { ...msg };
4854
cleanMsg.role = 'tool-results';
4855
return cleanMsg;
4856
}
4473
-
4857
+
4858
// Clean up messages that have both type and role - remove type
4859
if (msg.type && msg.role) {
4860
const cleanMsg = { ...msg };
4861
delete cleanMsg.type;
4862
return cleanMsg;
4863
}
4480
-
4864
+
4865
// Convert any remaining messages with type but no role
4866
if (msg.type && !msg.role) {
4867
const cleanMsg = { ...msg };
4869
delete cleanMsg.type;
4870
return cleanMsg;
4871
}
4488
-
4872
+
4873
return msg;
4874
});
4875
}
4492
-
4876
+
4877
// Validate that the chat's model still exists
4878
if (chat.config && chat.config.model && chat.llmProviderId) {
4879
const provider = this.llmProviders.get(chat.llmProviderId);
4882
let modelExists = false;
4883
const providerType = chat.config.model.provider;
4884
const modelName = chat.config.model.id;
4501
-
4885
+
4886
if (providerType && modelName && provider.availableProviders[providerType]) {
4887
const models = provider.availableProviders[providerType].models || [];
4888
modelExists = models.some(m => {
4890
return mId === modelName;
4891
});
4892
}
4509
-
4893
+
4894
if (!modelExists) {
4895
const oldModelString = ChatConfig.modelConfigToString(chat.config.model);
4896
console.error(`Chat ${chat.id} has invalid model ${oldModelString}. Model not found in available providers.`);
4513
-
4897
+
4898
// Mark the chat as having an invalid model
4899
chat.hasInvalidModel = true;
4516
-
4900
+
4901
// DO NOT automatically reset or save!
4902
// The user must manually select a valid model
4903
}
4904
}
4905
}
4522
-
4906
+
4907
// Validate MCP server ID - set to null if it doesn't exist
4908
if (chat.config && chat.config.mcpServer) {
4909
if (!this.mcpServers.has(chat.config.mcpServer)) {
4919
chat.config.mcpServer = null;
4920
}
4921
}
4538
-
4922
+
4923
// Ensure currentAssistantGroup exists for loaded chats
4924
if (!Object.prototype.hasOwnProperty.call(chat, 'currentAssistantGroup')) {
4925
chat.currentAssistantGroup = null;
4926
}
4543
-
4927
+
4928
// Ensure pendingToolCalls is a Map (it gets serialized as {} in localStorage)
4929
if (!chat.pendingToolCalls || !(chat.pendingToolCalls instanceof Map)) {
4930
chat.pendingToolCalls = new Map();
4931
}
4548
-
4932
+
4933
// Check if the chat was saved while waiting for a response (broken state)
4934
if (chat.spinnerState || chat.isProcessing) {
4935
chat.wasWaitingOnLoad = true;
4937
chat.spinnerState = null;
4938
chat.isProcessing = false;
4939
}
4556
-
4940
+
4941
// Reconstruct MessageOptimizer instance for loaded chats
This file is too large to show in full.