1
-/**
2
- * Main application logic for the Netdata MCP LLM Client
3
- */
4
-
5
-import { MessageOptimizer } from './message-optimizer.js';
6
-import * as ChatConfig from './chat-config.js';
7
-import * as TitleGenerator from './title.js';
8
-import * as SystemMsg from './system-msg.js';
9
-import { SafetyChecker, SafetyLimitError, SAFETY_LIMITS } from './safety-limits.js';
10
-
11
-class NetdataMCPChat {
12
- constructor() {
13
- // Log version on startup
14
- console.log('🚀 Netdata MCP Web Client v1.0.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
19
- this.chats = new Map(); // Chat sessions
20
- this.communicationLog = []; // Universal log (not saved)
21
- this.tokenUsageHistory = new Map(); // Track token usage per chat
22
- this.toolInclusionStates = new Map(); // Track which tools are included/excluded per chat
23
- // Removed global currentContextWindow - now stored per chat
24
- // Removed global shouldStopProcessing - now stored per chat
25
- // Removed global isProcessing - now stored per chat
26
- this.modelPricing = {}; // Initialize model pricing storage
27
- this.modelLimits = {}; // Initialize model context limits storage
28
- this.copiedModel = null; // Track copied model for paste functionality
29
-
30
- // Safety protections
31
- this.safetyChecker = new SafetyChecker();
32
-
33
- // Per-chat DOM management
34
- this.chatContainers = new Map(); // Map of chatId -> DOM container (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
-
40
- // Default system prompt
41
- this.defaultSystemPrompt = SystemMsg.DEFAULT_SYSTEM_PROMPT;
42
-
43
- // Load last used system prompt from localStorage or use default
44
- this.lastSystemPrompt = localStorage.getItem('lastSystemPrompt') || this.defaultSystemPrompt;
45
-
46
- this.initializeUI();
47
-
48
- // Delay resizable initialization to ensure DOM is ready
49
- setTimeout(() => {
50
- this.initializeResizable();
51
- }, 0);
52
-
53
- // Clear current chat ID to always start fresh
54
- localStorage.removeItem('currentChatId');
55
-
56
- // Get reference to main container
57
- this.chatContainersEl = document.getElementById('chatContainers');
58
- this.welcomeScreen = document.getElementById('welcomeScreen');
59
-
60
- // Show welcome screen initially
61
- if (this.welcomeScreen) {
62
- this.welcomeScreen.style.display = 'flex';
63
- }
64
-
65
- this.loadSettings();
66
-
67
- // Track if user has interacted with chat selection
68
- this.userHasSelectedChat = false;
69
-
70
- // Track if we have a pending new chat load
71
- this.pendingNewChatLoad = false;
72
-
73
- // Track if providers are loaded
74
- this.providersLoaded = false;
75
-
76
- // Initialize providers and then create default chat
77
- // First initialize LLM provider, then MCP servers (which need the provider URL)
78
- this.initializeDefaultLLMProvider().then(() => {
79
- return this.initializeDefaultMCPServers();
80
- }).then(async () => {
81
- // Mark providers as loaded
82
- this.providersLoaded = true;
83
-
84
- // Update chat sessions after providers are loaded
85
- this.updateChatSessions();
86
-
87
- // Only create a new chat if we have both MCP servers and LLM providers
88
- if (this.mcpServers.size > 0 && this.llmProviders.size > 0) {
89
- // Always create a new chat on startup
90
- const newChatId = await this.createDefaultChatIfNeeded();
91
-
92
- // If a new chat was created AND user hasn't selected a chat, load it
93
- if (newChatId && !this.userHasSelectedChat) {
94
- // Mark that we have a pending new chat load
95
- this.pendingNewChatLoad = true;
96
- this.pendingNewChatId = newChatId;
97
-
98
- // Give DOM time to update after chat creation
99
- this.pendingNewChatTimeout = setTimeout(() => {
100
- // Double-check user hasn't selected a chat in the meantime
101
- if (!this.userHasSelectedChat && this.pendingNewChatLoad) {
102
- this.loadChat(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
-
121
- // Add global error handlers to catch unhandled errors
122
- this.setupGlobalErrorHandlers();
123
- }
124
-
125
- setupGlobalErrorHandlers() {
126
- // Catch unhandled JavaScript errors
127
- window.addEventListener('error', (event) => {
128
- console.error('Unhandled JavaScript error:', event.error);
129
- this.showGlobalError(`JavaScript Error: ${event.error?.message || 'Unknown error'}`);
130
- });
131
-
132
- // Catch unhandled promise rejections
133
- window.addEventListener('unhandledrejection', (event) => {
134
- console.error('Unhandled promise rejection:', event.reason);
135
- this.showGlobalError(`Promise Error: ${event.reason?.message || event.reason || 'Unknown promise rejection'}`);
136
- // Prevent the default browser error console log
137
- event.preventDefault();
138
- });
139
- }
140
-
141
- /**
142
- * Extract tool calls from message content array
143
- * @param {Array|string} content - Message content (can be array of blocks or string)
144
- * @returns {Array} - Array of tool call objects with id, name, and arguments
145
- */
146
- extractToolsFromContent(content) {
147
- if (!Array.isArray(content)) return [];
148
- return content
149
- .filter(block => block.type === 'tool_use')
150
- .map(block => ({
151
- id: block.id,
152
- name: block.name,
153
- arguments: block.input
154
- }));
155
- }
156
-
157
- /**
158
- * Safe message operations that automatically persist changes
159
- * These methods ensure messages are never lost by auto-saving after each operation
160
- *
161
- * IMPORTANT: Always use these methods instead of direct array manipulation
162
- * - addMessage() instead of messages.push()
163
- * - insertMessage() instead of messages.splice(index, 0, item)
164
- * - removeMessage() instead of messages.splice(index, count)
165
- * - removeLastMessage() instead of messages.pop()
166
- *
167
- * CRITICAL ORDERING RULE: Always save messages BEFORE displaying them!
168
- * 1. Call addMessage() to save the message
169
- * 2. Call processRenderEvent() to display it
170
- * This ensures users never see messages that aren't persisted
171
- *
172
- * ATOMIC OPERATIONS: Use batchMode for multi-step operations
173
- * this.batchMode = true;
174
- * try {
175
- * // Multiple operations
176
- * } finally {
177
- * this.batchMode = false;
178
- * this.autoSave(chatId);
179
- * }
180
- */
181
- addMessage(chatId, message) {
182
- if (!chatId) {
183
- console.error('addMessage called without chatId');
184
- return;
185
- }
186
-
187
- const chat = this.chats.get(chatId);
188
- if (!chat) {
189
- console.error('addMessage: chat not found for chatId:', chatId);
190
- return;
191
- }
192
-
193
- // Calculate and add price to message if it has token usage
194
- if (message.usage && message.model) {
195
- const price = this.calculateMessagePrice(message.model, message.usage);
196
- if (price !== null) {
197
- message.price = price;
198
- }
199
- }
200
-
201
- chat.messages.push(message);
202
- chat.updatedAt = new Date().toISOString();
203
-
204
- // Update cumulative token pricing
205
- this.updateChatTokenPricing(chat);
206
-
207
- // NOTE: Sub-chat cost accumulation is now handled in processSingleLLMResponse
208
- // after tool-results are added to the parent chat, ensuring proper timing
209
-
210
- // Update the cumulative token display
211
- this.updateCumulativeTokenDisplay(chatId);
212
-
213
- this.autoSave(chatId);
214
- }
215
-
216
- insertMessage(chatId, index, message) {
217
- if (!chatId) {
218
- console.error('insertMessage called without chatId');
219
- return;
220
- }
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
-
228
- chat.messages.splice(index, 0, message);
229
- chat.updatedAt = new Date().toISOString();
230
-
231
- // Update cumulative token pricing
232
- this.updateChatTokenPricing(chat);
233
-
234
- // Update the cumulative token display
235
- this.updateCumulativeTokenDisplay(chatId);
236
-
237
- this.autoSave(chatId);
238
- }
239
-
240
- removeMessage(chatId, index, count = 1) {
241
- if (!chatId) {
242
- console.error('removeMessage called without chatId');
243
- return;
244
- }
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
-
252
- chat.messages.splice(index, count);
253
- chat.updatedAt = new Date().toISOString();
254
-
255
- // Update cumulative token pricing
256
- this.updateChatTokenPricing(chat);
257
-
258
- // Update the cumulative token display
259
- this.updateCumulativeTokenDisplay(chatId);
260
-
261
- this.autoSave(chatId);
262
- }
263
-
264
- removeLastMessage(chatId) {
265
- if (!chatId) {
266
- console.error('removeLastMessage called without chatId');
267
- return;
268
- }
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
-
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
-
282
- /**
283
- * Truncate messages from a specific index onwards, creating accounting records if needed
284
- * @param {string} chatId - The chat ID
285
- * @param {number} startIndex - Index from which to truncate (exclusive - messages from this index onwards are removed)
286
- * @param {string} reason - Reason for truncation (e.g., 'Redo from user message')
287
- */
288
- truncateMessages(chatId, startIndex, reason = 'Messages truncated') {
289
- const chat = this.chats.get(chatId);
290
- if (!chat) {
291
- console.error('truncateMessages: chat not found for chatId:', chatId);
292
- return;
293
- }
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
-
302
- // Find messages that will be discarded (from startIndex onwards)
303
- const discardedMessages = chat.messages.slice(startIndex);
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 ||
313
- (usage.cacheCreationInputTokens || 0) > 0) {
314
- hasTokens = true;
315
- break;
316
- }
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 ||
323
- (tokens.cacheCreationTokens || 0) > 0) {
324
- hasTokens = true;
325
- break;
326
- }
327
- }
328
- }
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
-
335
- for (const message of discardedMessages) {
336
- if (message.usage && message.model) {
337
- const model = message.model;
338
- if (!tokensByModel.has(model)) {
339
- tokensByModel.set(model, {
340
- inputTokens: 0,
341
- outputTokens: 0,
342
- cacheReadTokens: 0,
343
- cacheCreationTokens: 0,
344
- messageCount: 0
345
- });
346
- }
347
-
348
- const tokens = tokensByModel.get(model);
349
- tokens.inputTokens += message.usage.promptTokens || 0;
350
- tokens.outputTokens += message.usage.completionTokens || 0;
351
- tokens.cacheCreationTokens += message.usage.cacheCreationInputTokens || 0;
352
- tokens.cacheReadTokens += message.usage.cacheReadInputTokens || 0;
353
- tokens.messageCount++;
354
- } else if (message.role === 'accounting' && message.model && message.cumulativeTokens) {
355
- // CRITICAL: Also collect tokens from accounting nodes being replaced
356
- const model = message.model;
357
- if (!tokensByModel.has(model)) {
358
- tokensByModel.set(model, {
359
- inputTokens: 0,
360
- outputTokens: 0,
361
- cacheReadTokens: 0,
362
- cacheCreationTokens: 0,
363
- messageCount: 0
364
- });
365
- }
366
-
367
- const tokens = tokensByModel.get(model);
368
- const cumTokens = message.cumulativeTokens;
369
- tokens.inputTokens += cumTokens.inputTokens || 0;
370
- tokens.outputTokens += cumTokens.outputTokens || 0;
371
- tokens.cacheCreationTokens += cumTokens.cacheCreationTokens || 0;
372
- tokens.cacheReadTokens += cumTokens.cacheReadTokens || 0;
373
- tokens.messageCount += message.discardedMessages || 0;
374
- }
375
- }
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 ||
382
- tokens.cacheReadTokens > 0 || tokens.cacheCreationTokens > 0) {
383
- const accountingNode = {
384
- role: 'accounting',
385
- timestamp: new Date().toISOString(),
386
- model,
387
- cumulativeTokens: tokens,
388
- reason,
389
- discardedMessages: tokens.messageCount
390
- };
391
- this.insertMessage(chatId, insertIndex, accountingNode);
392
- insertIndex++;
393
- }
394
- }
395
-
396
- // Remove all messages after accounting nodes
397
- const toRemove = chat.messages.length - insertIndex;
398
- if (toRemove > 0) {
399
- this.removeMessage(chatId, insertIndex, toRemove);
400
- }
401
- } else {
402
- // No tokens to preserve, just remove messages
403
- this.removeMessage(chatId, startIndex, messagesToDiscard);
404
- }
405
-
406
- this.autoSave(chatId);
407
- }
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' &&
417
- m.role !== 'system-summary' &&
418
- m.role !== 'title' &&
419
- m.role !== 'summary' &&
420
- m.role !== 'accounting'
421
- );
422
- }
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 =>
430
- m.role === 'user'
431
- );
432
- return userMessages.length === 1;
433
- }
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 =>
441
- m.role === 'assistant'
442
- ).length;
443
- }
444
-
445
- /**
446
- * Check if a string contains markdown formatting
447
- */
448
- isMarkdownContent(content) {
449
- if (typeof content !== 'string' || !content.trim()) {
450
- return false;
451
- }
452
-
453
- // Common markdown patterns
454
- const markdownPatterns = [
455
- /^#+\s/m, // Headers: # ## ###
456
- /\*\*.*\*\*/, // Bold: **text**
457
- /\*.*\*/, // Italic: *text*
458
- /`.*`/, // Inline code: `code`
459
- /```[\s\S]*?```/, // Code blocks: ```code```
460
- /^\s*[-*+]\s/m, // Unordered lists: - * +
461
- /^\s*\d+\.\s/m, // Ordered lists: 1. 2.
462
- /^\s*>\s/m, // Blockquotes: >
463
- /\[.*\]\(.*\)/, // Links: [text](url)
464
- /!\[.*\]\(.*\)/, // Images: 
465
- /^\s*\|.*\|/m, // Tables: | col1 | col2 |
466
- /^---+$/m, // Horizontal rules: ---
467
- /~~.*~~/, // Strikethrough: ~~text~~
468
- ];
469
-
470
- return markdownPatterns.some(pattern => pattern.test(content));
471
- }
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
-
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) {
483
- if (this.pendingSaveTimeouts[chatId]) {
484
- clearTimeout(this.pendingSaveTimeouts[chatId]);
485
- }
486
- } else {
487
- this.pendingSaveTimeouts = {};
488
- }
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
-
497
- /**
498
- * Save chat configuration - only saves to chatConfig_chat_XXX for saved chats
499
- * For unsaved chats, only updates lastChatConfig
500
- */
501
- saveChatConfigSmart(chatId, config) {
502
- const chat = this.chats.get(chatId);
503
- if (!chat) {
504
- console.error(`[saveChatConfigSmart] Chat not found for chatId: ${chatId}`);
505
- return;
506
- }
507
-
508
- // Always save as last config for new chats to inherit
509
- ChatConfig.saveLastConfig(config);
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
-
517
- // Calculate price for a single message based on its model and usage
518
- calculateMessagePrice(model, usage) {
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') {
524
- modelName = ChatConfig.getModelDisplayName(model);
525
- } else if (model?.id) {
526
- modelName = model.id;
527
- }
528
-
529
- const pricing = this.modelPricing[modelName];
530
- if (!pricing) { return null; }
531
-
532
- let totalCost = 0;
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
-
539
- // For Anthropic models with cache pricing
540
- if (pricing.cacheWrite !== undefined && pricing.cacheRead !== undefined) {
541
- totalCost += promptTokens / 1_000_000 * pricing.input;
542
- totalCost += cacheReadTokens / 1_000_000 * pricing.cacheRead;
543
- totalCost += cacheCreationTokens / 1_000_000 * pricing.cacheWrite;
544
- totalCost += completionTokens / 1_000_000 * pricing.output;
545
- }
546
- // For OpenAI models with cache pricing
547
- else if (pricing.cacheRead !== undefined) {
548
- const cachedInputTokens = cacheReadTokens + cacheCreationTokens;
549
- totalCost += promptTokens / 1_000_000 * pricing.input;
550
- totalCost += cachedInputTokens / 1_000_000 * pricing.cacheRead;
551
- totalCost += completionTokens / 1_000_000 * pricing.output;
552
- }
553
- // For models without cache pricing
554
- else {
555
- const allInputTokens = promptTokens + cacheReadTokens + cacheCreationTokens;
556
- totalCost += allInputTokens / 1_000_000 * pricing.input;
557
- totalCost += completionTokens / 1_000_000 * pricing.output;
558
- }
559
-
560
- return totalCost;
561
- }
562
-
563
- // Update the chat's cumulative token pricing
564
- updateChatTokenPricing(chat) {
565
- if (!chat) {
566
- console.error('updateChatTokenPricing called without chat object');
567
- return;
568
- }
569
-
570
- // Initialize if not present
571
- if (!chat.totalTokensPrice) {
572
- chat.totalTokensPrice = {
573
- input: 0,
574
- output: 0,
575
- cacheRead: 0,
576
- cacheCreation: 0,
577
- totalCost: 0
578
- };
579
- }
580
-
581
- if (!chat.perModelTokensPrice) {
582
- chat.perModelTokensPrice = {};
583
- }
584
-
585
- // Reset totals
586
- chat.totalTokensPrice = {
587
- input: 0,
588
- output: 0,
589
- cacheRead: 0,
590
- cacheCreation: 0,
591
- totalCost: 0
592
- };
593
- chat.perModelTokensPrice = {};
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
-
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
-
607
- // Update per-model tokens
608
- if (!chat.perModelTokensPrice[model]) {
609
- chat.perModelTokensPrice[model] = {
610
- input: 0,
611
- output: 0,
612
- cacheRead: 0,
613
- cacheCreation: 0,
614
- totalCost: 0
615
- };
616
- }
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
-
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
-
630
- // Handle accounting nodes
631
- if (message.role === 'accounting' && message.cumulativeTokens) {
632
- // Add the preserved tokens from accounting node
633
- chat.totalTokensPrice.input += message.cumulativeTokens.inputTokens || 0;
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
-
638
- // Note: We can't attribute accounting node tokens to specific models
639
- // They represent aggregated tokens from deleted messages
640
- }
641
- }
642
-
643
- // Add sub-chat costs from tool-results
644
- this.aggregateSubChatCostsFromToolResults(chat);
645
- }
646
-
647
- /**
648
- * Update parent's tool-result with sub-chat costs
649
- */
650
- updateParentToolResultCosts(parentChatId, toolCallId, subChat) {
651
- const parentChat = this.chats.get(parentChatId);
652
- if (!parentChat) {
653
- console.error(`[updateParentToolResultCosts] Parent chat ${parentChatId} not found`);
654
- return;
655
- }
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) {
661
- const toolResult = message.toolResults.find(tr => tr.toolCallId === toolCallId);
662
- if (toolResult) {
663
- // Store the sub-chat's current costs
664
- toolResult.subChatCosts = {
665
- totalTokens: { ...subChat.totalTokensPrice },
666
- perModel: {}
667
- };
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
-
674
- // Save parent chat to persist the updated costs
675
- this.autoSave(parentChatId);
676
- return;
677
- }
678
- }
679
- }
680
-
681
- console.error(`[updateParentToolResultCosts] Tool result ${toolCallId} not found in parent messages`);
682
- }
683
-
684
- /**
685
- * Aggregate sub-chat costs from tool-results that have subChatCosts
686
- */
687
- aggregateSubChatCostsFromToolResults(chat) {
688
- for (const message of chat.messages) {
689
- if (message.role === 'tool-results' && message.toolResults) {
690
- for (const toolResult of message.toolResults) {
691
- if (toolResult.subChatCosts) {
692
- const costs = toolResult.subChatCosts;
693
-
694
- // Add to total tokens
695
- chat.totalTokensPrice.input += costs.totalTokens.input || 0;
696
- chat.totalTokensPrice.output += costs.totalTokens.output || 0;
697
- chat.totalTokensPrice.cacheRead += costs.totalTokens.cacheRead || 0;
698
- chat.totalTokensPrice.cacheCreation += costs.totalTokens.cacheCreation || 0;
699
- chat.totalTokensPrice.totalCost += costs.totalTokens.totalCost || 0;
700
-
701
- // Add to per-model tokens
702
- for (const [model, modelCosts] of Object.entries(costs.perModel)) {
703
- if (!chat.perModelTokensPrice[model]) {
704
- chat.perModelTokensPrice[model] = {
705
- input: 0,
706
- output: 0,
707
- cacheRead: 0,
708
- cacheCreation: 0,
709
- totalCost: 0
710
- };
711
- }
712
-
713
- chat.perModelTokensPrice[model].input += modelCosts.input || 0;
714
- chat.perModelTokensPrice[model].output += modelCosts.output || 0;
715
- chat.perModelTokensPrice[model].cacheRead += modelCosts.cacheRead || 0;
716
- chat.perModelTokensPrice[model].cacheCreation += modelCosts.cacheCreation || 0;
717
- chat.perModelTokensPrice[model].totalCost += modelCosts.totalCost || 0;
718
- }
719
- }
720
- }
721
- }
722
- }
723
- }
724
-
725
-
726
- // Migrate old chat data to include token pricing
727
- migrateTokenPricing(chat) {
728
- // Initialize structures if not present
729
- if (!chat.totalTokensPrice) {
730
- chat.totalTokensPrice = {
731
- input: 0,
732
- output: 0,
733
- cacheRead: 0,
734
- cacheCreation: 0,
735
- totalCost: 0
736
- };
737
- }
738
-
739
- if (!chat.perModelTokensPrice) {
740
- chat.perModelTokensPrice = {};
741
- }
742
-
743
- // Process all messages to calculate prices
744
- for (const message of chat.messages) {
745
- if (message.usage && !message.price) {
746
- // Add model if missing (use chat's model as fallback)
747
- if (!message.model) {
748
- message.model = ChatConfig.getChatModelString(chat);
749
- }
750
-
751
- // Calculate price
752
- if (message.model) {
753
- const price = this.calculateMessagePrice(message.model, message.usage);
754
- if (price !== null) {
755
- message.price = price;
756
- }
757
- }
758
- }
759
- }
760
-
761
- // Recalculate cumulative pricing
762
- this.updateChatTokenPricing(chat);
763
-
764
- // Save the migrated data
765
- this.saveChatToStorage(chat.id);
766
- }
767
-
768
- initializeUI() {
769
- // Chat sidebar
770
- this.newChatBtn = document.getElementById('newChatBtn');
771
- this.newChatBtn.addEventListener('click', () => this.createNewChatDirectly());
772
- this.chatSessions = document.getElementById('chatSessions');
773
-
774
- // Event delegation for delete buttons
775
- this.chatSessions.addEventListener('click', (e) => {
776
- // Cast to Element to help IDE recognize DOM methods
777
- /** @type {Element} */
778
- const target = e.target;
779
- const deleteBtn = target.closest('.btn-delete-chat');
780
- if (deleteBtn) {
781
- e.stopPropagation();
782
- const chatId = deleteBtn.dataset.chatId;
783
- if (chatId) {
784
- this.deleteChat(chatId);
785
- }
786
- }
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'));
794
-
795
- // Chat area - Main containers only, not individual chat elements
796
- this.chatContainersEl = document.getElementById('chatContainers');
797
- this.welcomeScreen = document.getElementById('welcomeScreen');
798
-
799
- // These will be set when switching chats for backward compatibility
800
- this.chatTitle = null;
801
- this.sendMessageBtn = null;
802
- this.reconnectMcpBtn = null;
803
- this.copyMetricsBtn = null;
804
- this.summarizeBtn = null;
805
- this.generateTitleBtn = null;
806
- this.llmModelDropdown = null;
807
- this.currentModelText = null;
808
- this.mcpServerDropdown = null;
809
- this.currentMcpText = null;
810
-
811
- // Close dropdowns when clicking outside (global handler)
812
- document.addEventListener('click', () => {
813
- // Close all open dropdowns in all chat containers
814
- this.chatContainers.forEach((container) => {
815
- const elements = container._elements;
816
- if (elements) {
817
- // Check each dropdown exists before accessing style
818
- if (elements.llmModelDropdown && elements.llmModelDropdown.style) {
819
- elements.llmModelDropdown.style.display = 'none';
820
- }
821
- if (elements.mcpServerDropdown && elements.mcpServerDropdown.style) {
822
- elements.mcpServerDropdown.style.display = 'none';
823
- }
824
- }
825
- });
826
- });
827
-
828
- // Log panel
829
- this.logPanel = document.getElementById('logPanel');
830
- this.toggleLogBtn = document.getElementById('toggleLogBtn');
831
- this.expandLogBtn = document.getElementById('expandLogBtn');
832
- this.clearLogBtn = document.getElementById('clearLogBtn');
833
- this.downloadLogBtn = document.getElementById('downloadLogBtn');
834
- this.logContent = document.getElementById('logContent');
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());
840
-
841
- // Sidebar management
842
- this.chatSidebar = document.getElementById('chatSidebar');
843
- this.toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
844
-
845
- // Set up sidebar toggle button
846
- this.toggleSidebarBtn.addEventListener('click', () => this.toggleChatSidebar());
847
-
848
- // Load sidebar states from localStorage
849
- this.loadSidebarStates();
850
-
851
- // Temperature control - will be set when switching chats
852
-
853
- // Settings modal
854
- this.settingsModal = document.getElementById('settingsModal');
855
- this.setupModal('settingsModal', 'settingsBackdrop', 'closeSettingsBtn');
856
-
857
- // Settings lists
858
- this.mcpServersList = document.getElementById('mcpServersList');
859
- this.addMcpServerBtn = document.getElementById('addMcpServerBtn');
860
-
861
- this.addMcpServerBtn.addEventListener('click', () => this.showModal('addMcpModal'));
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');
866
- // this.newChatLlmProvider = document.getElementById('newChatLlmProvider');
867
- // this.newChatModelGroup = document.getElementById('newChatModelGroup');
868
- // this.newChatModel = document.getElementById('newChatModel');
869
- // this.newChatTitle = document.getElementById('newChatTitle');
870
- // this.createChatBtn = document.getElementById('createChatBtn');
871
- // this.cancelNewChatBtn = document.getElementById('cancelNewChatBtn');
872
- //
873
- // this.newChatLlmProvider.addEventListener('change', () => this.updateNewChatModels());
874
- // this.createChatBtn.addEventListener('click', () => this.createNewChat());
875
- // this.cancelNewChatBtn.addEventListener('click', () => this.hideModal('newChatModal'));
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');
883
-
884
- this.saveMcpServerBtn.addEventListener('click', () => this.addMcpServer());
885
- this.cancelAddMcpBtn.addEventListener('click', () => this.hideModal('addMcpModal'));
886
-
887
- // System prompt modal controls
888
- this.systemPromptModal = document.getElementById('systemPromptModal');
889
- this.systemPromptTextarea = document.getElementById('systemPromptTextarea');
890
- this.closeSystemPromptBtn = document.getElementById('closeSystemPromptBtn');
891
- this.systemPromptBackdrop = document.getElementById('systemPromptBackdrop');
892
- this.cancelSystemPromptBtn = document.getElementById('cancelSystemPromptBtn');
893
- this.saveSystemPromptBtn = document.getElementById('saveSystemPromptBtn');
894
- this.resetToDefaultPromptBtn = document.getElementById('resetToDefaultPromptBtn');
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'));
899
- this.saveSystemPromptBtn.addEventListener('click', () => {
900
- // Get the chatId from the modal's data attribute
901
- const chatId = this.systemPromptModal.dataset.chatId;
902
- if (chatId) {
903
- this.saveSystemPrompt(chatId);
904
- }
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
- });
912
-
913
- // Auto-generate server name from URL
914
- this.mcpServerUrl.addEventListener('input', () => {
915
- if (!this.mcpServerName.value) {
916
- try {
917
- const url = new URL(this.mcpServerUrl.value);
918
- this.mcpServerName.value = url.hostname || 'MCP Server';
919
- } catch {
920
- // Invalid URL, ignore
921
- }
922
- }
923
- });
924
-
925
- // Tooltips are now CSS-only, no initialization needed
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');
932
-
933
- // Retry button handler
934
- this.retryModelsBtn.addEventListener('click', async () => {
935
- this.hideModal('noModelsModal');
936
- await this.initializeDefaultLLMProvider();
937
- });
938
- }
939
-
940
- setupModal(modalId, backdropId, closeId) {
941
- const backdrop = document.getElementById(backdropId);
942
- const closeBtn = document.getElementById(closeId);
943
-
944
- backdrop.addEventListener('click', () => this.hideModal(modalId));
945
- closeBtn.addEventListener('click', () => this.hideModal(modalId));
946
- }
947
-
948
-
949
- showModal(modalId) {
950
- document.getElementById(modalId).classList.add('show');
951
- }
952
-
953
- hideModal(modalId) {
954
- document.getElementById(modalId).classList.remove('show');
955
- }
956
-
957
- showNoModelsModal(proxyUrl) {
958
- // Update the proxy URL in the modal
959
- this.noModelsProxyUrl.textContent = proxyUrl;
960
-
961
- // Show the modal
962
- this.showModal('noModelsModal');
963
-
964
- // Disable the backdrop click since we don't want users to close it
965
- this.noModelsBackdrop.onclick = null;
966
- }
967
-
968
- validateChatModels() {
969
- // Validate each chat's model
970
- for (const [chatId, chat] of this.chats) {
971
- // Skip if chat doesn't have proper config
972
- if (!chat.config || !chat.config.model) {
973
- continue;
974
- }
975
-
976
- if (chat.llmProviderId) {
977
- const provider = this.llmProviders.get(chat.llmProviderId);
978
- if (provider && provider.availableProviders) {
979
- // Check if the model exists
980
- let modelExists = false;
981
- const providerType = chat.config.model.provider;
982
- const modelName = chat.config.model.id;
983
-
984
- if (providerType && modelName && provider.availableProviders[providerType]) {
985
- const models = provider.availableProviders[providerType].models || [];
986
- modelExists = models.some(m => {
987
- const mId = typeof m === 'string' ? m : m.id;
988
- return mId === modelName;
989
- });
990
- }
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.`);
995
-
996
- // Mark the chat as having an invalid model
997
- chat.hasInvalidModel = true;
998
-
999
- // DO NOT automatically reset or save!
1000
- // The user must manually select a valid model
1001
- }
1002
- }
1003
- }
1004
- }
1005
- }
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);
1013
- if (!container || !container._elements) { return; }
1014
-
1015
- const elements = container._elements;
1016
- const provider = this.llmProviders.get(chat.llmProviderId);
1017
-
1018
- // Update LLM model display
1019
- if (provider && chat.config?.model?.id) {
1020
- const modelDisplay = chat.config.model.id;
1021
- if (elements.llmMeta) {
1022
- elements.llmMeta.textContent = modelDisplay;
1023
- }
1024
- if (elements.currentModelText) {
1025
- elements.currentModelText.textContent = modelDisplay;
1026
- }
1027
- } else {
1028
- if (elements.llmMeta) {
1029
- elements.llmMeta.textContent = 'Model: Not found';
1030
- }
1031
- if (elements.currentModelText) {
1032
- elements.currentModelText.textContent = 'Select Model';
1033
- }
1034
- }
1035
- }
1036
-
1037
- isModelValid(model, provider) {
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') {
1043
- const modelConfig = ChatConfig.modelConfigFromString(model);
1044
- providerType = modelConfig?.provider;
1045
- modelName = modelConfig?.id;
1046
- } else if (model.provider && model.id) {
1047
- providerType = model.provider;
1048
- modelName = model.id;
1049
- } else {
1050
- return false;
1051
- }
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
- }
1061
-
1062
- populateModelDropdown(chatId, dropdown = null, buttonElement = null) {
1063
- if (!chatId) {
1064
- console.error('[populateModelDropdown] Called without chatId');
1065
- return;
1066
- }
1067
- const targetChatId = chatId;
1068
- let targetDropdown = dropdown || this.llmModelDropdown;
1069
-
1070
- const chat = this.chats.get(targetChatId);
1071
- if (!chat) {
1072
- console.error(`[showModelSelector] Chat not found for chatId: ${targetChatId}`);
1073
- return;
1074
- }
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
- }
1081
-
1082
- // Create a modal overlay instead of using the dropdown
1083
- const overlay = document.createElement('div');
1084
- overlay.className = 'model-selector-overlay';
1085
- overlay.style.cssText = `
1086
- position: fixed;
1087
- top: 0;
1088
- left: 0;
1089
- right: 0;
1090
- bottom: 0;
1091
- background: rgba(0, 0, 0, 0.5);
1092
- z-index: 9999;
1093
- `;
1094
-
1095
- // Get button position for dropdown-like positioning
1096
- const buttonRect = buttonElement ? buttonElement.getBoundingClientRect() : null;
1097
-
1098
- const modalContent = document.createElement('div');
1099
- modalContent.style.cssText = `
1100
- width: 900px !important;
1101
- min-width: 900px !important;
1102
- max-width: 900px !important;
1103
- max-height: 64vh;
1104
- position: fixed;
1105
- background: var(--background-color);
1106
- border-radius: 8px;
1107
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
1108
- border: 1px solid var(--border-color);
1109
- padding: 0;
1110
- display: flex;
1111
- flex-direction: column;
1112
- `;
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;
1119
-
1120
- if (spaceBelow >= 400 || spaceBelow > spaceAbove) {
1121
- // Show below button
1122
- modalContent.style.top = `${buttonRect.bottom + 5}px`;
1123
- modalContent.style.bottom = 'auto';
1124
- } else {
1125
- // Show above button
1126
- modalContent.style.bottom = `${window.innerHeight - buttonRect.top + 5}px`;
1127
- modalContent.style.top = 'auto';
1128
- }
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);
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
- }
1140
-
1141
- modalContent.style.left = `${left}px`;
1142
- } else {
1143
- // Fallback to center if no button provided
1144
- modalContent.style.top = '50%';
1145
- modalContent.style.left = '50%';
1146
- modalContent.style.transform = 'translate(-50%, -50%)';
1147
- }
1148
-
1149
- // Close when clicking overlay
1150
- overlay.addEventListener('click', (e) => {
1151
- if (e.target === overlay) {
1152
- overlay.remove();
1153
- // Update displays
1154
- this.updateChatHeader(chatId);
1155
- this.updateChatSessions();
1156
- }
1157
- });
1158
-
1159
- // Prevent clicks inside modal from closing
1160
- modalContent.addEventListener('click', (e) => {
1161
- e.stopPropagation();
1162
- });
1163
-
1164
- overlay.appendChild(modalContent);
1165
- document.body.appendChild(overlay);
1166
-
1167
- // Use modalContent as our target for populating
1168
- targetDropdown = modalContent;
1169
-
1170
- // Get current config
1171
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
1172
-
1173
- // Ensure the config is assigned to the chat object
1174
- if (!chat.config) {
1175
- chat.config = config;
1176
- }
1177
-
1178
- // Create header section (fixed)
1179
- const headerSection = document.createElement('div');
1180
- headerSection.style.cssText = `
1181
- flex-shrink: 0;
1182
- position: relative;
1183
- padding: 12px 16px;
1184
- border-bottom: 1px solid var(--border-color);
1185
- background: var(--surface-color);
1186
- `;
1187
-
1188
- // Add title
1189
- const headerTitle = document.createElement('h3');
1190
- headerTitle.style.cssText = `
1191
- margin: 0;
1192
- font-size: 16px;
1193
- font-weight: 600;
1194
- color: var(--text-primary);
1195
- `;
1196
- headerTitle.textContent = 'Model & Optimization Settings';
1197
- headerSection.appendChild(headerTitle);
1198
-
1199
- // Add close button at the top
1200
- const closeButton = document.createElement('button');
1201
- closeButton.style.cssText = `
1202
- position: absolute;
1203
- top: 10px;
1204
- right: 10px;
1205
- background: none;
1206
- border: none;
1207
- font-size: 24px;
1208
- cursor: pointer;
1209
- color: var(--text-secondary);
1210
- z-index: 1;
1211
- padding: 0;
1212
- width: 32px;
1213
- height: 32px;
1214
- display: flex;
1215
- align-items: center;
1216
- justify-content: center;
1217
- border-radius: 4px;
1218
- transition: background 0.2s;
1219
- `;
1220
- closeButton.innerHTML = '×';
1221
- closeButton.addEventListener('mouseenter', () => {
1222
- closeButton.style.background = 'var(--hover-color)';
1223
- });
1224
- closeButton.addEventListener('mouseleave', () => {
1225
- closeButton.style.background = 'none';
1226
- });
1227
- closeButton.addEventListener('click', () => {
1228
- overlay.remove();
1229
- // Update displays
1230
- this.updateChatHeader(chatId);
1231
- this.updateChatSessions();
1232
- });
1233
- headerSection.appendChild(closeButton);
1234
- targetDropdown.appendChild(headerSection);
1235
-
1236
- // Create scrollable content container
1237
- const contentContainer = document.createElement('div');
1238
- contentContainer.style.cssText = `
1239
- flex: 1;
1240
- overflow-y: auto;
1241
- min-height: 200px;
1242
- max-height: calc(64vh - 120px); /* Account for header and footer */
1243
- scrollbar-width: thin;
1244
- scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
1245
- `;
1246
-
1247
- // Add cost optimization settings section to content container
1248
- this.addCostOptimizationSection(contentContainer, chatId, config);
1249
- targetDropdown.appendChild(contentContainer);
1250
-
1251
- // Add footer section with cost estimation
1252
- // Footer section removed - no cost estimation needed
1253
- }
1254
-
1255
- addCostOptimizationSection(dropdown, chatId, config) {
1256
- const section = document.createElement('div');
1257
- section.style.cssText = `
1258
- padding: 8px 12px;
1259
- background: var(--surface-color);
1260
- border-bottom: 1px solid var(--border-color);
1261
- `;
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
- `;
1268
-
1269
- const chat = this.chats.get(chatId);
1270
- if (!chat) {
1271
- console.error('addCostOptimizationSection: Chat not found for ID:', chatId);
1272
- return;
1273
- }
1274
-
1275
- // Get all available models - removed as unused
1276
- // const allModels = this.getAllAvailableModels();
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;';
1281
-
1282
- const currentMaxTokens = chat.config.model.params.maxTokens;
1283
- chatModelDiv.innerHTML = `
1284
- <span>Chat with</span>
1285
- <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1286
- <button class="model-select-btn" id="chatModel_${chatId}"
1287
- style="padding: 2px 8px; border: 1px solid var(--border-color);
1288
- border-radius: 4px; background: var(--background-color);
1289
- color: var(--text-primary); cursor: pointer;
1290
- display: flex; align-items: center; gap: 4px;">
1291
- <span class="model-name">${ChatConfig.getChatModelString(chat) || 'Select model'}</span>
1292
- <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1293
- </button>
1294
- </div>
1295
-
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);
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;`;
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);
1339
-
1340
- toolSumDiv.innerHTML = `
1341
- <label style="display: flex; align-items: center; cursor: pointer;">
1342
- <input type="checkbox" id="toolSummarization_${chatId}" ${_isEnabled ? '' : 'disabled'}
1343
- ${chat.config.optimisation.toolSummarisation.enabled ? 'checked' : ''}
1344
- style="margin-right: 6px;">
1345
- <span>Summarize tool responses of at least</span>
1346
- </label>
1347
- <select id="toolThreshold_${chatId}" ${_isEnabled ? '' : 'disabled'}
1348
- style="width: 70px; padding: 2px 4px; border: 1px solid var(--border-color);
1349
- border-radius: 4px; background: var(--background-color); color: var(--text-primary);
1350
- cursor: pointer;">
1351
- <option value="0" ${currentThreshold === 0 ? 'selected' : ''}>0 (all)</option>
1352
- <option value="5" ${currentThreshold === 5 ? 'selected' : ''}>5</option>
1353
- <option value="10" ${currentThreshold === 10 ? 'selected' : ''}>10</option>
1354
- <option value="20" ${currentThreshold === 20 ? 'selected' : ''}>20</option>
1355
- <option value="30" ${currentThreshold === 30 ? 'selected' : ''}>30</option>
1356
- <option value="40" ${currentThreshold === 40 ? 'selected' : ''}>40</option>
1357
- <option value="50" ${currentThreshold === 50 ? 'selected' : ''}>50</option>
1358
- <option value="60" ${currentThreshold === 60 ? 'selected' : ''}>60</option>
1359
- <option value="70" ${currentThreshold === 70 ? 'selected' : ''}>70</option>
1360
- <option value="80" ${currentThreshold === 80 ? 'selected' : ''}>80</option>
1361
- <option value="90" ${currentThreshold === 90 ? 'selected' : ''}>90</option>
1362
- <option value="100" ${currentThreshold === 100 ? 'selected' : ''}>100</option>
1363
- </select>
1364
- <span>KiB size, with</span>
1365
- <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1366
- <button class="model-select-btn" id="toolSumModel_${chatId}" ${_isEnabled ? '' : 'disabled'}
1367
- style="padding: 2px 8px; border: 1px solid var(--border-color);
1368
- border-radius: 4px; background: var(--background-color);
1369
- color: var(--text-primary); cursor: pointer;
1370
- display: flex; align-items: center; gap: 4px;">
1371
- <span class="model-name">${toolSumModel || 'Select model'}</span>
1372
- <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1373
- </button>
1374
- </div>
1375
- `;
1376
-
1377
- section.appendChild(toolSumDiv);
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;`;
1383
-
1384
- const currentPercent = chat.config.optimisation.autoSummarisation.triggerPercent || 50;
1385
- const autoSumModel = ChatConfig.modelConfigToString(chat.config.optimisation.autoSummarisation.model) || ChatConfig.getChatModelString(chat);
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' : ''}
1390
- style="margin-right: 6px;">
1391
- <span>Summarize conversation when context window above</span>
1392
- </label>
1393
- <select id="autoSumThreshold_${chatId}"
1394
- style="width: 70px; padding: 2px 4px; border: 1px solid var(--border-color);
1395
- border-radius: 4px; background: var(--background-color); color: var(--text-primary);
1396
- cursor: pointer;">
1397
- <option value="30">30%</option>
1398
- <option value="40">40%</option>
1399
- <option value="50" ${currentPercent === 50 ? 'selected' : ''}>50%</option>
1400
- <option value="60">60%</option>
1401
- <option value="70">70%</option>
1402
- <option value="80">80%</option>
1403
- <option value="90">90%</option>
1404
- </select>
1405
- <span>with</span>
1406
- <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1407
- <button class="model-select-btn" id="autoSumModel_${chatId}"
1408
- style="padding: 2px 8px; border: 1px solid var(--border-color);
1409
- border-radius: 4px; background: var(--background-color);
1410
- color: var(--text-primary); cursor: pointer;
1411
- display: flex; align-items: center; gap: 4px;">
1412
- <span class="model-name">${autoSumModel || 'Select model'}</span>
1413
- <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1414
- </button>
1415
- </div>
1416
- `;
1417
-
1418
- section.appendChild(autoSumDiv);
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;' : ''}`;
1424
-
1425
- const titleGenModel = ChatConfig.modelConfigToString(chat.config.optimisation.titleGeneration?.model);
1426
-
1427
- titleGenDiv.innerHTML = `
1428
- <label style="display: flex; align-items: center; cursor: pointer;">
1429
- <input type="checkbox" id="titleGeneration_${chatId}" ${titleGenEnabled ? 'checked' : ''}
1430
- style="margin-right: 6px;">
1431
- <span>Generate chat titles with</span>
1432
- </label>
1433
- <div class="model-select-wrapper" style="position: relative; display: inline-block;">
1434
- <button class="model-select-btn" id="titleGenModel_${chatId}"
1435
- style="padding: 2px 8px; border: 1px solid var(--border-color);
1436
- border-radius: 4px; background: var(--background-color);
1437
- color: var(--text-primary); cursor: pointer;
1438
- display: flex; align-items: center; gap: 4px;"
1439
- ${!titleGenEnabled ? 'disabled' : ''}>
1440
- <span class="model-name">${titleGenModel || 'Select model'}</span>
1441
- <i class="fas fa-chevron-down" style="font-size: 10px;"></i>
1442
- </button>
1443
- </div>
1444
- `;
1445
-
1446
- section.appendChild(titleGenDiv);
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;' : ''}`;
1452
-
1453
- const forgetAfterConclusions = chat.config.optimisation.toolMemory.forgetAfterConclusions;
1454
-
1455
- toolMemoryDiv.innerHTML = `
1456
- <label style="display: flex; align-items: center; cursor: pointer;">
1457
- <input type="checkbox" id="toolMemory_${chatId}" ${toolMemoryEnabled ? 'checked' : ''}
1458
- style="margin-right: 6px;">
1459
- <span>Stop sending tool responses after the assistant concludes</span>
1460
- </label>
1461
- <select id="toolMemoryThreshold_${chatId}"
1462
- style="width: 50px; padding: 2px 4px; border: 1px solid var(--border-color);
1463
- border-radius: 4px; background: var(--background-color); color: var(--text-primary);
1464
- cursor: pointer;"
1465
- ${!toolMemoryEnabled ? 'disabled' : ''}>
1466
- <option value="0" ${forgetAfterConclusions === 0 ? 'selected' : ''}>0</option>
1467
- <option value="1" ${forgetAfterConclusions === 1 ? 'selected' : ''}>1</option>
1468
- <option value="2" ${forgetAfterConclusions === 2 ? 'selected' : ''}>2</option>
1469
- <option value="3" ${forgetAfterConclusions === 3 ? 'selected' : ''}>3</option>
1470
- </select>
1471
- <span>times</span>
1472
- `;
1473
-
1474
- section.appendChild(toolMemoryDiv);
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;
1485
- const cacheControlDisabled = !supportsCacheControl;
1486
- cacheControlDiv.style.cssText = `display: flex; align-items: center; gap: 8px; margin-bottom: 8px; ${cacheControlDisabled ? 'opacity: 0.5;' : ''}`;
1487
-
1488
- cacheControlDiv.innerHTML = `
1489
- <label style="display: flex; align-items: center;">
1490
- <span>Cache control:</span>
1491
- </label>
1492
- <select id="cacheControl_${chatId}"
1493
- style="width: 100px; padding: 2px 4px; border: 1px solid var(--border-color);
1494
- border-radius: 4px; background: var(--background-color); color: var(--text-primary);
1495
- cursor: pointer;"
1496
- ${cacheControlDisabled ? 'disabled' : ''}>
1497
- <option value="all-off" ${cacheControlMode === 'all-off' ? 'selected' : ''}>Off</option>
1498
- <option value="system" ${cacheControlMode === 'system' ? 'selected' : ''}>System</option>
1499
- <option value="cached" ${cacheControlMode === 'cached' ? 'selected' : ''}>Cached</option>
1500
- </select>
1501
- ${!supportsCacheControl ? '<span style="color: var(--text-secondary); font-size: 12px;">Anthropic only</span>' : ''}
1502
- `;
1503
-
1504
- section.appendChild(cacheControlDiv);
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);';
1509
-
1510
- const currentTemp = chat.config.model.params.temperature;
1511
- const currentTopP = chat.config.model.params.topP;
1512
-
1513
- paramsDiv.innerHTML = `
1514
- <div style="display: flex; flex-direction: column; gap: 12px;">
1515
- <!-- Temperature Control -->
1516
- <div style="display: flex; align-items: center; gap: 12px;">
1517
- <label style="font-size: 13px; font-weight: 600; color: var(--text-primary); min-width: 100px;">
1518
- Temperature
1519
- </label>
1520
- <div style="flex: 1; display: flex; align-items: center; gap: 8px;">
1521
- <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px;">Focused</span>
1522
- <input type="range" id="temperature_${chatId}" min="0" max="2" step="0.1" value="${currentTemp}"
1523
- style="flex: 1; height: 4px; accent-color: var(--primary-color);">
1524
- <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px; text-align: right;">Creative</span>
1525
- <span id="tempValue_${chatId}" style="font-size: 12px; font-weight: 600; color: var(--primary-color); min-width: 30px; text-align: right;">${currentTemp.toFixed(1)}</span>
1526
- </div>
1527
- </div>
1528
-
1529
- <!-- TopP Control -->
1530
- <div style="display: flex; align-items: center; gap: 12px;">
1531
- <label style="font-size: 13px; font-weight: 600; color: var(--text-primary); min-width: 100px;">
1532
- Top P
1533
- </label>
1534
- <div style="flex: 1; display: flex; align-items: center; gap: 8px;">
1535
- <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px;">Precise</span>
1536
- <input type="range" id="topP_${chatId}" min="0" max="1" step="0.05" value="${currentTopP}"
1537
- style="flex: 1; height: 4px; accent-color: var(--primary-color);">
1538
- <span style="font-size: 11px; color: var(--text-tertiary); min-width: 50px; text-align: right;">Diverse</span>
1539
- <span id="topPValue_${chatId}" style="font-size: 12px; font-weight: 600; color: var(--primary-color); min-width: 30px; text-align: right;">${currentTopP.toFixed(2)}</span>
1540
- </div>
1541
- </div>
1542
- </div>
1543
- `;
1544
-
1545
- section.appendChild(paramsDiv);
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}`);
1552
-
1553
- tempSlider.addEventListener('input', (e) => {
1554
- const value = parseFloat(e.target.value);
1555
- tempValueLabel.textContent = value.toFixed(1);
1556
- });
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
- });
1563
-
1564
- topPSlider.addEventListener('input', (e) => {
1565
- const value = parseFloat(e.target.value);
1566
- topPValueLabel.textContent = value.toFixed(2);
1567
- });
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
- });
1574
-
1575
- section.appendChild(document.createElement('div')); // spacer
1576
-
1577
- // Initialize model selection buttons
1578
- this.initializeModelSelectionButtons(section, chatId, chat, config);
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}`);
1584
-
1585
- toolSumCheckbox.addEventListener('change', (e) => {
1586
- e.stopPropagation();
1587
- const enabled = toolSumCheckbox.checked;
1588
- thresholdSelect.disabled = !enabled;
1589
- toolModelBtn.disabled = !enabled;
1590
- toolSumDiv.style.opacity = enabled ? '1' : '0.5';
1591
- this.updateOptimizationSetting(chatId, 'toolSummarization', enabled);
1592
- });
1593
-
1594
- thresholdSelect.addEventListener('change', (e) => {
1595
- e.stopPropagation();
1596
- const kbValue = parseInt(e.target.value, 10);
1597
- const validKbValue = isNaN(kbValue) ? 20 : kbValue; // Default 20KB only if invalid, allow 0
1598
- const byteValue = validKbValue * 1024;
1599
- this.updateToolThreshold(chatId, byteValue);
1600
- });
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}`);
1606
-
1607
- autoSumCheckbox.addEventListener('change', (e) => {
1608
- e.stopPropagation();
1609
- const enabled = autoSumCheckbox.checked;
1610
- autoSumSelect.disabled = !enabled;
1611
- autoModelBtn.disabled = !enabled;
1612
- autoSumDiv.style.opacity = enabled ? '1' : '0.5';
1613
- this.updateOptimizationSetting(chatId, 'autoSummarization', enabled);
1614
- });
1615
-
1616
- autoSumSelect.addEventListener('change', (e) => {
1617
- e.stopPropagation();
1618
- const percent = parseInt(e.target.value, 10) || 50;
1619
- this.updateAutoSumThreshold(chatId, percent);
1620
- });
1621
-
1622
- // Title Generation controls
1623
- const titleGenCheckbox = section.querySelector(`#titleGeneration_${chatId}`);
1624
- const titleModelBtn = section.querySelector(`#titleGenModel_${chatId}`);
1625
-
1626
- titleGenCheckbox.addEventListener('change', (e) => {
1627
- e.stopPropagation();
1628
- const enabled = titleGenCheckbox.checked;
1629
- titleModelBtn.disabled = !enabled;
1630
- titleGenDiv.style.opacity = enabled ? '1' : '0.5';
1631
- this.updateOptimizationSetting(chatId, 'titleGeneration', enabled);
1632
- });
1633
-
1634
- // Tool Memory controls
1635
- const toolMemoryCheckbox = section.querySelector(`#toolMemory_${chatId}`);
1636
- const toolMemorySelect = section.querySelector(`#toolMemoryThreshold_${chatId}`);
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';
1643
-
1644
- // Cache control is no longer mutually exclusive with tool memory
1645
-
1646
- this.updateOptimizationSetting(chatId, 'toolMemory', enabled);
1647
- });
1648
-
1649
- toolMemorySelect.addEventListener('change', (e) => {
1650
- e.stopPropagation();
1651
- const newForgetAfterConclusions = parseInt(e.target.value, 10);
1652
- this.updateToolMemoryThreshold(chatId, newForgetAfterConclusions);
1653
- });
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) {
1718
- cacheControlSelect.addEventListener('change', (e) => {
1719
- e.stopPropagation();
1720
- const newCacheMode = e.target.value;
1721
- this.updateCacheControlMode(chatId, newCacheMode);
1722
- });
1723
- }
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) => {
1728
- e.stopPropagation();
1729
- this.updateOptimizationSetting(chatId, checkbox.id.split('_')[0], checkbox.checked);
1730
- });
1731
- });
1732
-
1733
- section.querySelectorAll('label').forEach(label => {
1734
- label.addEventListener('click', (e) => {
1735
- e.stopPropagation();
1736
- });
1737
- });
1738
-
1739
- dropdown.appendChild(section);
1740
- }
1741
-
1742
- formatContextWindow(limit) {
1743
- if (!limit) return '--';
1744
- if (limit >= 1000000) return `${(limit / 1000000).toFixed(1)}M`;
1745
- if (limit >= 1000) return `${(limit / 1000).toFixed(0)}k`;
1746
- return limit.toString();
1747
- }
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
1886
- * @returns {string} HTML string for the tooltip
1887
- */
1888
- createModelTooltip(chat) {
1889
- if (!chat || !chat.config || !chat.config.model) {
1890
- return 'No model configured';
1891
- }
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;
1897
-
1898
- // Get MCP server name
1899
- const mcpServer = this.mcpServers.get(config.mcpServer);
1900
- const mcpServerName = mcpServer ? mcpServer.name : config.mcpServer;
1901
-
1902
- // Get optimization models
1903
- const toolSumModel = config.optimisation.toolSummarisation.model ?
1904
- ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.toolSummarisation.model)) :
1905
- 'Primary';
1906
- const autoSumModel = config.optimisation.autoSummarisation.model ?
1907
- ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.autoSummarisation.model)) :
1908
- 'Primary';
1909
- const titleGenModel = config.optimisation.titleGeneration.model ?
1910
- ChatConfig.getModelDisplayName(ChatConfig.modelConfigToString(config.optimisation.titleGeneration.model)) :
1911
- 'Primary';
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
- };
1918
-
1919
- // Helper to show enabled/disabled status compactly
1920
- const status = (enabled) => enabled ?
1921
- '<span style="color: var(--success-color);">✓</span>' :
1922
- '<span style="color: var(--error-color);">✗</span>';
1923
-
1924
- let tooltipHtml = `
1925
- <div style="min-width: 300px;">
1926
- <table style="width: 100%; font-size: 11px; border-collapse: collapse;">
1927
- <tr style="border-bottom: 1px solid var(--border-color);">
1928
- <td colspan="2" style="padding: 6px; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
1929
- ${modelString}
1930
- </td>
1931
- </tr>`;
1932
-
1933
- // Model parameters section (no provider, more condensed)
1934
- tooltipHtml += `
1935
- <tr>
1936
- <td style="padding: 4px 6px; color: var(--text-secondary);">Context:</td>
1937
- <td style="padding: 4px 6px; text-align: right;">${this.formatContextWindow(contextLimit)}</td>
1938
- </tr>
1939
- <tr>
1940
- <td style="padding: 4px 6px; color: var(--text-secondary);">Params:</td>
1941
- <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
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>`;
1945
-
1946
- // Pricing section (condensed with bold prices)
1947
- if (modelInfo.input || modelInfo.output) {
1948
- tooltipHtml += `
1949
- <tr>
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)}`;
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
- }
1960
-
1961
- tooltipHtml += `</td></tr>`;
1962
- }
1963
-
1964
- // All optimization settings in a compact section
1965
- tooltipHtml += `
1966
- <tr style="border-top: 1px solid var(--border-color);">
1967
- <td style="padding: 4px 6px; color: var(--text-secondary);">Tool Summary:</td>
1968
- <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1969
- ${status(config.optimisation.toolSummarisation.enabled)}
1970
- ${config.optimisation.toolSummarisation.enabled ? `${config.optimisation.toolSummarisation.thresholdKiB}KiB ${toolSumModel}` : 'Disabled'}
1971
- </td>
1972
- </tr>
1973
- <tr>
1974
- <td style="padding: 4px 6px; color: var(--text-secondary);">Auto Summary:</td>
1975
- <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
1976
- ${status(config.optimisation.autoSummarisation.enabled)}
1977
- ${config.optimisation.autoSummarisation.enabled ? `${config.optimisation.autoSummarisation.triggerPercent}% ${autoSumModel}` : 'Disabled'}
1978
- </td>
1979
- </tr>
1980
- <tr>
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)}
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;">
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>
2000
- <td style="padding: 4px 6px; color: var(--text-secondary);">Auto Title:</td>
2001
- <td style="padding: 4px 6px; text-align: right; font-size: 10px;">
2002
- ${status(config.optimisation.titleGeneration.enabled)}
2003
- ${config.optimisation.titleGeneration.enabled ? titleGenModel : 'Disabled'}
2004
- </td>
2005
- </tr>`;
2006
-
2007
- // Server info with name
2008
- tooltipHtml += `
2009
- <tr style="border-top: 1px solid var(--border-color);">
2010
- <td style="padding: 4px 6px; color: var(--text-secondary);">MCP Server:</td>
2011
- <td style="padding: 4px 6px; text-align: right; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px;">
2012
- ${mcpServerName}
2013
- </td>
2014
- </tr>
2015
- </table>
2016
- </div>`;
2017
-
2018
- return tooltipHtml;
2019
- }
2020
-
2021
- getAllAvailableModels() {
2022
- const models = [];
2023
-
2024
- // Iterate through all LLM providers
2025
- this.llmProviders.forEach((provider) => {
2026
- // Check if provider has availableProviders (the actual structure from the proxy)
2027
- if (provider.availableProviders) {
2028
- Object.entries(provider.availableProviders).forEach(([providerType, providerConfig]) => {
2029
- if (providerConfig.models && Array.isArray(providerConfig.models)) {
2030
- providerConfig.models.forEach(model => {
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;
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
2041
- pricing: pricing || null,
2042
- endpoint: typeof model === 'object' ? model.endpoint : undefined,
2043
- supportsTools: typeof model === 'object' ? model.supportsTools : undefined
2044
- });
2045
- }
2046
- });
2047
- }
2048
- });
2049
- }
2050
- });
2051
-
2052
- // Sort by provider and then by model name
2053
- models.sort((a, b) => {
2054
- if (a.providerId !== b.providerId) {
2055
- return a.providerId.localeCompare(b.providerId);
2056
- }
2057
- return a.id.localeCompare(b.id);
2058
- });
2059
-
2060
- return models;
2061
- }
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;
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();
2076
-
2077
- // Create context menu
2078
- const menu = document.createElement('div');
2079
- menu.className = 'model-context-menu';
2080
- menu.style.cssText = `
2081
- position: fixed;
2082
- left: ${e.clientX}px;
2083
- top: ${e.clientY}px;
2084
- background: var(--background-color);
2085
- border: 1px solid var(--border-color);
2086
- border-radius: 4px;
2087
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
2088
- padding: 4px 0;
2089
- z-index: 10000;
2090
- `;
2091
-
2092
- const modelName = button.querySelector('.model-name').textContent;
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 = `
2099
- padding: 6px 12px;
2100
- cursor: pointer;
2101
- font-size: 13px;
2102
- `;
2103
- copyItem.textContent = `Copy "${modelName}"`;
2104
- copyItem.addEventListener('mouseenter', () => {
2105
- copyItem.style.background = 'var(--hover-color)';
2106
- });
2107
- copyItem.addEventListener('mouseleave', () => {
2108
- copyItem.style.background = '';
2109
- });
2110
- copyItem.addEventListener('click', () => {
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
- }
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
- `;
2129
- const displayName = this.copiedModelDisplayName || this.copiedModel;
2130
- pasteItem.textContent = `Paste "${displayName}"`;
2131
- pasteItem.addEventListener('mouseenter', () => {
2132
- pasteItem.style.background = 'var(--hover-color)';
2133
- });
2134
- pasteItem.addEventListener('mouseleave', () => {
2135
- pasteItem.style.background = '';
2136
- });
2137
- pasteItem.addEventListener('click', () => {
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);
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
- }
2150
-
2151
- if (menu.children.length === 0) {
2152
- const emptyItem = document.createElement('div');
2153
- emptyItem.style.cssText = `
2154
- padding: 6px 12px;
2155
- color: var(--text-secondary);
2156
- font-size: 13px;
2157
- `;
2158
- emptyItem.textContent = 'No model to copy/paste';
2159
- menu.appendChild(emptyItem);
2160
- }
2161
-
2162
- document.body.appendChild(menu);
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)) {
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(() => {
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
- });
2187
-
2188
- // Regular click to open model selection
2189
- button.addEventListener('click', (e) => {
2190
- e.stopPropagation();
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');
2195
- if (existingDropdown) {
2196
- existingDropdown.remove();
2197
- button.removeAttribute('data-dropdown-open');
2198
- }
2199
- return;
2200
- }
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'));
2205
-
2206
- // Create model selection dropdown with pricing table
2207
- const dropdown = document.createElement('div');
2208
- dropdown.className = 'model-selection-dropdown';
2209
-
2210
- // Mark button as having an open dropdown
2211
- button.setAttribute('data-dropdown-open', 'true');
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;
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;
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
- }
2229
-
2230
- dropdown.style.cssText = `
2231
- position: fixed;
2232
- ${shouldShowAbove ? 'bottom' : 'top'}: ${shouldShowAbove ? (viewportHeight - buttonRect.top + 4) : (buttonRect.bottom + 4)}px;
2233
- left: ${leftPosition}px;
2234
- background: var(--background-color);
2235
- border: 1px solid var(--border-color);
2236
- border-radius: 4px;
2237
- box-shadow: 0 4px 12px rgba(0,0,0,0.15);
2238
- z-index: 10000;
2239
- max-height: 400px;
2240
- min-width: 600px;
2241
- display: flex;
2242
- flex-direction: column;
2243
- `;
2244
-
2245
- // Add search box
2246
- const searchContainer = document.createElement('div');
2247
- searchContainer.style.cssText = `
2248
- padding: 8px;
2249
- border-bottom: 1px solid var(--border-color);
2250
- background: var(--surface-color);
2251
- position: sticky;
2252
- top: 0;
2253
- z-index: 2;
2254
- `;
2255
-
2256
- const searchInput = document.createElement('input');
2257
- searchInput.type = 'text';
2258
- searchInput.placeholder = 'Search models...';
2259
- searchInput.style.cssText = `
2260
- width: 100%;
2261
- padding: 6px 10px;
2262
- border: 1px solid var(--border-color);
2263
- border-radius: 4px;
2264
- background: var(--background-color);
2265
- color: var(--text-primary);
2266
- font-size: 13px;
2267
- `;
2268
- searchContainer.appendChild(searchInput);
2269
- dropdown.appendChild(searchContainer);
2270
-
2271
- // Create scrollable content container
2272
- const contentContainer = document.createElement('div');
2273
- contentContainer.style.cssText = `
2274
- flex: 1;
2275
- overflow-y: auto;
2276
- `;
2277
- dropdown.appendChild(contentContainer);
2278
-
2279
- // Focus search input when dropdown opens
2280
- setTimeout(() => searchInput.focus(), 0);
2281
-
2282
- // Function to update dropdown position on scroll/resize
2283
- const updateDropdownPosition = () => {
2284
- const newButtonRect = button.getBoundingClientRect();
2285
- const newViewportHeight = window.innerHeight;
2286
- const newViewportWidth = window.innerWidth;
2287
- const newSpaceBelow = newViewportHeight - newButtonRect.bottom;
2288
- const newShouldShowAbove = newSpaceBelow < dropdownHeight && newButtonRect.top > dropdownHeight;
2289
-
2290
- if (newShouldShowAbove) {
2291
- dropdown.style.top = 'auto';
2292
- dropdown.style.bottom = `${newViewportHeight - newButtonRect.top + 4}px`;
2293
- } else {
2294
- dropdown.style.bottom = 'auto';
2295
- dropdown.style.top = `${newButtonRect.bottom + 4}px`;
2296
- }
2297
-
2298
- // Update horizontal position
2299
- let newLeftPosition = newButtonRect.left;
2300
- if (newLeftPosition + dropdownMinWidth > newViewportWidth) {
2301
- newLeftPosition = Math.max(10, newViewportWidth - dropdownMinWidth - 10);
2302
- }
2303
- dropdown.style.left = `${newLeftPosition}px`;
2304
- };
2305
-
2306
- // Create pricing table
2307
- const models = this.getAllAvailableModels();
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
- }
2315
-
2316
- // Within same provider, sort by input price descending
2317
- const aInputPrice = a.pricing?.input || 0;
2318
- const bInputPrice = b.pricing?.input || 0;
2319
-
2320
- if (aInputPrice !== bInputPrice) {
2321
- return bInputPrice - aInputPrice; // Descending order (expensive first)
2322
- }
2323
-
2324
- // If prices are equal, sort by name descending (newer models typically have later names)
2325
- return b.id.localeCompare(a.id);
2326
- });
2327
-
2328
- // Check if there are any models
2329
- if (!models || models.length === 0) {
2330
- contentContainer.innerHTML = `
2331
- <div style="padding: 20px; text-align: center; color: var(--text-secondary);">
2332
- No models available. Please check your LLM provider configuration.
2333
- </div>
2334
- `;
2335
- document.body.appendChild(dropdown);
2336
-
2337
- // Close dropdown on outside click
2338
- const closeDropdown = (evt) => {
2339
- if (!dropdown.contains(evt.target) && !button.contains(evt.target)) {
2340
- if (dropdown.parentElement) {
2341
- dropdown.parentElement.removeChild(dropdown);
2342
- }
2343
- document.removeEventListener('click', closeDropdown, true);
2344
- document.removeEventListener('mousedown', closeDropdown, true);
2345
- window.removeEventListener('scroll', updateDropdownPosition, true);
2346
- window.removeEventListener('resize', updateDropdownPosition);
2347
- }
2348
- };
2349
-
2350
- // Use capture phase to ensure we catch clicks before they're stopped by modal
2351
- setTimeout(() => {
2352
- document.addEventListener('click', closeDropdown, true);
2353
- document.addEventListener('mousedown', closeDropdown, true);
2354
- window.addEventListener('scroll', updateDropdownPosition, true);
2355
- window.addEventListener('resize', updateDropdownPosition);
2356
- }, 0);
2357
- return;
2358
- }
2359
- const table = document.createElement('table');
2360
- table.style.cssText = `
2361
- width: 100%;
2362
- border-collapse: collapse;
2363
- font-size: 12px;
2364
- `;
2365
-
2366
- // Table header
2367
- const thead = document.createElement('thead');
2368
- thead.innerHTML = `
2369
- <tr style="background: var(--surface-color); position: sticky; top: 0; z-index: 1;">
2370
- <th style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border-color);">Model</th>
2371
- <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">Context</th>
2372
- <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">Input $/MTok</th>
2373
- <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">Output $/MTok</th>
2374
- <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">CacheR $/MTok</th>
2375
- <th style="padding: 8px; text-align: right; border-bottom: 1px solid var(--border-color);">CacheW $/MTok</th>
2376
- </tr>
2377
- `;
2378
- table.appendChild(thead);
2379
-
2380
- const tbody = document.createElement('tbody');
2381
-
2382
- // Function to rebuild table body with filtered models
2383
- const rebuildTableBody = (filteredModels) => {
2384
- tbody.innerHTML = '';
2385
- let currentProvider = null;
2386
-
2387
- filteredModels.forEach(model => {
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
- `;
2398
- providerRow.innerHTML = `
2399
- <td colspan="6" style="padding: 8px; text-transform: uppercase; font-size: 11px;">
2400
- ${currentProvider}
2401
- </td>
2402
- `;
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
- `;
2420
-
2421
- // Mark selected row for scrolling
2422
- if (isSelected) {
2423
- tr.setAttribute('data-selected', 'true');
2424
- }
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>
2455
- <td style="padding: 8px; text-align: right;">$${outputPrice.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
- `;
2459
-
2460
- tbody.appendChild(tr);
2461
- });
2462
- };
2463
-
2464
- // Initial build with all models
2465
- rebuildTableBody(models);
2466
-
2467
- // Auto-scroll to currently selected model after initial table build
2468
- requestAnimationFrame(() => {
2469
- const selectedRow = tbody.querySelector('tr[data-selected="true"]');
2470
- if (selectedRow) {
2471
- const scrollContainer = contentContainer;
2472
- const containerRect = scrollContainer.getBoundingClientRect();
2473
- const rowRect = selectedRow.getBoundingClientRect();
2474
-
2475
- const rowTop = rowRect.top - containerRect.top + scrollContainer.scrollTop;
2476
- const rowBottom = rowTop + rowRect.height;
2477
- const containerHeight = scrollContainer.clientHeight;
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
2482
- const scrollTarget = rowTop - (containerHeight / 2) + (rowRect.height / 2);
2483
- scrollContainer.scrollTop = Math.max(0, scrollTarget);
2484
- }
2485
- }
2486
- });
2487
-
2488
- // Add search functionality
2489
- searchInput.addEventListener('input', (event) => {
2490
- const searchTerm = event.target.value.toLowerCase().trim();
2491
-
2492
- if (!searchTerm) {
2493
- rebuildTableBody(models);
2494
- return;
2495
- }
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();
2501
-
2502
- return modelId.includes(searchTerm) ||
2503
- providerId.includes(searchTerm) ||
2504
- fullId.includes(searchTerm);
2505
- });
2506
-
2507
- if (filteredModels.length === 0) {
2508
- tbody.innerHTML = `
2509
- <tr>
2510
- <td colspan="6" style="padding: 20px; text-align: center; color: var(--text-secondary);">
2511
- No models found matching "${searchTerm}"
2512
- </td>
2513
- </tr>
2514
- `;
2515
- } else {
2516
- rebuildTableBody(filteredModels);
2517
-
2518
- // Auto-scroll to selected model after search rebuild
2519
- requestAnimationFrame(() => {
2520
- const selectedRow = tbody.querySelector('tr[data-selected="true"]');
2521
- if (selectedRow) {
2522
- const scrollContainer = contentContainer;
2523
- const containerRect = scrollContainer.getBoundingClientRect();
2524
- const rowRect = selectedRow.getBoundingClientRect();
2525
-
2526
- const rowTop = rowRect.top - containerRect.top + scrollContainer.scrollTop;
2527
- const rowBottom = rowTop + rowRect.height;
2528
- const containerHeight = scrollContainer.clientHeight;
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);
2533
- }
2534
- }
2535
- });
2536
- }
2537
- });
2538
-
2539
- // Handle keyboard navigation
2540
- searchInput.addEventListener('keydown', (keyEvent) => {
2541
- if (keyEvent.key === 'Escape') {
2542
- dropdown.remove();
2543
- button.removeAttribute('data-dropdown-open');
2544
- } else if (keyEvent.key === 'ArrowDown') {
2545
- keyEvent.preventDefault();
2546
- const firstRow = tbody.querySelector('tr[style*="cursor: pointer"]');
2547
- if (firstRow) {
2548
- firstRow.focus();
2549
- firstRow.style.background = 'var(--hover-color)';
2550
- }
2551
- }
2552
- });
2553
-
2554
- // Close dropdown on outside click
2555
- const closeDropdown = (evt) => {
2556
- // Check if click is outside dropdown and button
2557
- if (!dropdown.contains(evt.target) && !button.contains(evt.target)) {
2558
- if (dropdown.parentElement) {
2559
- dropdown.parentElement.removeChild(dropdown);
2560
- }
2561
- button.removeAttribute('data-dropdown-open');
2562
- document.removeEventListener('click', closeDropdown, true);
2563
- document.removeEventListener('mousedown', closeDropdown, true);
2564
- window.removeEventListener('scroll', updateDropdownPosition, true);
2565
- window.removeEventListener('resize', updateDropdownPosition);
2566
- }
2567
- };
2568
-
2569
- // Click listeners are now added directly when creating rows
2570
-
2571
- // Add event listeners
2572
- // Use capture phase to ensure we catch clicks before they're stopped by modal
2573
- setTimeout(() => {
2574
- document.addEventListener('click', closeDropdown, true);
2575
- document.addEventListener('mousedown', closeDropdown, true);
2576
- window.addEventListener('scroll', updateDropdownPosition, true);
2577
- window.addEventListener('resize', updateDropdownPosition);
2578
- }, 0);
2579
-
2580
- // Now append elements after functions are defined
2581
- table.appendChild(tbody);
2582
- contentContainer.appendChild(table);
2583
-
2584
- // Append dropdown to body for proper z-index layering
2585
- document.body.appendChild(dropdown);
2586
- });
2587
- };
2588
-
2589
- // Initialize all model selection buttons
2590
- createModelDropdown(`chatModel_${chatId}`, ChatConfig.getChatModelString(chat), (model) => {
2591
- this.updateChatModel(chatId, model);
2592
- });
2593
-
2594
- createModelDropdown(`toolSumModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.toolSummarisation.model) || ChatConfig.getChatModelString(chat), (model) => {
2595
- this.updateToolSummarizationModel(chatId, model);
2596
- });
2597
-
2598
- createModelDropdown(`autoSumModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.autoSummarisation.model) || ChatConfig.getChatModelString(chat), (model) => {
2599
- this.updateAutoSummarizationModel(chatId, model);
2600
- });
2601
-
2602
- createModelDropdown(`titleGenModel_${chatId}`, ChatConfig.modelConfigToString(chat.config.optimisation.titleGeneration?.model), (model) => {
2603
- this.updateTitleGenerationModel(chatId, model);
2604
- });
2605
- }
2606
-
2607
- updateOptimizationSetting(chatId, settingType, enabled) {
2608
- const chat = this.chats.get(chatId);
2609
- if (!chat) {
2610
- console.error(`[updateOptimizationSetting] Chat not found for chatId: ${chatId}, settingType: ${settingType}`);
2611
- return;
2612
- }
2613
-
2614
- // Get current config or create defaults
2615
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2616
-
2617
- // Update the specific setting
2618
- switch (settingType) {
2619
- case 'toolSummarization':
2620
- config.optimisation.toolSummarisation.enabled = enabled;
2621
- break;
2622
- case 'toolMemory':
2623
- config.optimisation.toolMemory.enabled = enabled;
2624
- break;
2625
- case 'autoSummarization':
2626
- config.optimisation.autoSummarisation.enabled = enabled;
2627
- break;
2628
- case 'titleGeneration':
2629
- config.optimisation.titleGeneration.enabled = enabled;
2630
- break;
2631
- default:
2632
- console.warn(`Unknown setting type: ${settingType}`);
2633
- break;
2634
- }
2635
-
2636
- // Update chat config
2637
- chat.config = config;
2638
-
2639
- // Recreate MessageOptimizer with new settings
2640
- const optimizerSettings = {
2641
- ...config,
2642
- llmProviderFactory: config.optimisation.toolSummarisation.enabled ? window.createLLMProvider : undefined
2643
- };
2644
-
2645
- try {
2646
- chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2647
- } catch (error) {
2648
- console.error('[updateOptimizationSetting] Failed to create MessageOptimizer:', error);
2649
- }
2650
-
2651
- // Save config
2652
- this.saveChatConfigSmart(chatId, config);
2653
-
2654
- // Auto-save chat
2655
- this.autoSave(chatId);
2656
- }
2657
-
2658
- updateCacheControlMode(chatId, cacheMode) {
2659
- const chat = this.chats.get(chatId);
2660
- if (!chat) {
2661
- console.error(`[updateCacheControlMode] Chat not found for chatId: ${chatId}, cacheMode: ${cacheMode}`);
2662
- return;
2663
- }
2664
-
2665
- // Get current config or create defaults
2666
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2667
-
2668
- // Update cache control mode
2669
- config.optimisation.cacheControl = cacheMode;
2670
-
2671
- // Update chat config
2672
- chat.config = config;
2673
-
2674
- // Recreate MessageOptimizer with new settings
2675
- const optimizerSettings = {
2676
- ...config,
2677
- llmProviderFactory: config.optimisation.toolSummarisation.enabled ? window.createLLMProvider : undefined
2678
- };
2679
-
2680
- try {
2681
- chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2682
- } catch (error) {
2683
- console.error('[updateCacheControlMode] Failed to create MessageOptimizer:', error);
2684
- }
2685
-
2686
- // Save config
2687
- this.saveChatConfigSmart(chatId, config);
2688
-
2689
- // Auto-save chat
2690
- this.autoSave(chatId);
2691
- }
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
- }
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
- }
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
2747
-
2748
- chat.config = config;
2749
- this.recreateMessageOptimizer(chat, config);
2750
- this.saveChatConfigSmart(chatId, config);
2751
- this.autoSave(chatId);
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
- }
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
- }
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
- }
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
- }
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
- }
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
- }
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
- }
2877
-
2878
- updateToolThreshold(chatId, threshold) {
2879
- const chat = this.chats.get(chatId);
2880
- if (!chat) return;
2881
-
2882
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2883
- config.optimisation.toolSummarisation.thresholdKiB = Math.floor(threshold / 1024);
2884
-
2885
- chat.config = config;
2886
- this.recreateMessageOptimizer(chat, config);
2887
- this.saveChatConfigSmart(chatId, config);
2888
- this.autoSave(chatId);
2889
- }
2890
-
2891
- updateAutoSumThreshold(chatId, percent) {
2892
- const chat = this.chats.get(chatId);
2893
- if (!chat) return;
2894
-
2895
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2896
- config.optimisation.autoSummarisation.triggerPercent = percent;
2897
-
2898
- chat.config = config;
2899
- this.recreateMessageOptimizer(chat, config);
2900
- this.saveChatConfigSmart(chatId, config);
2901
- this.autoSave(chatId);
2902
- }
2903
-
2904
- updateToolMemoryThreshold(chatId, forgetAfterConclusions) {
2905
- const chat = this.chats.get(chatId);
2906
- if (!chat) return;
2907
-
2908
- const config = chat.config || ChatConfig.loadChatConfig(chatId);
2909
- config.optimisation.toolMemory.forgetAfterConclusions = forgetAfterConclusions;
2910
-
2911
- chat.config = config;
2912
- this.recreateMessageOptimizer(chat, config);
2913
- this.saveChatConfigSmart(chatId, config);
2914
- this.autoSave(chatId);
2915
- }
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
- };
2923
-
2924
- try {
2925
- chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
2926
- } catch (error) {
2927
- console.error('[recreateMessageOptimizer] Failed to create MessageOptimizer:', error);
2928
- }
2929
- }
2930
-
2931
-
2932
- populateMCPDropdown(chatId, dropdown = null) {
2933
- if (!chatId) {
2934
- console.error('[populateMCPDropdown] Called without chatId');
2935
- return;
2936
- }
2937
- const targetChatId = chatId;
2938
- const targetDropdown = dropdown || this.mcpServerDropdown;
2939
-
2940
- targetDropdown.innerHTML = '';
2941
-
2942
- // Sort servers by name
2943
- const sortedServers = Array.from(this.mcpServers.entries())
2944
- .sort(([, a], [, b]) => a.name.localeCompare(b.name));
2945
-
2946
- for (const [id, server] of sortedServers) {
2947
- const item = document.createElement('button');
2948
- item.className = 'dropdown-item';
2949
-
2950
- // Check actual connection status from mcpConnections
2951
- const mcpConnection = this.mcpConnections.get(id);
2952
- const isConnected = mcpConnection && mcpConnection.isReady();
2953
-
2954
- item.innerHTML = `
2955
- <div style="display: flex; align-items: flex-start; gap: 8px;">
2956
- <span style="flex-shrink: 0;">${isConnected ? '🟢' : '🔴'}</span>
2957
- <div style="flex: 1; min-width: 0;">
2958
- <div>${server.name}</div>
2959
- <small style="display: block; color: var(--text-tertiary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${server.url}</small>
2960
- </div>
2961
- </div>
2962
- `;
2963
-
2964
- const chat = this.chats.get(targetChatId);
2965
- if (chat && chat.mcpServerId === id) {
2966
- item.classList.add('active');
2967
- }
2968
-
2969
- item.onclick = () => {
2970
- this.switchMcpServer(id, targetChatId).catch(error => {
2971
- console.error('Failed to switch MCP server:', error);
2972
- this.showError('Failed to switch MCP server', targetChatId, false);
2973
- });
2974
- targetDropdown.style.display = 'none';
2975
- };
2976
-
2977
- targetDropdown.appendChild(item);
2978
- }
2979
- }
2980
-
2981
- async switchMcpServer(newServerId, chatId) {
2982
- const chat = this.chats.get(chatId);
2983
- if (!chat || chat.mcpServerId === newServerId) { return; }
2984
-
2985
- try {
2986
- // Ensure connection to new MCP server
2987
- await this.ensureMcpConnection(newServerId);
2988
-
2989
- chat.mcpServerId = newServerId;
2990
- this.autoSave(chat.id);
2991
-
2992
- // Update UI
2993
- const server = this.mcpServers.get(newServerId);
2994
- this.currentMcpText.textContent = server.name;
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
- }
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
- }
3008
-
3009
- this.addLogEntry('SYSTEM', {
3010
- timestamp: new Date().toISOString(),
3011
- direction: 'info',
3012
- message: `Switched to MCP server: ${server.name}`
3013
- });
3014
-
3015
- } catch (error) {
3016
- this.showError(`Failed to switch MCP server: ${error.message}`, chatId, false);
3017
- }
3018
- }
3019
-
3020
- saveSystemPrompt(chatId) {
3021
- const chat = this.chats.get(chatId);
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
- }
3029
-
3030
- // Check if prompt actually changed
3031
- if (newPrompt === chat.systemPrompt) {
3032
- this.hideModal('systemPromptModal');
3033
- return;
3034
- }
3035
-
3036
- // Update the chat's system prompt
3037
- chat.systemPrompt = newPrompt;
3038
-
3039
- // Clear messages and reset the conversation
3040
- chat.messages = [];
3041
- chat.updatedAt = new Date().toISOString();
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
- });
3059
-
3060
- // Save settings
3061
- this.saveSettings();
3062
-
3063
- // Reload the chat (force refresh since we cleared messages)
3064
- this.loadChat(chatId, true);
3065
-
3066
- // Hide modal
3067
- this.hideModal('systemPromptModal');
3068
-
3069
- // Show notification
3070
- this.addSystemMessage('System prompt updated. Conversation has been reset.', chatId);
3071
- }
3072
-
3073
- toggleTheme() {
3074
- const html = document.documentElement;
3075
- const currentTheme = html.getAttribute('data-theme');
3076
- const newTheme = currentTheme === 'light' ? 'dark' : 'light';
3077
- html.setAttribute('data-theme', newTheme);
3078
- localStorage.setItem('theme', newTheme);
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');
3086
-
3087
- // Make sure the resize handle exists
3088
- if (!chatSidebarResize) {
3089
- console.error('Chat sidebar resize handle not found');
3090
- } else {
3091
- this.setupResize(chatSidebarResize, 'horizontal', (delta) => {
3092
- const isCollapsed = chatSidebar.classList.contains('collapsed');
3093
- const currentWidth = chatSidebar.offsetWidth;
3094
- const newWidth = currentWidth + delta;
3095
-
3096
- // If collapsed and dragging to expand (delta > 0)
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');
3136
-
3137
- this.setupResize(logPanelResize, 'horizontal', (delta) => {
3138
- // First, ensure the panel is not collapsed
3139
- if (logPanel.classList.contains('collapsed')) {
3140
- // Expand it first
3141
- logPanel.classList.remove('collapsed');
3142
- this.toggleLogBtn.innerHTML = '<i class="fas fa-chevron-right"></i>';
3143
- this.expandLogBtn.style.display = 'none';
3144
- localStorage.setItem('logCollapsed', 'false');
3145
- // Set initial width when expanding
3146
- logPanel.style.width = '300px';
3147
- }
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));
3152
- logPanel.style.width = newWidth + 'px';
3153
- this.savePaneSizes();
3154
- }, logPanel);
3155
-
3156
- // Chat input resize is now handled per-chat in the createChatDOM method
3157
- // No global chat input container exists anymore
3158
- }
3159
-
3160
- setupResize(handle, direction, onResize, element) {
3161
- if (!handle) {
3162
- console.warn('setupResize called with null handle');
3163
- return;
3164
- }
3165
-
3166
- let isResizing = false;
3167
- let startPos = 0;
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();
3175
-
3176
- // Add active class for visual feedback
3177
- handle.classList.add('resize-active');
3178
-
3179
- // Add resizing class to element if provided
3180
- if (element) {
3181
- element.classList.add('resizing');
3182
- }
3183
- };
3184
-
3185
- const doResize = (e) => {
3186
- if (!isResizing) { return; }
3187
-
3188
- const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
3189
- const delta = currentPos - startPos;
3190
- startPos = currentPos;
3191
-
3192
- onResize(delta);
3193
- };
3194
-
3195
- const stopResize = () => {
3196
- if (!isResizing) { return; }
3197
- isResizing = false;
3198
- document.body.style.cursor = '';
3199
- document.body.style.userSelect = '';
3200
-
3201
- // Remove active class
3202
- handle.classList.remove('resize-active');
3203
-
3204
- // Remove resizing class from element if provided
3205
- if (element) {
3206
- element.classList.remove('resizing');
3207
- }
3208
- };
3209
-
3210
- handle.addEventListener('mousedown', startResize);
3211
- document.addEventListener('mousemove', doResize);
3212
- document.addEventListener('mouseup', stopResize);
3213
-
3214
- // Also handle mouse leave to stop resize
3215
- document.addEventListener('mouseleave', stopResize);
3216
- }
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
- }
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)));
3228
-
3229
- if (isVertical) {
3230
- container.style.height = newSize + 'px';
3231
- } else {
3232
- container.style.width = newSize + 'px';
3233
- }
3234
- }, container);
3235
- }
3236
-
3237
- savePaneSizes() {
3238
- const sizes = {
3239
- chatSidebar: this.chatSidebar ? this.chatSidebar.offsetWidth : 280,
3240
- logPanel: this.logPanel ? this.logPanel.classList.contains('collapsed') ? 40 : this.logPanel.offsetWidth : 300,
3241
- logPanelCollapsed: this.logPanel ? this.logPanel.classList.contains('collapsed') : false
3242
- };
3243
- localStorage.setItem('paneSizes', JSON.stringify(sizes));
3244
- }
3245
-
3246
- loadPaneSizes() {
3247
- const savedSizes = localStorage.getItem('paneSizes');
3248
- if (savedSizes) {
3249
- try {
3250
- const sizes = JSON.parse(savedSizes);
3251
-
3252
- if (sizes.chatSidebar && this.chatSidebar && !this.chatSidebar.classList.contains('collapsed')) {
3253
- this.chatSidebar.style.width = sizes.chatSidebar + 'px';
3254
- }
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
- }
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);
3266
- }
3267
- }
3268
- }
3269
-
3270
- toggleLog() {
3271
- const isCollapsed = this.logPanel.classList.toggle('collapsed');
3272
- this.toggleLogBtn.innerHTML = isCollapsed ? '<i class="fas fa-chevron-left"></i>' : '<i class="fas fa-chevron-right"></i>';
3273
- this.expandLogBtn.style.display = isCollapsed ? 'block' : 'none';
3274
- localStorage.setItem('logCollapsed', String(isCollapsed));
3275
- this.savePaneSizes();
3276
- }
3277
-
3278
- toggleChatSidebar() {
3279
- const isCollapsed = this.chatSidebar.classList.toggle('collapsed');
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';
3284
-
3285
- if (isCollapsed) {
3286
- // Store current width before collapsing
3287
- const currentWidth = this.chatSidebar.offsetWidth;
3288
- if (currentWidth > 40) {
3289
- localStorage.setItem('chatSidebarWidth', String(currentWidth));
3290
- }
3291
- // Override any inline width when collapsed
3292
- this.chatSidebar.style.width = '';
3293
- } else {
3294
- // Restore previous width
3295
- const savedWidth = localStorage.getItem('chatSidebarWidth') || '280';
3296
- this.chatSidebar.style.width = savedWidth + 'px';
3297
- }
3298
-
3299
- localStorage.setItem('chatSidebarCollapsed', String(isCollapsed));
3300
- this.savePaneSizes();
3301
- }
3302
-
3303
- loadSidebarStates() {
3304
- // Load chat sidebar state
3305
- const chatSidebarCollapsed = localStorage.getItem('chatSidebarCollapsed') === 'true';
3306
- if (chatSidebarCollapsed) {
3307
- this.chatSidebar.classList.add('collapsed');
3308
- // Note: CSS rotates the icon 180deg when collapsed, so keep it as chevron-left
3309
- const icon = this.toggleSidebarBtn.querySelector('i');
3310
- icon.className = 'fas fa-chevron-left';
3311
- }
3312
-
3313
- // Load log panel state
3314
- const logCollapsed = localStorage.getItem('logCollapsed') === 'true';
3315
- if (logCollapsed) {
3316
- this.logPanel.classList.add('collapsed');
3317
- this.toggleLogBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
3318
- this.expandLogBtn.style.display = 'block';
3319
- }
3320
- }
3321
-
3322
- handleRateLimitError(chatId, retryAfterSeconds, retryCount = 0) {
3323
- const chat = this.chats.get(chatId);
3324
- if (!chat) return;
3325
-
3326
- // Store retry count in chat for tracking
3327
- chat.rateLimitRetryCount = retryCount;
3328
-
3329
- // If we couldn't parse retry time, use exponential backoff
3330
- let waitTime;
3331
- if (retryAfterSeconds && retryAfterSeconds > 0) {
3332
- // Add a small buffer to ensure we wait long enough
3333
- waitTime = retryAfterSeconds + 1;
3334
- } else {
3335
- // Exponential backoff: 5s, 10s, 20s, 40s, 80s...
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
- }
3339
-
3340
- let remainingSeconds = Math.ceil(waitTime);
3341
-
3342
- // Mark that we're in rate limit countdown - this prevents other operations from clearing the spinner
3343
- chat.isInRateLimitCountdown = true;
3344
-
3345
- // Show waiting spinner with countdown immediately
3346
- this.showWaitingCountdown(chatId, remainingSeconds);
3347
-
3348
- const updateCountdown = () => {
3349
- remainingSeconds--;
3350
- if (remainingSeconds > 0) {
3351
- // Only update if we're still in countdown mode
3352
- if (chat.isInRateLimitCountdown) {
3353
- this.updateWaitingCountdown(chatId, remainingSeconds);
3354
- setTimeout(updateCountdown, 1000);
3355
- }
3356
- } else {
3357
- // Clear the flag and retry
3358
- chat.isInRateLimitCountdown = false;
3359
- // Don't hide the countdown - let retryLLMRequest transition to thinking spinner
3360
- // This ensures there's no gap in the spinner display
3361
- this.retryLLMRequest(chatId);
3362
- }
3363
- };
3364
-
3365
- // Start the countdown
3366
- setTimeout(updateCountdown, 1000);
3367
- }
3368
-
3369
- async retryLLMRequest(chatId) {
3370
- const chat = this.chats.get(chatId);
3371
- if (!chat) return;
3372
-
3373
- try {
3374
- const mcpConnection = this.mcpConnections.get(chat.mcpServerId);
3375
- const proxyProvider = this.llmProviders.get(chat.llmProviderId);
3376
-
3377
- if (!mcpConnection || !proxyProvider || !chat.config?.model) {
3378
- this.showError('Cannot retry: MCP server or LLM provider not available', chatId);
3379
- return;
3380
- }
3381
-
3382
- // Create provider instance
3383
- const providerType = chat.config.model.provider;
3384
- const modelName = chat.config.model.id;
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
- };
3395
-
3396
- // Build messages from current state
3397
- const { messages, cacheControlIndex } = this.buildMessagesForAPI(chat, provider.prefersCachedTools, mcpConnection);
3398
-
3399
- // Get available tools
3400
- const tools = Array.from(mcpConnection.tools.values());
3401
-
3402
- // Increment retry count for next attempt
3403
- const currentRetryCount = chat.rateLimitRetryCount || 0;
3404
- chat.rateLimitRetryCount = currentRetryCount + 1;
3405
-
3406
- // Call assistant with proper error handling
3407
- const temperature = this.getCurrentTemperature(chatId);
3408
- const response = await this.callAssistant({
3409
- chatId,
3410
- provider,
3411
- messages,
3412
- tools,
3413
- temperature,
3414
- cacheControlIndex,
3415
- context: `Retry (attempt ${chat.rateLimitRetryCount})`
3416
- });
3417
-
3418
- // Check if rate limit was handled
3419
- if (response._rateLimitHandled) {
3420
- return; // Rate limit retry will happen automatically with exponential backoff
3421
- }
3422
-
3423
- // Success - reset retry count
3424
- chat.rateLimitRetryCount = 0;
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);
3429
-
3430
- // Success - assistant has concluded
3431
- this.assistantConcluded(chatId);
3432
-
3433
- } catch (error) {
3434
- // Clean up on error - but preserve rate limit waiting spinner
3435
- this.assistantFailed(chatId, error);
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');
3442
-
3443
- // Determine error type
3444
- let errorType = 'llm_error';
3445
- if (error.message.includes('MCP') || error.message.includes('connection')) {
3446
- errorType = 'mcp_error';
3447
- } else if (error.message.includes('Tool')) {
3448
- errorType = 'tool_error';
3449
- }
3450
-
3451
- // Update error state
3452
- this.showError(errorMessage, chatId, false, errorType);
3453
-
3454
- // Add error message
3455
- this.addMessage(chatId, {
3456
- role: 'error',
3457
- content: errorMessage,
3458
- errorMessageIndex: lastUserMessageIndex,
3459
- errorType
3460
- });
3461
-
3462
- this.processRenderEvent({
3463
- type: 'error-message',
3464
- content: errorMessage,
3465
- errorMessageIndex: lastUserMessageIndex,
3466
- errorType
3467
- }, chatId);
3468
- }
3469
- }
3470
- }
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
- }
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
- }
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
- }
3491
-
3492
- // Safety check: Check iteration limit before continuing
3493
- try {
3494
- // Only check iteration limit here, not request size
3495
- // Request size will be checked in the provider when actual request is built
3496
- const currentIterations = this.safetyChecker.getIterationCount(chat.id);
3497
- if (currentIterations >= SAFETY_LIMITS.MAX_CONSECUTIVE_TOOL_ITERATIONS) {
3498
- throw new SafetyLimitError('ITERATIONS', SAFETY_LIMITS.ERRORS.TOO_MANY_ITERATIONS(currentIterations, SAFETY_LIMITS.MAX_CONSECUTIVE_TOOL_ITERATIONS));
3499
- }
3500
- } catch (error) {
3501
- if (error instanceof SafetyLimitError) {
3502
- this.addMessage(chat.id, {
3503
- role: 'error',
3504
- content: error.message,
3505
- errorType: 'safety_limit',
3506
- isRetryable: false
3507
- });
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
- }
3517
-
3518
- // Send next request to LLM
3519
- const temperature = this.getCurrentTemperature(chat.id);
3520
- // eslint-disable-next-line no-await-in-loop
3521
- const response = await this.callAssistant({
3522
- chatId: chat.id,
3523
- provider,
3524
- messages,
3525
- tools,
3526
- temperature,
3527
- cacheControlIndex,
3528
- context: 'Processing tools'
3529
- });
3530
-
3531
- // Check if rate limit was handled automatically
3532
- if (response._rateLimitHandled) {
3533
- return { rateLimitHandled: true };
3534
- }
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
- }
3541
-
3542
- async processSingleLLMResponse(chat, mcpConnection, provider, messages, tools, cacheControlIndex, response) {
3543
- const llmResponseTime = response._responseTime || 0;
3544
-
3545
- // Track token usage
3546
- if (response.usage) {
3547
- this.updateTokenUsage(chat.id, response.usage, ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`);
3548
- }
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
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);
3560
-
3561
- if (response.content) {
3562
- // Create and save the assistant message
3563
- const assistantMsg = {
3564
- role: 'assistant',
3565
- content: response.content,
3566
- usage: response.usage || null,
3567
- responseTime: llmResponseTime || null,
3568
- model: ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`,
3569
- turn: chat.currentTurn
3570
- };
3571
- this.addMessage(chat.id, assistantMsg);
3572
-
3573
- // Display it
3574
- const messageIndex = chat.messages.length - 1;
3575
- this.processRenderEvent({
3576
- type: 'assistant-message',
3577
- content: response.content,
3578
- messageIndex
3579
- }, chat.id);
3580
-
3581
- // Track cumulative tokens
3582
- if (response.usage) {
3583
- const modelUsed = ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`;
3584
- this.addCumulativeTokens(
3585
- chat.id,
3586
- modelUsed,
3587
- response.usage.promptTokens || 0,
3588
- response.usage.completionTokens || 0,
3589
- response.usage.cacheReadInputTokens || 0,
3590
- response.usage.cacheCreationInputTokens || 0
3591
- );
3592
- }
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
3597
- // This prevents infinite loops when LLM responds with only thinking tags
3598
- messages.push({ role: 'assistant', content: cleanedContent || '' });
3599
- }
3600
- return;
3601
- }
3602
-
3603
- // Process response with tool calls
3604
- let assistantMessageIndex = null;
3605
-
3606
- // Save assistant message first
3607
- if (response.content || toolsInContent.length > 0) {
3608
- const assistantMessage = {
3609
- role: 'assistant',
3610
- content: response.content || '',
3611
- usage: response.usage || null,
3612
- responseTime: llmResponseTime || null,
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;
3619
-
3620
- // Track cumulative tokens
3621
- if (response.usage) {
3622
- const modelUsed = ChatConfig.getChatModelString(chat) || `${provider.type}:${provider.model}`;
3623
- this.addCumulativeTokens(
3624
- chat.id,
3625
- modelUsed,
3626
- response.usage.promptTokens || 0,
3627
- response.usage.completionTokens || 0,
3628
- response.usage.cacheReadInputTokens || 0,
3629
- response.usage.cacheCreationInputTokens || 0
3630
- );
3631
- }
3632
-
3633
- // Display metrics and content
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);
3640
-
3641
- if (response.content) {
3642
- this.processRenderEvent({
3643
- type: 'assistant-message',
3644
- content: response.content,
3645
- messageIndex: assistantMessageIndex
3646
- }, chat.id);
3647
- }
3648
-
3649
- // Add to API messages - keep original content with tool calls
3650
- const assistantMsg = {
3651
- role: 'assistant',
3652
- content: response.content || ''
3653
- };
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
- }
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);
3667
-
3668
- // Safety check for concurrent tools
3669
- try {
3670
- this.safetyChecker.checkConcurrentToolsLimit(extractedTools);
3671
- } catch (error) {
3672
- if (error instanceof SafetyLimitError) {
3673
- this.addMessage(chat.id, {
3674
- role: 'error',
3675
- content: error.message,
3676
- errorType: 'safety_limit',
3677
- isRetryable: false
3678
- });
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
- }
3688
-
3689
- // Ensure assistant group exists
3690
- if (!this.getCurrentAssistantGroup(chat.id)) {
3691
- this.processRenderEvent({
3692
- type: 'assistant-message',
3693
- content: '',
3694
- messageIndex: assistantMessageIndex
3695
- }, chat.id);
3696
- }
3697
-
3698
- // Execute tools and collect results
3699
- const toolResults = await this.executeToolCalls(chat, mcpConnection, extractedTools, assistantMessageIndex);
3700
-
3701
- // Store tool results
3702
- if (toolResults.length > 0) {
3703
- this.addMessage(chat.id, {
3704
- role: 'tool-results',
3705
- toolResults,
3706
- turn: chat.currentTurn
3707
- });
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) {
3712
- const subChat = this.chats.get(toolResult.subChatId);
3713
- if (subChat) {
3714
- // Ensure sub-chat has up-to-date token pricing
3715
- this.updateChatTokenPricing(subChat);
3716
- // Store costs in parent tool-result
3717
- this.updateParentToolResultCosts(chat.id, toolResult.toolCallId, subChat);
3718
- } else {
3719
- console.error(`[processSingleLLMResponse] ERROR: Sub-chat ${toolResult.subChatId} not found when trying to update parent costs for tool ${toolResult.toolCallId}`);
3720
- }
3721
- }
3722
- }
3723
-
3724
- // CRITICAL FIX: Update parent chat token pricing to include all sub-chat costs
3725
- this.updateChatTokenPricing(chat);
3726
- this.updateAllTokenDisplays(chat.id);
3727
-
3728
- // Add to messages for API
3729
- const includedResults = toolResults.filter(tr => tr.includeInContext !== false);
3730
- if (includedResults.length > 0) {
3731
- const toolResultsMessage = {
3732
- role: 'tool-results',
3733
- toolResults: includedResults.map(tr => ({
3734
- toolCallId: tr.toolCallId,
3735
- toolName: tr.name,
3736
- result: tr.result
3737
- }))
3738
- };
3739
-
3740
- messages.push(toolResultsMessage);
3741
-
3742
- // Sub-chats are now processed immediately during tool execution (interleaved)
3743
- }
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
- }
3751
-
3752
- async executeToolCalls(chat, mcpConnection, toolCalls, assistantMessageIndex) {
3753
- const toolResults = [];
3754
-
3755
- for (const toolCall of toolCalls) {
3756
- if (!toolCall.id) {
3757
- console.error('[executeToolCalls] Tool call missing required id:', toolCall);
3758
- continue;
3759
- }
3760
-
3761
- try {
3762
- const { arguments: toolArgs } = toolCall || {};
3763
-
3764
- // Show tool call in UI
3765
- this.processRenderEvent({
3766
- type: 'tool-call',
3767
- name: toolCall.name,
3768
- arguments: toolArgs,
3769
- id: toolCall.id,
3770
- includeInContext: toolCall.includeInContext !== false
3771
- }, chat.id);
3772
-
3773
- // Show tool execution spinner
3774
- this.showToolExecuting(chat.id, toolCall.name);
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;
3781
-
3782
- // Hide tool execution spinner
3783
- this.hideToolExecuting(chat.id);
3784
-
3785
- // Parse result
3786
- const result = this.parseToolResult(rawResult);
3787
- const responseSize = typeof result === 'string'
3788
- ? result.length
3789
- : JSON.stringify(result).length;
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) {
3797
- this.processRenderEvent({
3798
- type: 'tool-result',
3799
- name: toolCall.name,
3800
- result,
3801
- toolCallId: toolCall.id,
3802
- responseTime: toolResponseTime,
3803
- responseSize,
3804
- messageIndex: assistantMessageIndex
3805
- }, chat.id);
3806
- }
3807
-
3808
- if (shouldCreateSubChat) {
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);
3812
-
3813
- // Show secondary assistant waiting spinner for main chat
3814
- this.showSecondaryAssistantWaiting(chat.id);
3815
-
3816
- // Render sub-chat DOM BEFORE processing starts so users can see it populate
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);
3823
- if (!subChatContainer) {
3824
- console.error(`[executeToolCalls] Sub-chat container not found after renderSubChatAsItem for ${subChatId}`);
3825
- // Update status to failed since we can't process without a container
3826
- this.updateSubChatStatus(chat.id, toolCall.id, 'failed');
3827
- continue;
3828
- }
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);
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
- }
3843
-
3844
- // Update sub-chat status to final state
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
- }
3855
-
3856
- // Hide secondary assistant waiting spinner
3857
- this.hideSecondaryAssistantWaiting(chat.id);
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,
3864
- responseTime: toolResponseTime,
3865
- responseSize,
3866
- messageIndex: assistantMessageIndex,
3867
- subChatId,
3868
- wasProcessedBySubChat: !!summarizedResult
3869
- }, chat.id);
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,
3878
- result: summarizedResult || result, // Use summarized result if available
3879
- includeInContext: true,
3880
- subChatId, // Keep for tracking
3881
- wasProcessedBySubChat: !!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);
3887
-
3888
- // Update the DOM display to show final state
3889
- this.updateToolResultDisplay(chat.id, toolCall.id, processedToolResult);
3890
-
3891
- // CRITICAL: Save the updated parent chat with summarized results
3892
- this.autoSave(chat.id);
3893
- } else {
3894
- toolResults.push({
3895
- toolCallId: toolCall.id,
3896
- name: toolCall.name,
3897
- result,
3898
- includeInContext: true
3899
- });
3900
- }
3901
-
3902
- } catch (error) {
3903
- const errorMsg = `Tool error (${toolCall.name}): ${error.message}`;
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);
3913
-
3914
- toolResults.push({
3915
- toolCallId: toolCall.id,
3916
- name: toolCall.name,
3917
- result: { error: errorMsg },
3918
- includeInContext: true
3919
- });
3920
- }
3921
- }
3922
-
3923
- return toolResults;
3924
- }
3925
-
3926
- /**
3927
- * Centralized function to call the assistant API with consistent error handling
3928
- * @param {Object} params - Parameters for the assistant call
3929
- * @param {string} params.chatId - Chat ID
3930
- * @param {Object} params.provider - LLM provider instance
3931
- * @param {Array} params.messages - Messages array
3932
- * @param {Array} params.tools - Available tools
3933
- * @param {number} params.temperature - Temperature setting
3934
- * @param {number} params.cacheControlIndex - Cache control index
3935
- * @param {string} params.context - Context for error messages (e.g., 'Redo', 'Retry', 'Send')
3936
- * @returns {Promise<Object>} Response from the assistant
3937
- */
3938
- async callAssistant({ chatId, provider, messages, tools, temperature, cacheControlIndex, context = 'Request' }) {
3939
- if (!chatId) {
3940
- throw new Error('[callAssistant] Missing required chatId parameter');
3941
- }
3942
-
3943
- const chat = this.chats.get(chatId);
3944
- if (!chat) {
3945
- throw new Error(`[callAssistant] Chat not found: ${chatId}`);
3946
- }
3947
-
3948
- // Show thinking spinner
3949
- this.showAssistantThinking(chatId);
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;
3956
-
3957
- // Store response time
3958
- response._responseTime = llmResponseTime;
3959
-
3960
- // Hide spinner on success
3961
- this.hideAssistantThinking(chatId);
3962
-
3963
- return response;
3964
-
3965
- } catch (error) {
3966
- // Check for rate limit error FIRST before hiding spinner
3967
- const isRateLimitError = error.message && (
3968
- error.message.includes('Rate limit') ||
3969
- error.message.includes('429') ||
3970
- error.message.includes('rate_limit_exceeded') ||
3971
- error.message.includes('529') ||
3972
- error.message.includes('overloaded_error') ||
3973
- error.message.includes('Overloaded')
3974
- );
3975
-
3976
- // Extract retry-after seconds if available (handles multiple formats)
3977
- let retryAfterSeconds = null;
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
- ];
3985
-
3986
- for (const pattern of patterns) {
3987
- const match = error.message && error.message.match(pattern);
3988
- if (match) {
3989
- retryAfterSeconds = parseFloat(match[1]);
3990
- break;
3991
- }
3992
- }
3993
-
3994
- if (isRateLimitError) {
3995
- // Always handle rate limit errors, even without retry time
3996
- // Don't hide spinner - let handleRateLimitError manage the transition
3997
- const retryCount = chat.rateLimitRetryCount || 0;
3998
- this.handleRateLimitError(chatId, retryAfterSeconds, retryCount);
3999
- // Return a special marker to indicate rate limit handling
4000
- return { _rateLimitHandled: true };
4001
- }
4002
-
4003
- // Only hide spinner for non-rate-limit errors
4004
- this.hideAssistantThinking(chatId);
4005
-
4006
- // Add context to error
4007
- error.context = context;
4008
- throw error;
4009
- }
4010
- }
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
4015
- */
4016
- assistantConcluded(chatId) {
4017
- if (!chatId) {
4018
- console.error('[assistantConcluded] Called without chatId');
4019
- return;
4020
- }
4021
-
4022
- const chat = this.chats.get(chatId);
4023
- if (!chat) {
4024
- console.error(`[assistantConcluded] Chat not found: ${chatId}`);
4025
- return;
4026
- }
4027
-
4028
- // Clear any spinners
4029
- this.clearSpinnerState(chatId);
4030
-
4031
- // Clear processing states
4032
- chat.isProcessing = false;
4033
-
4034
- // Clear stop-related flags to ensure they're ready for next time
4035
- chat.shouldStopProcessing = false;
4036
- chat.processingWasStoppedByUser = false;
4037
-
4038
- // Clear error state on successful conclusion
4039
- this.clearError(chatId);
4040
-
4041
- // Clear current assistant group
4042
- this.clearCurrentAssistantGroup(chatId);
4043
-
4044
- // Reset safety checker iterations for next user message
4045
- this.safetyChecker.resetIterations(chatId);
4046
-
4047
- // Update only this chat's tile
4048
- this.updateChatTileStatus(chatId);
4049
-
4050
- // Save the chat state
4051
- chat.updatedAt = new Date().toISOString();
4052
- this.autoSave(chatId);
4053
-
4054
- // Always re-enable input for the chat that concluded
4055
- const container = this.getChatContainer(chatId);
4056
- if (container && container._elements) {
4057
- const input = container._elements.input;
4058
- if (input) {
4059
- // Always re-enable contentEditable
4060
- input.contentEditable = true;
4061
-
4062
- // Only focus if this is the active chat
4063
- if (chatId === this.getActiveChatId()) {
4064
- input.focus();
4065
- }
4066
- }
4067
-
4068
- // Update send button state
4069
- const sendBtn = container._elements.sendBtn;
4070
- if (sendBtn && input) {
4071
- try {
4072
- const content = this.getEditableContent(input).trim();
4073
- sendBtn.disabled = !content;
4074
- } catch (error) {
4075
- console.error('[assistantConcluded] ERROR getting editable content:', error);
4076
- sendBtn.disabled = true;
4077
- }
4078
- }
4079
- }
4080
- }
4081
-
4082
- /**
4083
- * Called when the assistant fails with an error
4084
- * Handles cleanup differently based on error type
4085
- */
4086
- assistantFailed(chatId, error = null) {
4087
- if (!chatId) {
4088
- console.error('[assistantFailed] Called without chatId');
4089
- return;
4090
- }
4091
-
4092
- const chat = this.chats.get(chatId);
4093
- if (!chat) {
4094
- console.error(`[assistantFailed] Chat not found: ${chatId}`);
4095
- return;
4096
- }
4097
-
4098
- // Check if this is a rate limit error that's being handled
4099
- const isRateLimitHandled = error && error._rateLimitHandled;
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
4106
-
4107
- // Clear processing states
4108
- chat.isProcessing = false;
4109
-
4110
- // Clear stop-related flags when failure is handled
4111
- chat.shouldStopProcessing = false;
4112
- chat.processingWasStoppedByUser = false;
4113
-
4114
- // Clear current assistant group
4115
- this.clearCurrentAssistantGroup(chatId);
4116
-
4117
- // Reset safety checker iterations for next user message
4118
- this.safetyChecker.resetIterations(chatId);
4119
-
4120
- // Update only this chat's tile
4121
- this.updateChatTileStatus(chatId);
4122
-
4123
- // Save the chat state
4124
- chat.updatedAt = new Date().toISOString();
4125
- this.autoSave(chatId);
4126
-
4127
- // Re-enable input if not rate limited (always for the chat that failed)
4128
- if (!isRateLimitHandled) {
4129
- const container = this.getChatContainer(chatId);
4130
- if (container && container._elements) {
4131
- const input = container._elements.input;
4132
- if (input) {
4133
- // Always re-enable contentEditable
4134
- input.contentEditable = true;
4135
-
4136
- // Only focus if this is the active chat
4137
- if (chatId === this.getActiveChatId()) {
4138
- input.focus();
4139
- }
4140
- }
4141
- }
4142
- }
4143
- }
4144
-
4145
- /**
4146
- * Shows a professional confirmation dialog modal
4147
- * @param {string} title - Dialog title
4148
- * @param {string} message - Confirmation message
4149
- * @param {string} confirmText - Text for confirm button (default: 'OK')
4150
- * @param {string} cancelText - Text for cancel button (default: 'Cancel')
4151
- * @param {boolean} isDanger - Whether this is a dangerous action (shows red confirm button)
4152
- * @returns {Promise<boolean>} - Resolves to true if confirmed, false if cancelled
4153
- */
4154
- showConfirmDialog(title, message, confirmText = 'OK', cancelText = 'Cancel', isDanger = false) {
4155
- return new Promise((resolve) => {
4156
- // Create modal container
4157
- const modal = document.createElement('div');
4158
- modal.className = 'confirm-modal-container';
4159
- modal.innerHTML = `
4160
- <div class="confirm-modal-backdrop"></div>
4161
- <div class="confirm-modal">
4162
- <div class="confirm-modal-header">
4163
- <h3>${this.escapeHtml(title)}</h3>
4164
- </div>
4165
- <div class="confirm-modal-body">
4166
- <p>${this.escapeHtml(message)}</p>
4167
- </div>
4168
- <div class="confirm-modal-footer">
4169
- <button class="btn btn-secondary confirm-cancel">${this.escapeHtml(cancelText)}</button>
4170
- <button class="btn ${isDanger ? 'btn-danger' : 'btn-primary'} confirm-ok">${this.escapeHtml(confirmText)}</button>
4171
- </div>
4172
- </div>
4173
- `;
4174
-
4175
- document.body.appendChild(modal);
4176
-
4177
- // Focus the confirm button
4178
- const confirmBtn = modal.querySelector('.confirm-ok');
4179
- const cancelBtn = modal.querySelector('.confirm-cancel');
4180
- confirmBtn.focus();
4181
-
4182
- // Define all functions using function declarations to avoid hoisting issues
4183
- function cleanup() {
4184
- modal.remove();
4185
- document.removeEventListener('keydown', handleKeydown);
4186
- }
4187
-
4188
- function handleConfirm() {
4189
- cleanup();
4190
- resolve(true);
4191
- }
4192
-
4193
- function handleCancel() {
4194
- cleanup();
4195
- resolve(false);
4196
- }
4197
-
4198
- function handleKeydown(e) {
4199
- if (e.key === 'Enter') {
4200
- e.preventDefault();
4201
- handleConfirm();
4202
- } else if (e.key === 'Escape') {
4203
- e.preventDefault();
4204
- handleCancel();
4205
- }
4206
- }
4207
-
4208
- // Add event listeners
4209
- confirmBtn.addEventListener('click', handleConfirm);
4210
- cancelBtn.addEventListener('click', handleCancel);
4211
- modal.querySelector('.confirm-modal-backdrop').addEventListener('click', handleCancel);
4212
- document.addEventListener('keydown', handleKeydown);
4213
- });
4214
- }
4215
-
4216
- /**
4217
- * Escapes HTML to prevent XSS
4218
- */
4219
- escapeHtml(text) {
4220
- const div = document.createElement('div');
4221
- div.textContent = text;
4222
- return div.innerHTML;
4223
- }
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)');
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);
4234
-
4235
- // Remove after animation
4236
- setTimeout(() => toast.remove(), 3000);
4237
-
4238
- // Log error
4239
- this.addLogEntry('ERROR', {
4240
- timestamp: new Date().toISOString(),
4241
- direction: 'error',
4242
- message
4243
- });
4244
-
4245
- // Also show in chat if chatId is provided
4246
- if (chatId) {
4247
- const chat = this.chats.get(chatId);
4248
-
4249
- if (chat) {
4250
- // Clear any spinners (from second method)
4251
- this.clearSpinnerState(chatId);
4252
-
4253
- // Save to messages if requested
4254
- if (saveToMessages) {
4255
- const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
4256
- this.addMessage(chatId, {
4257
- role: 'error',
4258
- content: message,
4259
- errorMessageIndex: lastUserMessageIndex,
4260
- timestamp: new Date().toISOString()
4261
- });
4262
- }
4263
-
4264
- // Set error state with structured error object (enhanced from second method)
4265
- chat.hasError = true;
4266
- chat.lastError = {
4267
- message,
4268
- type: errorType,
4269
- timestamp: Date.now()
4270
- };
4271
-
4272
- // Update UI
4273
- this.updateChatSessions();
4274
- this.updateChatTileStatus(chatId);
4275
- }
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);
4283
-
4284
- // Scroll to bottom using the chat-specific container
4285
- container._elements.messages.scrollTop = container._elements.messages.scrollHeight;
4286
- }
4287
- }
4288
- }
4289
-
4290
- // Global errors (not specific to any chat)
4291
- showGlobalError(message) {
4292
- this.showError(message, null);
4293
- }
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);
4301
-
4302
- // Remove after animation
4303
- setTimeout(() => toast.remove(), 3000);
4304
- }
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);
4312
-
4313
- // Remove after animation
4314
- setTimeout(() => toast.remove(), 3000);
4315
-
4316
- // Log error
4317
- this.addLogEntry('ERROR', {
4318
- timestamp: new Date().toISOString(),
4319
- direction: 'error',
4320
- message
4321
- });
4322
-
4323
- // Also show in chat with retry button if chatId is provided
4324
- if (chatId) {
4325
- const container = this.getChatContainer(chatId);
4326
- if (container && container._elements && container._elements.messages) {
4327
- const messageDiv = document.createElement('div');
4328
- messageDiv.className = 'message error';
4329
- const buttonIcon = buttonLabel === 'Continue' ? 'fa-play' : 'fa-redo';
4330
- messageDiv.innerHTML = `
4331
- <div><i class="fas fa-times-circle"></i> ${message}</div>
4332
- <button class="btn btn-warning btn-small" style="margin-top: 8px;">
4333
- <i class="fas ${buttonIcon}"></i> ${buttonLabel}
4334
- </button>
4335
- `;
4336
-
4337
- const retryBtn = messageDiv.querySelector('button');
4338
- retryBtn.onclick = async () => {
4339
- retryBtn.disabled = true;
4340
- retryBtn.textContent = 'Retrying...';
4341
- await retryCallback();
4342
- };
4343
-
4344
- container._elements.messages.appendChild(messageDiv);
4345
-
4346
- // Scroll to bottom using the chat-specific container
4347
- container._elements.messages.scrollTop = container._elements.messages.scrollHeight;
4348
- }
4349
- }
4350
- }
4351
-
4352
- addLogEntry(source, entry) {
4353
- const logEntry = {
4354
- ...entry,
4355
- source
4356
- };
4357
- this.communicationLog.push(logEntry);
4358
- this.updateLogDisplay(logEntry);
4359
- }
4360
-
4361
- updateLogDisplay(entry) {
4362
- const entryDiv = document.createElement('div');
4363
- entryDiv.className = 'log-entry';
4364
-
4365
- const directionClass = entry.direction;
4366
- let directionSymbol;
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
- }
4374
-
4375
- let metadataHtml = '';
4376
- if (entry.metadata && Object.keys(entry.metadata).length > 0) {
4377
- metadataHtml = `<div class="log-metadata">`;
4378
- for (const [key, value] of Object.entries(entry.metadata)) {
4379
- metadataHtml += `<span class="metadata-item">${key}: ${value}</span>`;
4380
- }
4381
- metadataHtml += `</div>`;
4382
- }
4383
-
4384
- // Create a unique ID for this entry
4385
- const entryId = `log-entry-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
4386
-
4387
- // Create header with copy button
4388
- const headerDiv = document.createElement('div');
4389
- headerDiv.className = 'log-entry-header';
4390
-
4391
- const infoDiv = document.createElement('div');
4392
- infoDiv.className = 'log-entry-info';
4393
- infoDiv.innerHTML = `
4394
- <span class="log-timestamp">${new Date(entry.timestamp).toLocaleTimeString()}</span>
4395
- <span class="log-source">[${entry.source}]</span>
4396
- <span class="log-direction ${directionClass}">${directionSymbol}</span>
4397
- `;
4398
-
4399
- // Create copy button using standardized method
4400
- const copyBtn = this.createCopyButton({
4401
- buttonClass: 'btn-copy-log',
4402
- onCopy: () => {
4403
- const messageElement = document.getElementById(entryId);
4404
- return messageElement.textContent || messageElement.innerText;
4405
- }
4406
- });
4407
- copyBtn.setAttribute('data-entry-id', entryId);
4408
-
4409
- headerDiv.appendChild(infoDiv);
4410
- headerDiv.appendChild(copyBtn);
4411
-
4412
- entryDiv.appendChild(headerDiv);
4413
-
4414
- // Add metadata if present
4415
- if (metadataHtml) {
4416
- const metadataDiv = document.createElement('div');
4417
- metadataDiv.innerHTML = metadataHtml;
4418
- entryDiv.appendChild(metadataDiv);
4419
- }
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);
4427
-
4428
- this.logContent.appendChild(entryDiv);
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;
4433
-
4434
- if (isAtBottom) {
4435
- this.logContent.scrollTop = this.logContent.scrollHeight;
4436
- }
4437
- }
4438
-
4439
- formatLogMessage(message) {
4440
- try {
4441
- const parsed = JSON.parse(message);
4442
- return JSON.stringify(parsed, null, 2);
4443
- } catch {
4444
- return message;
4445
- }
4446
- }
4447
-
4448
- // Shared clipboard utility that works in all contexts
4449
- async writeToClipboard(text) {
4450
- // Check if clipboard API is available
4451
- if (navigator.clipboard && navigator.clipboard.writeText) {
4452
- return navigator.clipboard.writeText(text);
4453
- } else {
4454
- // Fallback for older browsers or insecure contexts
4455
- const textArea = document.createElement('textarea');
4456
- textArea.value = text;
4457
- textArea.style.position = 'fixed';
4458
- textArea.style.left = '-999999px';
4459
- textArea.style.top = '-999999px';
4460
- document.body.appendChild(textArea);
4461
- textArea.focus();
4462
- textArea.select();
4463
-
4464
- try {
4465
- const successful = document.execCommand('copy');
4466
- if (!successful) {
4467
- throw new Error('Copy command failed');
4468
- }
4469
- } finally {
4470
- textArea.remove();
4471
- }
4472
- }
4473
- }
4474
-
4475
- // Create a standardized copy button
4476
- createCopyButton(options = {}) {
4477
- const {
4478
- tooltip = 'Copy to clipboard',
4479
- iconClass = 'fas fa-clipboard',
4480
- buttonClass = '',
4481
- onCopy = null
4482
- } = options;
4483
-
4484
- const button = document.createElement('button');
4485
- button.className = `copy-button ${buttonClass}`.trim();
4486
- button.setAttribute('data-tooltip', tooltip);
4487
- button.innerHTML = `<i class="${iconClass}"></i>`;
4488
-
4489
- button.addEventListener('click', async (e) => {
4490
- e.stopPropagation();
4491
- if (onCopy) {
4492
- try {
4493
- const textToCopy = await onCopy();
4494
- if (textToCopy !== undefined && textToCopy !== null) {
4495
- await this.handleCopyButtonClick(button, textToCopy);
4496
- }
4497
- } catch (error) {
4498
- console.error('Copy button error:', error);
4499
- this.showCopyButtonError(button);
4500
- }
4501
- }
4502
- });
4503
-
4504
- return button;
4505
- }
4506
-
4507
- // Handle copy button click with standardized feedback
4508
- async handleCopyButtonClick(button, text) {
4509
- const originalHTML = button.innerHTML;
4510
-
4511
- try {
4512
- await this.writeToClipboard(text);
4513
-
4514
- // Show success feedback
4515
- button.innerHTML = '<i class="fas fa-check"></i>';
4516
- button.style.color = 'var(--success-color)';
4517
-
4518
- setTimeout(() => {
4519
- button.innerHTML = originalHTML;
4520
- button.style.color = '';
4521
- }, 1500);
4522
- } catch (err) {
4523
- console.error('Failed to copy to clipboard:', err);
4524
- this.showCopyButtonError(button, originalHTML);
4525
- }
4526
- }
4527
-
4528
- // Show error feedback on copy button
4529
- showCopyButtonError(button, originalHTML = null) {
4530
- const htmlToRestore = originalHTML || button.innerHTML;
4531
-
4532
- button.innerHTML = '<i class="fas fa-times"></i>';
4533
- button.style.color = 'var(--danger-color)';
4534
-
4535
- setTimeout(() => {
4536
- button.innerHTML = htmlToRestore;
4537
- button.style.color = '';
4538
- }, 1500);
4539
- }
4540
-
4541
- // Legacy method for backward compatibility
4542
- async copyToClipboard(text, button) {
4543
- await this.handleCopyButtonClick(button, text);
4544
- }
4545
-
4546
- // Redo from a specific point in the conversation
4547
- async redoFromMessage(messageIndex, chatId) {
4548
- const chat = this.chats.get(chatId);
4549
- if (!chat) { return; }
4550
-
4551
- // Clear broken state when starting redo operation
4552
- chat.wasWaitingOnLoad = false;
4553
-
4554
- // Find the message to redo from
4555
- const message = chat.messages[messageIndex];
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);
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
- }
4566
-
4567
- // Get model config
4568
- const providerType = chat.config.model.provider;
4569
- const modelName = chat.config.model.id;
4570
- if (!providerType || !modelName) {
4571
- this.showError('Invalid model configuration in chat', chatId);
4572
- return;
4573
- }
4574
-
4575
- // Get the API type from the provider configuration
4576
- const providerApiType = proxyProvider.availableProviders?.[providerType]?.type || providerType;
4577
-
4578
- // Create the LLM provider
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
- };
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);
4592
-
4593
- // Show thinking spinner AFTER loadChat to prevent it from being cleared
4594
- this.showAssistantThinking(chatId);
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
- }
4601
-
4602
- // Resend the user message with full prior context
4603
- const result = await this.processMessageWithTools(freshChat, mcpConnection, provider, message.content);
4604
-
4605
- // Check if rate limit was handled - don't conclude if so
4606
- if (result && result.rateLimitHandled) {
4607
- return;
4608
- }
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;
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') {
4619
- triggeringUserMessage = chat.messages[i];
4620
- break;
4621
- }
4622
- }
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);
4629
-
4630
- // Show thinking spinner AFTER loadChat to prevent it from being cleared
4631
- this.showAssistantThinking(chatId);
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
- }
4638
-
4639
- // Resend the triggering user message with full prior context
4640
- const result = await this.processMessageWithTools(freshChat, mcpConnection, provider, triggeringUserMessage.content);
4641
-
4642
- // Check if rate limit was handled - don't conclude if so
4643
- if (result && result.rateLimitHandled) {
4644
- return;
4645
- }
4646
-
4647
- // Success - assistant has concluded
4648
- this.assistantConcluded(chatId);
4649
- } else {
4650
- this.showError('Cannot find the user message that triggered this response', chatId);
4651
- }
4652
- } else {
4653
- // For any other message type, show error (shouldn't happen with our button logic)
4654
- this.showError('Redo is only available for user and assistant messages', chatId);
4655
- }
4656
- } catch (error) {
4657
- // Check for rate limit error
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;
4668
-
4669
- if (isRateLimitError) {
4670
- // Handle rate limit with automatic retry (even without retry time)
4671
- const retryCount = chat.rateLimitRetryCount || 0;
4672
- this.handleRateLimitError(chatId, retryAfterSeconds, retryCount);
4673
- // Mark error as handled to prevent spinner clearing
4674
- error._rateLimitHandled = true;
4675
- // Don't show error UI for rate limits
4676
- this.assistantFailed(chatId, error);
4677
- return;
4678
- } else {
4679
- // Show error with retry button
4680
- const errorMessage = `Redo failed: ${error.message}`;
4681
- const lastUserMessageIndex = chat.messages.findLastIndex(m => m.role === 'user');
4682
-
4683
- // Determine error type
4684
- let errorType = 'llm_error';
4685
- if (error.message.includes('MCP') || error.message.includes('connection')) {
4686
- errorType = 'mcp_error';
4687
- } else if (error.message.includes('Tool')) {
4688
- errorType = 'tool_error';
4689
- }
4690
-
4691
- this.addMessage(chatId, {
4692
- role: 'error',
4693
- content: errorMessage,
4694
- errorMessageIndex: lastUserMessageIndex,
4695
- errorType
4696
- });
4697
-
4698
- this.processRenderEvent({
4699
- type: 'error-message',
4700
- content: errorMessage,
4701
- errorMessageIndex: lastUserMessageIndex,
4702
- errorType
4703
- }, chatId);
4704
- }
4705
-
4706
- // Clean up on error - but preserve rate limit waiting spinner
4707
- this.assistantFailed(chatId, error);
4708
- }
4709
- }
4710
-
4711
- clearLog() {
4712
- this.showConfirmDialog(
4713
- 'Clear Communication Log',
4714
- 'Are you sure you want to clear all communication logs?',
4715
- 'Clear',
4716
- 'Cancel'
4717
- ).then(confirmed => {
4718
- if (confirmed) {
4719
- this.communicationLog = [];
4720
- this.logContent.innerHTML = '';
4721
- }
4722
- });
4723
- }
4724
-
4725
- downloadLog() {
4726
- const logText = this.communicationLog.map(entry => {
4727
- return `[${entry.timestamp}] [${entry.source}] ${entry.direction}: ${entry.message}`;
4728
- }).join('\n\n');
4729
-
4730
- const blob = new Blob([logText], { type: 'text/plain' });
4731
- const url = URL.createObjectURL(blob);
4732
- const a = document.createElement('a');
4733
- a.href = url;
4734
- a.download = `mcp-communication-log-${new Date().toISOString()}.txt`;
4735
- a.click();
4736
- URL.revokeObjectURL(url);
4737
- }
4738
-
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
4742
-
4743
- // Load theme
4744
- const savedTheme = localStorage.getItem('theme') || 'dark';
4745
- document.documentElement.setAttribute('data-theme', savedTheme);
4746
-
4747
- // Load log collapsed state (default to collapsed)
4748
- const logCollapsed = localStorage.getItem('logCollapsed') !== 'false';
4749
- if (logCollapsed) {
4750
- this.logPanel.classList.add('collapsed');
4751
- this.toggleLogBtn.innerHTML = '<i class="fas fa-chevron-left"></i>';
4752
- this.expandLogBtn.style.display = 'block';
4753
- }
4754
-
4755
- // Load pane sizes
4756
- this.loadPaneSizes();
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) {
4761
- try {
4762
- const servers = JSON.parse(savedMcpServers);
4763
- servers.forEach(server => {
4764
- this.mcpServers.set(server.id, server);
4765
- });
4766
- // Don't update the UI yet - wait for proxy servers to be loaded too
4767
- } catch (e) {
4768
- console.error('Failed to load MCP servers:', e);
4769
- }
4770
- }
4771
-
4772
- // LLM providers will be fetched from proxy on demand
4773
-
4774
- // Load chats - first try split storage, then fall back to legacy
4775
- this.loadChatsFromStorage();
4776
- }
4777
-
4778
- loadChatsFromStorage() {
4779
- // Load chats with pattern chat_TIMESTAMP
4780
- const chatKeyPrefix = 'chat_';
4781
-
4782
- // Scan localStorage for individual chat keys
4783
- for (let i = 0; i < localStorage.length; i++) {
4784
- const key = localStorage.key(i);
4785
- if (key && key.startsWith(chatKeyPrefix)) {
4786
- try {
4787
- const chatData = JSON.parse(localStorage.getItem(key));
4788
- if (chatData && chatData.id) {
4789
- // Ensure no chat is marked as active on load
4790
- chatData.isActive = false;
4791
- // Clean up empty draft messages
4792
- if (chatData.draftMessage === '' || (chatData.draftMessage && chatData.draftMessage.trim().length === 0)) {
4793
- chatData.draftMessage = null;
4794
- }
4795
- this.validateAndAddChat(chatData);
4796
- }
4797
- } catch (e) {
4798
- console.error(`Failed to load chat from key ${key}:`, e);
4799
- }
4800
- }
4801
- }
4802
-
4803
- // Don't update chat sessions yet - wait until after default chat is created
4804
- // this.updateChatSessions();
4805
- }
4806
-
4807
-
4808
- validateAndAddChat(chat) {
4809
- // IMMEDIATE MIGRATION - Delete ALL old properties
4810
- delete chat.model;
4811
- delete chat.temperature;
4812
- delete chat.optimizerSettings;
4813
- delete chat.primaryModel;
4814
- delete chat.secondaryModel;
4815
- delete chat.toolSummarization;
4816
- delete chat.autoSummarization;
4817
- delete chat.toolMemory;
4818
- delete chat.cacheControl;
4819
-
4820
- // Migrate old chat format to new config format if needed
4821
- if (!chat.config) {
4822
- // No config - create default
4823
- chat.config = ChatConfig.createDefaultConfig();
4824
- // If we had an mcpServerId, preserve it
4825
- if (chat.mcpServerId) {
4826
- chat.config.mcpServer = chat.mcpServerId;
4827
- }
4828
- }
4829
-
4830
- // Ensure config is valid
4831
- chat.config = ChatConfig.validateConfig(chat.config);
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 => {
4836
- // Convert old tool-results format with results field
4837
- if (msg.type === 'tool-results' && msg.results) {
4838
- // Convert to new format
4839
- return {
4840
- role: 'tool-results',
4841
- toolResults: msg.results.map(result => ({
4842
- toolCallId: result.toolCallId,
4843
- toolName: result.toolName || 'unknown',
4844
- result: result.content || result.result || ''
4845
- })),
4846
- timestamp: msg.timestamp
4847
- };
4848
- }
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 };
4853
- delete cleanMsg.type;
4854
- cleanMsg.role = 'tool-results';
4855
- return cleanMsg;
4856
- }
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
- }
4864
-
4865
- // Convert any remaining messages with type but no role
4866
- if (msg.type && !msg.role) {
4867
- const cleanMsg = { ...msg };
4868
- cleanMsg.role = cleanMsg.type;
4869
- delete cleanMsg.type;
4870
- return cleanMsg;
4871
- }
4872
-
4873
- return msg;
4874
- });
4875
- }
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);
4880
- if (provider && provider.availableProviders) {
4881
- // Check if the model exists in the provider's available models
4882
- let modelExists = false;
4883
- const providerType = chat.config.model.provider;
4884
- const modelName = chat.config.model.id;
4885
-
4886
- if (providerType && modelName && provider.availableProviders[providerType]) {
4887
- const models = provider.availableProviders[providerType].models || [];
4888
- modelExists = models.some(m => {
4889
- const mId = typeof m === 'string' ? m : m.id;
4890
- return mId === modelName;
4891
- });
4892
- }
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.`);
4897
-
4898
- // Mark the chat as having an invalid model
4899
- chat.hasInvalidModel = true;
4900
-
4901
- // DO NOT automatically reset or save!
4902
- // The user must manually select a valid model
4903
- }
4904
- }
4905
- }
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)) {
4910
- console.error(`Chat ${chat.id} has invalid MCP server: ${chat.config.mcpServer} - setting to null`);
4911
- chat.config.mcpServer = null;
4912
- }
4913
- // Sync mcpServerId with config
4914
- chat.mcpServerId = chat.config.mcpServer;
4915
- } else if (chat.mcpServerId && !this.mcpServers.has(chat.mcpServerId)) {
4916
- console.error(`Chat ${chat.id} has invalid mcpServerId: ${chat.mcpServerId} - setting to null`);
4917
- chat.mcpServerId = null;
4918
- if (chat.config) {
4919
- chat.config.mcpServer = null;
4920
- }
4921
- }
4922
-
4923
- // Ensure currentAssistantGroup exists for loaded chats
4924
- if (!Object.prototype.hasOwnProperty.call(chat, 'currentAssistantGroup')) {
4925
- chat.currentAssistantGroup = null;
4926
- }
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
- }
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;
4936
- // Clear the spinner state since we're not actually waiting anymore
4937
- chat.spinnerState = null;
4938
- chat.isProcessing = false;
4939
- }
4940
-
4941
- // Reconstruct MessageOptimizer instance for loaded chats
4942
- if (!chat.messageOptimizer || !(chat.messageOptimizer instanceof MessageOptimizer)) {
4943
- // Config should already be migrated by this point
4944
- if (!chat.config) {
4945
- throw new Error(`Chat ${chat.id} has no config after migration`);
4946
- }
4947
-
4948
- // Get optimizer settings from config
4949
- const optimizerSettings = ChatConfig.getOptimizerSettings(chat.config, window.createLLMProvider);
4950
-
4951
- // Create MessageOptimizer instance
4952
- chat.messageOptimizer = new MessageOptimizer(optimizerSettings);
4953
- }
4954
-
4955
- // Ensure pendingToolCalls map exists
4956
- if (!chat.pendingToolCalls) {
4957
- chat.pendingToolCalls = new Map();
4958
- }
4959
-
4960
- // Add to memory - DO NOT SAVE!
4961
- // The chat is now in the correct format in memory
4962
- // It will only be saved when the user actually modifies it
4963
- this.chats.set(chat.id, chat);
4964
-
4965
- // Migrate token pricing and fix incomplete model names
4966
- this.migrateTokenPricing(chat);
4967
- }
4968
-
4969
- async initializeDefaultLLMProvider() {
4970
- // Always fetch models even if we have providers (to get fresh model list)
4971
-
4972
- // Auto-detect the proxy URL from the current origin
4973
- const proxyUrl = window.location.origin;
4974
-
4975
- try {
4976
- // Fetch available models from the same origin
4977
- const response = await fetch(`${proxyUrl}/models`);
4978
- if (!response.ok) {
4979
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4980
- }
4981
-
4982
- const data = await response.json();
4983
- const providers = data.providers || {};
4984
-
4985
- if (Object.keys(providers).length === 0) {
4986
- console.warn('No LLM providers configured in proxy');
4987
- this.showNoModelsModal(proxyUrl);
4988
- return;
4989
- }
4990
-
4991
- // Update availableProviders for all existing providers with the same proxyUrl
4992
- let updated = false;
4993
- for (const [, provider] of this.llmProviders) {
4994
- if (provider.proxyUrl === proxyUrl) {
4995
- provider.availableProviders = providers;
4996
- updated = true;
4997
- }
4998
- }
4999
-
This file is too large to show in full.