goto-assistant 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <title>goto-assistant</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
9
+ </head>
10
+ <body>
11
+ <div class="chat-layout">
12
+ <div class="sidebar">
13
+ <div class="sidebar-header">
14
+ <h2>Conversations</h2>
15
+ <button class="btn-icon" id="newChatBtn" title="New chat">+</button>
16
+ </div>
17
+ <div class="conversation-list" id="conversationList"></div>
18
+ </div>
19
+ <div class="sidebar-overlay" id="sidebarOverlay"></div>
20
+
21
+ <div class="chat-main">
22
+ <div class="chat-header">
23
+ <button class="hamburger-btn" id="hamburgerBtn">&#9776;</button>
24
+ <h2 id="chatTitle">New Conversation</h2>
25
+ <button class="settings-btn" id="settingsBtn" title="Settings">&#9881;</button>
26
+ </div>
27
+
28
+ <div class="messages" id="messages"></div>
29
+
30
+ <div class="input-area">
31
+ <div class="file-preview" id="filePreview"></div>
32
+ <div class="input-row">
33
+ <button class="file-upload-btn" id="fileUploadBtn" title="Attach image">&#128206;</button>
34
+ <input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden>
35
+ <textarea id="input" rows="1" placeholder="Send a message..." autofocus></textarea>
36
+ <button class="btn btn-primary" id="sendBtn">Send</button>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ </div>
41
+
42
+ <script>
43
+ let ws = null;
44
+ let currentConversationId = null;
45
+ let currentAssistantEl = null;
46
+ let pendingFiles = []; // Files selected but not yet sent
47
+
48
+ function connectWs() {
49
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
50
+ ws = new WebSocket(`${protocol}//${location.host}/ws`);
51
+ ws.onmessage = (e) => {
52
+ const msg = JSON.parse(e.data);
53
+ if (msg.type === 'chunk') {
54
+ if (!currentAssistantEl) {
55
+ currentAssistantEl = addMessage('assistant', '');
56
+ currentAssistantEl._raw = '';
57
+ }
58
+ currentAssistantEl._raw += msg.text;
59
+ // Update only the text div (last child)
60
+ const textDiv = currentAssistantEl.lastElementChild;
61
+ if (textDiv) textDiv.innerHTML = marked.parse(currentAssistantEl._raw);
62
+ } else if (msg.type === 'done') {
63
+ currentConversationId = msg.conversationId;
64
+ currentAssistantEl = null;
65
+ removeTypingIndicator();
66
+ loadConversations();
67
+ } else if (msg.type === 'error') {
68
+ removeTypingIndicator();
69
+ currentAssistantEl = null;
70
+ addMessage('assistant', 'Error: ' + msg.text);
71
+ }
72
+ };
73
+ ws.onclose = () => { setTimeout(connectWs, 2000); };
74
+ }
75
+
76
+ function parseContent(content) {
77
+ try {
78
+ const parsed = JSON.parse(content);
79
+ if (parsed && typeof parsed.text === 'string') return parsed;
80
+ } catch {}
81
+ return { text: content };
82
+ }
83
+
84
+ function addMessage(role, content, extraAttachments) {
85
+ const el = document.createElement('div');
86
+ el.className = 'message ' + role;
87
+ const parsed = parseContent(content);
88
+ const attachments = extraAttachments || parsed.attachments;
89
+
90
+ // Render attachment images
91
+ if (attachments && attachments.length > 0) {
92
+ const strip = document.createElement('div');
93
+ strip.className = 'message-attachments';
94
+ for (const att of attachments) {
95
+ const img = document.createElement('img');
96
+ img.className = 'attachment-img';
97
+ img.src = `/api/uploads/${att.fileId}`;
98
+ img.alt = att.filename;
99
+ img.loading = 'lazy';
100
+ strip.appendChild(img);
101
+ }
102
+ el.appendChild(strip);
103
+ }
104
+
105
+ const textContent = parsed.text;
106
+ const textEl = document.createElement('div');
107
+ if (role === 'assistant' && textContent) {
108
+ textEl.innerHTML = marked.parse(textContent);
109
+ } else if (textContent) {
110
+ textEl.textContent = textContent;
111
+ }
112
+ el.appendChild(textEl);
113
+ document.getElementById('messages').appendChild(el);
114
+ el.scrollIntoView({ behavior: 'smooth' });
115
+ return el;
116
+ }
117
+
118
+ function addTypingIndicator() {
119
+ const el = document.createElement('div');
120
+ el.className = 'typing-indicator';
121
+ el.id = 'typing';
122
+ el.textContent = 'Thinking...';
123
+ document.getElementById('messages').appendChild(el);
124
+ el.scrollIntoView({ behavior: 'smooth' });
125
+ }
126
+
127
+ function removeTypingIndicator() {
128
+ const el = document.getElementById('typing');
129
+ if (el) el.remove();
130
+ }
131
+
132
+ async function sendMessage() {
133
+ const input = document.getElementById('input');
134
+ const text = input.value.trim();
135
+ if ((!text && pendingFiles.length === 0) || !ws || ws.readyState !== WebSocket.OPEN) return;
136
+
137
+ // Upload pending files
138
+ let attachments = [];
139
+ if (pendingFiles.length > 0) {
140
+ for (const file of pendingFiles) {
141
+ const form = new FormData();
142
+ form.append('file', file);
143
+ try {
144
+ const res = await fetch('/api/upload', { method: 'POST', body: form });
145
+ if (res.ok) {
146
+ const data = await res.json();
147
+ attachments.push({ fileId: data.fileId, filename: data.filename, mimeType: data.mimeType });
148
+ }
149
+ } catch {}
150
+ }
151
+ pendingFiles = [];
152
+ renderFilePreview();
153
+ }
154
+
155
+ addMessage('user', text || '(image)', attachments.length > 0 ? attachments : undefined);
156
+ addTypingIndicator();
157
+ const payload = {
158
+ type: 'message',
159
+ text: text || 'Describe this image.',
160
+ conversationId: currentConversationId,
161
+ };
162
+ if (attachments.length > 0) payload.attachments = attachments;
163
+ ws.send(JSON.stringify(payload));
164
+ input.value = '';
165
+ input.style.height = 'auto';
166
+ }
167
+
168
+ // Auto-resize textarea
169
+ const input = document.getElementById('input');
170
+ input.addEventListener('input', () => {
171
+ input.style.height = 'auto';
172
+ input.style.height = Math.min(input.scrollHeight, 200) + 'px';
173
+ });
174
+ input.addEventListener('keydown', (e) => {
175
+ if (e.key === 'Enter' && !e.shiftKey) {
176
+ e.preventDefault();
177
+ sendMessage();
178
+ }
179
+ });
180
+
181
+ document.getElementById('sendBtn').addEventListener('click', sendMessage);
182
+
183
+ // File upload handling
184
+ document.getElementById('fileUploadBtn').addEventListener('click', () => {
185
+ document.getElementById('fileInput').click();
186
+ });
187
+ document.getElementById('fileInput').addEventListener('change', (e) => {
188
+ for (const file of e.target.files) {
189
+ pendingFiles.push(file);
190
+ }
191
+ e.target.value = '';
192
+ renderFilePreview();
193
+ });
194
+
195
+ function renderFilePreview() {
196
+ const preview = document.getElementById('filePreview');
197
+ preview.innerHTML = '';
198
+ if (pendingFiles.length === 0) { preview.style.display = 'none'; return; }
199
+ preview.style.display = 'flex';
200
+ pendingFiles.forEach((file, i) => {
201
+ const item = document.createElement('div');
202
+ item.className = 'file-preview-item';
203
+ const img = document.createElement('img');
204
+ img.src = URL.createObjectURL(file);
205
+ img.onload = () => URL.revokeObjectURL(img.src);
206
+ item.appendChild(img);
207
+ const removeBtn = document.createElement('button');
208
+ removeBtn.className = 'file-preview-remove';
209
+ removeBtn.textContent = '×';
210
+ removeBtn.addEventListener('click', () => {
211
+ pendingFiles.splice(i, 1);
212
+ renderFilePreview();
213
+ });
214
+ item.appendChild(removeBtn);
215
+ preview.appendChild(item);
216
+ });
217
+ }
218
+
219
+ // New chat
220
+ document.getElementById('newChatBtn').addEventListener('click', () => {
221
+ currentConversationId = null;
222
+ currentAssistantEl = null;
223
+ document.getElementById('messages').innerHTML = '';
224
+ document.getElementById('chatTitle').textContent = 'New Conversation';
225
+ document.querySelectorAll('.conversation-item').forEach(el => el.classList.remove('active'));
226
+ closeSidebar();
227
+ });
228
+
229
+ // Settings
230
+ document.getElementById('settingsBtn').addEventListener('click', () => {
231
+ window.location.href = '/setup.html';
232
+ });
233
+
234
+ // Load conversations
235
+ async function loadConversations() {
236
+ try {
237
+ const res = await fetch('/api/conversations');
238
+ const data = await res.json();
239
+ const list = document.getElementById('conversationList');
240
+ list.innerHTML = '';
241
+ data.conversations.forEach(c => {
242
+ const el = document.createElement('div');
243
+ el.className = 'conversation-item' + (c.id === currentConversationId ? ' active' : '');
244
+ const date = new Date(c.created_at).toLocaleDateString();
245
+ const displayTitle = c.title || `${c.provider} — ${date}`;
246
+ el.title = displayTitle;
247
+
248
+ const titleSpan = document.createElement('span');
249
+ titleSpan.className = 'conv-title';
250
+ titleSpan.textContent = displayTitle;
251
+ el.appendChild(titleSpan);
252
+
253
+ const deleteBtn = document.createElement('button');
254
+ deleteBtn.className = 'btn-icon delete-btn';
255
+ deleteBtn.textContent = '×';
256
+ deleteBtn.title = 'Delete conversation';
257
+ deleteBtn.addEventListener('click', async (e) => {
258
+ e.stopPropagation();
259
+ if (!confirm('Delete this conversation?')) return;
260
+ await fetch(`/api/conversations/${c.id}`, { method: 'DELETE' });
261
+ if (currentConversationId === c.id) {
262
+ currentConversationId = null;
263
+ currentAssistantEl = null;
264
+ document.getElementById('messages').innerHTML = '';
265
+ document.getElementById('chatTitle').textContent = 'New Conversation';
266
+ }
267
+ loadConversations();
268
+ });
269
+ el.appendChild(deleteBtn);
270
+
271
+ titleSpan.addEventListener('click', async () => {
272
+ currentConversationId = c.id;
273
+ document.getElementById('messages').innerHTML = '';
274
+ document.getElementById('chatTitle').textContent = displayTitle;
275
+ closeSidebar();
276
+ document.querySelectorAll('.conversation-item').forEach(e => e.classList.remove('active'));
277
+ el.classList.add('active');
278
+ try {
279
+ const msgRes = await fetch(`/api/conversations/${c.id}/messages`);
280
+ const msgData = await msgRes.json();
281
+ msgData.messages.forEach(m => addMessage(m.role, m.content));
282
+ } catch {}
283
+ });
284
+ list.appendChild(el);
285
+ });
286
+ } catch {}
287
+ }
288
+
289
+ // Sidebar toggle (mobile)
290
+ function toggleSidebar() {
291
+ document.querySelector('.sidebar').classList.toggle('open');
292
+ document.getElementById('sidebarOverlay').classList.toggle('open');
293
+ }
294
+ function closeSidebar() {
295
+ document.querySelector('.sidebar').classList.remove('open');
296
+ document.getElementById('sidebarOverlay').classList.remove('open');
297
+ }
298
+ document.getElementById('hamburgerBtn').addEventListener('click', toggleSidebar);
299
+ document.getElementById('sidebarOverlay').addEventListener('click', closeSidebar);
300
+
301
+ // Swipe gesture: right from left edge opens sidebar, left closes
302
+ let touchStartX = 0;
303
+ let touchStartY = 0;
304
+ document.addEventListener('touchstart', (e) => {
305
+ touchStartX = e.touches[0].clientX;
306
+ touchStartY = e.touches[0].clientY;
307
+ }, { passive: true });
308
+ document.addEventListener('touchend', (e) => {
309
+ const dx = e.changedTouches[0].clientX - touchStartX;
310
+ const dy = Math.abs(e.changedTouches[0].clientY - touchStartY);
311
+ if (dy > Math.abs(dx)) return; // vertical swipe
312
+ if (dx > 80 && touchStartX < 30) toggleSidebar();
313
+ else if (dx < -80 && document.querySelector('.sidebar').classList.contains('open')) closeSidebar();
314
+ }, { passive: true });
315
+
316
+ connectWs();
317
+ loadConversations();
318
+ </script>
319
+ </body>
320
+ </html>
@@ -0,0 +1,296 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <title>goto-assistant — Setup</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ <script src="/cron-sync.js"></script>
9
+ </head>
10
+ <body>
11
+ <div class="setup-container">
12
+ <h1>goto-assistant Setup</h1>
13
+
14
+ <div class="form-group">
15
+ <label>Provider</label>
16
+ <div class="radio-group">
17
+ <label><input type="radio" name="provider" value="claude" checked> Claude</label>
18
+ <label><input type="radio" name="provider" value="openai"> OpenAI</label>
19
+ </div>
20
+ </div>
21
+
22
+ <div class="form-group">
23
+ <label for="apiKey">API Key</label>
24
+ <input type="password" id="apiKey" placeholder="sk-ant-... or sk-...">
25
+ </div>
26
+
27
+ <div class="form-group">
28
+ <label for="baseUrl">Base URL <span style="font-weight:400;color:#888">(optional — for LiteLLM proxy)</span></label>
29
+ <input type="text" id="baseUrl" placeholder="Leave empty for direct API">
30
+ </div>
31
+
32
+ <div class="form-group">
33
+ <label for="model">Model</label>
34
+ <div class="btn-row">
35
+ <select id="model"><option value="">— Select provider first —</option></select>
36
+ <button class="btn btn-secondary" id="loadModelsBtn">Load Models</button>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="form-group">
41
+ <label>MCP Servers</label>
42
+ <div id="mcpServers" class="mcp-servers"></div>
43
+ <button class="btn btn-secondary" id="addServerBtn">+ Add Server</button>
44
+ </div>
45
+
46
+ <div class="form-group">
47
+ <label for="port">Server Port</label>
48
+ <input type="number" id="port" value="3000">
49
+ </div>
50
+
51
+ <button class="btn btn-primary" id="saveBtn">Save & Start</button>
52
+ <div id="status"></div>
53
+ </div>
54
+
55
+ <script>
56
+ const defaultServers = [
57
+ { name: 'cron', command: 'npx', args: '-y mcp-cron --transport stdio --prevent-sleep --mcp-config-path ./data/mcp.json --ai-provider anthropic --ai-model claude-sonnet-4-5-20250929', env: {} },
58
+ { name: 'memory', command: 'npx', args: '-y @modelcontextprotocol/server-memory', env: {} },
59
+ { name: 'filesystem', command: 'npx', args: '-y @modelcontextprotocol/server-filesystem .', env: {} },
60
+ ];
61
+
62
+ function getProvider() {
63
+ return document.querySelector('input[name="provider"]:checked').value;
64
+ }
65
+
66
+ // Render MCP servers
67
+ function renderServers(servers) {
68
+ const container = document.getElementById('mcpServers');
69
+ container.innerHTML = '';
70
+ servers.forEach((s, i) => {
71
+ const div = document.createElement('div');
72
+ div.className = 'mcp-server';
73
+ const envRows = Object.entries(s.env || {}).map(([k, v], ei) =>
74
+ `<div class="env-row">
75
+ <input type="text" placeholder="Key" value="${k}" data-server="${i}" data-env-key="${ei}">
76
+ <input type="text" placeholder="Value" value="${v}" data-server="${i}" data-env-val="${ei}">
77
+ <button class="btn-icon" onclick="removeEnv(${i},${ei})">×</button>
78
+ </div>`
79
+ ).join('');
80
+ div.innerHTML = `
81
+ <div class="mcp-server-header">
82
+ <input type="text" value="${s.name}" data-server="${i}" data-field="name" placeholder="Server name">
83
+ <button class="btn-icon" onclick="removeServer(${i})">×</button>
84
+ </div>
85
+ <div class="form-group">
86
+ <label>Command</label>
87
+ <input type="text" value="${s.command}" data-server="${i}" data-field="command">
88
+ </div>
89
+ <div class="form-group">
90
+ <label>Args</label>
91
+ <input type="text" value="${s.args}" data-server="${i}" data-field="args">
92
+ </div>
93
+ <div class="form-group">
94
+ <label>Environment Variables</label>
95
+ ${envRows}
96
+ <button class="btn btn-secondary" style="margin-top:4px;font-size:12px;padding:4px 10px" onclick="addEnv(${i})">+ Add Env</button>
97
+ </div>`;
98
+ container.appendChild(div);
99
+ });
100
+ }
101
+
102
+ let servers = [...defaultServers.map(s => ({ ...s, env: { ...s.env } }))];
103
+ renderServers(servers);
104
+
105
+ function readServers() {
106
+ const container = document.getElementById('mcpServers');
107
+ const items = container.querySelectorAll('.mcp-server');
108
+ return Array.from(items).map((item, i) => {
109
+ const name = item.querySelector('[data-field="name"]').value;
110
+ const command = item.querySelector('[data-field="command"]').value;
111
+ const args = item.querySelector('[data-field="args"]').value;
112
+ const envRows = item.querySelectorAll('.env-row');
113
+ const env = {};
114
+ envRows.forEach(row => {
115
+ const k = row.querySelector('[placeholder="Key"]').value.trim();
116
+ const v = row.querySelector('[placeholder="Value"]').value.trim();
117
+ if (k) env[k] = v;
118
+ });
119
+ return { name, command, args, env };
120
+ });
121
+ }
122
+
123
+ window.removeServer = (i) => { servers.splice(i, 1); renderServers(servers); };
124
+ window.addEnv = (i) => {
125
+ servers = readServers();
126
+ servers[i].env[''] = '';
127
+ renderServers(servers);
128
+ };
129
+ window.removeEnv = (si, ei) => {
130
+ servers = readServers();
131
+ const keys = Object.keys(servers[si].env);
132
+ delete servers[si].env[keys[ei]];
133
+ renderServers(servers);
134
+ };
135
+
136
+ document.getElementById('addServerBtn').addEventListener('click', () => {
137
+ servers = readServers();
138
+ servers.push({ name: '', command: 'npx', args: '', env: {} });
139
+ renderServers(servers);
140
+ });
141
+
142
+ // Sync cron server config with current provider/key/model/baseUrl selections
143
+ function syncCronConfig() {
144
+ servers = readServers();
145
+ const cron = servers.find(s => s.name === 'cron');
146
+ if (!cron) return;
147
+
148
+ const provider = getProvider();
149
+ const apiKey = document.getElementById('apiKey').value.trim();
150
+ const model = document.getElementById('model').value;
151
+ const baseUrl = document.getElementById('baseUrl').value.trim();
152
+
153
+ const result = buildCronConfig({
154
+ provider, apiKey, model, baseUrl,
155
+ currentArgs: cron.args,
156
+ });
157
+
158
+ cron.args = result.args;
159
+
160
+ // Update env: remove old API_KEY entries, set the correct one
161
+ const oldKeys = Object.keys(cron.env).filter(k => k.includes('API_KEY'));
162
+ oldKeys.forEach(k => delete cron.env[k]);
163
+ cron.env[result.envKey] = result.envValue;
164
+
165
+ renderServers(servers);
166
+ }
167
+
168
+ // Sync cron when provider, API key, base URL, or model changes
169
+ document.querySelectorAll('input[name="provider"]').forEach(radio => {
170
+ radio.addEventListener('change', syncCronConfig);
171
+ });
172
+ document.getElementById('apiKey').addEventListener('input', syncCronConfig);
173
+ document.getElementById('baseUrl').addEventListener('input', syncCronConfig);
174
+ document.getElementById('model').addEventListener('change', syncCronConfig);
175
+
176
+ // Load models
177
+ document.getElementById('loadModelsBtn').addEventListener('click', async () => {
178
+ const provider = getProvider();
179
+ const apiKey = document.getElementById('apiKey').value.trim();
180
+ const baseUrl = document.getElementById('baseUrl').value.trim();
181
+ if (!apiKey) { showStatus('Enter an API key first', 'error'); return; }
182
+
183
+ const select = document.getElementById('model');
184
+ select.innerHTML = '<option value="">Loading...</option>';
185
+
186
+ try {
187
+ const res = await fetch('/api/models', {
188
+ method: 'POST',
189
+ headers: { 'Content-Type': 'application/json' },
190
+ body: JSON.stringify({ provider, apiKey, baseUrl: baseUrl || undefined }),
191
+ });
192
+ const data = await res.json();
193
+ if (!res.ok) { showStatus(data.error, 'error'); return; }
194
+ select.innerHTML = data.models.map(m =>
195
+ `<option value="${m.id}">${m.name}</option>`
196
+ ).join('');
197
+ showStatus('Models loaded', 'success');
198
+ } catch (e) {
199
+ showStatus('Failed to load models', 'error');
200
+ }
201
+ });
202
+
203
+ // Save
204
+ document.getElementById('saveBtn').addEventListener('click', async () => {
205
+ const provider = getProvider();
206
+ const apiKey = document.getElementById('apiKey').value.trim();
207
+ const baseUrl = document.getElementById('baseUrl').value.trim();
208
+ const model = document.getElementById('model').value;
209
+ const port = parseInt(document.getElementById('port').value) || 3000;
210
+
211
+ if (!apiKey) { showStatus('API key is required', 'error'); return; }
212
+ if (!model) { showStatus('Select a model first', 'error'); return; }
213
+
214
+ // Sync cron one final time before saving
215
+ syncCronConfig();
216
+ servers = readServers();
217
+
218
+ const mcpServers = {};
219
+ servers.forEach(s => {
220
+ if (!s.name) return;
221
+ mcpServers[s.name] = {
222
+ command: s.command,
223
+ args: s.args.split(/\s+/).filter(Boolean),
224
+ ...(Object.keys(s.env).length > 0 ? { env: s.env } : {}),
225
+ };
226
+ });
227
+
228
+ const config = {
229
+ provider,
230
+ claude: {
231
+ apiKey: provider === 'claude' ? apiKey : '',
232
+ model: provider === 'claude' ? model : '',
233
+ baseUrl: provider === 'claude' ? baseUrl : '',
234
+ },
235
+ openai: {
236
+ apiKey: provider === 'openai' ? apiKey : '',
237
+ model: provider === 'openai' ? model : '',
238
+ baseUrl: provider === 'openai' ? baseUrl : '',
239
+ },
240
+ server: { port },
241
+ };
242
+
243
+ try {
244
+ const [configRes, mcpRes] = await Promise.all([
245
+ fetch('/api/setup', {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json' },
248
+ body: JSON.stringify(config),
249
+ }),
250
+ fetch('/api/mcp-servers', {
251
+ method: 'POST',
252
+ headers: { 'Content-Type': 'application/json' },
253
+ body: JSON.stringify({ mcpServers }),
254
+ }),
255
+ ]);
256
+ if (!configRes.ok || !mcpRes.ok) { showStatus('Failed to save config', 'error'); return; }
257
+ window.location.href = '/';
258
+ } catch (e) {
259
+ showStatus('Failed to save config', 'error');
260
+ }
261
+ });
262
+
263
+ function showStatus(msg, type) {
264
+ const el = document.getElementById('status');
265
+ el.className = 'status-msg ' + type;
266
+ el.textContent = msg;
267
+ }
268
+
269
+ // Pre-fill if editing existing config
270
+ (async () => {
271
+ try {
272
+ const [configRes, mcpRes] = await Promise.all([
273
+ fetch('/api/config'),
274
+ fetch('/api/mcp-servers'),
275
+ ]);
276
+ const data = await configRes.json();
277
+ const mcpData = await mcpRes.json();
278
+ if (data.configured && data.config) {
279
+ const c = data.config;
280
+ document.querySelector(`input[name="provider"][value="${c.provider}"]`).checked = true;
281
+ document.getElementById('port').value = c.server.port;
282
+ // Don't pre-fill masked API key
283
+ if (c[c.provider].baseUrl) document.getElementById('baseUrl').value = c[c.provider].baseUrl;
284
+ }
285
+ // Load MCP servers from dedicated endpoint
286
+ if (mcpData.mcpServers && Object.keys(mcpData.mcpServers).length > 0) {
287
+ servers = Object.entries(mcpData.mcpServers).map(([name, s]) => ({
288
+ name, command: s.command, args: s.args.join(' '), env: s.env || {},
289
+ }));
290
+ renderServers(servers);
291
+ }
292
+ } catch {}
293
+ })();
294
+ </script>
295
+ </body>
296
+ </html>