1
+/**
2
+ * Main application logic for the Netdata MCP LLM Client
3
+ */
4
+
5
+class NetdataMCPChat {
6
+ constructor() {
7
+ this.mcpServers = new Map(); // Multiple MCP servers
8
+ this.mcpConnections = new Map(); // Active MCP connections
9
+ this.llmProviders = new Map(); // Multiple LLM providers
10
+ this.chats = new Map(); // Chat sessions
11
+ this.currentChatId = null;
12
+ this.communicationLog = []; // Universal log (not saved)
13
+ this.tokenUsageHistory = new Map(); // Track token usage per chat
14
+ this.pendingAssistantMetrics = null; // Store metrics to add at end of assistant message
15
+
16
+ // Single source of truth for all model information
17
+ this.models = {
18
+ openai: [
19
+ // Currently available reasoning models
20
+ { value: 'o1-preview', text: 'o1 Preview (Reasoning)', category: 'reasoning', contextLimit: 128000 },
21
+ { value: 'o1-mini', text: 'o1 Mini (Reasoning)', category: 'reasoning', contextLimit: 128000 },
22
+ // GPT-4o series (Multimodal)
23
+ { value: 'gpt-4o', text: 'GPT-4o (Latest)', category: 'gpt-4', contextLimit: 128000 },
24
+ { value: 'gpt-4o-2024-11-20', text: 'GPT-4o (2024-11-20)', category: 'gpt-4', contextLimit: 128000 },
25
+ { value: 'gpt-4o-2024-08-06', text: 'GPT-4o (2024-08-06)', category: 'gpt-4', contextLimit: 128000 },
26
+ { value: 'gpt-4o-2024-05-13', text: 'GPT-4o (2024-05-13)', category: 'gpt-4', contextLimit: 128000 },
27
+ { value: 'gpt-4o-mini', text: 'GPT-4o Mini', category: 'gpt-4', contextLimit: 128000 },
28
+ { value: 'gpt-4o-mini-2024-07-18', text: 'GPT-4o Mini (2024-07-18)', category: 'gpt-4', contextLimit: 128000 },
29
+ // GPT-4 Turbo
30
+ { value: 'gpt-4-turbo', text: 'GPT-4 Turbo', category: 'gpt-4', contextLimit: 128000 },
31
+ { value: 'gpt-4-turbo-2024-04-09', text: 'GPT-4 Turbo (2024-04-09)', category: 'gpt-4', contextLimit: 128000 },
32
+ { value: 'gpt-4-turbo-preview', text: 'GPT-4 Turbo Preview', category: 'gpt-4', contextLimit: 128000 },
33
+ { value: 'gpt-4-0125-preview', text: 'GPT-4 (0125 Preview)', category: 'gpt-4', contextLimit: 128000 },
34
+ { value: 'gpt-4-1106-preview', text: 'GPT-4 (1106 Preview)', category: 'gpt-4', contextLimit: 128000 },
35
+ // Standard GPT-4
36
+ { value: 'gpt-4', text: 'GPT-4', category: 'gpt-4', contextLimit: 8192 },
37
+ { value: 'gpt-4-0613', text: 'GPT-4 (0613)', category: 'gpt-4', contextLimit: 8192 },
38
+ // GPT-3.5
39
+ { value: 'gpt-3.5-turbo', text: 'GPT-3.5 Turbo', category: 'gpt-3.5', contextLimit: 16385 },
40
+ { value: 'gpt-3.5-turbo-0125', text: 'GPT-3.5 Turbo (0125)', category: 'gpt-3.5', contextLimit: 16385 },
41
+ { value: 'gpt-3.5-turbo-1106', text: 'GPT-3.5 Turbo (1106)', category: 'gpt-3.5', contextLimit: 16385 }
42
+ ],
43
+ anthropic: [
44
+ // Claude 4 series (Latest - May 2025)
45
+ { value: 'claude-opus-4-20250514', text: 'Claude Opus 4', category: 'claude-4', contextLimit: 200000 },
46
+ { value: 'claude-sonnet-4-20250514', text: 'Claude Sonnet 4', category: 'claude-4', contextLimit: 200000 },
47
+ // Claude 3.7
48
+ { value: 'claude-3-7-sonnet-20250219', text: 'Claude 3.7 Sonnet', category: 'claude-3.7', contextLimit: 200000 },
49
+ { value: 'claude-3-7-sonnet-latest', text: 'Claude 3.7 Sonnet (Latest alias)', category: 'claude-3.7', contextLimit: 200000 },
50
+ // Claude 3.5 series
51
+ { value: 'claude-3-5-sonnet-20241022', text: 'Claude 3.5 Sonnet v2', category: 'claude-3.5', contextLimit: 200000 },
52
+ { value: 'claude-3-5-sonnet-latest', text: 'Claude 3.5 Sonnet (Latest alias)', category: 'claude-3.5', contextLimit: 200000 },
53
+ { value: 'claude-3-5-sonnet-20240620', text: 'Claude 3.5 Sonnet v1', category: 'claude-3.5', contextLimit: 200000 },
54
+ { value: 'claude-3-5-haiku-20241022', text: 'Claude 3.5 Haiku', category: 'claude-3.5', contextLimit: 200000 },
55
+ { value: 'claude-3-5-haiku-latest', text: 'Claude 3.5 Haiku (Latest alias)', category: 'claude-3.5', contextLimit: 200000 },
56
+ // Claude 3 series
57
+ { value: 'claude-3-opus-20240229', text: 'Claude 3 Opus', category: 'claude-3', contextLimit: 200000 },
58
+ { value: 'claude-3-opus-latest', text: 'Claude 3 Opus (Latest alias)', category: 'claude-3', contextLimit: 200000 },
59
+ { value: 'claude-3-sonnet-20240229', text: 'Claude 3 Sonnet', category: 'claude-3', contextLimit: 200000 },
60
+ { value: 'claude-3-haiku-20240307', text: 'Claude 3 Haiku', category: 'claude-3', contextLimit: 200000 }
61
+ ],
62
+ google: [
63
+ // Gemini 1.5 series (Currently available)
64
+ { value: 'gemini-1.5-pro', text: 'Gemini 1.5 Pro', category: 'current', contextLimit: 2000000 },
65
+ { value: 'gemini-1.5-pro-latest', text: 'Gemini 1.5 Pro Latest', category: 'current', contextLimit: 2000000 },
66
+ { value: 'gemini-1.5-pro-002', text: 'Gemini 1.5 Pro 002', category: 'current', contextLimit: 2000000 },
67
+ { value: 'gemini-1.5-pro-001', text: 'Gemini 1.5 Pro 001', category: 'current', contextLimit: 2000000 },
68
+ { value: 'gemini-1.5-flash', text: 'Gemini 1.5 Flash', category: 'current', contextLimit: 1000000 },
69
+ { value: 'gemini-1.5-flash-latest', text: 'Gemini 1.5 Flash Latest', category: 'current', contextLimit: 1000000 },
70
+ { value: 'gemini-1.5-flash-002', text: 'Gemini 1.5 Flash 002', category: 'current', contextLimit: 1000000 },
71
+ { value: 'gemini-1.5-flash-001', text: 'Gemini 1.5 Flash 001', category: 'current', contextLimit: 1000000 },
72
+ { value: 'gemini-1.5-flash-8b', text: 'Gemini 1.5 Flash 8B', category: 'current', contextLimit: 1000000 },
73
+ { value: 'gemini-1.5-flash-8b-latest', text: 'Gemini 1.5 Flash 8B Latest', category: 'current', contextLimit: 1000000 },
74
+ // Gemini 1.0 (Legacy)
75
+ { value: 'gemini-1.0-pro', text: 'Gemini 1.0 Pro', category: 'legacy', contextLimit: 32768 },
76
+ { value: 'gemini-1.0-pro-latest', text: 'Gemini 1.0 Pro Latest', category: 'legacy', contextLimit: 32768 },
77
+ { value: 'gemini-1.0-pro-001', text: 'Gemini 1.0 Pro 001', category: 'legacy', contextLimit: 32768 },
78
+ { value: 'gemini-pro', text: 'Gemini Pro (Legacy)', category: 'legacy', contextLimit: 32768 }
79
+ ]
80
+ };
81
+
82
+ // Build modelLimits from the models data for backwards compatibility
83
+ this.modelLimits = {};
84
+ for (const provider in this.models) {
85
+ for (const model of this.models[provider]) {
86
+ this.modelLimits[model.value] = model.contextLimit;
87
+ }
88
+ }
89
+
90
+ // Default system prompt
91
+ this.defaultSystemPrompt = `You are a helpful assistant with access to Netdata monitoring data through MCP (Model Context Protocol) tools.
92
+You can query metrics, check alerts, analyze system performance, and help users understand their infrastructure health.
93
+When users ask about their systems, use the available MCP tools to fetch real data and provide insights.`;
94
+
95
+ // Load last used system prompt from localStorage or use default
96
+ this.lastSystemPrompt = localStorage.getItem('lastSystemPrompt') || this.defaultSystemPrompt;
97
+
98
+ this.initializeUI();
99
+ this.initializeResizable();
100
+ this.loadSettings();
101
+ }
102
+
103
+ // Get available models for a provider type
104
+ getModelsForProviderType(providerType) {
105
+ // Use the single source of truth for models
106
+ return this.models[providerType] || [];
107
+ }
108
+
109
+ initializeUI() {
110
+ // Chat sidebar
111
+ this.newChatBtn = document.getElementById('newChatBtn');
112
+ this.newChatBtn.addEventListener('click', () => this.showNewChatModal());
113
+ this.chatSessions = document.getElementById('chatSessions');
114
+
115
+ // Sidebar footer controls
116
+ this.themeToggle = document.getElementById('themeToggle');
117
+ this.themeToggle.addEventListener('click', () => this.toggleTheme());
118
+ this.settingsBtn = document.getElementById('settingsBtn');
119
+ this.settingsBtn.addEventListener('click', () => this.showModal('settingsModal'));
120
+
121
+ // Chat area
122
+ this.chatTitle = document.getElementById('chatTitle');
123
+ this.chatMcp = document.getElementById('chatMcp');
124
+ this.chatLlm = document.getElementById('chatLlm');
125
+ this.chatMessages = document.getElementById('chatMessages');
126
+ this.chatInput = document.getElementById('chatInput');
127
+ this.sendMessageBtn = document.getElementById('sendMessageBtn');
128
+ this.reconnectMcpBtn = document.getElementById('reconnectMcpBtn');
129
+
130
+ this.sendMessageBtn.addEventListener('click', () => this.sendMessage());
131
+ this.reconnectMcpBtn.addEventListener('click', () => this.reconnectCurrentMcp());
132
+ this.chatInput.addEventListener('keydown', (e) => {
133
+ if (e.key === 'Enter' && !e.shiftKey) {
134
+ e.preventDefault();
135
+ this.sendMessage();
136
+ }
137
+ });
138
+
139
+ // Log panel
140
+ this.logPanel = document.getElementById('logPanel');
141
+ this.toggleLogBtn = document.getElementById('toggleLogBtn');
142
+ this.clearLogBtn = document.getElementById('clearLogBtn');
143
+ this.downloadLogBtn = document.getElementById('downloadLogBtn');
144
+ this.logContent = document.getElementById('logContent');
145
+
146
+ this.toggleLogBtn.addEventListener('click', () => this.toggleLog());
147
+ this.clearLogBtn.addEventListener('click', () => this.clearLog());
148
+ this.downloadLogBtn.addEventListener('click', () => this.downloadLog());
149
+
150
+ // Temperature control
151
+ this.temperatureControl = document.getElementById('temperatureControl');
152
+ this.temperatureSlider = document.getElementById('temperatureSlider');
153
+ this.temperatureValue = document.getElementById('temperatureValue');
154
+
155
+ // Initialize temperature display
156
+ this.updateTemperatureDisplay(0.7);
157
+
158
+ this.temperatureSlider.addEventListener('input', (e) => {
159
+ this.updateTemperatureDisplay(parseFloat(e.target.value));
160
+ });
161
+
162
+ this.temperatureSlider.addEventListener('change', (e) => {
163
+ const temp = parseFloat(e.target.value);
164
+ this.updateTemperatureDisplay(temp);
165
+ this.saveTemperatureForChat(temp);
166
+ });
167
+
168
+ // Settings modal
169
+ this.settingsModal = document.getElementById('settingsModal');
170
+ this.setupModal('settingsModal', 'settingsBackdrop', 'closeSettingsBtn');
171
+ this.setupTabs();
172
+
173
+ // Settings lists
174
+ this.mcpServersList = document.getElementById('mcpServersList');
175
+ this.llmProvidersList = document.getElementById('llmProvidersList');
176
+ this.addMcpServerBtn = document.getElementById('addMcpServerBtn');
177
+ this.addLlmProviderBtn = document.getElementById('addLlmProviderBtn');
178
+
179
+ this.addMcpServerBtn.addEventListener('click', () => this.showModal('addMcpModal'));
180
+ this.addLlmProviderBtn.addEventListener('click', () => this.showModal('addLlmModal'));
181
+
182
+ // New chat modal
183
+ this.setupModal('newChatModal', 'newChatBackdrop', 'closeNewChatBtn');
184
+ this.newChatMcpServer = document.getElementById('newChatMcpServer');
185
+ this.newChatLlmProvider = document.getElementById('newChatLlmProvider');
186
+ this.newChatModelGroup = document.getElementById('newChatModelGroup');
187
+ this.newChatModel = document.getElementById('newChatModel');
188
+ this.newChatTitle = document.getElementById('newChatTitle');
189
+ this.createChatBtn = document.getElementById('createChatBtn');
190
+ this.cancelNewChatBtn = document.getElementById('cancelNewChatBtn');
191
+
192
+ this.newChatLlmProvider.addEventListener('change', () => this.updateNewChatModels());
193
+ this.createChatBtn.addEventListener('click', () => this.createNewChat());
194
+ this.cancelNewChatBtn.addEventListener('click', () => this.hideModal('newChatModal'));
195
+
196
+ // Add MCP server modal
197
+ this.setupModal('addMcpModal', 'addMcpBackdrop', 'closeAddMcpBtn');
198
+ this.mcpServerUrl = document.getElementById('mcpServerUrl');
199
+ this.mcpServerName = document.getElementById('mcpServerName');
200
+ this.saveMcpServerBtn = document.getElementById('saveMcpServerBtn');
201
+ this.cancelAddMcpBtn = document.getElementById('cancelAddMcpBtn');
202
+
203
+ this.saveMcpServerBtn.addEventListener('click', () => this.addMcpServer());
204
+ this.cancelAddMcpBtn.addEventListener('click', () => this.hideModal('addMcpModal'));
205
+
206
+ // Add LLM provider modal
207
+ this.setupModal('addLlmModal', 'addLlmBackdrop', 'closeAddLlmBtn');
208
+ this.llmProxyUrl = document.getElementById('llmProxyUrl');
209
+ this.llmProviderName = document.getElementById('llmProviderName');
210
+ this.llmProvidersStatus = document.getElementById('llmProvidersStatus');
211
+ this.llmProvidersInfo = document.getElementById('llmProvidersInfo');
212
+ this.saveLlmProviderBtn = document.getElementById('saveLlmProviderBtn');
213
+ this.cancelAddLlmBtn = document.getElementById('cancelAddLlmBtn');
214
+
215
+ this.llmProxyUrl.addEventListener('blur', () => this.testProxyConnection());
216
+ this.saveLlmProviderBtn.addEventListener('click', () => this.addLlmProvider());
217
+ this.cancelAddLlmBtn.addEventListener('click', () => this.hideModal('addLlmModal'));
218
+
219
+ // System prompt modal controls
220
+ this.systemPromptTextarea = document.getElementById('systemPromptTextarea');
221
+ this.closeSystemPromptBtn = document.getElementById('closeSystemPromptBtn');
222
+ this.systemPromptBackdrop = document.getElementById('systemPromptBackdrop');
223
+ this.cancelSystemPromptBtn = document.getElementById('cancelSystemPromptBtn');
224
+ this.saveSystemPromptBtn = document.getElementById('saveSystemPromptBtn');
225
+ this.resetToDefaultPromptBtn = document.getElementById('resetToDefaultPromptBtn');
226
+
227
+ this.closeSystemPromptBtn.addEventListener('click', () => this.hideModal('systemPromptModal'));
228
+ this.systemPromptBackdrop.addEventListener('click', () => this.hideModal('systemPromptModal'));
229
+ this.cancelSystemPromptBtn.addEventListener('click', () => this.hideModal('systemPromptModal'));
230
+ this.saveSystemPromptBtn.addEventListener('click', () => this.saveSystemPrompt());
231
+ this.resetToDefaultPromptBtn.addEventListener('click', () => {
232
+ this.systemPromptTextarea.value = this.defaultSystemPrompt;
233
+ });
234
+
235
+ // Auto-generate server name from URL
236
+ this.mcpServerUrl.addEventListener('input', () => {
237
+ if (!this.mcpServerName.value) {
238
+ try {
239
+ const url = new URL(this.mcpServerUrl.value);
240
+ this.mcpServerName.value = url.hostname || 'MCP Server';
241
+ } catch (e) {
242
+ // Invalid URL, ignore
243
+ }
244
+ }
245
+ });
246
+ }
247
+
248
+ setupModal(modalId, backdropId, closeId) {
249
+ const modal = document.getElementById(modalId);
250
+ const backdrop = document.getElementById(backdropId);
251
+ const closeBtn = document.getElementById(closeId);
252
+
253
+ backdrop.addEventListener('click', () => this.hideModal(modalId));
254
+ closeBtn.addEventListener('click', () => this.hideModal(modalId));
255
+ }
256
+
257
+ setupTabs() {
258
+ const tabBtns = document.querySelectorAll('.tab-btn');
259
+ tabBtns.forEach(btn => {
260
+ btn.addEventListener('click', () => {
261
+ const tabName = btn.getAttribute('data-tab');
262
+
263
+ // Update active button
264
+ tabBtns.forEach(b => b.classList.remove('active'));
265
+ btn.classList.add('active');
266
+
267
+ // Update active content
268
+ document.querySelectorAll('.tab-content').forEach(content => {
269
+ content.classList.remove('active');
270
+ });
271
+ document.getElementById(`${tabName}-tab`).classList.add('active');
272
+ });
273
+ });
274
+ }
275
+
276
+ showModal(modalId) {
277
+ document.getElementById(modalId).classList.add('show');
278
+ }
279
+
280
+ hideModal(modalId) {
281
+ document.getElementById(modalId).classList.remove('show');
282
+ }
283
+
284
+ showSystemPromptModal() {
285
+ const chat = this.chats.get(this.currentChatId);
286
+ if (!chat) return;
287
+
288
+ // Load the current system prompt for this chat
289
+ this.systemPromptTextarea.value = chat.systemPrompt || this.defaultSystemPrompt;
290
+ this.showModal('systemPromptModal');
291
+ }
292
+
293
+ saveSystemPrompt() {
294
+ const chat = this.chats.get(this.currentChatId);
295
+ if (!chat) return;
296
+
297
+ const newPrompt = this.systemPromptTextarea.value.trim();
298
+ if (!newPrompt) {
299
+ this.showError('System prompt cannot be empty');
300
+ return;
301
+ }
302
+
303
+ // Check if prompt actually changed
304
+ if (newPrompt === chat.systemPrompt) {
305
+ this.hideModal('systemPromptModal');
306
+ return;
307
+ }
308
+
309
+ // Update the chat's system prompt
310
+ chat.systemPrompt = newPrompt;
311
+
312
+ // Clear messages and reset the conversation
313
+ chat.messages = [];
314
+ chat.updatedAt = new Date().toISOString();
315
+
316
+ // Save the new prompt as the last used one
317
+ this.lastSystemPrompt = newPrompt;
318
+ localStorage.setItem('lastSystemPrompt', newPrompt);
319
+
320
+ // Clear token usage history for this chat
321
+ this.tokenUsageHistory.set(this.currentChatId, {
322
+ requests: [],
323
+ model: chat.model
324
+ });
325
+
326
+ // Save settings
327
+ this.saveSettings();
328
+
329
+ // Reload the chat
330
+ this.loadChat(this.currentChatId);
331
+
332
+ // Hide modal
333
+ this.hideModal('systemPromptModal');
334
+
335
+ // Show notification
336
+ this.addSystemMessage('System prompt updated. Conversation has been reset.');
337
+ }
338
+
339
+ toggleTheme() {
340
+ const html = document.documentElement;
341
+ const currentTheme = html.getAttribute('data-theme');
342
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
343
+ html.setAttribute('data-theme', newTheme);
344
+ localStorage.setItem('theme', newTheme);
345
+ }
346
+
347
+ initializeResizable() {
348
+ // Chat sidebar resize
349
+ const chatSidebar = document.getElementById('chatSidebar');
350
+ const chatSidebarResize = document.getElementById('chatSidebarResize');
351
+
352
+ this.setupResize(chatSidebarResize, 'horizontal', (delta) => {
353
+ const currentWidth = chatSidebar.offsetWidth;
354
+ const newWidth = Math.max(200, Math.min(400, currentWidth + delta));
355
+ chatSidebar.style.width = newWidth + 'px';
356
+ this.savePaneSizes();
357
+ });
358
+
359
+ // Log panel resize
360
+ const logPanel = document.getElementById('logPanel');
361
+ const logPanelResize = document.getElementById('logPanelResize');
362
+
363
+ this.setupResize(logPanelResize, 'horizontal', (delta) => {
364
+ // First, ensure the panel is not collapsed
365
+ if (logPanel.classList.contains('collapsed')) {
366
+ // Expand it first
367
+ logPanel.classList.remove('collapsed');
368
+ this.toggleLogBtn.textContent = '◀';
369
+ localStorage.setItem('logCollapsed', 'false');
370
+ // Set initial width when expanding
371
+ logPanel.style.width = '300px';
372
+ }
373
+
374
+ const currentWidth = logPanel.offsetWidth;
375
+ // For right panel, dragging left (negative delta) should increase width
376
+ const newWidth = Math.max(200, Math.min(650, currentWidth + (-delta)));
377
+ logPanel.style.width = newWidth + 'px';
378
+ console.log('Log panel resize:', { currentWidth, delta, newWidth, offsetWidth: logPanel.offsetWidth });
379
+ this.savePaneSizes();
380
+ }, logPanel);
381
+
382
+ // Chat input resize
383
+ const chatInputContainer = document.getElementById('chatInputContainer');
384
+ const chatInputResize = document.getElementById('chatInputResize');
385
+
386
+ this.setupResize(chatInputResize, 'vertical', (delta) => {
387
+ const currentHeight = chatInputContainer.offsetHeight;
388
+ const newHeight = Math.max(80, Math.min(300, currentHeight - delta));
389
+ chatInputContainer.style.height = newHeight + 'px';
390
+
391
+ // No need to manually adjust textarea height anymore since it uses flexbox
392
+
393
+ this.savePaneSizes();
394
+ });
395
+ }
396
+
397
+ setupResize(handle, direction, onResize, element) {
398
+ let isResizing = false;
399
+ let startPos = 0;
400
+
401
+ const startResize = (e) => {
402
+ isResizing = true;
403
+ startPos = direction === 'horizontal' ? e.clientX : e.clientY;
404
+ document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
405
+ document.body.style.userSelect = 'none';
406
+ e.preventDefault();
407
+
408
+ // Add active class for visual feedback
409
+ handle.classList.add('resize-active');
410
+
411
+ // Add resizing class to element if provided
412
+ if (element) {
413
+ element.classList.add('resizing');
414
+ }
415
+ };
416
+
417
+ const doResize = (e) => {
418
+ if (!isResizing) return;
419
+
420
+ const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
421
+ const delta = currentPos - startPos;
422
+ startPos = currentPos;
423
+
424
+ onResize(delta);
425
+ };
426
+
427
+ const stopResize = () => {
428
+ if (!isResizing) return;
429
+ isResizing = false;
430
+ document.body.style.cursor = '';
431
+ document.body.style.userSelect = '';
432
+
433
+ // Remove active class
434
+ handle.classList.remove('resize-active');
435
+
436
+ // Remove resizing class from element if provided
437
+ if (element) {
438
+ element.classList.remove('resizing');
439
+ }
440
+ };
441
+
442
+ handle.addEventListener('mousedown', startResize);
443
+ document.addEventListener('mousemove', doResize);
444
+ document.addEventListener('mouseup', stopResize);
445
+
446
+ // Also handle mouse leave to stop resize
447
+ document.addEventListener('mouseleave', stopResize);
448
+ }
449
+
450
+ savePaneSizes() {
451
+ const logPanel = document.getElementById('logPanel');
452
+ const sizes = {
453
+ chatSidebar: document.getElementById('chatSidebar').offsetWidth,
454
+ logPanel: logPanel.classList.contains('collapsed') ? 40 : logPanel.offsetWidth,
455
+ logPanelCollapsed: logPanel.classList.contains('collapsed'),
456
+ chatInput: document.getElementById('chatInputContainer').offsetHeight
457
+ };
458
+ localStorage.setItem('paneSizes', JSON.stringify(sizes));
459
+ }
460
+
461
+ loadPaneSizes() {
462
+ const savedSizes = localStorage.getItem('paneSizes');
463
+ if (savedSizes) {
464
+ try {
465
+ const sizes = JSON.parse(savedSizes);
466
+
467
+ if (sizes.chatSidebar) {
468
+ document.getElementById('chatSidebar').style.width = sizes.chatSidebar + 'px';
469
+ }
470
+
471
+ if (sizes.logPanel) {
472
+ const logPanel = document.getElementById('logPanel');
473
+ // Only set width if not collapsed, or if we have a saved non-collapsed state
474
+ if (!logPanel.classList.contains('collapsed') || !sizes.logPanelCollapsed) {
475
+ logPanel.style.width = sizes.logPanel + 'px';
476
+ }
477
+ }
478
+
479
+ if (sizes.chatInput) {
480
+ const container = document.getElementById('chatInputContainer');
481
+ container.style.height = sizes.chatInput + 'px';
482
+
483
+ // No need to manually adjust textarea height anymore since it uses flexbox
484
+ }
485
+ } catch (e) {
486
+ console.error('Failed to load pane sizes:', e);
487
+ }
488
+ }
489
+ }
490
+
491
+ toggleLog() {
492
+ this.logPanel.classList.toggle('collapsed');
493
+ this.toggleLogBtn.textContent = this.logPanel.classList.contains('collapsed') ? '▶' : '◀';
494
+ localStorage.setItem('logCollapsed', this.logPanel.classList.contains('collapsed'));
495
+ this.savePaneSizes();
496
+ }
497
+
498
+ showError(message) {
499
+ // Show error toast
500
+ const toast = document.createElement('div');
501
+ toast.className = 'error-toast';
502
+ toast.textContent = message;
503
+ document.getElementById('errorToastContainer').appendChild(toast);
504
+
505
+ // Remove after animation
506
+ setTimeout(() => toast.remove(), 3000);
507
+
508
+ // Log error
509
+ this.addLogEntry('ERROR', {
510
+ timestamp: new Date().toISOString(),
511
+ direction: 'error',
512
+ message: message
513
+ });
514
+
515
+ // Also show in chat if there's an active chat
516
+ if (this.currentChatId) {
517
+ const messageDiv = document.createElement('div');
518
+ messageDiv.className = 'message error';
519
+ messageDiv.textContent = `❌ ${message}`;
520
+ this.chatMessages.appendChild(messageDiv);
521
+ this.scrollToBottom();
522
+ }
523
+ }
524
+
525
+ addLogEntry(source, entry) {
526
+ const logEntry = {
527
+ ...entry,
528
+ source: source
529
+ };
530
+ this.communicationLog.push(logEntry);
531
+ this.updateLogDisplay(logEntry);
532
+ }
533
+
534
+ updateLogDisplay(entry) {
535
+ const entryDiv = document.createElement('div');
536
+ entryDiv.className = 'log-entry';
537
+
538
+ let directionClass = entry.direction;
539
+ let directionSymbol = '';
540
+ switch(entry.direction) {
541
+ case 'sent': directionSymbol = '→'; break;
542
+ case 'received': directionSymbol = '←'; break;
543
+ case 'error': directionSymbol = '⚠'; break;
544
+ case 'info': directionSymbol = 'ℹ'; break;
545
+ }
546
+
547
+ let metadataHtml = '';
548
+ if (entry.metadata && Object.keys(entry.metadata).length > 0) {
549
+ metadataHtml = `<div class="log-metadata">`;
550
+ for (const [key, value] of Object.entries(entry.metadata)) {
551
+ metadataHtml += `<span class="metadata-item">${key}: ${value}</span>`;
552
+ }
553
+ metadataHtml += `</div>`;
554
+ }
555
+
556
+ // Create a unique ID for this entry
557
+ const entryId = `log-entry-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
558
+
559
+ entryDiv.innerHTML = `
560
+ <div class="log-entry-header">
561
+ <div class="log-entry-info">
562
+ <span class="log-timestamp">${new Date(entry.timestamp).toLocaleTimeString()}</span>
563
+ <span class="log-source">[${entry.source}]</span>
564
+ <span class="log-direction ${directionClass}">${directionSymbol}</span>
565
+ </div>
566
+ <button class="btn-copy-log" title="Copy to clipboard" data-entry-id="${entryId}">📋</button>
567
+ </div>
568
+ ${metadataHtml}
569
+ <div class="log-message" id="${entryId}">${this.formatLogMessage(entry.message)}</div>
570
+ `;
571
+
572
+ // Add click handler for copy button
573
+ const copyBtn = entryDiv.querySelector('.btn-copy-log');
574
+ copyBtn.addEventListener('click', () => {
575
+ const messageElement = document.getElementById(entryId);
576
+ const textToCopy = messageElement.textContent || messageElement.innerText;
577
+ this.copyToClipboard(textToCopy, copyBtn);
578
+ });
579
+
580
+ this.logContent.appendChild(entryDiv);
581
+ this.logContent.scrollTop = this.logContent.scrollHeight;
582
+ }
583
+
584
+ formatLogMessage(message) {
585
+ try {
586
+ const parsed = JSON.parse(message);
587
+ return JSON.stringify(parsed, null, 2);
588
+ } catch {
589
+ return message;
590
+ }
591
+ }
592
+
593
+ async copyToClipboard(text, button) {
594
+ try {
595
+ await navigator.clipboard.writeText(text);
596
+
597
+ // Show success feedback
598
+ const originalText = button.textContent;
599
+ button.textContent = '✓';
600
+ button.style.color = 'var(--success-color)';
601
+
602
+ setTimeout(() => {
603
+ button.textContent = originalText;
604
+ button.style.color = '';
605
+ }, 1500);
606
+ } catch (err) {
607
+ console.error('Failed to copy to clipboard:', err);
608
+
609
+ // Show error feedback
610
+ const originalText = button.textContent;
611
+ button.textContent = '✗';
612
+ button.style.color = 'var(--danger-color)';
613
+
614
+ setTimeout(() => {
615
+ button.textContent = originalText;
616
+ button.style.color = '';
617
+ }, 1500);
618
+ }
619
+ }
620
+
621
+ clearLog() {
622
+ if (confirm('Clear all communication logs?')) {
623
+ this.communicationLog = [];
624
+ this.logContent.innerHTML = '';
625
+ }
626
+ }
627
+
628
+ downloadLog() {
629
+ const logText = this.communicationLog.map(entry => {
630
+ return `[${entry.timestamp}] [${entry.source}] ${entry.direction}: ${entry.message}`;
631
+ }).join('\n\n');
632
+
633
+ const blob = new Blob([logText], { type: 'text/plain' });
634
+ const url = URL.createObjectURL(blob);
635
+ const a = document.createElement('a');
636
+ a.href = url;
637
+ a.download = `mcp-communication-log-${new Date().toISOString()}.txt`;
638
+ a.click();
639
+ URL.revokeObjectURL(url);
640
+ }
641
+
642
+ loadSettings() {
643
+ // Note: file:// protocol is now supported thanks to the proxy server
644
+ // The proxy handles CORS issues that would normally prevent direct API access
645
+
646
+ // Load theme
647
+ const savedTheme = localStorage.getItem('theme') || 'light';
648
+ document.documentElement.setAttribute('data-theme', savedTheme);
649
+
650
+ // Load log collapsed state
651
+ const logCollapsed = localStorage.getItem('logCollapsed') === 'true';
652
+ if (logCollapsed) {
653
+ this.logPanel.classList.add('collapsed');
654
+ this.toggleLogBtn.textContent = '▶';
655
+ }
656
+
657
+ // Load pane sizes
658
+ this.loadPaneSizes();
659
+
660
+ // Load MCP servers
661
+ const savedMcpServers = localStorage.getItem('mcpServers');
662
+ if (savedMcpServers) {
663
+ try {
664
+ const servers = JSON.parse(savedMcpServers);
665
+ servers.forEach(server => {
666
+ this.mcpServers.set(server.id, server);
667
+ });
668
+ this.updateMcpServersList();
669
+ } catch (e) {
670
+ console.error('Failed to load MCP servers:', e);
671
+ }
672
+ }
673
+
674
+ // Load LLM providers (proxy configurations)
675
+ const savedLlmProviders = localStorage.getItem('llmProviders');
676
+ if (savedLlmProviders) {
677
+ try {
678
+ const providers = JSON.parse(savedLlmProviders);
679
+ providers.forEach(p => {
680
+ const provider = {
681
+ id: p.id,
682
+ name: p.name,
683
+ proxyUrl: p.proxyUrl,
684
+ availableProviders: p.availableProviders,
685
+ onLog: (logEntry) => this.addLogEntry(p.name, logEntry)
686
+ };
687
+ this.llmProviders.set(p.id, provider);
688
+ });
689
+ this.updateLlmProvidersList();
690
+ } catch (e) {
691
+ console.error('Failed to load LLM providers:', e);
692
+ }
693
+ }
694
+
695
+ // Load chats
696
+ const savedChats = localStorage.getItem('chats');
697
+ if (savedChats) {
698
+ try {
699
+ const chats = JSON.parse(savedChats);
700
+ chats.forEach(chat => {
701
+ this.chats.set(chat.id, chat);
702
+ });
703
+ this.updateChatSessions();
704
+
705
+ // Load last active chat
706
+ const lastChatId = localStorage.getItem('currentChatId');
707
+ if (lastChatId && this.chats.has(lastChatId)) {
708
+ this.loadChat(lastChatId);
709
+ }
710
+ } catch (e) {
711
+ console.error('Failed to load chats:', e);
712
+ }
713
+ }
714
+ }
715
+
716
+ saveSettings() {
717
+ // Save MCP servers
718
+ const serversToSave = Array.from(this.mcpServers.values());
719
+ localStorage.setItem('mcpServers', JSON.stringify(serversToSave));
720
+
721
+ // Save LLM providers (proxy configurations only, no API keys)
722
+ const providersToSave = Array.from(this.llmProviders.entries()).map(([id, provider]) => ({
723
+ id: id,
724
+ name: provider.name,
725
+ proxyUrl: provider.proxyUrl,
726
+ availableProviders: provider.availableProviders
727
+ }));
728
+ localStorage.setItem('llmProviders', JSON.stringify(providersToSave));
729
+
730
+ // Save chats
731
+ const chatsToSave = Array.from(this.chats.values());
732
+ localStorage.setItem('chats', JSON.stringify(chatsToSave));
733
+
734
+ // Save current chat ID
735
+ if (this.currentChatId) {
736
+ localStorage.setItem('currentChatId', this.currentChatId);
737
+ }
738
+ }
739
+
740
+ // MCP Server Management
741
+ async addMcpServer() {
742
+ const url = this.mcpServerUrl.value.trim();
743
+ const name = this.mcpServerName.value.trim();
744
+
745
+ if (!url || !name) {
746
+ this.showError('Please fill in all fields');
747
+ return;
748
+ }
749
+
750
+ // Test connection
751
+ try {
752
+ const testClient = new MCPClient();
753
+ testClient.onLog = (logEntry) => this.addLogEntry(`MCP-${name}`, logEntry);
754
+ await testClient.connect(url);
755
+
756
+ // Connection successful, save server
757
+ const serverId = `mcp_${Date.now()}`;
758
+ const server = {
759
+ id: serverId,
760
+ name: name,
761
+ url: url,
762
+ connected: true
763
+ };
764
+
765
+ this.mcpServers.set(serverId, server);
766
+ this.mcpConnections.set(serverId, testClient);
767
+
768
+ this.saveSettings();
769
+ this.updateMcpServersList();
770
+ this.updateNewChatSelectors();
771
+
772
+ // Clear form
773
+ this.mcpServerUrl.value = '';
774
+ this.mcpServerName.value = '';
775
+ this.hideModal('addMcpModal');
776
+
777
+ this.addLogEntry('SYSTEM', {
778
+ timestamp: new Date().toISOString(),
779
+ direction: 'info',
780
+ message: `MCP server "${name}" added successfully`
781
+ });
782
+
783
+ } catch (error) {
784
+ this.showError(`Failed to connect to MCP server: ${error.message}`);
785
+ }
786
+ }
787
+
788
+ updateMcpServersList() {
789
+ this.mcpServersList.innerHTML = '';
790
+
791
+ if (this.mcpServers.size === 0) {
792
+ this.mcpServersList.innerHTML = '<div class="text-center text-muted">No MCP servers configured</div>';
793
+ return;
794
+ }
795
+
796
+ for (const [id, server] of this.mcpServers) {
797
+ const connection = this.mcpConnections.get(id);
798
+ const isConnected = connection && connection.isReady();
799
+
800
+ const serverDiv = document.createElement('div');
801
+ serverDiv.className = 'config-item';
802
+ serverDiv.innerHTML = `
803
+ <div class="config-item-info">
804
+ <div class="config-item-name">${server.name}</div>
805
+ <div class="config-item-details">${server.url}</div>
806
+ </div>
807
+ <div class="config-item-actions">
808
+ <div class="config-item-status">
809
+ <span class="status-dot ${isConnected ? 'connected' : 'disconnected'}"></span>
810
+ <span>${isConnected ? 'Connected' : 'Disconnected'}</span>
811
+ </div>
812
+ <button class="btn btn-small btn-danger" onclick="app.removeMcpServer('${id}')">Remove</button>
813
+ </div>
814
+ `;
815
+ this.mcpServersList.appendChild(serverDiv);
816
+ }
817
+ }
818
+
819
+ async removeMcpServer(serverId) {
820
+ if (confirm('Remove this MCP server?')) {
821
+ // Disconnect if connected
822
+ const connection = this.mcpConnections.get(serverId);
823
+ if (connection) {
824
+ connection.disconnect();
825
+ this.mcpConnections.delete(serverId);
826
+ }
827
+
828
+ this.mcpServers.delete(serverId);
829
+ this.saveSettings();
830
+ this.updateMcpServersList();
831
+ this.updateNewChatSelectors();
832
+
833
+ // Check if any chats use this server
834
+ for (const chat of this.chats.values()) {
835
+ if (chat.mcpServerId === serverId) {
836
+ chat.mcpServerId = null;
837
+ // Note: Chat becomes unusable without MCP server
838
+ }
839
+ }
840
+ this.saveSettings();
841
+ }
842
+ }
843
+
844
+ // LLM Provider Management
845
+ async testProxyConnection() {
846
+ const proxyUrl = this.llmProxyUrl.value.trim();
847
+ if (!proxyUrl) {
848
+ this.llmProvidersStatus.style.display = 'none';
849
+ return;
850
+ }
851
+
852
+ this.llmProvidersInfo.innerHTML = '<div style="color: var(--text-muted);">Connecting to proxy...</div>';
853
+ this.llmProvidersStatus.style.display = 'block';
854
+
855
+ try {
856
+ const response = await fetch(`${proxyUrl}/models`);
857
+ if (!response.ok) {
858
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
859
+ }
860
+
861
+ const data = await response.json();
862
+ const providers = data.providers || {};
863
+
864
+ if (Object.keys(providers).length === 0) {
865
+ this.llmProvidersInfo.innerHTML = '<div style="color: var(--color-error);">No providers configured in proxy. Please configure API keys in llm-proxy-config.json</div>';
866
+ return;
867
+ }
868
+
869
+ // Display available providers and models
870
+ let html = '<div style="color: var(--color-success);">✓ Connected successfully</div>';
871
+ html += '<div style="margin-top: 8px; font-size: 0.9em;">';
872
+
873
+ Object.entries(providers).forEach(([provider, config]) => {
874
+ html += `<div style="margin-bottom: 4px;"><strong>${provider}:</strong> ${config.models.length} models available</div>`;
875
+ });
876
+
877
+ html += '</div>';
878
+ this.llmProvidersInfo.innerHTML = html;
879
+
880
+ // Store available providers for later use
881
+ this.availableProviders = providers;
882
+
883
+ } catch (error) {
884
+ this.llmProvidersInfo.innerHTML = `<div style="color: var(--color-error);">Failed to connect: ${error.message}</div>`;
885
+ this.availableProviders = null;
886
+ }
887
+ }
888
+
889
+ async addLlmProvider() {
890
+ const proxyUrl = this.llmProxyUrl.value.trim();
891
+ const name = this.llmProviderName.value.trim();
892
+
893
+ if (!proxyUrl || !name) {
894
+ this.showError('Please fill in all fields');
895
+ return;
896
+ }
897
+
898
+ // Make sure we have tested the connection and have available providers
899
+ if (!this.availableProviders) {
900
+ await this.testProxyConnection();
901
+ if (!this.availableProviders) {
902
+ this.showError('Please test the proxy connection first');
903
+ return;
904
+ }
905
+ }
906
+
907
+ try {
908
+ const providerId = `llm_${Date.now()}`;
909
+
910
+ // Create a proxy provider object that holds all available providers
911
+ const provider = {
912
+ id: providerId,
913
+ name: name,
914
+ proxyUrl: proxyUrl,
915
+ availableProviders: this.availableProviders,
916
+ onLog: (logEntry) => this.addLogEntry(name, logEntry)
917
+ };
918
+
919
+ this.llmProviders.set(providerId, provider);
920
+ this.saveSettings();
921
+ this.updateLlmProvidersList();
922
+ this.updateNewChatSelectors();
923
+
924
+ // Clear form
925
+ this.llmProxyUrl.value = 'http://localhost:8081';
926
+ this.llmProviderName.value = '';
927
+ this.llmProvidersStatus.style.display = 'none';
928
+ this.availableProviders = null;
929
+ this.hideModal('addLlmModal');
930
+
931
+ this.addLogEntry('SYSTEM', {
932
+ timestamp: new Date().toISOString(),
933
+ direction: 'info',
934
+ message: `LLM proxy "${name}" added successfully`
935
+ });
936
+
937
+ } catch (error) {
938
+ this.showError(`Failed to add LLM proxy: ${error.message}`);
939
+ }
940
+ }
941
+
942
+ updateLlmProvidersList() {
943
+ this.llmProvidersList.innerHTML = '';
944
+
945
+ if (this.llmProviders.size === 0) {
946
+ this.llmProvidersList.innerHTML = '<div class="text-center text-muted">No LLM proxies configured</div>';
947
+ return;
948
+ }
949
+
950
+ for (const [id, provider] of this.llmProviders) {
951
+ const providerCount = Object.keys(provider.availableProviders || {}).length;
952
+ const modelCount = Object.values(provider.availableProviders || {})
953
+ .reduce((sum, p) => sum + (p.models || []).length, 0);
954
+
955
+ const providerDiv = document.createElement('div');
956
+ providerDiv.className = 'config-item';
957
+ providerDiv.innerHTML = `
958
+ <div class="config-item-info">
959
+ <div class="config-item-name">🔗 ${provider.name}</div>
960
+ <div class="config-item-details">${provider.proxyUrl} - ${providerCount} providers, ${modelCount} models</div>
961
+ </div>
962
+ <div class="config-item-actions">
963
+ <button class="btn btn-small btn-danger" onclick="app.removeLlmProvider('${id}')">Remove</button>
964
+ </div>
965
+ `;
966
+ this.llmProvidersList.appendChild(providerDiv);
967
+ }
968
+ }
969
+
970
+ removeLlmProvider(providerId) {
971
+ if (confirm('Remove this LLM provider?')) {
972
+ this.llmProviders.delete(providerId);
973
+ this.saveSettings();
974
+ this.updateLlmProvidersList();
975
+ this.updateNewChatSelectors();
976
+
977
+ // Check if any chats use this provider
978
+ for (const chat of this.chats.values()) {
979
+ if (chat.llmProviderId === providerId) {
980
+ chat.llmProviderId = null;
981
+ // Note: Chat becomes unusable without LLM provider
982
+ }
983
+ }
984
+ this.saveSettings();
985
+ }
986
+ }
987
+
988
+ getProviderIcon(type) {
989
+ switch(type) {
990
+ case 'openai': return '🤖';
991
+ case 'anthropic': return '🧠';
992
+ case 'google': return '🔮';
993
+ default: return '💬';
994
+ }
995
+ }
996
+
997
+ // Chat Management
998
+ showNewChatModal() {
999
+ this.updateNewChatSelectors();
1000
+ this.showModal('newChatModal');
1001
+ }
1002
+
1003
+ updateNewChatSelectors() {
1004
+ // Update MCP server selector
1005
+ this.newChatMcpServer.innerHTML = '<option value="">Select MCP Server</option>';
1006
+ for (const [id, server] of this.mcpServers) {
1007
+ const option = document.createElement('option');
1008
+ option.value = id;
1009
+ option.textContent = server.name;
1010
+ this.newChatMcpServer.appendChild(option);
1011
+ }
1012
+
1013
+ // Update LLM provider selector
1014
+ this.newChatLlmProvider.innerHTML = '<option value="">Select LLM Provider</option>';
1015
+ for (const [id, provider] of this.llmProviders) {
1016
+ const option = document.createElement('option');
1017
+ option.value = id;
1018
+ option.textContent = provider.name;
1019
+ this.newChatLlmProvider.appendChild(option);
1020
+ }
1021
+
1022
+ // Reset model selector
1023
+ this.newChatModelGroup.style.display = 'none';
1024
+ this.newChatModel.innerHTML = '<option value="">Select Model</option>';
1025
+
1026
+ // If only one MCP server, auto-select it
1027
+ if (this.mcpServers.size === 1) {
1028
+ const [id] = this.mcpServers.keys();
1029
+ this.newChatMcpServer.value = id;
1030
+ }
1031
+
1032
+ // If only one LLM provider, auto-select it and load models
1033
+ if (this.llmProviders.size === 1) {
1034
+ const [id] = this.llmProviders.keys();
1035
+ this.newChatLlmProvider.value = id;
1036
+ // Trigger model update
1037
+ this.updateNewChatModels();
1038
+ }
1039
+ }
1040
+
1041
+ updateNewChatModels() {
1042
+ const providerId = this.newChatLlmProvider.value;
1043
+ if (!providerId) {
1044
+ this.newChatModelGroup.style.display = 'none';
1045
+ return;
1046
+ }
1047
+
1048
+ const provider = this.llmProviders.get(providerId);
1049
+ if (!provider || !provider.availableProviders) {
1050
+ this.newChatModelGroup.style.display = 'none';
1051
+ return;
1052
+ }
1053
+
1054
+ // Show model selector
1055
+ this.newChatModelGroup.style.display = 'block';
1056
+ this.newChatModel.innerHTML = '<option value="">Select Model</option>';
1057
+
1058
+ // Add models from all available providers
1059
+ Object.entries(provider.availableProviders).forEach(([providerType, config]) => {
1060
+ const optgroup = document.createElement('optgroup');
1061
+ optgroup.label = providerType.charAt(0).toUpperCase() + providerType.slice(1);
1062
+
1063
+ config.models.forEach(modelName => {
1064
+ const option = document.createElement('option');
1065
+ // Store both provider type and model in the value
1066
+ option.value = `${providerType}:${modelName}`;
1067
+ option.textContent = modelName;
1068
+ optgroup.appendChild(option);
1069
+ });
1070
+
1071
+ this.newChatModel.appendChild(optgroup);
1072
+ });
1073
+ }
1074
+
1075
+ async createNewChat() {
1076
+ const mcpServerId = this.newChatMcpServer.value;
1077
+ const llmProviderId = this.newChatLlmProvider.value;
1078
+ const selectedModel = this.newChatModel.value;
1079
+ let title = this.newChatTitle.value.trim();
1080
+
1081
+ if (!mcpServerId || !llmProviderId) {
1082
+ this.showError('Please select both MCP server and LLM provider');
1083
+ return;
1084
+ }
1085
+
1086
+ if (!selectedModel) {
1087
+ this.showError('Please select a model');
1088
+ return;
1089
+ }
1090
+
1091
+ // Ensure MCP connection
1092
+ let mcpConnection;
1093
+ try {
1094
+ mcpConnection = await this.ensureMcpConnection(mcpServerId);
1095
+ // Update server list to show connected status
1096
+ this.updateMcpServersList();
1097
+ } catch (error) {
1098
+ this.showError(`Failed to connect to MCP server: ${error.message}`);
1099
+ return;
1100
+ }
1101
+
1102
+ // Generate title if not provided
1103
+ if (!title) {
1104
+ const server = this.mcpServers.get(mcpServerId);
1105
+ const provider = this.llmProviders.get(llmProviderId);
1106
+ title = `${server.name} - ${provider.name}`;
1107
+ }
1108
+
1109
+ const chatId = `chat_${Date.now()}`;
1110
+ const chat = {
1111
+ id: chatId,
1112
+ title: title,
1113
+ mcpServerId: mcpServerId,
1114
+ llmProviderId: llmProviderId,
1115
+ model: selectedModel, // Selected model for this chat
1116
+ messages: [],
1117
+ temperature: 0.7, // Default temperature
1118
+ systemPrompt: this.lastSystemPrompt, // Use the last system prompt
1119
+ createdAt: new Date().toISOString(),
1120
+ updatedAt: new Date().toISOString()
1121
+ };
1122
+
1123
+ this.chats.set(chatId, chat);
1124
+ this.currentChatId = chatId;
1125
+
1126
+ // Initialize token usage history for new chat
1127
+ this.tokenUsageHistory.set(chatId, {
1128
+ requests: [],
1129
+ model: selectedModel
1130
+ });
1131
+
1132
+ this.saveSettings();
1133
+ this.updateChatSessions();
1134
+ this.loadChat(chatId);
1135
+
1136
+ // Clear form and close modal
1137
+ this.newChatTitle.value = '';
1138
+ this.newChatModel.value = '';
1139
+ this.newChatModelGroup.style.display = 'none';
1140
+ this.hideModal('newChatModal');
1141
+ }
1142
+
1143
+ updateChatSessions() {
1144
+ this.chatSessions.innerHTML = '';
1145
+
1146
+ if (this.chats.size === 0) {
1147
+ this.chatSessions.innerHTML = '<div class="text-center text-muted mt-2">No chats yet</div>';
1148
+ return;
1149
+ }
1150
+
1151
+ const sortedChats = Array.from(this.chats.values()).sort((a, b) =>
1152
+ new Date(b.updatedAt) - new Date(a.updatedAt)
1153
+ );
1154
+
1155
+ for (const chat of sortedChats) {
1156
+ const sessionDiv = document.createElement('div');
1157
+ sessionDiv.className = `chat-session-item ${chat.id === this.currentChatId ? 'active' : ''}`;
1158
+
1159
+ const server = this.mcpServers.get(chat.mcpServerId);
1160
+ const provider = this.llmProviders.get(chat.llmProviderId);
1161
+
1162
+ // Get model display name
1163
+ let modelDisplay = 'No model';
1164
+ if (chat.model) {
1165
+ // Extract model name from format "provider:model-name"
1166
+ const parts = chat.model.split(':');
1167
+ modelDisplay = parts.length > 1 ? parts[1] : chat.model;
1168
+ }
1169
+
1170
+ // Calculate context usage percentage if available
1171
+ let contextInfo = '';
1172
+ if (chat.contextUsage && chat.lastModel) {
1173
+ // Extract model name from format "provider:model-name" if needed
1174
+ let modelName = chat.lastModel;
1175
+ if (modelName && modelName.includes(':')) {
1176
+ modelName = modelName.split(':')[1];
1177
+ }
1178
+ const limit = this.modelLimits[modelName] || 4096;
1179
+ const percentage = Math.round((chat.contextUsage / limit) * 100);
1180
+ const contextK = (chat.contextUsage / 1000).toFixed(1);
1181
+ contextInfo = ` • ${contextK}k/${(limit/1000).toFixed(0)}k`;
1182
+ }
1183
+
1184
+ sessionDiv.innerHTML = `
1185
+ <div class="session-content" onclick="app.loadChat('${chat.id}')">
1186
+ <div class="session-title">${chat.title}</div>
1187
+ <div class="session-meta">
1188
+ <span>${modelDisplay}${contextInfo}</span>
1189
+ <span>${new Date(chat.updatedAt).toLocaleDateString()}</span>
1190
+ </div>
1191
+ </div>
1192
+ <button class="btn-delete-chat" onclick="event.stopPropagation(); app.deleteChat('${chat.id}')" title="Delete chat">
1193
+ 🗑️
1194
+ </button>
1195
+ `;
1196
+
1197
+ this.chatSessions.appendChild(sessionDiv);
1198
+ }
1199
+ }
1200
+
1201
+ loadChat(chatId) {
1202
+ const chat = this.chats.get(chatId);
1203
+ if (!chat) return;
1204
+
1205
+ this.currentChatId = chatId;
1206
+ this.updateChatSessions();
1207
+
1208
+ // Initialize token usage history for this chat if it doesn't exist
1209
+ if (!this.tokenUsageHistory.has(chatId)) {
1210
+ this.tokenUsageHistory.set(chatId, {
1211
+ requests: [],
1212
+ model: chat.model
1213
+ });
1214
+ }
1215
+
1216
+ const server = this.mcpServers.get(chat.mcpServerId);
1217
+ const provider = this.llmProviders.get(chat.llmProviderId);
1218
+
1219
+ // Update UI
1220
+ this.chatTitle.textContent = chat.title;
1221
+ this.chatMcp.textContent = server ? `MCP: ${server.name}` : 'MCP: Not found';
1222
+ if (provider) {
1223
+ // Parse the model format "provider:model"
1224
+ const modelDisplay = chat.model ? chat.model.split(':')[1] || chat.model : 'No model selected';
1225
+ this.chatLlm.textContent = `LLM: ${provider.name} (${modelDisplay})`;
1226
+ } else {
1227
+ this.chatLlm.textContent = 'LLM: Not found';
1228
+ }
1229
+
1230
+ // Enable/disable input based on server and provider availability
1231
+ if (server && provider && this.mcpConnections.has(chat.mcpServerId)) {
1232
+ this.chatInput.disabled = false;
1233
+ this.sendMessageBtn.disabled = false;
1234
+ this.chatInput.placeholder = "Ask about your Netdata metrics...";
1235
+ // Hide reconnect button if shown
1236
+ const reconnectBtn = document.getElementById('reconnectMcpBtn');
1237
+ if (reconnectBtn) {
1238
+ reconnectBtn.style.display = 'none';
1239
+ }
1240
+ // Show temperature control and context window
1241
+ this.temperatureControl.style.display = 'flex';
1242
+ const indicator = document.getElementById('contextWindowIndicator');
1243
+ if (indicator) {
1244
+ indicator.style.display = 'flex';
1245
+ }
1246
+ const temp = chat.temperature || 0.7;
1247
+ this.temperatureSlider.value = temp;
1248
+ this.updateTemperatureDisplay(temp);
1249
+ } else {
1250
+ this.chatInput.disabled = true;
1251
+ this.sendMessageBtn.disabled = true;
1252
+ this.temperatureControl.style.display = 'flex';
1253
+ if (!server) {
1254
+ this.chatInput.placeholder = "MCP server not found";
1255
+ } else if (!provider) {
1256
+ this.chatInput.placeholder = "LLM provider not found";
1257
+ } else if (!this.mcpConnections.has(chat.mcpServerId)) {
1258
+ this.chatInput.placeholder = "MCP server disconnected - click Reconnect";
1259
+ // Show reconnect button
1260
+ this.showReconnectButton(chat.mcpServerId);
1261
+ } else {
1262
+ this.chatInput.placeholder = "MCP server or LLM provider not available";
1263
+ }
1264
+ // Still show context window even when disabled
1265
+ const indicator = document.getElementById('contextWindowIndicator');
1266
+ if (indicator) {
1267
+ indicator.style.display = 'flex';
1268
+ }
1269
+ }
1270
+
1271
+ // Load messages
1272
+ this.chatMessages.innerHTML = '';
1273
+ this.currentAssistantGroup = null; // Reset any current group
1274
+
1275
+ // Display system prompt as first message
1276
+ this.displaySystemPrompt(chat.systemPrompt || this.defaultSystemPrompt);
1277
+
1278
+ for (const msg of chat.messages) {
1279
+ if (msg.role === 'system') continue;
1280
+ this.displayStoredMessage(msg);
1281
+ }
1282
+ // Clear current group after loading
1283
+ this.currentAssistantGroup = null;
1284
+
1285
+ // Update context window indicator
1286
+ const tokenHistory = this.getTokenUsageForChat(chatId);
1287
+ const model = chat.model || (provider ? provider.model : null);
1288
+
1289
+ // Also check if we have saved context usage in the chat
1290
+ if (chat.contextUsage && chat.contextUsage > 0) {
1291
+ // Use the actual model from the chat, not lastModel which might be in wrong format
1292
+ this.updateContextWindowIndicator(chat.contextUsage, model || chat.model || 'unknown');
1293
+ } else if (tokenHistory.totalTokens > 0 && model) {
1294
+ this.updateContextWindowIndicator(tokenHistory.totalTokens, model);
1295
+ } else {
1296
+ // Show empty context window with the correct model
1297
+ this.updateContextWindowIndicator(0, model || chat.model || 'unknown');
1298
+ }
1299
+
1300
+ this.scrollToBottom();
1301
+ }
1302
+
1303
+ displayStoredMessage(msg) {
1304
+ switch(msg.type) {
1305
+ case 'user':
1306
+ // User messages reset the assistant group
1307
+ this.currentAssistantGroup = null;
1308
+ this.addMessage('user', msg.content);
1309
+ break;
1310
+
1311
+ case 'assistant':
1312
+ // Create new assistant group for this message
1313
+ this.currentAssistantGroup = null;
1314
+ // Display assistant message with saved statistics
1315
+ if (msg.content) {
1316
+ this.addMessage('assistant', msg.content, msg.usage, msg.responseTime);
1317
+ }
1318
+ // Display any tool calls in the same group
1319
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
1320
+ // Ensure we have a group even if there was no content
1321
+ if (!this.currentAssistantGroup) {
1322
+ this.addMessage('assistant', '', msg.usage, msg.responseTime);
1323
+ }
1324
+ for (const toolCall of msg.toolCalls) {
1325
+ this.addToolCall(toolCall.name, toolCall.arguments);
1326
+ }
1327
+ }
1328
+ break;
1329
+
1330
+ case 'tool-results':
1331
+ // Display all tool results in the current group
1332
+ for (const result of msg.results) {
1333
+ this.addToolResult(result.name, result.result);
1334
+ }
1335
+ break;
1336
+
1337
+ // Handle old format for backward compatibility
1338
+ case 'tool-call':
1339
+ this.addToolCall(msg.toolName, msg.args);
1340
+ break;
1341
+ case 'tool-result':
1342
+ this.addToolResult(msg.toolName, msg.result, 0, null);
1343
+ break;
1344
+
1345
+ case 'system':
1346
+ this.addSystemMessage(msg.content);
1347
+ break;
1348
+
1349
+ case 'error':
1350
+ // Just display the error in chat, don't trigger full error handling
1351
+ const messageDiv = document.createElement('div');
1352
+ messageDiv.className = 'message error';
1353
+ messageDiv.textContent = `❌ [Previous session error] ${msg.content}`;
1354
+ this.chatMessages.appendChild(messageDiv);
1355
+ break;
1356
+ }
1357
+ }
1358
+
1359
+ deleteChat(chatId) {
1360
+ if (!chatId) return;
1361
+
1362
+ const chat = this.chats.get(chatId);
1363
+ if (!chat) return;
1364
+
1365
+ if (confirm(`Delete chat "${chat.title}"?`)) {
1366
+ this.chats.delete(chatId);
1367
+ this.saveSettings();
1368
+ this.updateChatSessions();
1369
+
1370
+ // If this was the current chat, clear the display
1371
+ if (chatId === this.currentChatId) {
1372
+ this.currentChatId = null;
1373
+ this.chatTitle.textContent = 'Select or create a chat';
1374
+ this.chatMcp.textContent = '';
1375
+ this.chatLlm.textContent = '';
1376
+ this.chatMessages.innerHTML = '';
1377
+ this.chatInput.disabled = true;
1378
+ this.sendMessageBtn.disabled = true;
1379
+ this.reconnectMcpBtn.style.display = 'none';
1380
+ this.temperatureControl.style.display = 'flex';
1381
+
1382
+ // Show empty context window indicator
1383
+ const indicator = document.getElementById('contextWindowIndicator');
1384
+ if (indicator) {
1385
+ indicator.style.display = 'flex';
1386
+ this.updateContextWindowIndicator(0, 'unknown');
1387
+ }
1388
+ }
1389
+ }
1390
+ }
1391
+
1392
+ // Messaging
1393
+ async sendMessage() {
1394
+ const message = this.chatInput.value.trim();
1395
+ if (!message) return;
1396
+
1397
+ const chat = this.chats.get(this.currentChatId);
1398
+ if (!chat) return;
1399
+
1400
+ let mcpConnection;
1401
+ try {
1402
+ // Try to ensure MCP connection (will reconnect if needed)
1403
+ mcpConnection = await this.ensureMcpConnection(chat.mcpServerId);
1404
+ } catch (error) {
1405
+ this.showError(`Failed to connect to MCP server: ${error.message}`);
1406
+ return;
1407
+ }
1408
+
1409
+ const proxyProvider = this.llmProviders.get(chat.llmProviderId);
1410
+ if (!proxyProvider) {
1411
+ this.showError('LLM proxy not available');
1412
+ return;
1413
+ }
1414
+
1415
+ // Parse the model selection (format: "provider:model")
1416
+ const [providerType, modelName] = chat.model.split(':');
1417
+ if (!providerType || !modelName) {
1418
+ this.showError('Invalid model selection');
1419
+ return;
1420
+ }
1421
+
1422
+ // Create the actual LLM provider instance
1423
+ const provider = createLLMProvider(providerType, proxyProvider.proxyUrl, modelName);
1424
+ provider.onLog = proxyProvider.onLog;
1425
+
1426
+ // Clear any current assistant group since we're starting a new conversation turn
1427
+ this.currentAssistantGroup = null;
1428
+
1429
+ // Disable input
1430
+ this.chatInput.value = '';
1431
+ this.chatInput.disabled = true;
1432
+ this.sendMessageBtn.disabled = true;
1433
+
1434
+ // Add user message
1435
+ this.addMessage('user', message);
1436
+ chat.messages.push({ type: 'user', role: 'user', content: message });
1437
+
1438
+ // Show loading spinner
1439
+ this.showLoadingSpinner();
1440
+
1441
+ try {
1442
+ await this.processMessageWithTools(chat, mcpConnection, provider, message);
1443
+ } catch (error) {
1444
+ this.showError(`Error: ${error.message}`);
1445
+ chat.messages.push({ type: 'error', content: error.message });
1446
+ } finally {
1447
+ // Remove loading spinner
1448
+ this.hideLoadingSpinner();
1449
+
1450
+ // Finalize assistant group with metrics at bottom
1451
+ this.finalizeAssistantGroup();
1452
+
1453
+ // Clear current assistant group after processing is complete
1454
+ this.currentAssistantGroup = null;
1455
+
1456
+ chat.updatedAt = new Date().toISOString();
1457
+ this.saveSettings();
1458
+ this.chatInput.disabled = false;
1459
+ this.sendMessageBtn.disabled = false;
1460
+ this.chatInput.focus();
1461
+ }
1462
+ }
1463
+
1464
+ async processMessageWithTools(chat, mcpConnection, provider, userMessage) {
1465
+ // Build conversation history
1466
+ const messages = [];
1467
+
1468
+ // Add system prompt if first message
1469
+ if (chat.messages.filter(m => m.role === 'user').length === 1) {
1470
+ messages.push({ role: 'system', content: chat.systemPrompt || this.defaultSystemPrompt });
1471
+ }
1472
+
1473
+ // Add conversation history with our simplified structure
1474
+ for (let i = 0; i < chat.messages.length; i++) {
1475
+ const msg = chat.messages[i];
1476
+
1477
+ if (msg.type === 'user') {
1478
+ // Simple user message
1479
+ messages.push({ role: 'user', content: msg.content });
1480
+
1481
+ } else if (msg.type === 'assistant') {
1482
+ // Assistant message with potential tool calls
1483
+ const cleanedContent = msg.content ? this.cleanContentForAPI(msg.content) : '';
1484
+
1485
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
1486
+ // Assistant with tool calls
1487
+ if (provider.type === 'anthropic') {
1488
+ // Anthropic format with content blocks
1489
+ messages.push({
1490
+ role: 'assistant',
1491
+ content: [
1492
+ ...(cleanedContent ? [{ type: 'text', text: cleanedContent }] : []),
1493
+ ...msg.toolCalls.map(tc => ({
1494
+ type: 'tool_use',
1495
+ id: tc.id,
1496
+ name: tc.name,
1497
+ input: tc.arguments
1498
+ }))
1499
+ ]
1500
+ });
1501
+ } else if (provider.type === 'openai') {
1502
+ // OpenAI format
1503
+ messages.push({
1504
+ role: 'assistant',
1505
+ content: cleanedContent,
1506
+ tool_calls: msg.toolCalls.map(tc => ({
1507
+ id: tc.id,
1508
+ type: 'function',
1509
+ function: {
1510
+ name: tc.name,
1511
+ arguments: JSON.stringify(tc.arguments)
1512
+ }
1513
+ }))
1514
+ });
1515
+ } else {
1516
+ // Google format (will be handled by their convertMessages)
1517
+ messages.push({
1518
+ role: 'assistant',
1519
+ content: cleanedContent,
1520
+ toolCalls: msg.toolCalls
1521
+ });
1522
+ }
1523
+ } else if (cleanedContent) {
1524
+ // Assistant without tool calls
1525
+ messages.push({ role: 'assistant', content: cleanedContent });
1526
+ }
1527
+
1528
+ } else if (msg.type === 'tool-results') {
1529
+ // Tool results
1530
+ if (provider.type === 'anthropic') {
1531
+ // Anthropic wants tool results in a user message
1532
+ messages.push({
1533
+ role: 'user',
1534
+ content: msg.results.map(tr => ({
1535
+ type: 'tool_result',
1536
+ tool_use_id: tr.toolCallId,
1537
+ content: typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result)
1538
+ }))
1539
+ });
1540
+ } else {
1541
+ // OpenAI and others want individual tool messages
1542
+ for (const tr of msg.results) {
1543
+ messages.push(provider.formatToolResponse(
1544
+ tr.toolCallId,
1545
+ tr.result,
1546
+ tr.name
1547
+ ));
1548
+ }
1549
+ }
1550
+ }
1551
+ // Note: We skip old format messages (tool-call, tool-result) as they should not exist in new chats
1552
+ }
1553
+
1554
+ // Get available tools
1555
+ const tools = Array.from(mcpConnection.tools.values());
1556
+
1557
+ let attempts = 0;
1558
+ const maxAttempts = 10;
1559
+
1560
+ // Create assistant group at the start of processing
1561
+ // We'll add all content from this conversation turn to this single group
1562
+ this.currentAssistantGroup = null;
1563
+
1564
+ while (attempts < maxAttempts) {
1565
+ attempts++;
1566
+
1567
+ // Send to LLM with current temperature
1568
+ const temperature = this.getCurrentTemperature();
1569
+ const llmStartTime = Date.now();
1570
+ const response = await provider.sendMessage(messages, tools, temperature);
1571
+ const llmResponseTime = Date.now() - llmStartTime;
1572
+
1573
+ // Track token usage
1574
+ if (response.usage) {
1575
+ this.updateTokenUsage(chat.id, response.usage, chat.model || provider.model);
1576
+ }
1577
+
1578
+ // If no tool calls, display response and finish
1579
+ if (!response.toolCalls || response.toolCalls.length === 0) {
1580
+ if (response.content) {
1581
+ // Create assistant group on first response with content
1582
+ if (!this.currentAssistantGroup) {
1583
+ this.addMessage('assistant', response.content, response.usage, llmResponseTime);
1584
+ } else {
1585
+ // Add content to existing group
1586
+ this.addContentToAssistantGroup(response.content);
1587
+ }
1588
+ chat.messages.push({
1589
+ type: 'assistant',
1590
+ role: 'assistant',
1591
+ content: response.content,
1592
+ usage: response.usage || null,
1593
+ responseTime: llmResponseTime || null
1594
+ });
1595
+ // Clean content before sending back to API
1596
+ const cleanedContent = this.cleanContentForAPI(response.content);
1597
+ if (cleanedContent && cleanedContent.trim()) {
1598
+ messages.push({ role: 'assistant', content: cleanedContent });
1599
+ }
1600
+ }
1601
+ break;
1602
+ }
1603
+
1604
+ // Store assistant message with tool calls if any
1605
+ if (response.content || response.toolCalls) {
1606
+ // Display the message
1607
+ if (response.content) {
1608
+ // Create assistant group on first response with content
1609
+ if (!this.currentAssistantGroup) {
1610
+ this.addMessage('assistant', response.content, response.usage, llmResponseTime);
1611
+ } else {
1612
+ // Add content to existing group
1613
+ this.addContentToAssistantGroup(response.content);
1614
+ }
1615
+ }
1616
+
1617
+ // Store in our improved internal format
1618
+ const assistantMessage = {
1619
+ type: 'assistant',
1620
+ role: 'assistant',
1621
+ content: response.content || '',
1622
+ toolCalls: response.toolCalls || [],
1623
+ usage: response.usage || null,
1624
+ responseTime: llmResponseTime || null
1625
+ };
1626
+ chat.messages.push(assistantMessage);
1627
+
1628
+ // Build the message for the API
1629
+ const cleanedContent = response.content ? this.cleanContentForAPI(response.content) : '';
1630
+
1631
+ if (provider.type === 'anthropic' && response.toolCalls && response.toolCalls.length > 0) {
1632
+ // Anthropic format with content blocks
1633
+ messages.push({
1634
+ role: 'assistant',
1635
+ content: [
1636
+ ...(cleanedContent ? [{ type: 'text', text: cleanedContent }] : []),
1637
+ ...response.toolCalls.map(tc => ({
1638
+ type: 'tool_use',
1639
+ id: tc.id,
1640
+ name: tc.name,
1641
+ input: tc.arguments
1642
+ }))
1643
+ ]
1644
+ });
1645
+ } else if (response.toolCalls && response.toolCalls.length > 0) {
1646
+ // Other providers (OpenAI, Google)
1647
+ messages.push({
1648
+ role: 'assistant',
1649
+ content: cleanedContent,
1650
+ tool_calls: response.toolCalls.map(tc => ({
1651
+ id: tc.id,
1652
+ type: 'function',
1653
+ function: {
1654
+ name: tc.name,
1655
+ arguments: JSON.stringify(tc.arguments)
1656
+ }
1657
+ }))
1658
+ });
1659
+ } else if (cleanedContent) {
1660
+ // Just text, no tool calls
1661
+ messages.push({ role: 'assistant', content: cleanedContent });
1662
+ }
1663
+ }
1664
+
1665
+ // Execute tool calls and collect results
1666
+ if (response.toolCalls && response.toolCalls.length > 0) {
1667
+ // Ensure we have an assistant group even if there was no content
1668
+ if (!this.currentAssistantGroup) {
1669
+ this.addMessage('assistant', '', response.usage, llmResponseTime);
1670
+ }
1671
+
1672
+ const toolResults = [];
1673
+
1674
+ for (const toolCall of response.toolCalls) {
1675
+ try {
1676
+ // Show tool call in UI
1677
+ this.addToolCall(toolCall.name, toolCall.arguments);
1678
+
1679
+ // Execute tool and track timing
1680
+ const toolStartTime = Date.now();
1681
+ const rawResult = await mcpConnection.callTool(toolCall.name, toolCall.arguments);
1682
+ const toolResponseTime = Date.now() - toolStartTime;
1683
+
1684
+ // Parse the result to handle MCP's response format
1685
+ const result = this.parseToolResult(rawResult);
1686
+
1687
+ // Calculate response size
1688
+ const responseSize = typeof result === 'string'
1689
+ ? result.length
1690
+ : JSON.stringify(result).length;
1691
+
1692
+ // Show result in UI with timing and size
1693
+ this.addToolResult(toolCall.name, result, toolResponseTime, responseSize);
1694
+
1695
+ // Collect result
1696
+ toolResults.push({
1697
+ toolCallId: toolCall.id,
1698
+ name: toolCall.name,
1699
+ result: result
1700
+ });
1701
+
1702
+ } catch (error) {
1703
+ const errorMsg = `Tool error (${toolCall.name}): ${error.message}`;
1704
+ this.addToolResult(toolCall.name, { error: errorMsg }, 0, errorMsg.length);
1705
+
1706
+ // Collect error result
1707
+ toolResults.push({
1708
+ toolCallId: toolCall.id,
1709
+ name: toolCall.name,
1710
+ result: { error: errorMsg }
1711
+ });
1712
+ }
1713
+ }
1714
+
1715
+ // Store all tool results together
1716
+ if (toolResults.length > 0) {
1717
+ chat.messages.push({
1718
+ type: 'tool-results',
1719
+ results: toolResults
1720
+ });
1721
+
1722
+ // Add to conversation based on provider
1723
+ if (provider.type === 'anthropic') {
1724
+ // Anthropic wants tool results in a user message
1725
+ messages.push({
1726
+ role: 'user',
1727
+ content: toolResults.map(tr => ({
1728
+ type: 'tool_result',
1729
+ tool_use_id: tr.toolCallId,
1730
+ content: typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result)
1731
+ }))
1732
+ });
1733
+ } else {
1734
+ // OpenAI and others want individual tool messages
1735
+ for (const tr of toolResults) {
1736
+ messages.push(provider.formatToolResponse(
1737
+ tr.toolCallId,
1738
+ tr.result,
1739
+ tr.name
1740
+ ));
1741
+ }
1742
+ }
1743
+ }
1744
+ }
1745
+ }
1746
+
1747
+ if (attempts >= maxAttempts) {
1748
+ this.showError('Maximum tool call attempts reached');
1749
+ }
1750
+ }
1751
+
1752
+ addMessage(role, content, usage = null, responseTime = null) {
1753
+ let messageDiv;
1754
+
1755
+ if (role === 'assistant') {
1756
+ // For assistant messages, we create or use the current group
1757
+ if (!this.currentAssistantGroup) {
1758
+ // Create new assistant group
1759
+ const groupDiv = document.createElement('div');
1760
+ groupDiv.className = 'assistant-group';
1761
+
1762
+ // Store metrics to add later at the bottom
1763
+ if (usage || responseTime) {
1764
+ this.pendingAssistantMetrics = { usage, responseTime };
1765
+ }
1766
+
1767
+ this.currentAssistantGroup = groupDiv;
1768
+ this.chatMessages.appendChild(groupDiv);
1769
+ }
1770
+
1771
+ // Use the current group as our target
1772
+ messageDiv = this.currentAssistantGroup;
1773
+ } else {
1774
+ // For non-assistant messages, create a regular message div
1775
+ messageDiv = document.createElement('div');
1776
+ messageDiv.className = `message ${role}`;
1777
+ }
1778
+
1779
+ // Check if content has thinking tags
1780
+ const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/g;
1781
+ const hasThinking = thinkingRegex.test(content);
1782
+
1783
+ // Make user messages editable on click
1784
+ if (role === 'user' && this.currentChatId) {
1785
+ messageDiv.classList.add('editable-message');
1786
+ }
1787
+
1788
+ // Process content
1789
+ if (hasThinking && role === 'assistant') {
1790
+ // Reset regex for actual processing
1791
+ content.match(/<thinking>([\s\S]*?)<\/thinking>/g);
1792
+
1793
+ // Split content into parts
1794
+ let parts = [];
1795
+ let lastIndex = 0;
1796
+ let match;
1797
+ const regex = /<thinking>([\s\S]*?)<\/thinking>/g;
1798
+
1799
+ while ((match = regex.exec(content)) !== null) {
1800
+ // Add text before thinking
1801
+ if (match.index > lastIndex) {
1802
+ parts.push({
1803
+ type: 'text',
1804
+ content: content.substring(lastIndex, match.index).trim()
1805
+ });
1806
+ }
1807
+
1808
+ // Add thinking content
1809
+ parts.push({
1810
+ type: 'thinking',
1811
+ content: match[1].trim()
1812
+ });
1813
+
1814
+ lastIndex = regex.lastIndex;
1815
+ }
1816
+
1817
+ // Add remaining text
1818
+ if (lastIndex < content.length) {
1819
+ const remaining = content.substring(lastIndex).trim();
1820
+ if (remaining) {
1821
+ parts.push({
1822
+ type: 'text',
1823
+ content: remaining
1824
+ });
1825
+ }
1826
+ }
1827
+
1828
+ // Render parts
1829
+ parts.forEach((part, index) => {
1830
+ if (part.type === 'text' && part.content) {
1831
+ const textDiv = document.createElement('div');
1832
+ textDiv.className = 'message-content';
1833
+ // Use marked to render markdown
1834
+ textDiv.innerHTML = marked.parse(part.content);
1835
+ messageDiv.appendChild(textDiv);
1836
+ } else if (part.type === 'thinking') {
1837
+ const thinkingDiv = document.createElement('div');
1838
+ thinkingDiv.className = 'thinking-block';
1839
+
1840
+ const thinkingHeader = document.createElement('div');
1841
+ thinkingHeader.className = 'thinking-header';
1842
+ thinkingHeader.innerHTML = `
1843
+ <span class="thinking-toggle">▶</span>
1844
+ <span class="thinking-label">💭 Assistant's reasoning</span>
1845
+ `;
1846
+
1847
+ const thinkingContent = document.createElement('div');
1848
+ thinkingContent.className = 'thinking-content collapsed';
1849
+ thinkingContent.textContent = part.content;
1850
+
1851
+ thinkingHeader.addEventListener('click', () => {
1852
+ const isCollapsed = thinkingContent.classList.contains('collapsed');
1853
+ thinkingContent.classList.toggle('collapsed');
1854
+ const toggle = thinkingHeader.querySelector('.thinking-toggle');
1855
+ if (toggle) {
1856
+ toggle.textContent = isCollapsed ? '▼' : '▶';
1857
+ }
1858
+ });
1859
+
1860
+ thinkingDiv.appendChild(thinkingHeader);
1861
+ thinkingDiv.appendChild(thinkingContent);
1862
+ messageDiv.appendChild(thinkingDiv);
1863
+ }
1864
+ });
1865
+ } else {
1866
+ // Regular message without thinking tags
1867
+ const contentDiv = document.createElement('div');
1868
+ contentDiv.className = 'message-content';
1869
+
1870
+ if (role === 'assistant') {
1871
+ // Use marked to render markdown for assistant messages
1872
+ contentDiv.innerHTML = marked.parse(content);
1873
+ } else {
1874
+ // Keep user messages as plain text
1875
+ contentDiv.textContent = content;
1876
+ }
1877
+
1878
+ messageDiv.appendChild(contentDiv);
1879
+ }
1880
+
1881
+ // Only append non-assistant messages to chat (assistant groups are already appended)
1882
+ if (role !== 'assistant') {
1883
+ this.chatMessages.appendChild(messageDiv);
1884
+ }
1885
+
1886
+ // Add edit trigger after element is in DOM
1887
+ if (role === 'user' && this.currentChatId && content) {
1888
+ const contentDiv = messageDiv.querySelector('.message-content');
1889
+ if (contentDiv) {
1890
+ this.addEditTrigger(contentDiv, content, 'user');
1891
+ }
1892
+ }
1893
+ this.scrollToBottom();
1894
+ this.moveSpinnerToBottom();
1895
+ }
1896
+
1897
+ addSystemMessage(content) {
1898
+ const messageDiv = document.createElement('div');
1899
+ messageDiv.className = 'message system';
1900
+ messageDiv.textContent = content;
1901
+ this.chatMessages.appendChild(messageDiv);
1902
+ this.scrollToBottom();
1903
+ this.moveSpinnerToBottom();
1904
+ }
1905
+
1906
+ displaySystemPrompt(prompt) {
1907
+ const promptDiv = document.createElement('div');
1908
+ promptDiv.className = 'system-prompt-display';
1909
+
1910
+ const headerDiv = document.createElement('div');
1911
+ headerDiv.className = 'system-prompt-header';
1912
+ headerDiv.innerHTML = `<span class="system-prompt-label">System Prompt</span>`;
1913
+
1914
+ const contentDiv = document.createElement('div');
1915
+ contentDiv.className = 'system-prompt-content';
1916
+ contentDiv.textContent = prompt;
1917
+
1918
+ promptDiv.appendChild(headerDiv);
1919
+ promptDiv.appendChild(contentDiv);
1920
+
1921
+ this.chatMessages.appendChild(promptDiv);
1922
+
1923
+ // Add edit trigger after element is in DOM
1924
+ this.addEditTrigger(contentDiv, prompt, 'system');
1925
+ }
1926
+
1927
+ addEditTrigger(contentDiv, originalContent, type) {
1928
+ const wrapper = contentDiv.parentElement;
1929
+ if (!wrapper) {
1930
+ console.warn('Cannot add edit trigger - element not yet in DOM');
1931
+ return;
1932
+ }
1933
+ wrapper.style.position = 'relative';
1934
+
1935
+ // Create edit balloon
1936
+ const editBalloon = document.createElement('div');
1937
+ editBalloon.className = 'edit-balloon';
1938
+ editBalloon.innerHTML = 'Edit';
1939
+ editBalloon.style.display = 'none';
1940
+ wrapper.appendChild(editBalloon);
1941
+
1942
+ // Show balloon on hover
1943
+ contentDiv.addEventListener('mouseenter', () => {
1944
+ if (!contentDiv.classList.contains('editing')) {
1945
+ editBalloon.style.display = 'block';
1946
+ }
1947
+ });
1948
+
1949
+ wrapper.addEventListener('mouseleave', () => {
1950
+ editBalloon.style.display = 'none';
1951
+ });
1952
+
1953
+ // Handle click on balloon
1954
+ editBalloon.onclick = () => {
1955
+ if (type === 'user') {
1956
+ this.editUserMessage(contentDiv, originalContent);
1957
+ } else if (type === 'system') {
1958
+ this.editSystemPromptInline(contentDiv, originalContent);
1959
+ }
1960
+ editBalloon.style.display = 'none';
1961
+ };
1962
+ }
1963
+
1964
+ editSystemPromptInline(contentDiv, originalPrompt) {
1965
+ const chat = this.chats.get(this.currentChatId);
1966
+ if (!chat) return;
1967
+
1968
+ // Prevent multiple edit sessions
1969
+ if (contentDiv.classList.contains('editing')) return;
1970
+
1971
+ // Make content editable
1972
+ contentDiv.contentEditable = true;
1973
+ contentDiv.classList.add('editing');
1974
+
1975
+ // Just focus, don't select all - let user position cursor
1976
+ contentDiv.focus();
1977
+
1978
+ // Create floating save/cancel buttons
1979
+ const buttonsDiv = document.createElement('div');
1980
+ buttonsDiv.className = 'edit-actions-floating';
1981
+ buttonsDiv.innerHTML = `
1982
+ <button class="btn btn-small btn-primary" title="Save & Restart Chat (Enter)">✓</button>
1983
+ <button class="btn btn-small btn-secondary" title="Cancel (Escape)">✗</button>
1984
+ `;
1985
+ contentDiv.parentElement.appendChild(buttonsDiv);
1986
+
1987
+ // Position buttons
1988
+ const rect = contentDiv.getBoundingClientRect();
1989
+ buttonsDiv.style.top = (rect.bottom - contentDiv.parentElement.getBoundingClientRect().top + 4) + 'px';
1990
+
1991
+ // Handle save
1992
+ const save = () => {
1993
+ const newPrompt = contentDiv.textContent.trim();
1994
+ if (!newPrompt) {
1995
+ this.showError('System prompt cannot be empty');
1996
+ return;
1997
+ }
1998
+
1999
+ if (newPrompt === originalPrompt) {
2000
+ cancel();
2001
+ return;
2002
+ }
2003
+
2004
+ // Update the chat's system prompt
2005
+ chat.systemPrompt = newPrompt;
2006
+
2007
+ // Clear messages and reset the conversation
2008
+ chat.messages = [];
2009
+ chat.updatedAt = new Date().toISOString();
2010
+
2011
+ // Save the new prompt as the last used one
2012
+ this.lastSystemPrompt = newPrompt;
2013
+ localStorage.setItem('lastSystemPrompt', newPrompt);
2014
+
2015
+ // Clear token usage history for this chat
2016
+ this.tokenUsageHistory.set(this.currentChatId, {
2017
+ requests: [],
2018
+ model: chat.model
2019
+ });
2020
+
2021
+ // Save settings
2022
+ this.saveSettings();
2023
+
2024
+ // Reload the chat
2025
+ this.loadChat(this.currentChatId);
2026
+
2027
+ // Show notification
2028
+ this.addSystemMessage('System prompt updated. Conversation has been reset.');
2029
+ };
2030
+
2031
+ // Handle cancel
2032
+ const cancel = () => {
2033
+ contentDiv.contentEditable = false;
2034
+ contentDiv.classList.remove('editing');
2035
+ contentDiv.textContent = originalPrompt;
2036
+ buttonsDiv.remove();
2037
+ };
2038
+
2039
+ buttonsDiv.querySelector('.btn-primary').onclick = save;
2040
+ buttonsDiv.querySelector('.btn-secondary').onclick = cancel;
2041
+
2042
+ // Handle keyboard shortcuts
2043
+ contentDiv.addEventListener('keydown', (e) => {
2044
+ if (e.key === 'Enter' && !e.shiftKey) {
2045
+ e.preventDefault();
2046
+ save();
2047
+ } else if (e.key === 'Escape') {
2048
+ e.preventDefault();
2049
+ cancel();
2050
+ }
2051
+ });
2052
+
2053
+ // Handle click outside
2054
+ const clickOutside = (e) => {
2055
+ if (!contentDiv.contains(e.target) && !buttonsDiv.contains(e.target)) {
2056
+ cancel();
2057
+ document.removeEventListener('click', clickOutside);
2058
+ }
2059
+ };
2060
+ setTimeout(() => document.addEventListener('click', clickOutside), 0);
2061
+ }
2062
+
2063
+ // Finalize assistant group by adding metrics at the bottom
2064
+ finalizeAssistantGroup() {
2065
+ if (!this.currentAssistantGroup || !this.pendingAssistantMetrics) return;
2066
+
2067
+ const { usage, responseTime } = this.pendingAssistantMetrics;
2068
+
2069
+ const metricsFooter = document.createElement('div');
2070
+ metricsFooter.className = 'assistant-metrics-footer';
2071
+
2072
+ let metricsHtml = '';
2073
+
2074
+ // Add response time
2075
+ if (responseTime !== null) {
2076
+ const timeSeconds = (responseTime / 1000).toFixed(1);
2077
+ metricsHtml += `<span class="metric-item">⏱️ ${timeSeconds}s</span>`;
2078
+ }
2079
+
2080
+ // Add token usage
2081
+ if (usage) {
2082
+ const formatNumber = (num) => num.toLocaleString();
2083
+ metricsHtml += `
2084
+ <span class="metric-item">📥 ${formatNumber(usage.promptTokens)}</span>
2085
+ <span class="metric-item">📤 ${formatNumber(usage.completionTokens)}</span>
2086
+ <span class="metric-item">📊 ${formatNumber(usage.totalTokens)}</span>
2087
+ `;
2088
+ }
2089
+
2090
+ metricsFooter.innerHTML = metricsHtml;
2091
+ this.currentAssistantGroup.appendChild(metricsFooter);
2092
+
2093
+ // Clear pending metrics
2094
+ this.pendingAssistantMetrics = null;
2095
+ }
2096
+
2097
+ editUserMessage(contentDiv, originalContent) {
2098
+ const chat = this.chats.get(this.currentChatId);
2099
+ if (!chat) return;
2100
+
2101
+ // Prevent multiple edit sessions
2102
+ if (contentDiv.classList.contains('editing')) return;
2103
+
2104
+ // Find the message index
2105
+ let messageIndex = -1;
2106
+ for (let i = 0; i < chat.messages.length; i++) {
2107
+ if (chat.messages[i].role === 'user' && chat.messages[i].content === originalContent) {
2108
+ messageIndex = i;
2109
+ break;
2110
+ }
2111
+ }
2112
+
2113
+ if (messageIndex === -1) {
2114
+ this.showError('Message not found in chat history');
2115
+ return;
2116
+ }
2117
+
2118
+ // Make content editable
2119
+ contentDiv.contentEditable = true;
2120
+ contentDiv.classList.add('editing');
2121
+ const originalText = contentDiv.textContent;
2122
+
2123
+ // Just focus, don't select all - let user position cursor
2124
+ contentDiv.focus();
2125
+
2126
+ // Create floating save/cancel buttons
2127
+ const buttonsDiv = document.createElement('div');
2128
+ buttonsDiv.className = 'edit-actions-floating';
2129
+ buttonsDiv.innerHTML = `
2130
+ <button class="btn btn-small btn-primary" title="Save & Resend (Enter)">✓</button>
2131
+ <button class="btn btn-small btn-secondary" title="Cancel (Escape)">✗</button>
2132
+ `;
2133
+ contentDiv.parentElement.appendChild(buttonsDiv);
2134
+
2135
+ // Position buttons
2136
+ const rect = contentDiv.getBoundingClientRect();
2137
+ buttonsDiv.style.top = (rect.bottom - contentDiv.parentElement.getBoundingClientRect().top + 4) + 'px';
2138
+
2139
+ // Handle save
2140
+ const save = async () => {
2141
+ const newContent = contentDiv.textContent.trim();
2142
+ if (!newContent) {
2143
+ this.showError('Message cannot be empty');
2144
+ return;
2145
+ }
2146
+
2147
+ if (newContent === originalText) {
2148
+ // No change, just cancel
2149
+ cancel();
2150
+ return;
2151
+ }
2152
+
2153
+ // Clip history at this point
2154
+ chat.messages = chat.messages.slice(0, messageIndex);
2155
+ chat.updatedAt = new Date().toISOString();
2156
+
2157
+ // Save the clipped state
2158
+ this.saveSettings();
2159
+
2160
+ // Reload the chat to show clipped history
2161
+ this.loadChat(this.currentChatId);
2162
+
2163
+ // Send the new message
2164
+ this.chatInput.value = newContent;
2165
+ await this.sendMessage();
2166
+ };
2167
+
2168
+ // Handle cancel
2169
+ const cancel = () => {
2170
+ contentDiv.contentEditable = false;
2171
+ contentDiv.classList.remove('editing');
2172
+ contentDiv.textContent = originalText;
2173
+ buttonsDiv.remove();
2174
+ };
2175
+
2176
+ buttonsDiv.querySelector('.btn-primary').onclick = save;
2177
+ buttonsDiv.querySelector('.btn-secondary').onclick = cancel;
2178
+
2179
+ // Handle keyboard shortcuts
2180
+ contentDiv.addEventListener('keydown', (e) => {
2181
+ if (e.key === 'Enter' && !e.shiftKey) {
2182
+ e.preventDefault();
2183
+ save();
2184
+ } else if (e.key === 'Escape') {
2185
+ e.preventDefault();
2186
+ cancel();
2187
+ }
2188
+ });
2189
+
2190
+ // Handle click outside
2191
+ const clickOutside = (e) => {
2192
+ if (!contentDiv.contains(e.target) && !buttonsDiv.contains(e.target)) {
2193
+ cancel();
2194
+ document.removeEventListener('click', clickOutside);
2195
+ }
2196
+ };
2197
+ setTimeout(() => document.addEventListener('click', clickOutside), 0);
2198
+ }
2199
+
2200
+ // Helper to add content to the current assistant group
2201
+ addContentToAssistantGroup(content) {
2202
+ if (!this.currentAssistantGroup) {
2203
+ // Create a new group if we don't have one
2204
+ this.addMessage('assistant', '');
2205
+ }
2206
+
2207
+ // Check if content has thinking tags
2208
+ const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/g;
2209
+ const hasThinking = thinkingRegex.test(content);
2210
+
2211
+ if (hasThinking) {
2212
+ // Process thinking content
2213
+ let lastIndex = 0;
2214
+ let match;
2215
+ const regex = /<thinking>([\s\S]*?)<\/thinking>/g;
2216
+
2217
+ while ((match = regex.exec(content)) !== null) {
2218
+ // Add text before thinking
2219
+ if (match.index > lastIndex) {
2220
+ const textContent = content.substring(lastIndex, match.index).trim();
2221
+ if (textContent) {
2222
+ const textDiv = document.createElement('div');
2223
+ textDiv.className = 'message-content';
2224
+ textDiv.innerHTML = marked.parse(textContent);
2225
+ this.currentAssistantGroup.appendChild(textDiv);
2226
+ }
2227
+ }
2228
+
2229
+ // Add thinking block
2230
+ const thinkingDiv = document.createElement('div');
2231
+ thinkingDiv.className = 'thinking-block';
2232
+
2233
+ const thinkingHeader = document.createElement('div');
2234
+ thinkingHeader.className = 'thinking-header';
2235
+ thinkingHeader.innerHTML = `
2236
+ <span class="thinking-toggle">▶</span>
2237
+ <span class="thinking-label">💭 Assistant's reasoning</span>
2238
+ `;
2239
+
2240
+ const thinkingContent = document.createElement('div');
2241
+ thinkingContent.className = 'thinking-content collapsed';
2242
+ thinkingContent.textContent = match[1].trim();
2243
+
2244
+ thinkingHeader.addEventListener('click', () => {
2245
+ const isCollapsed = thinkingContent.classList.contains('collapsed');
2246
+ thinkingContent.classList.toggle('collapsed');
2247
+ const toggle = thinkingHeader.querySelector('.thinking-toggle');
2248
+ if (toggle) {
2249
+ toggle.textContent = isCollapsed ? '▼' : '▶';
2250
+ }
2251
+ });
2252
+
2253
+ thinkingDiv.appendChild(thinkingHeader);
2254
+ thinkingDiv.appendChild(thinkingContent);
2255
+ this.currentAssistantGroup.appendChild(thinkingDiv);
2256
+
2257
+ lastIndex = regex.lastIndex;
2258
+ }
2259
+
2260
+ // Add remaining text
2261
+ if (lastIndex < content.length) {
2262
+ const remaining = content.substring(lastIndex).trim();
2263
+ if (remaining) {
2264
+ const textDiv = document.createElement('div');
2265
+ textDiv.className = 'message-content';
2266
+ textDiv.innerHTML = marked.parse(remaining);
2267
+ this.currentAssistantGroup.appendChild(textDiv);
2268
+ }
2269
+ }
2270
+ } else if (content.trim()) {
2271
+ // Regular content without thinking tags
2272
+ const contentDiv = document.createElement('div');
2273
+ contentDiv.className = 'message-content';
2274
+ contentDiv.innerHTML = marked.parse(content);
2275
+ this.currentAssistantGroup.appendChild(contentDiv);
2276
+ }
2277
+
2278
+ this.scrollToBottom();
2279
+ this.moveSpinnerToBottom();
2280
+ }
2281
+
2282
+ addToolCall(toolName, args) {
2283
+ // If we have a current assistant group, append to it
2284
+ const targetContainer = this.currentAssistantGroup || this.chatMessages;
2285
+
2286
+ // Create tool container with unique ID
2287
+ const toolId = `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
2288
+
2289
+ const toolDiv = document.createElement('div');
2290
+ toolDiv.className = 'tool-block';
2291
+ toolDiv.dataset.toolId = toolId;
2292
+
2293
+ const toolHeader = document.createElement('div');
2294
+ toolHeader.className = 'tool-header';
2295
+ toolHeader.innerHTML = `
2296
+ <span class="tool-toggle">▶</span>
2297
+ <span class="tool-label">🔧 ${toolName}</span>
2298
+ <span class="tool-info">
2299
+ <span class="tool-status">⏳ Calling...</span>
2300
+ </span>
2301
+ `;
2302
+
2303
+ const toolContent = document.createElement('div');
2304
+ toolContent.className = 'tool-content collapsed';
2305
+
2306
+ // Add request section
2307
+ const requestSection = document.createElement('div');
2308
+ requestSection.className = 'tool-request-section';
2309
+ requestSection.innerHTML = `
2310
+ <div class="tool-section-header">📤 Request</div>
2311
+ <pre>${JSON.stringify(args, null, 2)}</pre>
2312
+ `;
2313
+ toolContent.appendChild(requestSection);
2314
+
2315
+ // Add separator (will be visible when response is added)
2316
+ const separator = document.createElement('div');
2317
+ separator.className = 'tool-separator';
2318
+ separator.style.display = 'none';
2319
+ toolContent.appendChild(separator);
2320
+
2321
+ // Placeholder for response
2322
+ const responseSection = document.createElement('div');
2323
+ responseSection.className = 'tool-response-section';
2324
+ responseSection.style.display = 'none';
2325
+ toolContent.appendChild(responseSection);
2326
+
2327
+ toolHeader.addEventListener('click', () => {
2328
+ const isCollapsed = toolContent.classList.contains('collapsed');
2329
+ toolContent.classList.toggle('collapsed');
2330
+ toolHeader.querySelector('.tool-toggle').textContent = isCollapsed ? '▼' : '▶';
2331
+ });
2332
+
2333
+ toolDiv.appendChild(toolHeader);
2334
+ toolDiv.appendChild(toolContent);
2335
+ targetContainer.appendChild(toolDiv);
2336
+
2337
+ // Store reference for later update
2338
+ this.pendingToolCalls = this.pendingToolCalls || new Map();
2339
+ this.pendingToolCalls.set(toolName, toolId);
2340
+
2341
+ // Only append to chat if we're not in a group
2342
+ if (!this.currentAssistantGroup) {
2343
+ this.chatMessages.appendChild(targetContainer);
2344
+ }
2345
+
2346
+ this.scrollToBottom();
2347
+ this.moveSpinnerToBottom();
2348
+ }
2349
+
2350
+ addToolResult(toolName, result, responseTime = 0, responseSize = null) {
2351
+ // Try to find the pending tool call
2352
+ const toolId = this.pendingToolCalls?.get(toolName);
2353
+
2354
+ if (toolId) {
2355
+ // Update existing tool block
2356
+ const toolDiv = document.querySelector(`[data-tool-id="${toolId}"]`);
2357
+ if (toolDiv) {
2358
+ // Update header status
2359
+ const statusSpan = toolDiv.querySelector('.tool-status');
2360
+ const infoSpan = toolDiv.querySelector('.tool-info');
2361
+
2362
+ // Use provided size or calculate it
2363
+ const resultSize = responseSize !== null ? responseSize : (
2364
+ typeof result === 'string'
2365
+ ? result.length
2366
+ : JSON.stringify(result).length
2367
+ );
2368
+
2369
+ // Format size info
2370
+ let sizeInfo = '';
2371
+ if (resultSize < 1024) {
2372
+ sizeInfo = `${resultSize} bytes`;
2373
+ } else if (resultSize < 1024 * 1024) {
2374
+ sizeInfo = `${(resultSize / 1024).toFixed(1)} KB`;
2375
+ } else {
2376
+ sizeInfo = `${(resultSize / (1024 * 1024)).toFixed(1)} MB`;
2377
+ }
2378
+
2379
+ // Format response time
2380
+ const timeInfo = responseTime > 0 ? `${(responseTime / 1000).toFixed(2)}s` : '';
2381
+
2382
+ // Update status
2383
+ if (statusSpan) {
2384
+ statusSpan.textContent = result.error ? '❌ Error' : '✅ Complete';
2385
+ }
2386
+
2387
+ // Add metrics
2388
+ if (infoSpan) {
2389
+ infoSpan.innerHTML = `
2390
+ <span class="tool-status">${result.error ? '❌ Error' : '✅ Complete'}</span>
2391
+ <span class="tool-metric">⏱️ ${timeInfo}</span>
2392
+ <span class="tool-metric">📦 ${sizeInfo}</span>
2393
+ `;
2394
+ }
2395
+
2396
+ // Update response section
2397
+ const responseSection = toolDiv.querySelector('.tool-response-section');
2398
+ const separator = toolDiv.querySelector('.tool-separator');
2399
+
2400
+ if (responseSection) {
2401
+ let formattedResult;
2402
+ if (typeof result === 'object') {
2403
+ if (result.error) {
2404
+ formattedResult = `<span style="color: var(--danger-color);">${result.error}</span>`;
2405
+ } else {
2406
+ formattedResult = `<pre>${JSON.stringify(result, null, 2)}</pre>`;
2407
+ }
2408
+ } else {
2409
+ formattedResult = result;
2410
+ }
2411
+
2412
+ responseSection.innerHTML = `
2413
+ <div class="tool-section-header">📥 Response</div>
2414
+ ${formattedResult}
2415
+ `;
2416
+ responseSection.style.display = 'block';
2417
+
2418
+ if (separator) {
2419
+ separator.style.display = 'block';
2420
+ }
2421
+ }
2422
+
2423
+ // Remove from pending
2424
+ this.pendingToolCalls.delete(toolName);
2425
+ } else {
2426
+ // Fallback: create new block if not found
2427
+ this.createStandaloneToolResult(toolName, result, responseTime, responseSize);
2428
+ }
2429
+ } else {
2430
+ // No pending call found, create standalone result
2431
+ this.createStandaloneToolResult(toolName, result, responseTime, responseSize);
2432
+ }
2433
+
2434
+ this.scrollToBottom();
2435
+ this.moveSpinnerToBottom();
2436
+ }
2437
+
2438
+ createStandaloneToolResult(toolName, result, responseTime = 0, responseSize = null) {
2439
+ const targetContainer = this.currentAssistantGroup || this.chatMessages;
2440
+
2441
+ const toolDiv = document.createElement('div');
2442
+ toolDiv.className = 'tool-block tool-result-block';
2443
+
2444
+ // Use provided size or calculate it
2445
+ const resultSize = responseSize !== null ? responseSize : (
2446
+ typeof result === 'string'
2447
+ ? result.length
2448
+ : JSON.stringify(result).length
2449
+ );
2450
+
2451
+ // Format size info
2452
+ let sizeInfo = '';
2453
+ if (resultSize < 1024) {
2454
+ sizeInfo = `${resultSize} bytes`;
2455
+ } else if (resultSize < 1024 * 1024) {
2456
+ sizeInfo = `${(resultSize / 1024).toFixed(1)} KB`;
2457
+ } else {
2458
+ sizeInfo = `${(resultSize / (1024 * 1024)).toFixed(1)} MB`;
2459
+ }
2460
+
2461
+ // Format response time
2462
+ const timeInfo = responseTime > 0 ? `${(responseTime / 1000).toFixed(2)}s` : '';
2463
+
2464
+ const toolHeader = document.createElement('div');
2465
+ toolHeader.className = 'tool-header';
2466
+ toolHeader.innerHTML = `
2467
+ <span class="tool-toggle">▶</span>
2468
+ <span class="tool-label">📊 Tool result: ${toolName}</span>
2469
+ <span class="tool-info">
2470
+ <span class="tool-metric">⏱️ ${timeInfo}</span>
2471
+ <span class="tool-metric">📦 ${sizeInfo}</span>
2472
+ </span>
2473
+ `;
2474
+
2475
+ const toolContent = document.createElement('div');
2476
+ toolContent.className = 'tool-content collapsed';
2477
+
2478
+ let formattedResult;
2479
+ if (typeof result === 'object') {
2480
+ if (result.error) {
2481
+ formattedResult = `<span style="color: var(--danger-color);">${result.error}</span>`;
2482
+ } else {
2483
+ formattedResult = `<pre>${JSON.stringify(result, null, 2)}</pre>`;
2484
+ }
2485
+ } else {
2486
+ formattedResult = result;
2487
+ }
2488
+
2489
+ toolContent.innerHTML = formattedResult;
2490
+
2491
+ toolHeader.addEventListener('click', () => {
2492
+ const isCollapsed = toolContent.classList.contains('collapsed');
2493
+ toolContent.classList.toggle('collapsed');
2494
+ toolHeader.querySelector('.tool-toggle').textContent = isCollapsed ? '▼' : '▶';
2495
+ });
2496
+
2497
+ toolDiv.appendChild(toolHeader);
2498
+ toolDiv.appendChild(toolContent);
2499
+ targetContainer.appendChild(toolDiv);
2500
+
2501
+ // Only append to chat if we're not in a group
2502
+ if (!this.currentAssistantGroup) {
2503
+ this.chatMessages.appendChild(targetContainer);
2504
+ }
2505
+ }
2506
+
2507
+ scrollToBottom() {
2508
+ this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
2509
+ }
2510
+
2511
+ // Helper method to clean content before sending to API
2512
+ cleanContentForAPI(content) {
2513
+ // Remove thinking tags and their content
2514
+ return content.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim();
2515
+ }
2516
+
2517
+ // Helper method to parse tool results from MCP
2518
+ parseToolResult(result) {
2519
+ // If result has a content property with type and text, extract all text content
2520
+ if (result && result.content && Array.isArray(result.content)) {
2521
+ const textContents = [];
2522
+
2523
+ for (const item of result.content) {
2524
+ if (item.type === 'text' && item.text) {
2525
+ // Try to parse the text as JSON if it looks like JSON
2526
+ try {
2527
+ const parsed = JSON.parse(item.text);
2528
+ textContents.push(parsed);
2529
+ } catch {
2530
+ textContents.push(item.text);
2531
+ }
2532
+ } else if (item.type === 'image' && item.data) {
2533
+ // Handle image content
2534
+ textContents.push({
2535
+ type: 'image',
2536
+ data: item.data,
2537
+ mimeType: item.mimeType || 'image/png'
2538
+ });
2539
+ } else if (item.type === 'resource' && item.resource) {
2540
+ // Handle resource references
2541
+ textContents.push({
2542
+ type: 'resource',
2543
+ uri: item.resource.uri,
2544
+ mimeType: item.resource.mimeType,
2545
+ text: item.resource.text
2546
+ });
2547
+ }
2548
+ }
2549
+
2550
+ // If we only have one text content, return it directly
2551
+ if (textContents.length === 1) {
2552
+ return textContents[0];
2553
+ } else if (textContents.length > 1) {
2554
+ // If multiple contents, return them as an array
2555
+ return textContents;
2556
+ }
2557
+ }
2558
+ return result;
2559
+ }
2560
+
2561
+ showLoadingSpinner() {
2562
+ // Remove any existing spinner first
2563
+ this.hideLoadingSpinner();
2564
+
2565
+ // Create spinner element
2566
+ const spinnerDiv = document.createElement('div');
2567
+ spinnerDiv.id = 'llm-loading-spinner';
2568
+ spinnerDiv.className = 'message assistant loading-spinner';
2569
+ spinnerDiv.innerHTML = `
2570
+ <div class="spinner-container">
2571
+ <div class="spinner"></div>
2572
+ <span class="spinner-text">Thinking...</span>
2573
+ </div>
2574
+ `;
2575
+ this.chatMessages.appendChild(spinnerDiv);
2576
+ this.scrollToBottom();
2577
+ }
2578
+
2579
+ hideLoadingSpinner() {
2580
+ const spinner = document.getElementById('llm-loading-spinner');
2581
+ if (spinner) {
2582
+ spinner.remove();
2583
+ }
2584
+ }
2585
+
2586
+ // Helper to ensure spinner stays at bottom
2587
+ moveSpinnerToBottom() {
2588
+ const spinner = document.getElementById('llm-loading-spinner');
2589
+ if (spinner && spinner.parentNode) {
2590
+ // Remove and re-append to ensure it's at the bottom
2591
+ spinner.parentNode.removeChild(spinner);
2592
+ this.chatMessages.appendChild(spinner);
2593
+ this.scrollToBottom();
2594
+ }
2595
+ }
2596
+
2597
+ showReconnectButton(mcpServerId) {
2598
+ this.reconnectMcpBtn.style.display = 'block';
2599
+ this.reconnectMcpBtn.dataset.mcpServerId = mcpServerId;
2600
+ }
2601
+
2602
+ async reconnectCurrentMcp() {
2603
+ const mcpServerId = this.reconnectMcpBtn.dataset.mcpServerId;
2604
+ if (!mcpServerId) return;
2605
+
2606
+ const server = this.mcpServers.get(mcpServerId);
2607
+ if (!server) {
2608
+ this.showError('MCP server configuration not found');
2609
+ return;
2610
+ }
2611
+
2612
+ this.reconnectMcpBtn.disabled = true;
2613
+ this.reconnectMcpBtn.textContent = 'Reconnecting...';
2614
+
2615
+ try {
2616
+ const mcpConnection = new MCPClient();
2617
+ mcpConnection.onLog = (logEntry) => this.addLogEntry(`MCP-${server.name}`, logEntry);
2618
+ await mcpConnection.connect(server.url);
2619
+
2620
+ // Store the connection
2621
+ this.mcpConnections.set(mcpServerId, mcpConnection);
2622
+
2623
+ // Update server status
2624
+ server.connected = true;
2625
+ this.saveSettings();
2626
+ this.updateMcpServersList();
2627
+
2628
+ // Reload current chat to update UI
2629
+ if (this.currentChatId) {
2630
+ this.loadChat(this.currentChatId);
2631
+ }
2632
+
2633
+ this.addLogEntry('SYSTEM', {
2634
+ timestamp: new Date().toISOString(),
2635
+ direction: 'info',
2636
+ message: `MCP server "${server.name}" reconnected successfully`
2637
+ });
2638
+
2639
+ } catch (error) {
2640
+ this.showError(`Failed to reconnect to MCP server: ${error.message}`);
2641
+ this.reconnectMcpBtn.disabled = false;
2642
+ this.reconnectMcpBtn.textContent = 'Reconnect MCP Server';
2643
+ }
2644
+ }
2645
+
2646
+ // Also add auto-reconnect on send if disconnected
2647
+ async ensureMcpConnection(mcpServerId) {
2648
+ if (this.mcpConnections.has(mcpServerId)) {
2649
+ const connection = this.mcpConnections.get(mcpServerId);
2650
+ if (connection.isReady()) {
2651
+ return connection;
2652
+ }
2653
+ }
2654
+
2655
+ // Try to reconnect
2656
+ const server = this.mcpServers.get(mcpServerId);
2657
+ if (!server) {
2658
+ throw new Error('MCP server configuration not found');
2659
+ }
2660
+
2661
+ const mcpConnection = new MCPClient();
2662
+ mcpConnection.onLog = (logEntry) => this.addLogEntry(`MCP-${server.name}`, logEntry);
2663
+ await mcpConnection.connect(server.url);
2664
+
2665
+ this.mcpConnections.set(mcpServerId, mcpConnection);
2666
+ return mcpConnection;
2667
+ }
2668
+
2669
+ // Token usage tracking methods
2670
+ updateTokenUsage(chatId, usage, model) {
2671
+ if (!this.tokenUsageHistory.has(chatId)) {
2672
+ this.tokenUsageHistory.set(chatId, {
2673
+ requests: [],
2674
+ model: model
2675
+ });
2676
+ }
2677
+
2678
+ const history = this.tokenUsageHistory.get(chatId);
2679
+
2680
+ // Add this request to history
2681
+ history.requests.push({
2682
+ timestamp: new Date().toISOString(),
2683
+ promptTokens: usage.promptTokens,
2684
+ completionTokens: usage.completionTokens,
2685
+ totalTokens: usage.totalTokens
2686
+ });
2687
+
2688
+ // The prompt tokens of the latest request include the entire conversation
2689
+ // So we use the latest prompt tokens as the true total
2690
+ const latestTotalTokens = usage.promptTokens;
2691
+
2692
+
2693
+ // Update context window indicator with the actual conversation size
2694
+ this.updateContextWindowIndicator(latestTotalTokens, model);
2695
+
2696
+ // Save context usage in the chat
2697
+ const chat = this.chats.get(chatId);
2698
+ if (chat) {
2699
+ chat.contextUsage = latestTotalTokens;
2700
+ chat.lastModel = model;
2701
+ this.saveSettings();
2702
+ }
2703
+
2704
+ // Update any pending conversation total displays
2705
+ const pendingTotals = document.querySelectorAll('[id^="conv-total-"]');
2706
+ pendingTotals.forEach(el => {
2707
+ if (el.textContent === 'Calculating...' || el.textContent.match(/^\d/)) {
2708
+ el.textContent = latestTotalTokens.toLocaleString();
2709
+ }
2710
+ });
2711
+ }
2712
+
2713
+ updateContextWindowIndicator(totalTokens, model) {
2714
+ const indicator = document.getElementById('contextWindowIndicator');
2715
+ const stats = document.getElementById('contextWindowStats');
2716
+ const fill = document.getElementById('contextWindowFill');
2717
+ const percentage = document.getElementById('contextWindowPercentage');
2718
+
2719
+ if (!indicator) return;
2720
+
2721
+ // Extract model name from format "provider:model-name" if needed
2722
+ let modelName = model;
2723
+ if (model && model.includes(':')) {
2724
+ modelName = model.split(':')[1];
2725
+ }
2726
+
2727
+ // Get model info
2728
+ const limit = this.modelLimits[modelName] || 4096;
2729
+ const percentUsed = Math.min((totalTokens / limit) * 100, 100);
2730
+
2731
+ // Show indicator
2732
+ indicator.style.display = 'flex';
2733
+
2734
+ // Update stats - show as "X tokens / Y tokens" or "Xk tokens / Yk tokens"
2735
+ if (totalTokens >= 1000 || limit >= 1000) {
2736
+ const totalDisplay = totalTokens >= 1000 ? `${(totalTokens / 1000).toFixed(1)}k` : totalTokens.toString();
2737
+ const limitDisplay = limit >= 1000 ? `${(limit / 1000).toFixed(0)}k` : limit.toString();
2738
+ stats.textContent = `${totalDisplay} tokens / ${limitDisplay} tokens`;
2739
+ } else {
2740
+ stats.textContent = `${totalTokens} tokens / ${limit} tokens`;
2741
+ }
2742
+
2743
+ // Update bar
2744
+ fill.style.width = percentUsed + '%';
2745
+
2746
+ // Only show percentage text in non-compact view
2747
+ if (percentage) {
2748
+ percentage.textContent = Math.round(percentUsed) + '%';
2749
+ }
2750
+
2751
+ // Update color based on usage
2752
+ fill.classList.remove('warning', 'danger');
2753
+ if (percentUsed >= 90) {
2754
+ fill.classList.add('danger');
2755
+ } else if (percentUsed >= 75) {
2756
+ fill.classList.add('warning');
2757
+ }
2758
+ }
2759
+
2760
+ getTokenUsageForChat(chatId) {
2761
+ const history = this.tokenUsageHistory.get(chatId);
2762
+ if (!history || history.requests.length === 0) {
2763
+ return { totalTokens: 0 };
2764
+ }
2765
+
2766
+ // Get the latest request's prompt tokens as the total
2767
+ const latestRequest = history.requests[history.requests.length - 1];
2768
+ return { totalTokens: latestRequest.promptTokens };
2769
+ }
2770
+
2771
+ // Temperature control methods
2772
+ updateTemperatureDisplay(temperature) {
2773
+ this.temperatureValue.textContent = temperature.toFixed(1);
2774
+
2775
+ // Set title attribute as hint for compact view
2776
+ let hint = '';
2777
+ if (temperature === 0) {
2778
+ hint = 'Deterministic';
2779
+ } else if (temperature <= 0.3) {
2780
+ hint = 'Very focused';
2781
+ } else if (temperature <= 0.5) {
2782
+ hint = 'Focused';
2783
+ } else if (temperature <= 0.7) {
2784
+ hint = 'Balanced';
2785
+ } else if (temperature <= 1.0) {
2786
+ hint = 'Creative';
2787
+ } else if (temperature <= 1.5) {
2788
+ hint = 'Very creative';
2789
+ } else {
2790
+ hint = 'Experimental';
2791
+ }
2792
+
2793
+ if (this.temperatureControl) {
2794
+ this.temperatureControl.title = `Temperature: ${hint}`;
2795
+ }
2796
+ }
2797
+
2798
+ saveTemperatureForChat(temperature) {
2799
+ if (!this.currentChatId) return;
2800
+
2801
+ const chat = this.chats.get(this.currentChatId);
2802
+ if (chat) {
2803
+ chat.temperature = temperature;
2804
+ chat.updatedAt = new Date().toISOString();
2805
+ this.saveSettings();
2806
+ }
2807
+ }
2808
+
2809
+ getCurrentTemperature() {
2810
+ if (!this.currentChatId) return 0.7;
2811
+
2812
+ const chat = this.chats.get(this.currentChatId);
2813
+ return chat ? (chat.temperature || 0.7) : 0.7;
2814
+ }
2815
+}
2816
+
2817
+// Initialize the application
2818
+document.addEventListener('DOMContentLoaded', () => {
2819
+ window.app = new NetdataMCPChat();
2820
+});