@cryptotaxi247 / netdata-1 / commits / 5c7c9cb29

Mcp3 (#20435)

* mcp-web-client: Added basic bring-your-key LLM web client - Show model name instead of LLM proxy name in chat list - Display token usage as "Xk/Yk" format instead of confusing percentages - Make context window progress bar larger with better readability - Center LLM responses at 80% width without background for natural flow - Combine tool call and result into single expandable area - Use subtle backgrounds for tool usage blocks - Fix context window indicator to show correct model limits * Unify limit parameter handling in MCP execute function tool - Remove duplicate `last` field and use only `limit` for all function types - Extract limit parameter once instead of twice - Allow non-history functions to accept limit parameter without errors - Simplify code by using single field for the same purpose This makes the API more consistent and flexible while reducing code complexity.

Costa Tsaousis committed Jun 6, 2025 at 17:31 UTC 5c7c9cb29668779e4c81ff313595dee75e065f52
11 files changed +6132 -25
src/web/mcp/mcp-tools-execute-function-internal.h
-1
@@ -124,7 +124,6 @@ typedef struct {
124 time_t before; // End time for the query (0 = not specified)
125 const char *cursor; // Pagination cursor (MCP standard) (referenced from json-c, not owned)
126 usec_t anchor; // Internal anchor timestamp converted from cursor (0 = not specified)
127 - size_t last; // Number of last rows (0 = not specified)
127 const char *direction; // Query direction: "forward" or "backward" (referenced from json-c, not owned)
128 const char *query; // Full-text search query (referenced from json-c, not owned)
129 } request;
src/web/mcp/mcp-tools-execute-function.c
+11 -24
@@ -876,8 +876,8 @@ void mcp_tool_execute_function_schema(BUFFER *buffer) {
876 buffer_json_object_close(buffer); // sort_order
877
878 mcp_schema_add_size_param(
879 - buffer, "limit", "Row limit",
880 - "Maximum number of rows to return",
879 + buffer, "limit", "Limit",
880 + "Number of entries to return",
881 0, 0, SIZE_MAX, false);
882
883 // Time-based parameters for functions with history
@@ -896,11 +896,6 @@ void mcp_tool_execute_function_schema(BUFFER *buffer) {
896 "Opaque cursor for pagination (follows MCP standard)",
897 NULL, false);
898
899 - mcp_schema_add_size_param(
900 - buffer, "limit", "Entries to return",
901 - "Number of entries to return",
902 - 0, 0, SIZE_MAX, false);
903 -
899 buffer_json_member_add_object(buffer, "direction");
900 {
901 buffer_json_member_add_string(buffer, "type", "string");
@@ -1527,8 +1522,8 @@ static void build_function_name_with_params(BUFFER *dest, const char *function_n
1522 buffer_sprintf(dest, " %s:%llu", string2str(entry->pagination.key), (unsigned long long)data->request.anchor);
1523 }
1524
1530 - if (entry->has_last && data->request.last > 0) {
1531 - buffer_sprintf(dest, " last:%zu", data->request.last);
1525 + if (entry->has_last && data->request.limit > 0) {
1526 + buffer_sprintf(dest, " last:%zu", data->request.limit);
1527 }
1528
1529 if (entry->has_direction && data->request.direction && *data->request.direction) {
@@ -1735,8 +1730,8 @@ static BUFFER *build_post_payload_with_selections(struct json_object *selections
1730 buffer_json_member_add_uint64(payload, string2str(entry->pagination.key), data->request.anchor);
1731 }
1732
1738 - if (entry->has_last && data->request.last > 0) {
1739 - buffer_json_member_add_uint64(payload, "last", data->request.last);
1733 + if (entry->has_last && data->request.limit > 0) {
1734 + buffer_json_member_add_uint64(payload, "last", data->request.limit);
1735 }
1736
1737 if (entry->has_direction && data->request.direction) {
@@ -2074,7 +2069,8 @@ static MCP_RETURN_CODE mcp_parse_function_request(MCP_FUNCTION_DATA *data, MCP_C
2069 return MCP_RC_BAD_REQUEST;
2070 }
2071
2077 - data->request.last = (size_t)mcp_params_extract_size(params, "limit", 0, 0, SIZE_MAX, mcpc->error);
2072 + // Extract limit parameter (used for both history and non-history functions)
2073 + data->request.limit = (size_t)mcp_params_extract_size(params, "limit", 0, 0, SIZE_MAX, mcpc->error);
2074 if (buffer_strlen(mcpc->error) > 0) {
2075 return MCP_RC_BAD_REQUEST;
2076 }
@@ -2145,15 +2141,7 @@ static MCP_RETURN_CODE mcp_parse_function_request(MCP_FUNCTION_DATA *data, MCP_C
2141 }
2142 }
2143
2148 - // limit
2149 - data->request.limit = 0; // 0 means no limit
2150 - if (json_object_object_get_ex(params, "limit", &obj) &&
2151 - json_object_is_type(obj, json_type_int)) {
2152 - int limit = json_object_get_int(obj);
2153 - if (limit > 0) {
2154 - data->request.limit = (size_t)limit;
2155 - }
2156 - }
2144 + // limit is already extracted above for all function types
2145
2146 // conditions array - parse it early
2147 if (json_object_object_get_ex(params, "conditions", &obj) &&
@@ -2389,8 +2377,7 @@ static bool check_requirements_and_violations(MCP_FUNCTION_DATA *data,
2377 // Check 1: Functions with has_history=false should not receive time parameters
2378 if (!registry_entry->has_history) {
2379 if (data->request.after > 0 || data->request.before > 0 ||
2392 - data->request.anchor > 0 || data->request.direction ||
2393 - data->request.last > 0) {
2380 + data->request.anchor > 0 || data->request.direction) {
2381 invalid_timeframe_on_non_history = true;
2382 }
2383 }
@@ -2555,7 +2542,7 @@ static bool check_requirements_and_violations(MCP_FUNCTION_DATA *data,
2542 if (invalid_timeframe_on_non_history) {
2543 buffer_strcat(message, "❌ TIMEFRAME PARAMETERS NOT SUPPORTED\n");
2544 buffer_strcat(message, " Problem: This function does not support time-based parameters\n");
2558 - buffer_strcat(message, " Invalid parameters: after, before, cursor, direction, limit\n");
2545 + buffer_strcat(message, " Invalid parameters: after, before, cursor, direction\n");
2546 buffer_strcat(message, " Solution: Remove all timeframe parameters from your request\n\n");
2547 }
2548
src/web/mcp/mcp-web-client/.gitignore new
+1
@@ -0,0 +1 @@
1 +node_modules
src/web/mcp/mcp-web-client/README.md new
+141
@@ -0,0 +1,141 @@
1 +# Netdata MCP LLM Client
2 +
3 +A web-based client for interacting with Netdata's Model Context Protocol (MCP) server using various LLM providers.
4 +
5 +## Setup Guide
6 +
7 +### Prerequisites
8 +
9 +- Node.js (v14 or higher)
10 +- A running Netdata instance with MCP server enabled
11 +- API keys for at least one LLM provider (OpenAI, Anthropic, or Google)
12 +
13 +### 1. Setting up the LLM Proxy Server
14 +
15 +The proxy server manages API keys securely and handles CORS for browser-based access to LLM APIs.
16 +
17 +#### First Run
18 +
19 +1. Start the proxy server:
20 + ```bash
21 + node llm-proxy.js
22 + ```
23 +
24 +2. On first run, it will create `~/.config/llm-proxy-config.json` and exit with instructions.
25 +
26 +3. Edit `~/.config/llm-proxy-config.json` to add your API keys:
27 + ```json
28 + {
29 + "port": 8081,
30 + "allowedOrigins": "*",
31 + "providers": {
32 + "openai": {
33 + "apiKey": "sk-YOUR-OPENAI-KEY",
34 + "models": ["gpt-4-turbo-preview", "gpt-4", "gpt-3.5-turbo"]
35 + },
36 + "anthropic": {
37 + "apiKey": "sk-ant-YOUR-ANTHROPIC-KEY",
38 + "models": ["claude-3-opus-20240229", "claude-3-sonnet-20240229"]
39 + },
40 + "google": {
41 + "apiKey": "YOUR-GOOGLE-AI-KEY",
42 + "models": ["gemini-pro", "gemini-pro-vision"]
43 + }
44 + }
45 + }
46 + ```
47 +
48 +4. Start the proxy server again:
49 + ```bash
50 + node llm-proxy.js
51 + ```
52 +
53 + You should see output like:
54 + ```
55 + LLM CORS Proxy Server running on http://localhost:8081
56 +
57 + Configured providers:
58 + - openai: 3 models
59 + - anthropic: 2 models
60 + ```
61 +
62 +### 2. Accessing the Web Client
63 +
64 +1. Open `index.html` in your web browser:
65 + - You can open it directly as a file (`file:///path/to/index.html`)
66 + - Or serve it via a web server if preferred
67 +
68 +2. Click the settings icon (⚙️) in the bottom left
69 +
70 +### 3. Configure MCP Server
71 +
72 +1. In Settings, go to the "MCP Servers" tab
73 +2. Click "+ Add MCP Server"
74 +3. Enter your Netdata MCP WebSocket URL:
75 + ```
76 + ws://localhost:19999/ws/mcp?api_key=YOUR_API_KEY
77 + ```
78 +4. Give it a name (e.g., "Local Netdata")
79 +5. Click "Add Server"
80 +
81 +### 4. Configure LLM Proxy
82 +
83 +1. In Settings, go to the "LLM Providers" tab
84 +2. Click "+ Add LLM Provider"
85 +3. Enter the proxy URL (default: `http://localhost:8081`)
86 +4. Give it a name (e.g., "Local LLM Proxy")
87 +5. The client will test the connection and show available providers
88 +6. Click "Add Provider"
89 +
90 +### 5. Create a Chat
91 +
92 +1. Click "+ New" in the chat sidebar
93 +2. Select your MCP server
94 +3. Select your LLM proxy
95 +4. Choose a model from the dropdown (organized by provider)
96 +5. Click "Create Chat"
97 +
98 +## Features
99 +
100 +- **Secure API Key Management**: API keys are stored only in the proxy server, never in the browser
101 +- **Multiple LLM Support**: Use OpenAI, Anthropic, or Google AI models
102 +- **Model Selection**: Choose specific models for each chat
103 +- **MCP Integration**: Full access to Netdata metrics and functions
104 +- **Chat History**: All conversations are saved locally
105 +- **Temperature Control**: Adjust response creativity per chat
106 +- **Context Window Tracking**: Monitor token usage in real-time
107 +
108 +## Proxy Endpoints
109 +
110 +The proxy server provides:
111 +- `GET /models` - List available providers and models
112 +- `POST /proxy/<provider>/<api-path>` - Proxy requests to LLM providers
113 +
114 +## Security Notes
115 +
116 +- API keys are only stored in `~/.config/llm-proxy-config.json` on the server
117 +- The web client never sees or stores API keys
118 +- Configure `allowedOrigins` in production for better security
119 +- Keep `~/.config/llm-proxy-config.json` secure and never commit it to version control
120 +
121 +## Troubleshooting
122 +
123 +### Proxy won't start
124 +- Check if port 8081 is already in use
125 +- Verify `~/.config/llm-proxy-config.json` is valid JSON
126 +- Ensure at least one API key is configured
127 +
128 +### Can't connect to proxy
129 +- Verify the proxy is running (`node llm-proxy.js`)
130 +- Check the proxy URL in settings (default: `http://localhost:8081`)
131 +- Check browser console for CORS errors
132 +
133 +### No models available
134 +- Ensure API keys are correctly configured in `~/.config/llm-proxy-config.json`
135 +- Restart the proxy after configuration changes
136 +- Test the connection in the LLM provider settings
137 +
138 +### MCP connection fails
139 +- Verify Netdata is running and MCP is enabled
140 +- Check the WebSocket URL format
141 +- Ensure the API key has appropriate permissions
\ No newline at end of file
src/web/mcp/mcp-web-client/app.js new
+2820
@@ -0,0 +1,2820 @@
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 +});
src/web/mcp/mcp-web-client/debug-messages.js new
+29
@@ -0,0 +1,29 @@
1 +/**
2 + * Debug helper to analyze message structure issues
3 + */
4 +
5 +function debugMessages(messages, title = "Messages") {
6 + console.group(`🔍 ${title}`);
7 + messages.forEach((msg, index) => {
8 + console.log(`Message ${index}:`, {
9 + role: msg.role,
10 + contentType: typeof msg.content,
11 + contentIsArray: Array.isArray(msg.content),
12 + content: msg.content
13 + });
14 +
15 + if (Array.isArray(msg.content)) {
16 + msg.content.forEach((block, blockIndex) => {
17 + console.log(` Block ${blockIndex}:`, {
18 + type: block.type,
19 + hasText: !!block.text,
20 + textType: typeof block.text,
21 + textIsArray: Array.isArray(block.text)
22 + });
23 + });
24 + }
25 + });
26 + console.groupEnd();
27 +}
28 +
29 +window.debugMessages = debugMessages;
src/web/mcp/mcp-web-client/index.html new
+266
@@ -0,0 +1,266 @@
1 +<!DOCTYPE html>
2 +<html lang="en" data-theme="light">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Netdata MCP LLM Client</title>
7 + <link rel="stylesheet" href="styles.css">
8 +</head>
9 +<body>
10 + <!-- Error Toast Container -->
11 + <div class="error-toast-container" id="errorToastContainer"></div>
12 +
13 + <!-- Main Application Layout -->
14 + <div class="app-container">
15 + <div class="app-body">
16 + <!-- Chat Sessions Sidebar -->
17 + <aside class="chat-sidebar" id="chatSidebar">
18 + <div class="sidebar-header">
19 + <h2>Chats</h2>
20 + <button id="newChatBtn" class="btn btn-primary btn-small">+ New</button>
21 + </div>
22 + <div class="chat-sessions" id="chatSessions"></div>
23 + <div class="sidebar-footer">
24 + <button id="themeToggle" class="btn-icon" title="Toggle Theme">
25 + <span class="theme-icon-light">🌙</span>
26 + <span class="theme-icon-dark">☀️</span>
27 + </button>
28 + <button id="settingsBtn" class="btn-icon" title="Settings">
29 + ⚙️
30 + </button>
31 + </div>
32 + </aside>
33 + <div class="resize-handle resize-handle-vertical" id="chatSidebarResize"></div>
34 +
35 + <!-- Main Chat Area -->
36 + <main class="chat-main" id="chatMain">
37 + <div class="chat-header" id="chatHeader">
38 + <div class="chat-info">
39 + <div>
40 + <h3 class="chat-title" id="chatTitle">Select or create a chat</h3>
41 + <div class="chat-meta">
42 + <span class="chat-mcp" id="chatMcp"></span>
43 + <span class="chat-llm" id="chatLlm"></span>
44 + </div>
45 + </div>
46 + <div class="chat-controls">
47 + <!-- Temperature Control -->
48 + <div class="temperature-control compact" id="temperatureControl" style="display: flex;">
49 + <span class="temperature-label">Temperature</span>
50 + <div class="temperature-controls">
51 + <input type="range" class="temperature-slider" id="temperatureSlider"
52 + min="0" max="2" step="0.1" value="0.7">
53 + <span class="temperature-value" id="temperatureValue">0.7</span>
54 + </div>
55 + </div>
56 + <!-- Context Window Indicator -->
57 + <div class="context-window-indicator compact" id="contextWindowIndicator" style="display: flex;">
58 + <span class="context-label">Context Window</span>
59 + <div class="context-window-bar">
60 + <div class="context-window-fill" id="contextWindowFill" style="width: 0%"></div>
61 + <span class="context-window-stats" id="contextWindowStats">0 / 4k</span>
62 + </div>
63 + </div>
64 + </div>
65 + </div>
66 + </div>
67 + <div class="chat-content" id="chatContent">
68 + <div class="chat-messages" id="chatMessages"></div>
69 + <div class="resize-handle resize-handle-horizontal" id="chatInputResize"></div>
70 + <div class="chat-input-container" id="chatInputContainer">
71 + <button id="reconnectMcpBtn" class="btn btn-primary" style="display: none;">Reconnect MCP Server</button>
72 + <div class="chat-input-wrapper">
73 + <textarea
74 + id="chatInput"
75 + class="chat-input"
76 + placeholder="Select or create a chat to start messaging..."
77 + rows="3"
78 + disabled
79 + ></textarea>
80 + <button id="sendMessageBtn" class="btn btn-send" disabled>Send</button>
81 + </div>
82 + </div>
83 + </div>
84 + </main>
85 + <div class="resize-handle resize-handle-vertical" id="logPanelResize"></div>
86 +
87 + <!-- Communication Log -->
88 + <aside class="log-panel" id="logPanel">
89 + <div class="log-header">
90 + <h3>Communication Log</h3>
91 + <button id="toggleLogBtn" class="btn-icon" title="Toggle Log">
92 + ◀
93 + </button>
94 + </div>
95 + <div class="log-controls">
96 + <button id="clearLogBtn" class="btn btn-small">Clear</button>
97 + <button id="downloadLogBtn" class="btn btn-small">Download</button>
98 + </div>
99 + <div class="log-content" id="logContent"></div>
100 + </aside>
101 + </div>
102 + </div>
103 +
104 + <!-- Configuration Modal -->
105 + <div class="modal" id="settingsModal">
106 + <div class="modal-backdrop" id="settingsBackdrop"></div>
107 + <div class="modal-content">
108 + <div class="modal-header">
109 + <h2>Settings</h2>
110 + <button class="btn-icon modal-close" id="closeSettingsBtn">✕</button>
111 + </div>
112 + <div class="modal-body">
113 + <div class="settings-tabs">
114 + <button class="tab-btn active" data-tab="mcp-servers">MCP Servers</button>
115 + <button class="tab-btn" data-tab="llm-providers">LLM Providers</button>
116 + </div>
117 +
118 + <!-- MCP Servers Tab -->
119 + <div class="tab-content active" id="mcp-servers-tab">
120 + <div class="config-section">
121 + <h3>MCP Servers</h3>
122 + <div class="config-list" id="mcpServersList"></div>
123 + <button id="addMcpServerBtn" class="btn btn-primary">+ Add MCP Server</button>
124 + </div>
125 + </div>
126 +
127 + <!-- LLM Providers Tab -->
128 + <div class="tab-content" id="llm-providers-tab">
129 + <div class="config-section">
130 + <h3>LLM Providers</h3>
131 + <div class="config-list" id="llmProvidersList"></div>
132 + <button id="addLlmProviderBtn" class="btn btn-primary">+ Add LLM Provider</button>
133 + </div>
134 + </div>
135 + </div>
136 + </div>
137 + </div>
138 +
139 + <!-- New Chat Modal -->
140 + <div class="modal" id="newChatModal">
141 + <div class="modal-backdrop" id="newChatBackdrop"></div>
142 + <div class="modal-content modal-small">
143 + <div class="modal-header">
144 + <h2>New Chat</h2>
145 + <button class="btn-icon modal-close" id="closeNewChatBtn">✕</button>
146 + </div>
147 + <div class="modal-body">
148 + <div class="form-group">
149 + <label for="newChatMcpServer">MCP Server*</label>
150 + <select id="newChatMcpServer" required>
151 + <option value="">Select MCP Server</option>
152 + </select>
153 + </div>
154 + <div class="form-group">
155 + <label for="newChatLlmProvider">LLM Provider*</label>
156 + <select id="newChatLlmProvider" required>
157 + <option value="">Select LLM Provider</option>
158 + </select>
159 + </div>
160 + <div class="form-group" id="newChatModelGroup" style="display: none;">
161 + <label for="newChatModel">Model*</label>
162 + <select id="newChatModel" required>
163 + <option value="">Select Model</option>
164 + </select>
165 + <small>Choose a model for this chat. You can change it later.</small>
166 + </div>
167 + <div class="form-group">
168 + <label for="newChatTitle">Chat Title (optional)</label>
169 + <input type="text" id="newChatTitle" placeholder="Auto-generated if empty">
170 + </div>
171 + </div>
172 + <div class="modal-footer">
173 + <button id="cancelNewChatBtn" class="btn btn-secondary">Cancel</button>
174 + <button id="createChatBtn" class="btn btn-primary">Create Chat</button>
175 + </div>
176 + </div>
177 + </div>
178 +
179 + <!-- Add MCP Server Modal -->
180 + <div class="modal" id="addMcpModal">
181 + <div class="modal-backdrop" id="addMcpBackdrop"></div>
182 + <div class="modal-content modal-small">
183 + <div class="modal-header">
184 + <h2>Add MCP Server</h2>
185 + <button class="btn-icon modal-close" id="closeAddMcpBtn">✕</button>
186 + </div>
187 + <div class="modal-body">
188 + <div class="form-group">
189 + <label for="mcpServerUrl">WebSocket URL*</label>
190 + <input type="text" id="mcpServerUrl" placeholder="ws://localhost:19999/ws/mcp?api_key=YOUR_KEY" required>
191 + <small>Include API key in URL if needed</small>
192 + </div>
193 + <div class="form-group">
194 + <label for="mcpServerName">Server Name*</label>
195 + <input type="text" id="mcpServerName" placeholder="e.g., Local Netdata, Production Server" required>
196 + </div>
197 + </div>
198 + <div class="modal-footer">
199 + <button id="cancelAddMcpBtn" class="btn btn-secondary">Cancel</button>
200 + <button id="saveMcpServerBtn" class="btn btn-primary">Add Server</button>
201 + </div>
202 + </div>
203 + </div>
204 +
205 + <!-- Add LLM Provider Modal -->
206 + <div class="modal" id="addLlmModal">
207 + <div class="modal-backdrop" id="addLlmBackdrop"></div>
208 + <div class="modal-content modal-small">
209 + <div class="modal-header">
210 + <h2>Add LLM Provider</h2>
211 + <button class="btn-icon modal-close" id="closeAddLlmBtn">✕</button>
212 + </div>
213 + <div class="modal-body">
214 + <div class="form-group">
215 + <label for="llmProxyUrl">Proxy URL*</label>
216 + <input type="text" id="llmProxyUrl" placeholder="http://localhost:8081" value="http://localhost:8081" required>
217 + <small>The LLM proxy server manages API keys and provides access to models</small>
218 + </div>
219 + <div class="form-group">
220 + <label for="llmProviderName">Provider Name*</label>
221 + <input type="text" id="llmProviderName" placeholder="e.g., Local LLM Proxy" required>
222 + </div>
223 + <div class="form-group" id="llmProvidersStatus" style="display: none;">
224 + <label>Available Providers</label>
225 + <div id="llmProvidersInfo" style="padding: 10px; background: var(--bg-secondary); border-radius: 4px;"></div>
226 + </div>
227 + </div>
228 + <div class="modal-footer">
229 + <button id="cancelAddLlmBtn" class="btn btn-secondary">Cancel</button>
230 + <button id="saveLlmProviderBtn" class="btn btn-primary">Add Provider</button>
231 + </div>
232 + </div>
233 + </div>
234 +
235 + <!-- System Prompt Modal -->
236 + <div class="modal" id="systemPromptModal">
237 + <div class="modal-backdrop" id="systemPromptBackdrop"></div>
238 + <div class="modal-content">
239 + <div class="modal-header">
240 + <h2>Edit System Prompt</h2>
241 + <button class="btn-icon modal-close" id="closeSystemPromptBtn">✕</button>
242 + </div>
243 + <div class="modal-body">
244 + <div class="form-group">
245 + <label for="systemPromptTextarea">System Prompt</label>
246 + <textarea id="systemPromptTextarea" rows="10" style="width: 100%; font-family: inherit;" placeholder="Enter the system prompt that will be sent at the beginning of each conversation..."></textarea>
247 + <small>This prompt is sent as the first message to the LLM to set its behavior and context. Changes will restart the conversation.</small>
248 + </div>
249 + <div class="form-group">
250 + <button id="resetToDefaultPromptBtn" class="btn btn-secondary btn-small">Reset to Default</button>
251 + </div>
252 + </div>
253 + <div class="modal-footer">
254 + <button id="cancelSystemPromptBtn" class="btn btn-secondary">Cancel</button>
255 + <button id="saveSystemPromptBtn" class="btn btn-primary">Save & Restart Chat</button>
256 + </div>
257 + </div>
258 + </div>
259 +
260 + <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
261 + <script src="debug-messages.js"></script>
262 + <script src="mcp-client.js"></script>
263 + <script src="llm-providers.js"></script>
264 + <script src="app.js"></script>
265 +</body>
266 +</html>
src/web/mcp/mcp-web-client/llm-providers.js new
+538
@@ -0,0 +1,538 @@
1 +/**
2 + * LLM Provider integrations for OpenAI, Anthropic, and Google
3 + */
4 +
5 +class LLMProvider {
6 + constructor(proxyUrl = 'http://localhost:8081') {
7 + this.onLog = null; // Logging callback
8 + this.proxyUrl = proxyUrl;
9 + }
10 +
11 + async sendMessage(messages, tools = [], temperature = 0.7) {
12 + throw new Error('sendMessage must be implemented by subclass');
13 + }
14 +
15 + log(direction, message, metadata = {}) {
16 + const logEntry = {
17 + timestamp: new Date().toISOString(),
18 + direction: direction,
19 + message: message,
20 + metadata: metadata
21 + };
22 +
23 + // Console log for debugging
24 + console.log(`[LLM ${direction.toUpperCase()}]`, logEntry);
25 +
26 + // UI log
27 + if (this.onLog) {
28 + this.onLog(logEntry);
29 + }
30 + }
31 +
32 + setProxyUrl(proxyUrl) {
33 + this.proxyUrl = proxyUrl;
34 + }
35 +}
36 +
37 +/**
38 + * OpenAI GPT Provider
39 + */
40 +class OpenAIProvider extends LLMProvider {
41 + constructor(proxyUrl, model = 'gpt-4-turbo-preview') {
42 + super(proxyUrl);
43 + this.model = model;
44 + this.type = 'openai';
45 + }
46 +
47 + get apiUrl() {
48 + return `${this.proxyUrl}/proxy/openai/v1/chat/completions`;
49 + }
50 +
51 + async sendMessage(messages, tools = [], temperature = 0.7) {
52 + const openaiTools = tools.map(tool => ({
53 + type: 'function',
54 + function: {
55 + name: tool.name,
56 + description: tool.description,
57 + parameters: tool.inputSchema || {}
58 + }
59 + }));
60 +
61 + const requestBody = {
62 + model: this.model,
63 + messages: messages,
64 + tools: openaiTools.length > 0 ? openaiTools : undefined,
65 + tool_choice: openaiTools.length > 0 ? 'auto' : undefined,
66 + temperature: temperature,
67 + max_tokens: 4096
68 + };
69 +
70 + this.log('sent', JSON.stringify(requestBody, null, 2), {
71 + provider: 'openai',
72 + model: this.model,
73 + url: this.apiUrl
74 + });
75 +
76 + let response;
77 + try {
78 + response = await fetch(this.apiUrl, {
79 + method: 'POST',
80 + headers: {
81 + 'Content-Type': 'application/json'
82 + },
83 + body: JSON.stringify(requestBody)
84 + });
85 + } catch (error) {
86 + this.log('error', `Failed to send request: ${error.message}`, {
87 + provider: 'openai',
88 + error: error.toString(),
89 + url: this.apiUrl
90 + });
91 + if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
92 + throw new Error('Connection Error: Cannot reach OpenAI API. Please ensure the proxy server is running on port 8081.');
93 + }
94 + throw error;
95 + }
96 +
97 + if (!response.ok) {
98 + const error = await response.json();
99 + this.log('error', `API error response: ${JSON.stringify(error)}`, {
100 + provider: 'openai',
101 + status: response.status,
102 + statusText: response.statusText
103 + });
104 + throw new Error(`OpenAI API error: ${error.error?.message || response.statusText}`);
105 + }
106 +
107 + const data = await response.json();
108 + this.log('received', JSON.stringify(data, null, 2), { provider: 'openai' });
109 +
110 + const choice = data.choices[0];
111 +
112 + return {
113 + content: choice.message.content,
114 + toolCalls: choice.message.tool_calls?.map(tc => ({
115 + id: tc.id,
116 + name: tc.function.name,
117 + arguments: JSON.parse(tc.function.arguments)
118 + })) || [],
119 + usage: data.usage ? {
120 + promptTokens: data.usage.prompt_tokens,
121 + completionTokens: data.usage.completion_tokens,
122 + totalTokens: data.usage.total_tokens
123 + } : null
124 + };
125 + }
126 +
127 + formatToolResponse(toolCallId, result) {
128 + // Handle different types of results
129 + let content;
130 + if (typeof result === 'string') {
131 + content = result;
132 + } else if (Array.isArray(result)) {
133 + // For arrays, stringify each element if needed and join
134 + content = result.map(item =>
135 + typeof item === 'string' ? item : JSON.stringify(item)
136 + ).join('\n\n');
137 + } else {
138 + content = JSON.stringify(result);
139 + }
140 +
141 + return {
142 + role: 'tool',
143 + tool_call_id: toolCallId,
144 + content: content
145 + };
146 + }
147 +}
148 +
149 +/**
150 + * Anthropic Claude Provider
151 + */
152 +class AnthropicProvider extends LLMProvider {
153 + constructor(proxyUrl, model = 'claude-3-opus-20240229') {
154 + super(proxyUrl);
155 + this.model = model;
156 + this.type = 'anthropic';
157 + }
158 +
159 + get apiUrl() {
160 + return `${this.proxyUrl}/proxy/anthropic/v1/messages`;
161 + }
162 +
163 + async sendMessage(messages, tools = [], temperature = 0.7) {
164 + // Convert messages to Anthropic format
165 + const anthropicMessages = this.convertMessages(messages);
166 +
167 + // Convert tools to Anthropic format
168 + const anthropicTools = tools.map(tool => ({
169 + name: tool.name,
170 + description: tool.description,
171 + input_schema: tool.inputSchema || {}
172 + }));
173 +
174 + const requestBody = {
175 + model: this.model,
176 + messages: anthropicMessages,
177 + tools: anthropicTools.length > 0 ? anthropicTools : undefined,
178 + max_tokens: 4096,
179 + temperature: temperature
180 + };
181 +
182 + this.log('sent', JSON.stringify(requestBody, null, 2), {
183 + provider: 'anthropic',
184 + model: this.model,
185 + url: this.apiUrl
186 + });
187 +
188 + let response;
189 + try {
190 + response = await fetch(this.apiUrl, {
191 + method: 'POST',
192 + headers: {
193 + 'Content-Type': 'application/json',
194 + 'anthropic-version': '2023-06-01'
195 + },
196 + body: JSON.stringify(requestBody)
197 + });
198 + } catch (error) {
199 + this.log('error', `Failed to send request: ${error.message}`, {
200 + provider: 'anthropic',
201 + error: error.toString(),
202 + url: this.apiUrl
203 + });
204 + if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
205 + throw new Error('Connection Error: Cannot reach Anthropic API. Please ensure the proxy server is running on port 8081.');
206 + }
207 + throw error;
208 + }
209 +
210 + if (!response.ok) {
211 + const error = await response.json();
212 + this.log('error', `API error response: ${JSON.stringify(error)}`, {
213 + provider: 'anthropic',
214 + status: response.status,
215 + statusText: response.statusText
216 + });
217 + throw new Error(`Anthropic API error: ${error.error?.message || response.statusText}`);
218 + }
219 +
220 + const data = await response.json();
221 + this.log('received', JSON.stringify(data, null, 2), { provider: 'anthropic' });
222 +
223 + // Extract content and tool calls
224 + let content = '';
225 + const toolCalls = [];
226 +
227 + for (const block of data.content) {
228 + if (block.type === 'text') {
229 + content += block.text;
230 + } else if (block.type === 'tool_use') {
231 + toolCalls.push({
232 + id: block.id,
233 + name: block.name,
234 + arguments: block.input
235 + });
236 + }
237 + }
238 +
239 + return {
240 + content,
241 + toolCalls,
242 + usage: data.usage ? {
243 + promptTokens: data.usage.input_tokens,
244 + completionTokens: data.usage.output_tokens,
245 + totalTokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0)
246 + } : null
247 + };
248 + }
249 +
250 + convertMessages(messages) {
251 + // With our new structure, messages should already be properly formatted
252 + // We just need to handle system messages and ensure alternating pattern
253 + const converted = [];
254 + let lastRole = null;
255 +
256 + for (const msg of messages) {
257 + if (msg.role === 'system') {
258 + // System messages will be prepended to first user message
259 + continue;
260 + }
261 +
262 + // Messages should already be in the correct format from processMessageWithTools
263 + const role = msg.role;
264 +
265 + // Check if we need to merge consecutive messages with same role
266 + if (role === lastRole && converted.length > 0) {
267 + // This should rarely happen with our new structure, but handle it gracefully
268 + const last = converted[converted.length - 1];
269 +
270 + // Convert string content to array if needed
271 + if (typeof last.content === 'string') {
272 + last.content = [{ type: 'text', text: last.content }];
273 + }
274 +
275 + // Merge content
276 + if (typeof msg.content === 'string') {
277 + last.content.push({ type: 'text', text: msg.content });
278 + } else if (Array.isArray(msg.content)) {
279 + last.content.push(...msg.content);
280 + }
281 + } else {
282 + // Add message as-is
283 + converted.push({
284 + role: role,
285 + content: msg.content
286 + });
287 + lastRole = role;
288 + }
289 + }
290 +
291 + // Add system message to first user message if exists
292 + const systemMsg = messages.find(m => m.role === 'system');
293 + if (systemMsg && converted.length > 0 && converted[0].role === 'user') {
294 + const firstMsg = converted[0];
295 + if (typeof firstMsg.content === 'string') {
296 + firstMsg.content = systemMsg.content + '\n\n' + firstMsg.content;
297 + } else if (Array.isArray(firstMsg.content)) {
298 + firstMsg.content.unshift({ type: 'text', text: systemMsg.content });
299 + }
300 + }
301 +
302 + return converted;
303 + }
304 +
305 + formatToolResponse(toolCallId, result) {
306 + // For Anthropic, tool results must be in user messages with tool_result blocks
307 + // Handle different types of results
308 + let content;
309 + if (typeof result === 'string') {
310 + content = result;
311 + } else if (Array.isArray(result)) {
312 + // For arrays, stringify each element if needed and join
313 + content = result.map(item =>
314 + typeof item === 'string' ? item : JSON.stringify(item)
315 + ).join('\n\n');
316 + } else {
317 + content = JSON.stringify(result);
318 + }
319 +
320 + // Return in Anthropic's expected format
321 + return {
322 + role: 'user',
323 + content: [{
324 + type: 'tool_result',
325 + tool_use_id: toolCallId,
326 + content: content
327 + }]
328 + };
329 + }
330 +}
331 +
332 +/**
333 + * Google Gemini Provider
334 + */
335 +class GoogleProvider extends LLMProvider {
336 + constructor(proxyUrl, model = 'gemini-pro') {
337 + super(proxyUrl);
338 + this.model = model;
339 + this.type = 'google';
340 + }
341 +
342 + get apiUrl() {
343 + return `${this.proxyUrl}/proxy/google/v1beta/models/${this.model}/generateContent`;
344 + }
345 +
346 + async sendMessage(messages, tools = [], temperature = 0.7) {
347 + // Convert messages to Gemini format
348 + const contents = this.convertMessages(messages);
349 +
350 + // Convert tools to Gemini format
351 + const functionDeclarations = tools.map(tool => ({
352 + name: tool.name,
353 + description: tool.description,
354 + parameters: tool.inputSchema || {}
355 + }));
356 +
357 + const requestBody = {
358 + contents: contents,
359 + generationConfig: {
360 + temperature: temperature,
361 + maxOutputTokens: 4096
362 + }
363 + };
364 +
365 + if (functionDeclarations.length > 0) {
366 + requestBody.tools = [{
367 + function_declarations: functionDeclarations
368 + }];
369 + }
370 +
371 + this.log('sent', JSON.stringify(requestBody, null, 2), {
372 + provider: 'google',
373 + model: this.model,
374 + url: this.apiUrl
375 + });
376 +
377 + let response;
378 + try {
379 + response = await fetch(this.apiUrl, {
380 + method: 'POST',
381 + headers: {
382 + 'Content-Type': 'application/json'
383 + },
384 + body: JSON.stringify(requestBody)
385 + });
386 + } catch (error) {
387 + this.log('error', `Failed to send request: ${error.message}`, {
388 + provider: 'google',
389 + error: error.toString(),
390 + url: this.apiUrl
391 + });
392 + if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
393 + throw new Error('Connection Error: Cannot reach Google AI API. Please ensure the proxy server is running on port 8081.');
394 + }
395 + throw error;
396 + }
397 +
398 + if (!response.ok) {
399 + const error = await response.json();
400 + this.log('error', `API error response: ${JSON.stringify(error)}`, {
401 + provider: 'google',
402 + status: response.status,
403 + statusText: response.statusText
404 + });
405 + throw new Error(`Google API error: ${error.error?.message || response.statusText}`);
406 + }
407 +
408 + const data = await response.json();
409 + this.log('received', JSON.stringify(data, null, 2), { provider: 'google' });
410 + const candidate = data.candidates[0];
411 +
412 + // Extract content and function calls
413 + let content = '';
414 + const toolCalls = [];
415 +
416 + for (const part of candidate.content.parts) {
417 + if (part.text) {
418 + content += part.text;
419 + } else if (part.functionCall) {
420 + toolCalls.push({
421 + id: this.generateId(),
422 + name: part.functionCall.name,
423 + arguments: part.functionCall.args
424 + });
425 + }
426 + }
427 +
428 + // Google returns token counts in usageMetadata
429 + const usage = data.usageMetadata ? {
430 + promptTokens: data.usageMetadata.promptTokenCount,
431 + completionTokens: data.usageMetadata.candidatesTokenCount,
432 + totalTokens: data.usageMetadata.totalTokenCount
433 + } : null;
434 +
435 + return { content, toolCalls, usage };
436 + }
437 +
438 + convertMessages(messages) {
439 + const contents = [];
440 +
441 + for (const msg of messages) {
442 + if (msg.role === 'system') {
443 + // Prepend system message to first user message
444 + continue;
445 + }
446 +
447 + const parts = [];
448 +
449 + if (msg.role === 'tool') {
450 + // Function response from formatToolResponse
451 + parts.push({
452 + functionResponse: {
453 + name: msg.tool_name,
454 + response: {
455 + content: msg.content
456 + }
457 + }
458 + });
459 + } else if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
460 + // Assistant with text and tool calls
461 + if (msg.content) {
462 + parts.push({ text: msg.content });
463 + }
464 + for (const tc of msg.toolCalls) {
465 + parts.push({
466 + functionCall: {
467 + name: tc.name,
468 + args: tc.arguments
469 + }
470 + });
471 + }
472 + } else if (msg.content) {
473 + // Regular text message
474 + parts.push({ text: msg.content });
475 + }
476 +
477 + if (parts.length > 0) {
478 + contents.push({
479 + role: msg.role === 'assistant' ? 'model' : 'user',
480 + parts: parts
481 + });
482 + }
483 + }
484 +
485 + // Add system message to first content if exists
486 + const systemMsg = messages.find(m => m.role === 'system');
487 + if (systemMsg && contents.length > 0 && contents[0].parts[0].text) {
488 + contents[0].parts[0].text = systemMsg.content + '\n\n' + contents[0].parts[0].text;
489 + }
490 +
491 + return contents;
492 + }
493 +
494 + formatToolResponse(toolCallId, result, toolName) {
495 + // Handle different types of results
496 + let content;
497 + if (typeof result === 'string') {
498 + content = result;
499 + } else if (Array.isArray(result)) {
500 + // For arrays, stringify each element if needed and join
501 + content = result.map(item =>
502 + typeof item === 'string' ? item : JSON.stringify(item)
503 + ).join('\n\n');
504 + } else {
505 + content = JSON.stringify(result);
506 + }
507 +
508 + return {
509 + role: 'tool',
510 + tool_call_id: toolCallId,
511 + tool_name: toolName,
512 + content: content
513 + };
514 + }
515 +
516 + generateId() {
517 + return 'call_' + Math.random().toString(36).substr(2, 9);
518 + }
519 +}
520 +
521 +/**
522 + * Factory function to create appropriate LLM provider
523 + */
524 +function createLLMProvider(provider, proxyUrl, model) {
525 + switch (provider) {
526 + case 'openai':
527 + return new OpenAIProvider(proxyUrl, model);
528 + case 'anthropic':
529 + return new AnthropicProvider(proxyUrl, model);
530 + case 'google':
531 + return new GoogleProvider(proxyUrl, model);
532 + default:
533 + throw new Error(`Unknown provider: ${provider}`);
534 + }
535 +}
536 +
537 +// Export for use in other modules
538 +window.createLLMProvider = createLLMProvider;
src/web/mcp/mcp-web-client/llm-proxy.js new
+333
@@ -0,0 +1,333 @@
1 +#!/usr/bin/env node
2 +
3 +const http = require('http');
4 +const https = require('https');
5 +const url = require('url');
6 +const fs = require('fs');
7 +const path = require('path');
8 +const os = require('os');
9 +
10 +// Configuration file path in user's home directory
11 +const CONFIG_DIR = path.join(os.homedir(), '.config');
12 +const CONFIG_FILE = path.join(CONFIG_DIR, 'llm-proxy-config.json');
13 +
14 +// Default configuration template
15 +const DEFAULT_CONFIG = {
16 + port: 8081,
17 + allowedOrigins: '*',
18 + providers: {
19 + openai: {
20 + apiKey: '',
21 + models: [
22 + 'gpt-4o',
23 + 'gpt-4o-mini',
24 + 'gpt-4-turbo',
25 + 'gpt-4-turbo-preview',
26 + 'gpt-4',
27 + 'gpt-3.5-turbo',
28 + 'gpt-3.5-turbo-16k'
29 + ]
30 + },
31 + anthropic: {
32 + apiKey: '',
33 + models: [
34 + 'claude-opus-4-20250514',
35 + 'claude-sonnet-4-20250514',
36 + 'claude-3-7-sonnet-20250219',
37 + 'claude-3-5-haiku-20241022',
38 + 'claude-3-5-sonnet-20241022',
39 + 'claude-3-5-sonnet-20240620',
40 + 'claude-3-opus-20240229',
41 + 'claude-3-sonnet-20240229',
42 + 'claude-3-haiku-20240307'
43 + ]
44 + },
45 + google: {
46 + apiKey: '',
47 + models: [
48 + 'gemini-2.0-flash-exp',
49 + 'gemini-2.0-flash-thinking-exp',
50 + 'gemini-1.5-pro',
51 + 'gemini-1.5-flash',
52 + 'gemini-pro',
53 + 'gemini-pro-vision'
54 + ]
55 + }
56 + }
57 +};
58 +
59 +// LLM Provider configurations
60 +const LLM_PROVIDERS = {
61 + anthropic: {
62 + baseUrl: 'https://api.anthropic.com',
63 + authHeader: 'x-api-key'
64 + },
65 + openai: {
66 + baseUrl: 'https://api.openai.com',
67 + authHeader: 'Authorization',
68 + authPrefix: 'Bearer '
69 + },
70 + google: {
71 + baseUrl: 'https://generativelanguage.googleapis.com',
72 + authHeader: null // Google uses API key in URL
73 + }
74 +};
75 +
76 +// Load or create configuration
77 +function loadConfig() {
78 + // Ensure .config directory exists
79 + if (!fs.existsSync(CONFIG_DIR)) {
80 + fs.mkdirSync(CONFIG_DIR, { recursive: true });
81 + }
82 +
83 + if (!fs.existsSync(CONFIG_FILE)) {
84 + console.log(`Configuration file not found. Creating ${CONFIG_FILE}`);
85 + fs.writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2));
86 + console.log('\nPlease edit the configuration file and add your API keys.');
87 + console.log('Then restart the proxy server.');
88 + process.exit(0);
89 + }
90 +
91 + try {
92 + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
93 +
94 + // Check if any API keys are configured
95 + const hasApiKeys = Object.values(config.providers).some(provider => provider.apiKey && provider.apiKey.length > 0);
96 +
97 + if (!hasApiKeys) {
98 + console.error('\nError: No API keys configured!');
99 + console.error(`Please edit ${CONFIG_FILE} and add at least one API key.`);
100 + console.error('\nExample:');
101 + console.error(' "openai": {');
102 + console.error(' "apiKey": "sk-...",');
103 + console.error(' "models": ["gpt-4", "gpt-3.5-turbo"]');
104 + console.error(' }');
105 + process.exit(1);
106 + }
107 +
108 + return config;
109 + } catch (error) {
110 + console.error(`Error reading configuration file: ${error.message}`);
111 + process.exit(1);
112 + }
113 +}
114 +
115 +// Load configuration
116 +const config = loadConfig();
117 +const PROXY_PORT = config.port || 8081;
118 +const ALLOWED_ORIGINS = config.allowedOrigins || '*';
119 +
120 +// Create proxy server
121 +const server = http.createServer(async (req, res) => {
122 + // Handle CORS preflight
123 + if (req.method === 'OPTIONS') {
124 + res.writeHead(200, {
125 + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS,
126 + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
127 + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, x-goog-api-key, anthropic-version',
128 + 'Access-Control-Max-Age': '86400'
129 + });
130 + res.end();
131 + return;
132 + }
133 +
134 + // Parse request URL
135 + const parsedUrl = url.parse(req.url, true);
136 + const pathParts = parsedUrl.pathname.split('/').filter(p => p);
137 +
138 + // Handle /models endpoint
139 + if (pathParts.length === 1 && pathParts[0] === 'models') {
140 + const availableProviders = {};
141 +
142 + Object.entries(config.providers).forEach(([provider, providerConfig]) => {
143 + if (providerConfig.apiKey && providerConfig.apiKey.length > 0) {
144 + availableProviders[provider] = {
145 + models: providerConfig.models || []
146 + };
147 + }
148 + });
149 +
150 + res.writeHead(200, {
151 + 'Content-Type': 'application/json',
152 + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS
153 + });
154 + res.end(JSON.stringify({ providers: availableProviders }));
155 + return;
156 + }
157 +
158 + // Expected format: /proxy/<provider>/<rest-of-path>
159 + if (pathParts.length < 2 || pathParts[0] !== 'proxy') {
160 + res.writeHead(404, {
161 + 'Content-Type': 'application/json',
162 + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS
163 + });
164 + res.end(JSON.stringify({ error: 'Invalid proxy path. Expected /proxy/<provider>/<path> or /models' }));
165 + return;
166 + }
167 +
168 + const provider = pathParts[1];
169 + const apiPath = '/' + pathParts.slice(2).join('/');
170 +
171 + // Check if provider is configured
172 + const providerConfig = config.providers[provider.toLowerCase()];
173 + if (!providerConfig || !providerConfig.apiKey) {
174 + res.writeHead(400, {
175 + 'Content-Type': 'application/json',
176 + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS
177 + });
178 + res.end(JSON.stringify({ error: `Provider '${provider}' is not configured or has no API key` }));
179 + return;
180 + }
181 +
182 + // Get provider URL configuration
183 + const providerUrlConfig = LLM_PROVIDERS[provider.toLowerCase()];
184 + if (!providerUrlConfig) {
185 + res.writeHead(400, {
186 + 'Content-Type': 'application/json',
187 + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS
188 + });
189 + res.end(JSON.stringify({ error: 'Unknown provider: ' + provider }));
190 + return;
191 + }
192 +
193 + // Build target URL
194 + let targetUrl;
195 +
196 + // Special handling for Google - need to adjust the path format
197 + if (provider.toLowerCase() === 'google') {
198 + // Google expects the full path including model name
199 + const adjustedPath = apiPath.replace('/generateContent', ':generateContent');
200 + targetUrl = new URL(providerUrlConfig.baseUrl + adjustedPath);
201 + // Add API key to URL for Google
202 + targetUrl.searchParams.append('key', providerConfig.apiKey);
203 + } else {
204 + targetUrl = new URL(providerUrlConfig.baseUrl + apiPath);
205 + }
206 +
207 + // Copy query parameters from original request (except Google's key)
208 + Object.keys(parsedUrl.query).forEach(key => {
209 + if (!(provider.toLowerCase() === 'google' && key === 'key')) {
210 + targetUrl.searchParams.append(key, parsedUrl.query[key]);
211 + }
212 + });
213 +
214 + // Prepare headers
215 + const headers = {
216 + 'Content-Type': req.headers['content-type'] || 'application/json',
217 + 'Accept': req.headers['accept'] || 'application/json',
218 + 'User-Agent': 'MCP-LLM-Proxy/1.0'
219 + };
220 +
221 + // Add authentication headers from config
222 + if (providerUrlConfig.authHeader) {
223 + if (providerUrlConfig.authPrefix) {
224 + headers[providerUrlConfig.authHeader] = providerUrlConfig.authPrefix + providerConfig.apiKey;
225 + } else {
226 + headers[providerUrlConfig.authHeader] = providerConfig.apiKey;
227 + }
228 + }
229 +
230 + // Forward anthropic-version header if present
231 + if (req.headers['anthropic-version']) {
232 + headers['anthropic-version'] = req.headers['anthropic-version'];
233 + }
234 +
235 + // Forward other relevant headers
236 + ['content-length', 'accept-encoding'].forEach(header => {
237 + if (req.headers[header]) {
238 + headers[header] = req.headers[header];
239 + }
240 + });
241 +
242 + // Collect request body
243 + let body = '';
244 + req.on('data', chunk => {
245 + body += chunk.toString();
246 + });
247 +
248 + req.on('end', () => {
249 + // Prepare options for the outgoing request
250 + const options = {
251 + hostname: targetUrl.hostname,
252 + port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
253 + path: targetUrl.pathname + targetUrl.search,
254 + method: req.method,
255 + headers: headers
256 + };
257 +
258 + // Choose http or https module
259 + const protocol = targetUrl.protocol === 'https:' ? https : http;
260 +
261 + // Log the proxied request for debugging
262 + console.log(`[${new Date().toISOString()}] Proxying ${req.method} request:`);
263 + console.log(` From: ${req.url}`);
264 + console.log(` To: ${targetUrl.href} (without API key in logs)`);
265 + console.log(` Provider: ${provider}`);
266 +
267 + // Make the request to the LLM provider
268 + const proxyReq = protocol.request(options, (proxyRes) => {
269 + console.log(` Response: ${proxyRes.statusCode} ${proxyRes.statusMessage}`);
270 +
271 + // Set CORS headers
272 + const responseHeaders = {
273 + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS,
274 + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
275 + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, x-goog-api-key, anthropic-version'
276 + };
277 +
278 + // Forward relevant response headers
279 + ['content-type', 'content-length', 'content-encoding'].forEach(header => {
280 + if (proxyRes.headers[header]) {
281 + responseHeaders[header] = proxyRes.headers[header];
282 + }
283 + });
284 +
285 + res.writeHead(proxyRes.statusCode, responseHeaders);
286 +
287 + // Handle streaming response
288 + proxyRes.on('data', (chunk) => {
289 + res.write(chunk);
290 + });
291 +
292 + proxyRes.on('end', () => {
293 + res.end();
294 + });
295 + });
296 +
297 + proxyReq.on('error', (error) => {
298 + console.error('Proxy request error:', error);
299 + res.writeHead(502, {
300 + 'Content-Type': 'application/json',
301 + 'Access-Control-Allow-Origin': ALLOWED_ORIGINS
302 + });
303 + res.end(JSON.stringify({ error: 'Failed to connect to LLM provider: ' + error.message }));
304 + });
305 +
306 + // Write request body if present
307 + if (body) {
308 + proxyReq.write(body);
309 + }
310 +
311 + proxyReq.end();
312 + });
313 +});
314 +
315 +// Start the server
316 +server.listen(PROXY_PORT, () => {
317 + console.log(`LLM CORS Proxy Server running on http://localhost:${PROXY_PORT}`);
318 + console.log('\nEndpoints:');
319 + console.log(` GET http://localhost:${PROXY_PORT}/models - List available models`);
320 + console.log(` POST http://localhost:${PROXY_PORT}/proxy/<provider>/<api-path> - Proxy LLM requests`);
321 + console.log('\nConfigured providers:');
322 +
323 + Object.entries(config.providers).forEach(([provider, providerConfig]) => {
324 + if (providerConfig.apiKey && providerConfig.apiKey.length > 0) {
325 + console.log(` - ${provider}: ${providerConfig.models.length} models`);
326 + }
327 + });
328 +
329 + console.log('\nExamples:');
330 + console.log(` OpenAI: POST http://localhost:${PROXY_PORT}/proxy/openai/v1/chat/completions`);
331 + console.log(` Anthropic: POST http://localhost:${PROXY_PORT}/proxy/anthropic/v1/messages`);
332 + console.log(` Google: POST http://localhost:${PROXY_PORT}/proxy/google/v1beta/models/gemini-pro/generateContent`);
333 +});
\ No newline at end of file
src/web/mcp/mcp-web-client/mcp-client.js new
+356
@@ -0,0 +1,356 @@
1 +/**
2 + * MCP Client for WebSocket communication with Netdata MCP server
3 + */
4 +class MCPClient {
5 + constructor() {
6 + this.ws = null;
7 + this.url = null;
8 + this.apiKey = null;
9 + this.requestId = 1;
10 + this.pendingRequests = new Map();
11 + this.connectionPromise = null;
12 + this.capabilities = null;
13 + this.serverInfo = null;
14 + this.tools = new Map();
15 + this.resources = new Map();
16 + this.prompts = new Map();
17 + this.isInitialized = false;
18 +
19 + // Event handlers
20 + this.onConnectionChange = null;
21 + this.onMessage = null;
22 + this.onError = null;
23 + this.onNotification = null;
24 + this.onLog = null; // New handler for logging
25 + }
26 +
27 + /**
28 + * Log a communication event
29 + */
30 + log(direction, message, metadata = {}) {
31 + if (this.onLog) {
32 + this.onLog({
33 + timestamp: new Date().toISOString(),
34 + direction: direction, // 'sent', 'received', 'error', 'info'
35 + message: message,
36 + metadata: metadata
37 + });
38 + }
39 + }
40 +
41 + /**
42 + * Connect to the MCP WebSocket server
43 + */
44 + async connect(url) {
45 + if (this.ws && this.ws.readyState === WebSocket.OPEN) {
46 + throw new Error('Already connected');
47 + }
48 +
49 + this.url = url;
50 +
51 + return new Promise((resolve, reject) => {
52 + try {
53 + // URL already contains API key if needed
54 + this.ws = new WebSocket(url, ['mcp']);
55 +
56 + this.ws.onopen = async () => {
57 + console.log('WebSocket connected');
58 + this.log('info', 'WebSocket connection established', { url: url });
59 + if (this.onConnectionChange) {
60 + this.onConnectionChange('connected');
61 + }
62 +
63 + try {
64 + // Initialize MCP session
65 + await this.initialize();
66 + resolve();
67 + } catch (error) {
68 + reject(error);
69 + }
70 + };
71 +
72 + this.ws.onmessage = (event) => {
73 + this.handleMessage(event.data);
74 + };
75 +
76 + this.ws.onerror = (error) => {
77 + console.error('WebSocket error:', error);
78 + this.log('error', `WebSocket error: ${error.message || 'Unknown error'}`, { error: error });
79 + if (this.onError) {
80 + this.onError(error);
81 + }
82 + reject(error);
83 + };
84 +
85 + this.ws.onclose = (event) => {
86 + console.log('WebSocket disconnected');
87 + this.log('info', 'WebSocket connection closed', {
88 + code: event.code,
89 + reason: event.reason || 'No reason provided',
90 + wasClean: event.wasClean
91 + });
92 + this.isInitialized = false;
93 + if (this.onConnectionChange) {
94 + this.onConnectionChange('disconnected');
95 + }
96 + this.cleanup();
97 + };
98 +
99 + } catch (error) {
100 + this.log('error', `Failed to create WebSocket connection: ${error.message}`, { url: url, error: error });
101 + reject(error);
102 + }
103 + });
104 + }
105 +
106 + /**
107 + * Initialize MCP session with the server
108 + */
109 + async initialize() {
110 + // Send initialize request
111 + const initResponse = await this.sendRequest('initialize', {
112 + protocolVersion: '2024-11-05',
113 + capabilities: {
114 + tools: {},
115 + logging: {}
116 + },
117 + clientInfo: {
118 + name: 'Netdata MCP Web Client',
119 + version: '1.0.0'
120 + }
121 + });
122 +
123 + this.serverInfo = initResponse.serverInfo;
124 + this.capabilities = initResponse.capabilities;
125 +
126 + // Notify server that we're initialized
127 + await this.sendNotification('notifications/initialized', {});
128 +
129 + // List available tools
130 + if (this.capabilities?.tools) {
131 + await this.listTools();
132 + }
133 +
134 + // List available resources
135 + if (this.capabilities?.resources) {
136 + await this.listResources();
137 + }
138 +
139 + // List available prompts
140 + if (this.capabilities?.prompts) {
141 + await this.listPrompts();
142 + }
143 +
144 + this.isInitialized = true;
145 + }
146 +
147 + /**
148 + * List available tools from the server
149 + */
150 + async listTools() {
151 + const response = await this.sendRequest('tools/list', {});
152 + if (response.tools) {
153 + this.tools.clear();
154 + response.tools.forEach(tool => {
155 + this.tools.set(tool.name, tool);
156 + });
157 + }
158 + return response.tools;
159 + }
160 +
161 + /**
162 + * List available resources from the server
163 + */
164 + async listResources() {
165 + const response = await this.sendRequest('resources/list', {});
166 + if (response.resources) {
167 + this.resources.clear();
168 + response.resources.forEach(resource => {
169 + this.resources.set(resource.uri, resource);
170 + });
171 + }
172 + return response.resources;
173 + }
174 +
175 + /**
176 + * List available prompts from the server
177 + */
178 + async listPrompts() {
179 + const response = await this.sendRequest('prompts/list', {});
180 + if (response.prompts) {
181 + this.prompts.clear();
182 + response.prompts.forEach(prompt => {
183 + this.prompts.set(prompt.name, prompt);
184 + });
185 + }
186 + return response.prompts;
187 + }
188 +
189 + /**
190 + * Call a tool on the MCP server
191 + */
192 + async callTool(toolName, args = {}) {
193 + if (!this.tools.has(toolName)) {
194 + throw new Error(`Tool '${toolName}' not found`);
195 + }
196 +
197 + return await this.sendRequest('tools/call', {
198 + name: toolName,
199 + arguments: args
200 + });
201 + }
202 +
203 + /**
204 + * Read a resource from the MCP server
205 + */
206 + async readResource(uri) {
207 + if (!this.resources.has(uri)) {
208 + throw new Error(`Resource '${uri}' not found`);
209 + }
210 +
211 + return await this.sendRequest('resources/read', {
212 + uri: uri
213 + });
214 + }
215 +
216 + /**
217 + * Get a prompt from the MCP server
218 + */
219 + async getPrompt(promptName, args = {}) {
220 + if (!this.prompts.has(promptName)) {
221 + throw new Error(`Prompt '${promptName}' not found`);
222 + }
223 +
224 + return await this.sendRequest('prompts/get', {
225 + name: promptName,
226 + arguments: args
227 + });
228 + }
229 +
230 + /**
231 + * Send a JSON-RPC request to the server
232 + */
233 + async sendRequest(method, params = {}) {
234 + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
235 + throw new Error('WebSocket is not connected');
236 + }
237 +
238 + const id = this.requestId++;
239 + const request = {
240 + jsonrpc: '2.0',
241 + method: method,
242 + params: params,
243 + id: id
244 + };
245 +
246 + return new Promise((resolve, reject) => {
247 + this.pendingRequests.set(id, { resolve, reject });
248 + const requestStr = JSON.stringify(request);
249 + this.log('sent', requestStr, { method, params });
250 + this.ws.send(requestStr);
251 +
252 + // Set timeout for request
253 + setTimeout(() => {
254 + if (this.pendingRequests.has(id)) {
255 + this.pendingRequests.delete(id);
256 + this.log('error', `Request ${id} timed out`, { method, id });
257 + reject(new Error(`Request ${id} timed out`));
258 + }
259 + }, 30000); // 30 second timeout
260 + });
261 + }
262 +
263 + /**
264 + * Send a JSON-RPC notification to the server
265 + */
266 + async sendNotification(method, params = {}) {
267 + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
268 + throw new Error('WebSocket is not connected');
269 + }
270 +
271 + const notification = {
272 + jsonrpc: '2.0',
273 + method: method,
274 + params: params
275 + };
276 +
277 + const notificationStr = JSON.stringify(notification);
278 + this.log('sent', notificationStr, { method, params, type: 'notification' });
279 + this.ws.send(notificationStr);
280 + }
281 +
282 + /**
283 + * Handle incoming messages from the server
284 + */
285 + handleMessage(data) {
286 + try {
287 + this.log('received', data);
288 + const message = JSON.parse(data);
289 +
290 + // Handle response to a request
291 + if (message.id !== undefined) {
292 + const pending = this.pendingRequests.get(message.id);
293 + if (pending) {
294 + this.pendingRequests.delete(message.id);
295 + if (message.error) {
296 + this.log('error', `Request ${message.id} failed: ${message.error.message}`, { error: message.error });
297 + pending.reject(new Error(message.error.message || 'Unknown error'));
298 + } else {
299 + pending.resolve(message.result);
300 + }
301 + }
302 + }
303 + // Handle notifications from server
304 + else if (message.method) {
305 + if (this.onNotification) {
306 + this.onNotification(message.method, message.params);
307 + }
308 + }
309 +
310 + // Pass message to general handler
311 + if (this.onMessage) {
312 + this.onMessage(message);
313 + }
314 +
315 + } catch (error) {
316 + console.error('Error parsing message:', error);
317 + this.log('error', `Failed to parse message: ${error.message}`, { rawData: data });
318 + if (this.onError) {
319 + this.onError(error);
320 + }
321 + }
322 + }
323 +
324 + /**
325 + * Disconnect from the MCP server
326 + */
327 + disconnect() {
328 + if (this.ws) {
329 + this.log('info', 'Closing WebSocket connection', { url: this.url, state: 'disconnecting' });
330 + this.ws.close();
331 + }
332 + }
333 +
334 + /**
335 + * Clean up resources
336 + */
337 + cleanup() {
338 + this.pendingRequests.clear();
339 + this.tools.clear();
340 + this.resources.clear();
341 + this.prompts.clear();
342 + this.ws = null;
343 + }
344 +
345 + /**
346 + * Check if connected and initialized
347 + */
348 + isReady() {
349 + return this.ws &&
350 + this.ws.readyState === WebSocket.OPEN &&
351 + this.isInitialized;
352 + }
353 +}
354 +
355 +// Export for use in other modules
356 +window.MCPClient = MCPClient;
src/web/mcp/mcp-web-client/styles.css new
+1637
@@ -0,0 +1,1637 @@
1 +/* CSS Variables for theming */
2 +:root[data-theme="light"] {
3 + --primary-color: #00ab44;
4 + --secondary-color: #35414a;
5 + --background-color: #f7f8f9;
6 + --surface-color: #ffffff;
7 + --text-primary: #35414a;
8 + --text-secondary: #6c757d;
9 + --border-color: #dee2e6;
10 + --success-color: #28a745;
11 + --danger-color: #dc3545;
12 + --warning-color: #ffc107;
13 + --info-color: #17a2b8;
14 + --hover-color: #e9ecef;
15 + --modal-backdrop: rgba(0, 0, 0, 0.5);
16 + --shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
17 + --chat-user-bg: var(--primary-color);
18 + --chat-user-text: white;
19 + --chat-assistant-bg: #f1f3f5;
20 + --chat-assistant-text: var(--text-primary);
21 +}
22 +
23 +:root[data-theme="dark"] {
24 + --primary-color: #00d152;
25 + --secondary-color: #e8e8e8;
26 + --background-color: #1a1a1a;
27 + --surface-color: #2d2d2d;
28 + --text-primary: #e8e8e8;
29 + --text-secondary: #a0a0a0;
30 + --border-color: #404040;
31 + --success-color: #4caf50;
32 + --danger-color: #f44336;
33 + --warning-color: #ff9800;
34 + --info-color: #2196f3;
35 + --hover-color: #3a3a3a;
36 + --modal-backdrop: rgba(0, 0, 0, 0.8);
37 + --shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
38 + --chat-user-bg: var(--primary-color);
39 + --chat-user-text: #1a1a1a;
40 + --chat-assistant-bg: #3a3a3a;
41 + --chat-assistant-text: var(--text-primary);
42 +}
43 +
44 +* {
45 + margin: 0;
46 + padding: 0;
47 + box-sizing: border-box;
48 +}
49 +
50 +body {
51 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
52 + background-color: var(--background-color);
53 + color: var(--text-primary);
54 + line-height: 1.6;
55 + height: 100vh;
56 + overflow: hidden;
57 +}
58 +
59 +/* Error Toast */
60 +.error-toast-container {
61 + position: fixed;
62 + top: 20px;
63 + left: 50%;
64 + transform: translateX(-50%);
65 + z-index: 10000;
66 + display: flex;
67 + flex-direction: column;
68 + gap: 10px;
69 +}
70 +
71 +.error-toast {
72 + background-color: var(--danger-color);
73 + color: white;
74 + padding: 4px 8px;
75 + border-radius: 4px;
76 + box-shadow: var(--shadow);
77 + animation: slideDown 0.3s ease-out, fadeOut 0.3s ease-out 2.7s forwards;
78 +}
79 +
80 +@keyframes slideDown {
81 + from {
82 + transform: translateY(-100%);
83 + opacity: 0;
84 + }
85 + to {
86 + transform: translateY(0);
87 + opacity: 1;
88 + }
89 +}
90 +
91 +@keyframes fadeOut {
92 + to {
93 + opacity: 0;
94 + transform: translateY(-20px);
95 + }
96 +}
97 +
98 +/* App Layout */
99 +.app-container {
100 + display: flex;
101 + flex-direction: column;
102 + height: 100vh;
103 +}
104 +
105 +.app-body {
106 + flex: 1;
107 + display: flex;
108 + overflow: hidden;
109 + height: 100vh;
110 +}
111 +
112 +/* Chat Sidebar */
113 +.chat-sidebar {
114 + width: 280px;
115 + min-width: 200px;
116 + max-width: 400px;
117 + background: var(--surface-color);
118 + border-right: 1px solid var(--border-color);
119 + display: flex;
120 + flex-direction: column;
121 + position: relative;
122 +}
123 +
124 +.sidebar-footer {
125 + padding: 4px;
126 + border-top: 1px solid var(--border-color);
127 + display: flex;
128 + gap: 8px;
129 + justify-content: center;
130 +}
131 +
132 +.sidebar-header {
133 + padding: 5px;
134 + border-bottom: 1px solid var(--border-color);
135 + display: flex;
136 + justify-content: space-between;
137 + align-items: center;
138 +}
139 +
140 +.sidebar-header h2 {
141 + font-size: 16px;
142 + margin: 0;
143 +}
144 +
145 +.chat-sessions {
146 + flex: 1;
147 + overflow-y: auto;
148 + padding: 3px;
149 +}
150 +
151 +.chat-session-item {
152 + display: flex;
153 + align-items: center;
154 + gap: 8px;
155 + margin-bottom: 4px;
156 + background: var(--background-color);
157 + border-radius: 6px;
158 + transition: all 0.2s;
159 +}
160 +
161 +.session-content {
162 + flex: 1;
163 + padding: 4px;
164 + cursor: pointer;
165 +}
166 +
167 +.btn-delete-chat {
168 + background: transparent;
169 + border: none;
170 + font-size: 14px;
171 + cursor: pointer;
172 + padding: 8px;
173 + opacity: 0;
174 + transition: opacity 0.2s;
175 +}
176 +
177 +.chat-session-item:hover .btn-delete-chat {
178 + opacity: 0.7;
179 +}
180 +
181 +.btn-delete-chat:hover {
182 + opacity: 1 !important;
183 +}
184 +
185 +/* Make emoji darker in dark theme for better visibility */
186 +:root[data-theme="dark"] .btn-delete-chat {
187 + filter: invert(1) grayscale(1);
188 +}
189 +
190 +.chat-session-item:hover {
191 + background: var(--hover-color);
192 +}
193 +
194 +.chat-session-item.active {
195 + background: var(--primary-color);
196 + color: white;
197 +}
198 +
199 +:root[data-theme="dark"] .chat-session-item.active {
200 + color: #1a1a1a;
201 +}
202 +
203 +.chat-session-item.active .session-meta {
204 + color: rgba(255, 255, 255, 0.8);
205 +}
206 +
207 +:root[data-theme="dark"] .chat-session-item.active .session-meta {
208 + color: rgba(0, 0, 0, 0.7);
209 +}
210 +
211 +.session-title {
212 + font-weight: 500;
213 + margin-bottom: 4px;
214 + overflow: hidden;
215 + text-overflow: ellipsis;
216 + white-space: nowrap;
217 +}
218 +
219 +.session-meta {
220 + font-size: 12px;
221 + color: var(--text-secondary);
222 + display: flex;
223 + justify-content: space-between;
224 +}
225 +
226 +/* Main Chat Area */
227 +.chat-main {
228 + flex: 1;
229 + display: flex;
230 + flex-direction: column;
231 + background: var(--background-color);
232 + min-width: 300px;
233 + position: relative;
234 +}
235 +
236 +.chat-content {
237 + flex: 1;
238 + display: flex;
239 + flex-direction: column;
240 + overflow: hidden;
241 +}
242 +
243 +.chat-header {
244 + padding: 4px 7px;
245 + background: var(--surface-color);
246 + border-bottom: 1px solid var(--border-color);
247 +}
248 +
249 +.chat-info {
250 + display: flex;
251 + justify-content: space-between;
252 + align-items: center;
253 + gap: 20px;
254 + width: 100%;
255 +}
256 +
257 +.chat-controls {
258 + display: flex;
259 + align-items: center;
260 + gap: 20px;
261 + flex-wrap: nowrap;
262 + flex: 1;
263 + max-width: 50%;
264 + justify-content: flex-end;
265 +}
266 +
267 +.chat-title {
268 + margin: 0;
269 + font-size: 16px;
270 +}
271 +
272 +.chat-meta {
273 + font-size: 13px;
274 + color: var(--text-secondary);
275 + margin-top: 4px;
276 +}
277 +
278 +.chat-meta span:not(:last-child)::after {
279 + content: " • ";
280 + margin: 0 4px;
281 +}
282 +
283 +.chat-messages {
284 + flex: 1;
285 + overflow-y: auto;
286 + padding: 5px;
287 + display: flex;
288 + flex-direction: column;
289 + gap: 3px;
290 + min-height: 200px;
291 +}
292 +
293 +.message {
294 + padding: 12px 16px;
295 + border-radius: 4px;
296 + max-width: 80%;
297 + word-wrap: break-word;
298 +}
299 +
300 +.message.user {
301 + background-color: var(--chat-user-bg);
302 + color: var(--chat-user-text);
303 + align-self: flex-end;
304 + position: relative;
305 +}
306 +
307 +/* Edit balloon */
308 +.edit-balloon {
309 + position: absolute;
310 + top: -8px;
311 + right: -8px;
312 + background: var(--primary-color);
313 + color: white;
314 + padding: 1px 4px;
315 + border-radius: 12px;
316 + font-size: 11px;
317 + font-weight: 500;
318 + cursor: pointer;
319 + box-shadow: var(--shadow);
320 + white-space: nowrap;
321 + z-index: 100;
322 + transition: all 0.2s;
323 +}
324 +
325 +:root[data-theme="dark"] .edit-balloon {
326 + color: #1a1a1a;
327 +}
328 +
329 +.edit-balloon:hover {
330 + transform: scale(1.1);
331 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
332 +}
333 +
334 +.message-content.editing {
335 + background: var(--background-color);
336 + padding: 8px;
337 + border-radius: 4px;
338 + outline: 2px solid var(--primary-color);
339 + outline-offset: -2px;
340 + min-height: 1.5em;
341 +}
342 +
343 +.edit-actions-floating {
344 + position: absolute;
345 + right: 0;
346 + display: flex;
347 + gap: 4px;
348 + z-index: 1001;
349 +}
350 +
351 +.message.assistant {
352 + background-color: var(--chat-assistant-bg);
353 + color: var(--chat-assistant-text);
354 + align-self: flex-start;
355 +}
356 +
357 +.message.system {
358 + background-color: var(--info-color);
359 + color: white;
360 + align-self: center;
361 + max-width: 90%;
362 + text-align: center;
363 +}
364 +
365 +.message.error {
366 + background-color: var(--danger-color);
367 + color: white;
368 + align-self: center;
369 + max-width: 90%;
370 +}
371 +
372 +/* System Prompt Display */
373 +.system-prompt-display {
374 + background-color: var(--hover-color);
375 + border: 1px solid var(--border-color);
376 + border-radius: 8px;
377 + padding: 16px 20px;
378 + margin-bottom: 16px;
379 + max-width: 100%;
380 +}
381 +
382 +.system-prompt-header {
383 + display: flex;
384 + justify-content: space-between;
385 + align-items: center;
386 + margin-bottom: 8px;
387 +}
388 +
389 +.system-prompt-label {
390 + font-weight: 600;
391 + font-size: 14px;
392 + color: var(--text-secondary);
393 +}
394 +
395 +.system-prompt-content {
396 + font-size: 13px;
397 + color: var(--text-secondary);
398 + white-space: pre-wrap;
399 + line-height: 1.5;
400 +}
401 +
402 +/* System prompt editing */
403 +.system-prompt-content.editing {
404 + background: var(--background-color);
405 + padding: 8px;
406 + border-radius: 4px;
407 + outline: 2px solid var(--primary-color);
408 + outline-offset: -2px;
409 + min-height: 3em;
410 +}
411 +
412 +/* Assistant group styling */
413 +.assistant-group {
414 + background-color: transparent;
415 + color: var(--text-primary);
416 + padding: 0;
417 + border-radius: 0;
418 + width: 80%;
419 + margin: 0 auto;
420 + align-self: center;
421 + display: flex;
422 + flex-direction: column;
423 + gap: 4px;
424 +}
425 +
426 +/* Message content styling */
427 +.message-content {
428 + width: 100%;
429 + overflow-wrap: break-word;
430 + word-break: break-word;
431 +}
432 +
433 +/* Message content within assistant groups - no extra styling needed since group has the background */
434 +.assistant-group .message-content {
435 + padding: 0;
436 + background: transparent;
437 +}
438 +
439 +/* Markdown elements inside messages */
440 +.message-content h1,
441 +.message-content h2,
442 +.message-content h3,
443 +.message-content h4,
444 +.message-content h5,
445 +.message-content h6 {
446 + margin: 0.5em 0;
447 + font-weight: 600;
448 +}
449 +
450 +.message-content h1 { font-size: 1.5em; }
451 +.message-content h2 { font-size: 1.3em; }
452 +.message-content h3 { font-size: 1.1em; }
453 +.message-content h4 { font-size: 1em; }
454 +.message-content h5 { font-size: 0.9em; }
455 +.message-content h6 { font-size: 0.85em; }
456 +
457 +.message-content p {
458 + margin: 0.5em 0;
459 +}
460 +
461 +.message-content ul,
462 +.message-content ol {
463 + margin: 0.5em 0;
464 + padding-left: 1.5em;
465 +}
466 +
467 +.message-content li {
468 + margin: 0.25em 0;
469 +}
470 +
471 +.message-content pre {
472 + background-color: rgba(0, 0, 0, 0.1);
473 + border-radius: 4px;
474 + padding: 0.2em;
475 + overflow-x: auto;
476 + margin: 0.5em 0;
477 +}
478 +
479 +.message-content code {
480 + background-color: rgba(0, 0, 0, 0.1);
481 + padding: 0.1em 0.15em;
482 + border-radius: 3px;
483 + font-family: 'Courier New', monospace;
484 + font-size: 0.9em;
485 +}
486 +
487 +.message-content pre code {
488 + background-color: transparent;
489 + padding: 0;
490 +}
491 +
492 +.message-content blockquote {
493 + margin: 0.5em 0;
494 + padding-left: 1em;
495 + border-left: 3px solid var(--border-color);
496 +}
497 +
498 +.message-content table {
499 + border-collapse: collapse;
500 + width: 100%;
501 + margin: 0.5em 0;
502 +}
503 +
504 +.message-content th,
505 +.message-content td {
506 + border: 1px solid var(--border-color);
507 + padding: 0.2em;
508 + text-align: left;
509 +}
510 +
511 +.message-content th {
512 + background-color: var(--hover-color);
513 + font-weight: 600;
514 +}
515 +
516 +.message-content a {
517 + color: var(--primary-color);
518 + text-decoration: none;
519 +}
520 +
521 +.message-content a:hover {
522 + text-decoration: underline;
523 +}
524 +
525 +.message-content hr {
526 + border: none;
527 + border-top: 1px solid var(--border-color);
528 + margin: 1em 0;
529 +}
530 +
531 +/* Ensure markdown content doesn't overflow */
532 +.message-content img {
533 + max-width: 100%;
534 + height: auto;
535 +}
536 +
537 +/* Tool blocks (calls and results) */
538 +.tool-block {
539 + margin: 4px 0;
540 + background-color: rgba(128, 128, 128, 0.05);
541 + border-radius: 4px;
542 + overflow: hidden;
543 + border: none;
544 + font-size: 12px;
545 +}
546 +
547 +.tool-header {
548 + padding: 2px 4px;
549 + cursor: pointer;
550 + user-select: none;
551 + display: flex;
552 + align-items: center;
553 + gap: 2px;
554 + background-color: rgba(128, 128, 128, 0.05);
555 + transition: background-color 0.2s;
556 +}
557 +
558 +.tool-header:hover {
559 + background-color: rgba(128, 128, 128, 0.1);
560 +}
561 +
562 +.tool-toggle {
563 + font-size: 12px;
564 + font-family: monospace;
565 + transition: transform 0.2s;
566 +}
567 +
568 +.tool-label {
569 + font-weight: 500;
570 + color: var(--text-primary);
571 +}
572 +
573 +.tool-info {
574 + font-size: 11px;
575 + color: var(--text-secondary);
576 + margin-left: auto;
577 + font-style: italic;
578 + display: flex;
579 + gap: 12px;
580 + align-items: center;
581 +}
582 +
583 +.tool-metric {
584 + white-space: nowrap;
585 +}
586 +
587 +.tool-content {
588 + padding: 3px 6px;
589 + white-space: pre-wrap;
590 + line-height: 1.5;
591 + max-height: 400px;
592 + overflow-y: auto;
593 + transition: all 0.3s ease;
594 + background-color: rgba(128, 128, 128, 0.05);
595 +}
596 +
597 +.tool-content.collapsed {
598 + max-height: 0;
599 + padding: 0 4px;
600 + opacity: 0;
601 +}
602 +
603 +.tool-content pre {
604 + margin: 8px 0;
605 + overflow-x: auto;
606 + background-color: rgba(0, 0, 0, 0.05);
607 + padding: 8px;
608 + border-radius: 4px;
609 +}
610 +
611 +/* Different styling for tool results */
612 +.tool-result-block .tool-header {
613 + background-color: rgba(128, 128, 128, 0.05);
614 +}
615 +
616 +.tool-result-block .tool-label {
617 + color: var(--info-color);
618 +}
619 +
620 +/* Thinking blocks */
621 +.thinking-block {
622 + margin: 4px 0;
623 + background-color: rgba(128, 128, 128, 0.05);
624 + border-radius: 4px;
625 + overflow: hidden;
626 + border: none;
627 +}
628 +
629 +.thinking-header {
630 + padding: 3px 4px;
631 + cursor: pointer;
632 + user-select: none;
633 + display: flex;
634 + align-items: center;
635 + gap: 8px;
636 + background-color: rgba(128, 128, 128, 0.05);
637 + transition: background-color 0.2s;
638 +}
639 +
640 +.thinking-label-row {
641 + display: flex;
642 + align-items: center;
643 + gap: 8px;
644 +}
645 +
646 +.thinking-metrics {
647 + display: flex;
648 + gap: 12px;
649 + font-size: 12px;
650 + color: var(--text-secondary);
651 +}
652 +
653 +.thinking-header:hover {
654 + background-color: rgba(128, 128, 128, 0.1);
655 +}
656 +
657 +.thinking-toggle {
658 + font-size: 12px;
659 + font-family: monospace;
660 + transition: transform 0.2s;
661 +}
662 +
663 +.thinking-label {
664 + font-size: 13px;
665 + font-weight: 500;
666 + color: var(--text-secondary);
667 +}
668 +
669 +.thinking-content {
670 + padding: 3px 6px;
671 + white-space: pre-wrap;
672 + font-size: 12px;
673 + line-height: 1.5;
674 + color: var(--text-secondary);
675 + max-height: 400px;
676 + overflow-y: auto;
677 + transition: all 0.3s ease;
678 + background-color: rgba(128, 128, 128, 0.05);
679 +}
680 +
681 +.thinking-content.collapsed {
682 + max-height: 0;
683 + padding: 0 3px;
684 + opacity: 0;
685 +}
686 +
687 +.chat-input-container {
688 + padding: 5px;
689 + background: var(--surface-color);
690 + border-top: 1px solid var(--border-color);
691 + display: flex;
692 + flex-direction: column;
693 + gap: 12px;
694 + height: 120px;
695 + min-height: 80px;
696 + max-height: 300px;
697 +}
698 +
699 +#reconnectMcpBtn {
700 + width: 100%;
701 + flex-shrink: 0;
702 +}
703 +
704 +.chat-input-wrapper {
705 + flex: 1;
706 + display: flex;
707 + gap: 12px;
708 + align-items: stretch;
709 +}
710 +
711 +.chat-input {
712 + flex: 1;
713 + padding: 3px;
714 + border: 1px solid var(--border-color);
715 + border-radius: 4px;
716 + resize: none;
717 + font-family: inherit;
718 + font-size: 14px;
719 + background: var(--background-color);
720 + color: var(--text-primary);
721 + min-height: 50px;
722 +}
723 +
724 +.chat-input:focus {
725 + outline: none;
726 + border-color: var(--primary-color);
727 +}
728 +
729 +/* Log Panel */
730 +.log-panel {
731 + width: 300px;
732 + min-width: 40px;
733 + max-width: 650px;
734 + background: var(--surface-color);
735 + border-left: 1px solid var(--border-color);
736 + display: flex;
737 + flex-direction: column;
738 + position: relative;
739 +}
740 +
741 +/* Only apply transition when not resizing */
742 +.log-panel:not(.resizing) {
743 + transition: width 0.3s ease;
744 +}
745 +
746 +.log-panel.collapsed {
747 + width: 40px !important;
748 + min-width: 40px;
749 +}
750 +
751 +/* Ensure resize handle is always accessible */
752 +#logPanelResize {
753 + position: relative;
754 +}
755 +
756 +.log-panel.collapsed .log-controls,
757 +.log-panel.collapsed .log-content,
758 +.log-panel.collapsed .log-header h3 {
759 + display: none;
760 +}
761 +
762 +.log-header {
763 + padding: 5px;
764 + border-bottom: 1px solid var(--border-color);
765 + display: flex;
766 + justify-content: space-between;
767 + align-items: center;
768 +}
769 +
770 +.log-header h3 {
771 + font-size: 16px;
772 + margin: 0;
773 +}
774 +
775 +.log-controls {
776 + padding: 3px 5px;
777 + display: flex;
778 + gap: 8px;
779 +}
780 +
781 +.log-content {
782 + flex: 1;
783 + overflow-y: auto;
784 + padding: 4px;
785 + font-family: 'Courier New', monospace;
786 + font-size: 12px;
787 +}
788 +
789 +.log-entry {
790 + margin-bottom: 12px;
791 + padding: 8px;
792 + background: var(--background-color);
793 + border-radius: 4px;
794 +}
795 +
796 +.log-entry-header {
797 + display: flex;
798 + justify-content: space-between;
799 + align-items: center;
800 +}
801 +
802 +.log-entry-info {
803 + display: flex;
804 + align-items: center;
805 + gap: 8px;
806 +}
807 +
808 +.btn-copy-log {
809 + padding: 4px 8px;
810 + background: var(--hover-color);
811 + border: 1px solid var(--border-color);
812 + border-radius: 4px;
813 + cursor: pointer;
814 + font-size: 14px;
815 + transition: all 0.2s;
816 + color: var(--text-primary);
817 +}
818 +
819 +.btn-copy-log:hover {
820 + background: var(--primary-color);
821 + color: white;
822 + border-color: var(--primary-color);
823 +}
824 +
825 +:root[data-theme="dark"] .btn-copy-log:hover {
826 + color: #1a1a1a;
827 +}
828 +
829 +.log-timestamp {
830 + color: var(--text-secondary);
831 +}
832 +
833 +.log-direction {
834 + font-weight: 600;
835 + margin: 0 8px;
836 +}
837 +
838 +.log-direction.sent {
839 + color: var(--primary-color);
840 +}
841 +
842 +.log-direction.received {
843 + color: var(--info-color);
844 +}
845 +
846 +.log-direction.error {
847 + color: var(--danger-color);
848 +}
849 +
850 +.log-direction.info {
851 + color: var(--warning-color);
852 +}
853 +
854 +.log-message {
855 + margin-top: 4px;
856 + white-space: pre-wrap;
857 + word-break: break-word;
858 + max-height: 400px;
859 + overflow-y: auto;
860 + font-size: 11px;
861 +}
862 +
863 +.log-metadata {
864 + margin-top: 4px;
865 + font-size: 11px;
866 + color: var(--text-secondary);
867 +}
868 +
869 +.metadata-item {
870 + display: inline-block;
871 + margin-right: 12px;
872 + padding: 2px 6px;
873 + background: rgba(128, 128, 128, 0.1);
874 + border-radius: 3px;
875 +}
876 +
877 +/* Buttons */
878 +.btn {
879 + padding: 3px 5px;
880 + border: none;
881 + border-radius: 4px;
882 + font-size: 14px;
883 + font-weight: 500;
884 + cursor: pointer;
885 + transition: all 0.2s;
886 + background: var(--background-color);
887 + color: var(--text-primary);
888 +}
889 +
890 +.btn:hover {
891 + opacity: 0.9;
892 +}
893 +
894 +.btn:disabled {
895 + opacity: 0.5;
896 + cursor: not-allowed;
897 +}
898 +
899 +.btn-primary {
900 + background: var(--primary-color);
901 + color: white;
902 +}
903 +
904 +:root[data-theme="dark"] .btn-primary {
905 + color: #1a1a1a;
906 +}
907 +
908 +.btn-secondary {
909 + background: var(--secondary-color);
910 + color: white;
911 +}
912 +
913 +:root[data-theme="dark"] .btn-secondary {
914 + color: #1a1a1a;
915 +}
916 +
917 +.btn-danger {
918 + background: var(--danger-color);
919 + color: white;
920 +}
921 +
922 +.btn-small {
923 + padding: 6px 12px;
924 + font-size: 13px;
925 +}
926 +
927 +.btn-send {
928 + background: var(--primary-color);
929 + color: white;
930 +}
931 +
932 +:root[data-theme="dark"] .btn-send {
933 + color: #1a1a1a;
934 +}
935 +
936 +.btn-icon {
937 + background: transparent;
938 + border: none;
939 + font-size: 18px;
940 + cursor: pointer;
941 + padding: 8px;
942 + border-radius: 4px;
943 + transition: background 0.2s;
944 +}
945 +
946 +.btn-icon:hover {
947 + background: var(--hover-color);
948 +}
949 +
950 +/* Theme Toggle */
951 +[data-theme="light"] .theme-icon-dark {
952 + display: none;
953 +}
954 +
955 +[data-theme="dark"] .theme-icon-light {
956 + display: none;
957 +}
958 +
959 +/* Modals */
960 +.modal {
961 + display: none;
962 + position: fixed;
963 + inset: 0;
964 + z-index: 1000;
965 +}
966 +
967 +.modal.show {
968 + display: flex;
969 + align-items: center;
970 + justify-content: center;
971 +}
972 +
973 +.modal-backdrop {
974 + position: absolute;
975 + inset: 0;
976 + background: var(--modal-backdrop);
977 +}
978 +
979 +.modal-content {
980 + position: relative;
981 + background: var(--surface-color);
982 + border-radius: 8px;
983 + max-width: 600px;
984 + width: 90%;
985 + max-height: 80vh;
986 + display: flex;
987 + flex-direction: column;
988 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
989 +}
990 +
991 +.modal-small {
992 + max-width: 400px;
993 +}
994 +
995 +.modal-header {
996 + padding: 20px;
997 + border-bottom: 1px solid var(--border-color);
998 + display: flex;
999 + justify-content: space-between;
1000 + align-items: center;
1001 +}
1002 +
1003 +.modal-header h2 {
1004 + margin: 0;
1005 + font-size: 20px;
1006 +}
1007 +
1008 +.modal-body {
1009 + padding: 20px;
1010 + overflow-y: auto;
1011 +}
1012 +
1013 +.modal-footer {
1014 + padding: 5px 7px;
1015 + border-top: 1px solid var(--border-color);
1016 + display: flex;
1017 + justify-content: flex-end;
1018 + gap: 10px;
1019 +}
1020 +
1021 +/* Settings Tabs */
1022 +.settings-tabs {
1023 + display: flex;
1024 + gap: 10px;
1025 + margin-bottom: 20px;
1026 +}
1027 +
1028 +.tab-btn {
1029 + padding: 3px 5px;
1030 + background: transparent;
1031 + border: none;
1032 + border-bottom: 2px solid transparent;
1033 + cursor: pointer;
1034 + font-size: 14px;
1035 + font-weight: 500;
1036 + color: var(--text-primary);
1037 + transition: all 0.2s;
1038 +}
1039 +
1040 +.tab-btn:hover {
1041 + color: var(--primary-color);
1042 +}
1043 +
1044 +.tab-btn.active {
1045 + border-bottom-color: var(--primary-color);
1046 + color: var(--primary-color);
1047 +}
1048 +
1049 +.tab-content {
1050 + display: none;
1051 +}
1052 +
1053 +.tab-content.active {
1054 + display: block;
1055 +}
1056 +
1057 +/* Config Lists */
1058 +.config-list {
1059 + margin-bottom: 20px;
1060 +}
1061 +
1062 +.config-item {
1063 + display: flex;
1064 + justify-content: space-between;
1065 + align-items: center;
1066 + padding: 4px;
1067 + margin-bottom: 8px;
1068 + background: var(--background-color);
1069 + border-radius: 6px;
1070 +}
1071 +
1072 +.config-item-info {
1073 + flex: 1;
1074 +}
1075 +
1076 +.config-item-name {
1077 + font-weight: 500;
1078 + margin-bottom: 4px;
1079 +}
1080 +
1081 +.config-item-details {
1082 + font-size: 13px;
1083 + color: var(--text-secondary);
1084 +}
1085 +
1086 +.config-item-status {
1087 + display: flex;
1088 + align-items: center;
1089 + gap: 4px;
1090 + font-size: 12px;
1091 +}
1092 +
1093 +.status-dot {
1094 + width: 8px;
1095 + height: 8px;
1096 + border-radius: 50%;
1097 +}
1098 +
1099 +.status-dot.connected {
1100 + background: var(--success-color);
1101 +}
1102 +
1103 +.status-dot.disconnected {
1104 + background: var(--danger-color);
1105 +}
1106 +
1107 +/* Forms */
1108 +.form-group {
1109 + margin-bottom: 16px;
1110 +}
1111 +
1112 +.form-group label {
1113 + display: block;
1114 + margin-bottom: 6px;
1115 + font-weight: 500;
1116 + font-size: 14px;
1117 +}
1118 +
1119 +.form-group input,
1120 +.form-group select,
1121 +.form-group textarea {
1122 + width: 100%;
1123 + padding: 3px 4px;
1124 + border: 1px solid var(--border-color);
1125 + border-radius: 4px;
1126 + font-size: 14px;
1127 + background: var(--background-color);
1128 + color: var(--text-primary);
1129 +}
1130 +
1131 +.form-group input:focus,
1132 +.form-group select:focus,
1133 +.form-group textarea:focus {
1134 + outline: none;
1135 + border-color: var(--primary-color);
1136 +}
1137 +
1138 +.form-group small {
1139 + display: block;
1140 + margin-top: 4px;
1141 + font-size: 12px;
1142 + color: var(--text-secondary);
1143 +}
1144 +
1145 +/* Utilities */
1146 +.text-center {
1147 + text-align: center;
1148 +}
1149 +
1150 +.text-muted {
1151 + color: var(--text-secondary);
1152 +}
1153 +
1154 +.mt-2 {
1155 + margin-top: 8px;
1156 +}
1157 +
1158 +.mb-2 {
1159 + margin-bottom: 8px;
1160 +}
1161 +
1162 +/* Scrollbars */
1163 +::-webkit-scrollbar {
1164 + width: 8px;
1165 + height: 8px;
1166 +}
1167 +
1168 +::-webkit-scrollbar-track {
1169 + background: var(--background-color);
1170 +}
1171 +
1172 +::-webkit-scrollbar-thumb {
1173 + background: var(--border-color);
1174 + border-radius: 4px;
1175 +}
1176 +
1177 +::-webkit-scrollbar-thumb:hover {
1178 + background: var(--text-secondary);
1179 +}
1180 +
1181 +/* Resize Handles */
1182 +.resize-handle {
1183 + background-color: var(--border-color);
1184 + transition: background-color 0.2s;
1185 + user-select: none;
1186 + position: relative;
1187 + z-index: 10;
1188 + flex-shrink: 0;
1189 +}
1190 +
1191 +.resize-handle:hover {
1192 + background-color: var(--primary-color);
1193 +}
1194 +
1195 +.resize-handle.resize-active {
1196 + background-color: var(--primary-color);
1197 + opacity: 0.8;
1198 +}
1199 +
1200 +.resize-handle-vertical {
1201 + width: 8px;
1202 + cursor: col-resize;
1203 + margin: 0 -2px;
1204 +}
1205 +
1206 +.resize-handle-horizontal {
1207 + height: 8px;
1208 + cursor: row-resize;
1209 + width: 100%;
1210 + margin: -2px 0;
1211 +}
1212 +
1213 +.resize-handle::after {
1214 + content: '';
1215 + position: absolute;
1216 + background-color: inherit;
1217 +}
1218 +
1219 +.resize-handle-vertical::after {
1220 + top: 50%;
1221 + left: 50%;
1222 + transform: translate(-50%, -50%);
1223 + width: 12px;
1224 + height: 40px;
1225 + border-radius: 6px;
1226 + opacity: 0.3;
1227 +}
1228 +
1229 +.resize-handle-horizontal::after {
1230 + top: 50%;
1231 + left: 50%;
1232 + transform: translate(-50%, -50%);
1233 + width: 40px;
1234 + height: 12px;
1235 + border-radius: 6px;
1236 + opacity: 0.3;
1237 +}
1238 +
1239 +.resize-handle:hover::after {
1240 + opacity: 0.6;
1241 +}
1242 +
1243 +/* Loading Spinner */
1244 +.loading-spinner {
1245 + display: flex;
1246 + align-items: center;
1247 + justify-content: flex-start;
1248 +}
1249 +
1250 +.spinner-container {
1251 + display: flex;
1252 + align-items: center;
1253 + gap: 12px;
1254 + padding: 8px;
1255 +}
1256 +
1257 +.spinner {
1258 + width: 24px;
1259 + height: 24px;
1260 + border: 3px solid var(--border-color);
1261 + border-top-color: var(--primary-color);
1262 + border-radius: 50%;
1263 + animation: spin 1s linear infinite;
1264 +}
1265 +
1266 +@keyframes spin {
1267 + to {
1268 + transform: rotate(360deg);
1269 + }
1270 +}
1271 +
1272 +.spinner-text {
1273 + font-size: 14px;
1274 + color: var(--text-secondary);
1275 + font-style: italic;
1276 +}
1277 +
1278 +/* Responsive */
1279 +@media (max-width: 768px) {
1280 + .chat-sidebar {
1281 + width: 240px;
1282 + }
1283 +
1284 + .log-panel {
1285 + width: 240px;
1286 + }
1287 +
1288 + .message {
1289 + max-width: 90%;
1290 + }
1291 +
1292 + .resize-handle {
1293 + display: none;
1294 + }
1295 +}
1296 +
1297 +/* Assistant Metrics Footer */
1298 +.assistant-metrics-footer {
1299 + display: flex;
1300 + justify-content: flex-end;
1301 + gap: 12px;
1302 + margin-top: 8px;
1303 + padding-top: 8px;
1304 + border-top: 1px solid var(--border-color);
1305 + font-size: 12px;
1306 + color: var(--text-secondary);
1307 +}
1308 +
1309 +.metric-item {
1310 + display: inline-flex;
1311 + align-items: center;
1312 + gap: 4px;
1313 + white-space: nowrap;
1314 +}
1315 +
1316 +.token-total-with-tooltip {
1317 + position: relative;
1318 + cursor: help;
1319 +}
1320 +
1321 +.token-tooltip {
1322 + position: absolute;
1323 + top: 100%;
1324 + right: 0;
1325 + margin-top: 4px;
1326 + background: var(--surface-color);
1327 + border: 1px solid var(--border-color);
1328 + border-radius: 4px;
1329 + padding: 8px;
1330 + font-size: 11px;
1331 + white-space: nowrap;
1332 + box-shadow: var(--shadow);
1333 + display: none;
1334 + z-index: 1000;
1335 + min-width: 200px;
1336 +}
1337 +
1338 +.token-total-with-tooltip:hover .token-tooltip {
1339 + display: block;
1340 +}
1341 +
1342 +.tooltip-item {
1343 + display: flex;
1344 + justify-content: space-between;
1345 + gap: 12px;
1346 + margin: 2px 0;
1347 +}
1348 +
1349 +.tooltip-label {
1350 + color: var(--text-secondary);
1351 +}
1352 +
1353 +.tooltip-value {
1354 + color: var(--text-primary);
1355 + font-weight: 500;
1356 +}
1357 +
1358 +.token-usage-item {
1359 + display: flex;
1360 + align-items: center;
1361 + gap: 4px;
1362 +}
1363 +
1364 +.token-usage-label {
1365 + font-weight: 500;
1366 +}
1367 +
1368 +.token-usage-value {
1369 + color: var(--text-primary);
1370 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', monospace;
1371 +}
1372 +
1373 +.token-usage-icon {
1374 + font-size: 14px;
1375 +}
1376 +
1377 +/* Tool section styling */
1378 +.tool-section-header {
1379 + font-size: 11px;
1380 + font-weight: 600;
1381 + color: var(--text-secondary);
1382 + margin-bottom: 6px;
1383 + text-transform: uppercase;
1384 + letter-spacing: 0.5px;
1385 +}
1386 +
1387 +.tool-separator {
1388 + height: 1px;
1389 + background-color: var(--border-color);
1390 + margin: 8px 0;
1391 + opacity: 0.3;
1392 +}
1393 +
1394 +.tool-request-section,
1395 +.tool-response-section {
1396 + padding: 4px 0;
1397 +}
1398 +
1399 +/* Dark mode adjustments for tool blocks */
1400 +[data-theme="dark"] .tool-block,
1401 +[data-theme="dark"] .thinking-block {
1402 + background-color: rgba(255, 255, 255, 0.03);
1403 +}
1404 +
1405 +[data-theme="dark"] .tool-header {
1406 + background-color: rgba(255, 255, 255, 0.03);
1407 +}
1408 +
1409 +[data-theme="dark"] .tool-header:hover {
1410 + background-color: rgba(255, 255, 255, 0.06);
1411 +}
1412 +
1413 +[data-theme="dark"] .tool-content,
1414 +[data-theme="dark"] .thinking-content {
1415 + background-color: rgba(255, 255, 255, 0.03);
1416 +}
1417 +
1418 +/* Context Window Indicator */
1419 +.context-window-indicator {
1420 + display: flex;
1421 + flex-direction: column;
1422 + gap: 4px;
1423 + flex: 1;
1424 +}
1425 +
1426 +.context-window-indicator.compact {
1427 + padding: 0;
1428 + background: transparent;
1429 + border: none;
1430 + margin: 0;
1431 + flex-direction: column;
1432 + align-items: center;
1433 + gap: 4px;
1434 +}
1435 +
1436 +.context-label {
1437 + font-size: 11px;
1438 + font-weight: 500;
1439 + color: var(--text-secondary);
1440 + text-align: center;
1441 +}
1442 +
1443 +.context-window-header {
1444 + display: flex;
1445 + justify-content: space-between;
1446 + align-items: center;
1447 + margin-bottom: 8px;
1448 +}
1449 +
1450 +.context-window-title {
1451 + font-size: 14px;
1452 + font-weight: 500;
1453 + color: var(--text-primary);
1454 +}
1455 +
1456 +.context-window-indicator.compact .context-window-bar {
1457 + flex: 0 0 auto;
1458 + width: 300px;
1459 + height: 36px;
1460 + background: var(--surface-color);
1461 + border: 2px solid var(--border-color);
1462 + border-radius: 8px;
1463 + overflow: hidden;
1464 + position: relative;
1465 + display: flex;
1466 + align-items: center;
1467 + margin: 0;
1468 +}
1469 +
1470 +.context-window-indicator.compact .context-window-stats {
1471 + font-size: 13px;
1472 + color: var(--text-primary);
1473 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', monospace;
1474 + white-space: nowrap;
1475 + position: absolute;
1476 + left: 50%;
1477 + top: 50%;
1478 + transform: translate(-50%, -50%);
1479 + z-index: 10;
1480 + font-weight: normal;
1481 + text-shadow:
1482 + 0 0 3px var(--surface-color),
1483 + 0 0 6px var(--surface-color);
1484 +}
1485 +
1486 +.context-window-fill {
1487 + height: 100%;
1488 + background: var(--primary-color);
1489 + transition: width 0.3s ease, background-color 0.3s ease;
1490 + position: relative;
1491 + opacity: 0.8;
1492 +}
1493 +
1494 +.context-window-fill.warning {
1495 + background: var(--warning-color);
1496 +}
1497 +
1498 +.context-window-fill.danger {
1499 + background: var(--danger-color);
1500 +}
1501 +
1502 +.context-window-percentage {
1503 + position: absolute;
1504 + right: 8px;
1505 + top: 50%;
1506 + transform: translateY(-50%);
1507 + font-size: 11px;
1508 + font-weight: bold;
1509 + color: var(--text-primary);
1510 + text-shadow: 0 0 2px var(--surface-color);
1511 +}
1512 +
1513 +/* Token breakdown tooltip */
1514 +.token-breakdown {
1515 + position: relative;
1516 + cursor: help;
1517 +}
1518 +
1519 +.token-breakdown-tooltip {
1520 + position: absolute;
1521 + bottom: 100%;
1522 + left: 50%;
1523 + transform: translateX(-50%);
1524 + background: var(--surface-color);
1525 + border: 1px solid var(--border-color);
1526 + border-radius: 4px;
1527 + padding: 8px;
1528 + margin-bottom: 4px;
1529 + font-size: 11px;
1530 + white-space: nowrap;
1531 + box-shadow: var(--shadow);
1532 + display: none;
1533 + z-index: 1000;
1534 +}
1535 +
1536 +.token-breakdown:hover .token-breakdown-tooltip {
1537 + display: block;
1538 +}
1539 +
1540 +.token-breakdown-item {
1541 + display: flex;
1542 + justify-content: space-between;
1543 + gap: 12px;
1544 + margin: 2px 0;
1545 +}
1546 +
1547 +.token-breakdown-label {
1548 + color: var(--text-secondary);
1549 +}
1550 +
1551 +.token-breakdown-value {
1552 + color: var(--text-primary);
1553 + font-weight: 500;
1554 +}
1555 +
1556 +/* Temperature Control */
1557 +.temperature-control {
1558 + display: flex;
1559 + flex-direction: column;
1560 + gap: 4px;
1561 + flex: 1;
1562 +}
1563 +
1564 +.temperature-control.compact {
1565 + padding: 0;
1566 + background: transparent;
1567 + border: none;
1568 + margin: 0;
1569 +}
1570 +
1571 +.temperature-label {
1572 + font-size: 11px;
1573 + font-weight: 500;
1574 + color: var(--text-secondary);
1575 + text-align: center;
1576 +}
1577 +
1578 +.temperature-controls {
1579 + display: flex;
1580 + align-items: center;
1581 + gap: 8px;
1582 + justify-content: center;
1583 +}
1584 +
1585 +.temperature-control.compact .temperature-slider {
1586 + flex: 1;
1587 + min-width: 100px;
1588 + max-width: 200px;
1589 + -webkit-appearance: none;
1590 + appearance: none;
1591 + height: 6px;
1592 + background: var(--border-color);
1593 + border: 1px solid var(--border-color);
1594 + border-radius: 3px;
1595 + outline: none;
1596 + position: relative;
1597 + margin: 0;
1598 +}
1599 +
1600 +.temperature-control.compact .temperature-slider::-webkit-slider-thumb {
1601 + -webkit-appearance: none;
1602 + appearance: none;
1603 + width: 16px;
1604 + height: 16px;
1605 + background: var(--primary-color);
1606 + border-radius: 50%;
1607 + cursor: pointer;
1608 + transition: transform 0.1s ease;
1609 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
1610 +}
1611 +
1612 +.temperature-control.compact .temperature-slider::-webkit-slider-thumb:hover {
1613 + transform: scale(1.2);
1614 +}
1615 +
1616 +.temperature-control.compact .temperature-slider::-moz-range-thumb {
1617 + width: 16px;
1618 + height: 16px;
1619 + background: var(--primary-color);
1620 + border-radius: 50%;
1621 + cursor: pointer;
1622 + border: none;
1623 + transition: transform 0.1s ease;
1624 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
1625 +}
1626 +
1627 +.temperature-control.compact .temperature-slider::-moz-range-thumb:hover {
1628 + transform: scale(1.2);
1629 +}
1630 +
1631 +.temperature-control.compact .temperature-value {
1632 + font-size: 11px;
1633 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', monospace;
1634 + color: var(--text-primary);
1635 + min-width: 25px;
1636 + text-align: left;
1637 +}