alvin-bot 4.4.1

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.
Files changed (136) hide show
  1. package/.env.example +43 -0
  2. package/BACKLOG.md +223 -0
  3. package/CHANGELOG.md +63 -0
  4. package/CLAUDE.example.md +152 -0
  5. package/CODE_OF_CONDUCT.md +52 -0
  6. package/CONTRIBUTING.md +72 -0
  7. package/LICENSE +21 -0
  8. package/README.md +529 -0
  9. package/SECURITY.md +38 -0
  10. package/SOUL.example.md +60 -0
  11. package/TOOLS.example.md +42 -0
  12. package/alvin-bot.config.example.json +24 -0
  13. package/bin/cli.js +1088 -0
  14. package/dist/.metadata_never_index +0 -0
  15. package/dist/claude.js +102 -0
  16. package/dist/config.js +65 -0
  17. package/dist/engine.js +90 -0
  18. package/dist/find-claude-binary.js +98 -0
  19. package/dist/handlers/commands.js +1489 -0
  20. package/dist/handlers/document.js +187 -0
  21. package/dist/handlers/message.js +200 -0
  22. package/dist/handlers/photo.js +154 -0
  23. package/dist/handlers/platform-message.js +275 -0
  24. package/dist/handlers/video.js +237 -0
  25. package/dist/handlers/voice.js +148 -0
  26. package/dist/i18n.js +299 -0
  27. package/dist/index.js +442 -0
  28. package/dist/init-data-dir.js +81 -0
  29. package/dist/middleware/auth.js +215 -0
  30. package/dist/migrate.js +139 -0
  31. package/dist/paths.js +87 -0
  32. package/dist/platforms/discord.js +161 -0
  33. package/dist/platforms/index.js +130 -0
  34. package/dist/platforms/signal.js +205 -0
  35. package/dist/platforms/slack.js +318 -0
  36. package/dist/platforms/telegram.js +111 -0
  37. package/dist/platforms/types.js +8 -0
  38. package/dist/platforms/whatsapp.js +648 -0
  39. package/dist/providers/claude-sdk-provider.js +173 -0
  40. package/dist/providers/codex-cli-provider.js +121 -0
  41. package/dist/providers/index.js +7 -0
  42. package/dist/providers/openai-compatible.js +388 -0
  43. package/dist/providers/registry.js +209 -0
  44. package/dist/providers/tool-executor.js +450 -0
  45. package/dist/providers/types.js +205 -0
  46. package/dist/services/access.js +144 -0
  47. package/dist/services/asset-index.js +230 -0
  48. package/dist/services/browser-manager.js +161 -0
  49. package/dist/services/browser.js +121 -0
  50. package/dist/services/compaction.js +129 -0
  51. package/dist/services/cron.js +462 -0
  52. package/dist/services/custom-tools.js +317 -0
  53. package/dist/services/delivery-queue.js +154 -0
  54. package/dist/services/elevenlabs.js +58 -0
  55. package/dist/services/embeddings.js +386 -0
  56. package/dist/services/exec-guard.js +46 -0
  57. package/dist/services/fallback-order.js +151 -0
  58. package/dist/services/heartbeat.js +192 -0
  59. package/dist/services/hooks.js +44 -0
  60. package/dist/services/imagegen.js +72 -0
  61. package/dist/services/language-detect.js +144 -0
  62. package/dist/services/markdown.js +63 -0
  63. package/dist/services/mcp.js +252 -0
  64. package/dist/services/memory.js +133 -0
  65. package/dist/services/personality.js +227 -0
  66. package/dist/services/plugins.js +171 -0
  67. package/dist/services/reminders.js +97 -0
  68. package/dist/services/restart.js +48 -0
  69. package/dist/services/security-audit.js +66 -0
  70. package/dist/services/self-search.js +129 -0
  71. package/dist/services/session.js +93 -0
  72. package/dist/services/skills.js +287 -0
  73. package/dist/services/standing-orders.js +29 -0
  74. package/dist/services/subagents.js +142 -0
  75. package/dist/services/sudo.js +243 -0
  76. package/dist/services/telegram.js +113 -0
  77. package/dist/services/tool-discovery.js +214 -0
  78. package/dist/services/usage-tracker.js +137 -0
  79. package/dist/services/users.js +199 -0
  80. package/dist/services/voice.js +95 -0
  81. package/dist/tui/index.js +507 -0
  82. package/dist/web/canvas.js +30 -0
  83. package/dist/web/doctor-api.js +606 -0
  84. package/dist/web/openai-compat.js +252 -0
  85. package/dist/web/server.js +1351 -0
  86. package/dist/web/setup-api.js +1078 -0
  87. package/docs/mcp.example.json +16 -0
  88. package/docs/screenshots/00-Login.png +0 -0
  89. package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
  90. package/docs/screenshots/02-Chat.png +0 -0
  91. package/docs/screenshots/03-Dashboard-Overview.png +0 -0
  92. package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
  93. package/docs/screenshots/05-Personality-Editor.png +0 -0
  94. package/docs/screenshots/06-Memory-Manager.png +0 -0
  95. package/docs/screenshots/07-Active-Sessions.png +0 -0
  96. package/docs/screenshots/08-File-Browser.png +0 -0
  97. package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
  98. package/docs/screenshots/10-Custom-Tools.png +0 -0
  99. package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
  100. package/docs/screenshots/12-Messaging-Platforms.png +0 -0
  101. package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
  102. package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
  103. package/docs/screenshots/13-User-Management.png +0 -0
  104. package/docs/screenshots/14-Web-Terminal.png +0 -0
  105. package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
  106. package/docs/screenshots/16-Settings-and-Env.png +0 -0
  107. package/docs/screenshots/TG-commands.png +0 -0
  108. package/docs/screenshots/TG.png +0 -0
  109. package/docs/screenshots/_Mac-Installer.png +0 -0
  110. package/docs/tools.example.json +33 -0
  111. package/install.sh +165 -0
  112. package/package.json +190 -0
  113. package/plugins/calendar/index.js +270 -0
  114. package/plugins/email/index.js +231 -0
  115. package/plugins/finance/index.js +254 -0
  116. package/plugins/notes/index.js +227 -0
  117. package/plugins/smarthome/index.js +230 -0
  118. package/plugins/weather/index.js +122 -0
  119. package/skills/apple-notes/SKILL.md +31 -0
  120. package/skills/browse/SKILL.md +136 -0
  121. package/skills/code-project/SKILL.md +43 -0
  122. package/skills/data-analysis/SKILL.md +39 -0
  123. package/skills/document-creation/SKILL.md +48 -0
  124. package/skills/email-summary/SKILL.md +46 -0
  125. package/skills/github/SKILL.md +42 -0
  126. package/skills/summarize/SKILL.md +28 -0
  127. package/skills/system-admin/SKILL.md +39 -0
  128. package/skills/weather/SKILL.md +34 -0
  129. package/skills/web-research/SKILL.md +35 -0
  130. package/web/public/canvas.html +52 -0
  131. package/web/public/css/style.css +555 -0
  132. package/web/public/index.html +189 -0
  133. package/web/public/js/app.js +3102 -0
  134. package/web/public/js/i18n.js +1048 -0
  135. package/web/public/js/icons.js +104 -0
  136. package/web/public/login.html +48 -0
@@ -0,0 +1,3102 @@
1
+ /**
2
+ * Alvin Bot Web UI — Client-side application
3
+ * Professional redesign with Lucide icons, i18n, command palette
4
+ */
5
+
6
+ const API = '';
7
+ let ws = null;
8
+ let currentAssistantMsg = null;
9
+ let chatMessages = [];
10
+ let isTyping = false;
11
+ let notifySound = true;
12
+ const CHAT_STORAGE_KEY = 'alvinbot_chat_history';
13
+
14
+ // ── UI Init (icons, i18n, static elements) ──────────────
15
+ function initUI() {
16
+ // Sidebar title
17
+ document.getElementById('sidebar-title').innerHTML = `${icon('bot', 20)} <span>${t('app.title')}</span>`;
18
+ document.getElementById('bot-status').innerHTML = `<span class="status-dot offline"></span> ${t('connecting')}`;
19
+
20
+ // Nav
21
+ const NAV = [
22
+ { section: 'nav.main', items: [
23
+ { page: 'chat', icon: 'message-square', label: 'nav.chat' },
24
+ { page: 'dashboard', icon: 'layout-dashboard', label: 'nav.dashboard' },
25
+ ]},
26
+ { section: 'nav.ai', items: [
27
+ { page: 'models', icon: 'bot', label: 'nav.models' },
28
+ { page: 'personality', icon: 'palette', label: 'nav.personality' },
29
+ ]},
30
+ { section: 'nav.data', items: [
31
+ { page: 'memory', icon: 'brain', label: 'nav.memory' },
32
+ { page: 'sessions', icon: 'clipboard', label: 'nav.sessions' },
33
+ { page: 'files', icon: 'folder', label: 'nav.files' },
34
+ ]},
35
+ { section: 'nav.system', items: [
36
+ { page: 'cron', icon: 'timer', label: 'nav.cron' },
37
+ { page: 'tools', icon: 'hammer', label: 'nav.tools' },
38
+ { page: 'plugins', icon: 'plug', label: 'nav.plugins' },
39
+ { page: 'platforms', icon: 'smartphone', label: 'nav.platforms' },
40
+ { page: 'users', icon: 'users', label: 'nav.users' },
41
+ { page: 'terminal', icon: 'terminal', label: 'nav.terminal' },
42
+ { page: 'maintenance', icon: 'stethoscope', label: 'nav.maintenance' },
43
+ { page: 'settings', icon: 'settings', label: 'nav.settings' },
44
+ ]},
45
+ ];
46
+
47
+ let navHtml = '';
48
+ for (const section of NAV) {
49
+ navHtml += `<div class="nav-section">${t(section.section)}</div>`;
50
+ for (const item of section.items) {
51
+ const active = item.page === 'chat' ? ' active' : '';
52
+ navHtml += `<div class="nav-item${active}" data-page="${item.page}" data-icon="${item.icon}" data-label="${item.label}">${icon(item.icon, 16)} <span>${t(item.label)}</span></div>`;
53
+ }
54
+ }
55
+ document.getElementById('nav-container').innerHTML = navHtml;
56
+
57
+ // Sidebar footer
58
+ const langDe = getLang() === 'de' ? 'lang-active' : '';
59
+ const langEn = getLang() === 'en' ? 'lang-active' : '';
60
+ document.getElementById('sidebar-footer').innerHTML = `
61
+ <button onclick="toggleTheme()" title="${t('sidebar.theme')}">${icon('sun', 14)} <span>${t('sidebar.theme')}</span></button>
62
+ <button onclick="resetChat()" title="${t('chat.new.session')}">${icon('refresh-cw', 14)} <span>${t('sidebar.reset')}</span></button>
63
+ <button class="lang-toggle" onclick="toggleLang()" title="${t('sidebar.lang')}">
64
+ ${icon('languages', 14)}
65
+ <span><span class="${langDe}">DE</span> | <span class="${langEn}">EN</span></span>
66
+ </button>
67
+ `;
68
+
69
+ // Page title
70
+ document.getElementById('page-title').innerHTML = `${icon('message-square', 18)} ${t('nav.chat')}`;
71
+
72
+ // Cmd+K hint
73
+ document.getElementById('cmd-k-hint').textContent = navigator.platform?.includes('Mac') ? '⌘K' : 'Ctrl+K';
74
+
75
+ // Chat header
76
+ document.getElementById('chat-header').innerHTML = `
77
+ <label>${t('chat.model')}:</label>
78
+ <select id="chat-model" onchange="switchModel(this.value)"></select>
79
+ <label style="margin-left:8px">${t('chat.effort')}:</label>
80
+ <select id="chat-effort">
81
+ <option value="low">${t('chat.effort.low')}</option>
82
+ <option value="medium">${t('chat.effort.medium')}</option>
83
+ <option value="high" selected>${t('chat.effort.high')}</option>
84
+ <option value="max">${t('chat.effort.max')}</option>
85
+ </select>
86
+ <div style="flex:1"></div>
87
+ <button class="btn btn-sm btn-outline" onclick="exportChat('markdown')" title="⌘⇧E">${icon('download', 14)} ${t('chat.export')}</button>
88
+ <button class="btn btn-sm btn-outline" onclick="exportChat('json')">JSON</button>
89
+ `;
90
+
91
+ // Chat welcome
92
+ document.getElementById('chat-welcome').textContent = t('chat.welcome');
93
+
94
+ // Chat input area
95
+ document.getElementById('chat-input-area').innerHTML = `
96
+ <label for="file-upload" style="cursor:pointer;padding:4px 8px;opacity:0.6;transition:opacity 0.2s;display:flex;align-items:center" title="${t('chat.file.attach')}">
97
+ ${icon('paperclip', 20)}
98
+ </label>
99
+ <input type="file" id="file-upload" style="display:none" onchange="handleFileSelect(this.files)">
100
+ <textarea id="chat-input" placeholder="${t('chat.placeholder')}" rows="1"></textarea>
101
+ <button class="btn-send" id="send-btn" onclick="sendMessage()">${icon('send', 16)} ${t('chat.send')}</button>
102
+ `;
103
+
104
+ // Reply/file close buttons
105
+ document.getElementById('reply-close-btn').innerHTML = icon('x', 16);
106
+ document.getElementById('file-close-btn').innerHTML = icon('x', 16);
107
+
108
+ // Drop overlay
109
+ document.getElementById('drop-overlay').innerHTML = `${icon('paperclip', 24)} ${t('chat.file.drop')}`;
110
+
111
+ // Memory save button
112
+ document.getElementById('memory-save-btn').innerHTML = `${icon('save', 14)} ${t('save')}`;
113
+
114
+ // File buttons
115
+ document.getElementById('file-new-btn').innerHTML = `${icon('file-text', 14)} ${t('files.new')}`;
116
+ document.getElementById('file-up-btn').innerHTML = `${icon('arrow-up', 14)} ${t('files.up')}`;
117
+ document.getElementById('file-save-btn').innerHTML = `${icon('save', 14)} ${t('save')}`;
118
+ document.getElementById('file-delete-btn').innerHTML = `${icon('trash-2', 14)}`;
119
+ document.getElementById('file-close-editor-btn').innerHTML = icon('x', 14);
120
+ document.getElementById('file-breadcrumb').innerHTML = `${icon('folder', 14)} /`;
121
+
122
+ // Cron header
123
+ document.getElementById('cron-header').innerHTML = `
124
+ <div style="flex:1">
125
+ <h3 style="font-size:1em;margin-bottom:4px;display:flex;align-items:center;gap:6px">${icon('timer', 18)} ${t('cron.title')}</h3>
126
+ <div class="sub">${t('cron.desc')}</div>
127
+ </div>
128
+ <button class="btn btn-sm" onclick="showCreateCron()">${icon('plus', 14)} ${t('cron.create')}</button>
129
+ `;
130
+
131
+ // Cron type selector
132
+ document.getElementById('cron-name').placeholder = t('cron.name.placeholder');
133
+ const cronType = document.getElementById('cron-type');
134
+ cronType.innerHTML = `
135
+ <option value="reminder">${icon('timer', 14)} ${t('cron.type.reminder')}</option>
136
+ <option value="shell">${icon('zap', 14)} ${t('cron.type.shell')}</option>
137
+ <option value="http">${icon('globe', 14)} ${t('cron.type.http')}</option>
138
+ <option value="message">${icon('message-square', 14)} ${t('cron.type.message')}</option>
139
+ <option value="ai-query">${icon('bot', 14)} ${t('cron.type.ai')}</option>
140
+ `;
141
+ document.getElementById('cron-payload').placeholder = t('cron.payload.placeholder');
142
+ document.getElementById('cron-form-buttons').innerHTML = `
143
+ <button class="btn btn-sm" onclick="createCronJob()">${icon('save', 14)} ${t('cron.create')}</button>
144
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('cron-create-form').style.display='none'">${t('cancel')}</button>
145
+ `;
146
+
147
+ // Tools search
148
+ document.getElementById('tools-search').placeholder = `${t('tools.search.placeholder')}`;
149
+
150
+ // Terminal
151
+ document.getElementById('terminal-input').placeholder = t('terminal.placeholder');
152
+ document.getElementById('terminal-run-btn').innerHTML = `${icon('play', 14)} ${t('terminal.run')}`;
153
+
154
+ // Maintenance loading
155
+ document.getElementById('maint-loading').textContent = t('loading');
156
+
157
+ // Command palette
158
+ document.getElementById('cmd-palette-input').placeholder = t('cmd.placeholder');
159
+
160
+ // Personality card
161
+ document.getElementById('personality-card').innerHTML = `
162
+ <h3 style="display:flex;align-items:center;gap:8px">${icon('palette', 18)} ${t('personality.title')}</h3>
163
+ <div class="sub" style="margin-bottom:12px">${t('personality.desc')}</div>
164
+ <textarea class="editor" id="soul-editor" style="min-height:300px" placeholder="${t('personality.placeholder')}"></textarea>
165
+ <div style="margin-top:12px;display:flex;gap:8px">
166
+ <button class="btn" onclick="saveSoul()">${icon('save', 14)} ${t('personality.save')}</button>
167
+ <button class="btn btn-outline" onclick="loadPersonality()">${icon('refresh-cw', 14)} ${t('personality.reload')}</button>
168
+ </div>
169
+ `;
170
+
171
+ // Rebind nav events
172
+ bindNavEvents();
173
+ bindChatEvents();
174
+ }
175
+
176
+ // Called by i18n when language changes
177
+ function refreshUI() {
178
+ // Save current page
179
+ const activePage = document.querySelector('.nav-item.active')?.dataset?.page || 'chat';
180
+ initUI();
181
+ // Restore active page
182
+ const navItem = document.querySelector(`.nav-item[data-page="${activePage}"]`);
183
+ if (navItem) navItem.click();
184
+ }
185
+
186
+ // ── Chat Persistence ────────────────────────────────────
187
+ function saveChatToStorage() {
188
+ try { localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(chatMessages)); } catch { }
189
+ }
190
+
191
+ function restoreChatFromStorage() {
192
+ try {
193
+ const stored = localStorage.getItem(CHAT_STORAGE_KEY);
194
+ if (!stored) return;
195
+ const messages = JSON.parse(stored);
196
+ if (!Array.isArray(messages) || messages.length === 0) return;
197
+ chatMessages = messages;
198
+ for (const msg of messages) {
199
+ addMessage(msg.role, msg.text, msg.time, true);
200
+ }
201
+ } catch { }
202
+ }
203
+
204
+ function clearChatStorage() { localStorage.removeItem(CHAT_STORAGE_KEY); }
205
+
206
+ // ── Toast Notifications ─────────────────────────────────
207
+ function toast(message, type = 'success') {
208
+ const el = document.createElement('div');
209
+ el.className = `toast ${type}`;
210
+ const ic = type === 'success' ? icon('circle-check', 16) : icon('circle-alert', 16);
211
+ el.innerHTML = `${ic} <span>${escapeHtml(message)}</span>`;
212
+ document.body.appendChild(el);
213
+ setTimeout(() => el.remove(), 3000);
214
+ }
215
+
216
+ // ── Navigation ──────────────────────────────────────────
217
+ function bindNavEvents() {
218
+ document.querySelectorAll('.nav-item').forEach(item => {
219
+ item.addEventListener('click', () => {
220
+ document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
221
+ item.classList.add('active');
222
+ const page = item.dataset.page;
223
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
224
+ const pageEl = document.getElementById('page-' + page);
225
+ if (pageEl) pageEl.classList.add('active');
226
+ document.getElementById('page-title').innerHTML = `${icon(item.dataset.icon, 18)} ${t(item.dataset.label)}`;
227
+
228
+ const loaders = { dashboard: loadDashboard, memory: loadMemory, models: loadModels,
229
+ sessions: loadSessions, plugins: loadPlugins, tools: loadTools, cron: loadCron,
230
+ files: () => navigateFiles('.'), users: loadUsers, settings: loadSettings,
231
+ platforms: loadPlatforms, personality: loadPersonality, maintenance: loadMaintenance };
232
+ if (loaders[page]) loaders[page]();
233
+ });
234
+ });
235
+ }
236
+
237
+ // ── WebSocket ───────────────────────────────────────────
238
+ function connectWS() {
239
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
240
+ ws = new WebSocket(`${proto}//${location.host}`);
241
+ ws.onopen = () => {
242
+ document.getElementById('bot-status').innerHTML = `<span class="status-dot online"></span> ${t('connected')}`;
243
+ };
244
+ ws.onclose = () => {
245
+ document.getElementById('bot-status').innerHTML = `<span class="status-dot offline"></span> ${t('reconnecting')}`;
246
+ setTimeout(connectWS, 3000);
247
+ };
248
+ ws.onmessage = (e) => handleWSMessage(JSON.parse(e.data));
249
+ }
250
+
251
+ let pendingTools = [];
252
+ let currentToolGroup = null;
253
+
254
+ function handleWSMessage(msg) {
255
+ const typing = document.getElementById('typing-indicator');
256
+ switch (msg.type) {
257
+ case 'text':
258
+ typing.classList.remove('visible');
259
+ flushToolGroup();
260
+ if (!currentAssistantMsg) currentAssistantMsg = addMessage('assistant', '');
261
+ currentAssistantMsg.querySelector('.msg-text').innerHTML = renderMarkdown(msg.text || '');
262
+ scrollToBottom();
263
+ break;
264
+ case 'tool':
265
+ typing.classList.add('visible');
266
+ pendingTools.push({ name: msg.name, input: msg.input });
267
+ updateToolIndicator();
268
+ break;
269
+ case 'done':
270
+ flushToolGroup();
271
+ if (currentAssistantMsg && (msg.cost || msg.inputTokens || msg.outputTokens)) {
272
+ const costEl = document.createElement('span');
273
+ costEl.className = 'time';
274
+ const parts = [];
275
+ if (msg.inputTokens || msg.outputTokens) parts.push(`${(msg.inputTokens||0)+(msg.outputTokens||0)} tokens`);
276
+ if (msg.cost) parts.push(`$${msg.cost.toFixed(4)}`);
277
+ costEl.textContent = parts.join(' · ');
278
+ currentAssistantMsg.querySelector('.msg-text').appendChild(costEl);
279
+ }
280
+ if (currentAssistantMsg) {
281
+ chatMessages.push({ role: 'assistant', text: currentAssistantMsg.querySelector('.msg-text').textContent, time: timeStr() });
282
+ saveChatToStorage();
283
+ }
284
+ currentAssistantMsg = null;
285
+ document.getElementById('send-btn').disabled = false;
286
+ typing.classList.remove('visible');
287
+ if (notifySound) playNotifySound();
288
+ break;
289
+ case 'error':
290
+ flushToolGroup();
291
+ addMessage('system', `${msg.error}`);
292
+ currentAssistantMsg = null;
293
+ document.getElementById('send-btn').disabled = false;
294
+ typing.classList.remove('visible');
295
+ break;
296
+ case 'fallback':
297
+ addMessage('system', `${icon('zap', 14)} ${t('chat.fallback')}: ${msg.from} → ${msg.to}`);
298
+ break;
299
+ case 'reset':
300
+ document.getElementById('messages').innerHTML = `<div class="msg system">${t('chat.welcome')}</div><div class="typing-indicator" id="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>`;
301
+ addMessage('system', t('chat.session.reset'));
302
+ chatMessages = [];
303
+ pendingTools = [];
304
+ currentToolGroup = null;
305
+ clearChatStorage();
306
+ break;
307
+ }
308
+ }
309
+
310
+ function updateToolIndicator() {
311
+ const typing = document.getElementById('typing-indicator');
312
+ if (pendingTools.length > 0) {
313
+ typing.classList.add('visible');
314
+ typing.innerHTML = `<span style="font-size:0.75em;color:var(--fg2);display:flex;align-items:center;gap:4px">${icon('wrench', 14)} ${pendingTools[pendingTools.length - 1].name}...</span>`;
315
+ }
316
+ }
317
+
318
+ function flushToolGroup() {
319
+ if (pendingTools.length === 0) return;
320
+ const typing = document.getElementById('typing-indicator');
321
+ typing.innerHTML = '<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>';
322
+
323
+ const group = document.createElement('div');
324
+ group.className = 'msg tool-group';
325
+ const count = pendingTools.length;
326
+ const names = [...new Set(pendingTools.map(t => t.name))];
327
+ const summary = names.length <= 3 ? names.join(', ') : names.slice(0, 3).join(', ') + ` +${names.length - 3}`;
328
+
329
+ group.innerHTML = `
330
+ <div class="tool-group-header" onclick="this.parentElement.classList.toggle('expanded')">
331
+ <span class="tool-group-icon">${icon('wrench', 14)}</span>
332
+ <span class="tool-group-label">${t('chat.tools.used', { count })}</span>
333
+ <span class="tool-group-names">${summary}</span>
334
+ <span class="tool-group-chevron">${icon('chevron-right', 14)}</span>
335
+ </div>
336
+ <div class="tool-group-details">
337
+ ${pendingTools.map(t => `<div class="tool-group-item"><span class="tool-item-name">${escapeHtml(t.name)}</span>${t.input ? `<pre class="tool-item-input">${escapeHtml(typeof t.input === 'string' ? t.input : JSON.stringify(t.input, null, 2))}</pre>` : ''}</div>`).join('')}
338
+ </div>
339
+ `;
340
+
341
+ const container = document.getElementById('messages');
342
+ container.insertBefore(group, typing);
343
+ pendingTools = [];
344
+ scrollToBottom();
345
+ }
346
+
347
+ // ── Sound ───────────────────────────────────────────────
348
+ function playNotifySound() {
349
+ try {
350
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
351
+ const osc = ctx.createOscillator();
352
+ const gain = ctx.createGain();
353
+ osc.connect(gain); gain.connect(ctx.destination);
354
+ osc.frequency.value = 800; gain.gain.value = 0.1;
355
+ osc.start(); osc.stop(ctx.currentTime + 0.1);
356
+ } catch { }
357
+ }
358
+
359
+ // ── Markdown Rendering ──────────────────────────────────
360
+ function renderMarkdown(text) {
361
+ return text
362
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
363
+ .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
364
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
365
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
366
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
367
+ .replace(/^- (.+)$/gm, '• $1')
368
+ .replace(/\n/g, '<br>');
369
+ }
370
+
371
+ function escapeHtml(s) {
372
+ if (!s) return '';
373
+ const div = document.createElement('div');
374
+ div.textContent = s;
375
+ return div.innerHTML;
376
+ }
377
+
378
+ function timeStr() {
379
+ return new Date().toLocaleTimeString(getLang() === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit' });
380
+ }
381
+
382
+ // ── Reply State ─────────────────────────────────────────
383
+ let _replyTo = null;
384
+
385
+ function setReply(msgIndex, text, role) {
386
+ _replyTo = { msgIndex, text, role };
387
+ const preview = document.getElementById('reply-preview');
388
+ const previewText = document.getElementById('reply-preview-text');
389
+ const sender = role === 'user' ? t('chat.reply.you') : t('chat.reply.bot');
390
+ previewText.textContent = `↩ ${sender}: ${text.substring(0, 120)}${text.length > 120 ? '…' : ''}`;
391
+ preview.style.display = 'flex';
392
+ document.getElementById('chat-input').focus();
393
+ }
394
+
395
+ function clearReply() {
396
+ _replyTo = null;
397
+ document.getElementById('reply-preview').style.display = 'none';
398
+ }
399
+
400
+ // ── File Upload State ───────────────────────────────────
401
+ let _pendingFile = null;
402
+
403
+ function handleFileSelect(files) {
404
+ if (!files || !files.length) return;
405
+ const file = files[0];
406
+ const reader = new FileReader();
407
+ reader.onload = () => {
408
+ _pendingFile = { name: file.name, type: file.type, size: file.size, dataUrl: reader.result };
409
+ const preview = document.getElementById('file-preview');
410
+ const previewText = document.getElementById('file-preview-text');
411
+ const sizeKb = (file.size / 1024).toFixed(1);
412
+ previewText.innerHTML = `${icon('paperclip', 14)} ${file.name} (${sizeKb} KB)`;
413
+ preview.style.display = 'flex';
414
+ };
415
+ reader.readAsDataURL(file);
416
+ }
417
+
418
+ function clearFileUpload() {
419
+ _pendingFile = null;
420
+ document.getElementById('file-preview').style.display = 'none';
421
+ document.getElementById('file-upload').value = '';
422
+ }
423
+
424
+ // ── Drag & Drop ─────────────────────────────────────────
425
+ function initDragDrop() {
426
+ const msgs = document.getElementById('messages');
427
+ if (!msgs) return;
428
+ let dragCounter = 0;
429
+ msgs.setAttribute('data-drop-text', `${t('chat.file.drop')}`);
430
+ msgs.addEventListener('dragenter', (e) => { e.preventDefault(); dragCounter++; msgs.classList.add('drag-over'); });
431
+ msgs.addEventListener('dragleave', (e) => { e.preventDefault(); dragCounter--; if (dragCounter <= 0) { dragCounter = 0; msgs.classList.remove('drag-over'); } });
432
+ msgs.addEventListener('dragover', (e) => { e.preventDefault(); });
433
+ msgs.addEventListener('drop', (e) => {
434
+ e.preventDefault(); dragCounter = 0; msgs.classList.remove('drag-over');
435
+ if (e.dataTransfer?.files?.length) handleFileSelect(e.dataTransfer.files);
436
+ });
437
+ }
438
+
439
+ // ── Chat ────────────────────────────────────────────────
440
+ let _msgCounter = 0;
441
+
442
+ function addMessage(role, text, customTime, skipSave) {
443
+ const msgIdx = _msgCounter++;
444
+ const el = document.createElement('div');
445
+ el.className = 'msg ' + role;
446
+ el.dataset.msgIndex = msgIdx;
447
+
448
+ if (role === 'user' && _replyTo && !skipSave) {
449
+ const quote = document.createElement('div');
450
+ quote.className = 'reply-quote';
451
+ const sender = _replyTo.role === 'user' ? t('chat.reply.you') : t('chat.reply.bot');
452
+ quote.textContent = `${sender}: ${_replyTo.text.substring(0, 100)}`;
453
+ el.appendChild(quote);
454
+ }
455
+
456
+ if (role === 'user' && _pendingFile && !skipSave) {
457
+ const badge = document.createElement('div');
458
+ badge.className = 'file-badge';
459
+ badge.innerHTML = `${icon('paperclip', 12)} ${escapeHtml(_pendingFile.name)}`;
460
+ el.appendChild(badge);
461
+ }
462
+
463
+ const textEl = document.createElement('span');
464
+ textEl.className = 'msg-text';
465
+ if (role === 'assistant') {
466
+ textEl.innerHTML = renderMarkdown(text);
467
+ } else {
468
+ textEl.textContent = text;
469
+ }
470
+ el.appendChild(textEl);
471
+
472
+ if (role !== 'system') {
473
+ const time = document.createElement('span');
474
+ time.className = 'time';
475
+ time.textContent = customTime || timeStr();
476
+ el.appendChild(time);
477
+
478
+ const replyBtn = document.createElement('button');
479
+ replyBtn.className = 'msg-reply-btn';
480
+ replyBtn.innerHTML = `${icon('chevron-right', 12)} ${t('chat.reply')}`;
481
+ replyBtn.title = t('chat.reply');
482
+ replyBtn.onclick = () => setReply(msgIdx, text, role);
483
+ el.appendChild(replyBtn);
484
+ }
485
+
486
+ const container = document.getElementById('messages');
487
+ container.insertBefore(el, document.getElementById('typing-indicator'));
488
+ if (!skipSave) scrollToBottom();
489
+ return el;
490
+ }
491
+
492
+ function scrollToBottom() {
493
+ const msgs = document.getElementById('messages');
494
+ msgs.scrollTop = msgs.scrollHeight;
495
+ }
496
+
497
+ function sendMessage() {
498
+ const input = document.getElementById('chat-input');
499
+ const text = input.value.trim();
500
+ if ((!text && !_pendingFile) || !ws || ws.readyState !== 1) return;
501
+
502
+ const effort = document.getElementById('chat-effort')?.value;
503
+ let fullText = text || '';
504
+ let replyContext = null;
505
+ if (_replyTo) {
506
+ replyContext = { role: _replyTo.role, text: _replyTo.text };
507
+ const replyLabel = _replyTo.role === 'user' ? 'my' : 'your';
508
+ fullText = `[Reply to ${replyLabel} message: "${_replyTo.text.substring(0, 300)}"]\n\n${fullText}`;
509
+ }
510
+ let fileInfo = null;
511
+ if (_pendingFile) {
512
+ fileInfo = { name: _pendingFile.name, type: _pendingFile.type, size: _pendingFile.size };
513
+ const fileRef = `[File attached: ${_pendingFile.name} (${_pendingFile.type}, ${(_pendingFile.size/1024).toFixed(1)} KB)]`;
514
+ fullText = fullText ? `${fileRef}\n\n${fullText}` : fileRef;
515
+ }
516
+
517
+ const tm = timeStr();
518
+ addMessage('user', text || `${_pendingFile?.name || 'File'}`, tm);
519
+ chatMessages.push({ role: 'user', text: fullText, time: tm, replyTo: replyContext, file: fileInfo });
520
+ saveChatToStorage();
521
+
522
+ const payload = { type: 'chat', text: fullText, effort };
523
+ if (_pendingFile) payload.file = { name: _pendingFile.name, type: _pendingFile.type, dataUrl: _pendingFile.dataUrl };
524
+ ws.send(JSON.stringify(payload));
525
+
526
+ input.value = '';
527
+ input.style.height = 'auto';
528
+ clearReply();
529
+ clearFileUpload();
530
+ document.getElementById('send-btn').disabled = true;
531
+ document.getElementById('typing-indicator').classList.add('visible');
532
+ scrollToBottom();
533
+ }
534
+
535
+ function resetChat() {
536
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'reset' }));
537
+ chatMessages = [];
538
+ clearChatStorage();
539
+ }
540
+
541
+ function exportChat(format = 'markdown') {
542
+ if (chatMessages.length === 0) { toast(t('chat.no.export'), 'error'); return; }
543
+ fetch(API + '/api/chat/export', {
544
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
545
+ body: JSON.stringify({ messages: chatMessages, format }),
546
+ }).then(res => res.text()).then(text => {
547
+ const ext = format === 'json' ? 'json' : 'md';
548
+ const blob = new Blob([text], { type: 'text/plain' });
549
+ const a = document.createElement('a');
550
+ a.href = URL.createObjectURL(blob);
551
+ a.download = `alvin-bot-chat-${new Date().toISOString().slice(0,10)}.${ext}`;
552
+ a.click();
553
+ toast(t('chat.exported'));
554
+ });
555
+ }
556
+
557
+ function bindChatEvents() {
558
+ const chatInput = document.getElementById('chat-input');
559
+ if (!chatInput) return;
560
+
561
+ // Remove existing listeners by replacing element (clean slate)
562
+ const newInput = chatInput.cloneNode(true);
563
+ chatInput.parentNode.replaceChild(newInput, chatInput);
564
+
565
+ newInput.addEventListener('keydown', (e) => {
566
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
567
+ });
568
+ newInput.addEventListener('input', function() {
569
+ this.style.height = 'auto';
570
+ this.style.height = Math.min(this.scrollHeight, 140) + 'px';
571
+ });
572
+ }
573
+
574
+ // Global keyboard shortcuts
575
+ document.addEventListener('keydown', (e) => {
576
+ if (e.key === 'n' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); resetChat(); }
577
+ if (e.key === 'e' && (e.metaKey || e.ctrlKey) && e.shiftKey) { e.preventDefault(); exportChat(); }
578
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openCommandPalette(); }
579
+ });
580
+
581
+ // ── Dashboard ───────────────────────────────────────────
582
+ async function loadDashboard() {
583
+ const res = await fetch(API + '/api/status');
584
+ const data = await res.json();
585
+ const tok = data.tokens || {};
586
+ const fmtTokens = (n) => n >= 1000000 ? (n/1000000).toFixed(1) + 'M' : n >= 1000 ? (n/1000).toFixed(1) + 'k' : String(n || 0);
587
+ // Setup status cards (only show if something is unconfigured)
588
+ const setup = data.setup || {};
589
+ let setupHtml = '';
590
+ if (!setup.telegram || !setup.provider) {
591
+ const tgStatus = setup.telegram
592
+ ? `<span style="color:var(--green)">✅ Connected</span>`
593
+ : `<span style="color:var(--yellow)">⚠️ Not configured</span> — <a href="#" onclick="navigateTo('platforms');return false" style="color:var(--accent)">Set up</a>`;
594
+ const aiStatus = setup.provider
595
+ ? `<span style="color:var(--green)">✅ ${data.model.name}</span>`
596
+ : `<span style="color:var(--yellow)">⚠️ Not configured</span> — <a href="#" onclick="navigateTo('models');return false" style="color:var(--accent)">Set up</a>`;
597
+ setupHtml = `
598
+ <div class="card" style="grid-column:1/-1;border:1px solid var(--yellow);background:var(--bg2)">
599
+ <h3>${icon('alert-triangle', 14)} Setup Status</h3>
600
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px">
601
+ <div><strong>Telegram:</strong> ${tgStatus}</div>
602
+ <div><strong>AI Provider:</strong> ${aiStatus}</div>
603
+ </div>
604
+ </div>
605
+ `;
606
+ }
607
+
608
+ document.getElementById('dashboard-cards').innerHTML = `
609
+ ${setupHtml}
610
+ <div class="card"><h3>${icon('bot', 14)} ${t('dashboard.model')}</h3><div class="value">${data.model.name}</div><div class="sub">${data.model.model}</div></div>
611
+ <div class="card"><h3>${icon('clock', 14)} ${t('dashboard.uptime')}</h3><div class="value">${Math.floor(data.bot.uptime/3600)}h ${Math.floor(data.bot.uptime%3600/60)}m</div><div class="sub">v${data.bot.version}</div></div>
612
+ <div class="card"><h3>${icon('zap', 14)} ${t('dashboard.tokens')}</h3><div class="value">${fmtTokens(tok.total)}</div><div class="sub">${fmtTokens(tok.totalInput)} ${t('dashboard.tokens.in')} · ${fmtTokens(tok.totalOutput)} ${t('dashboard.tokens.out')} · $${(tok.totalCost || 0).toFixed(4)}</div></div>
613
+ <div class="card"><h3>${icon('brain', 14)} ${t('dashboard.memory')}</h3><div class="value">${data.memory.dailyLogs} ${t('dashboard.memory.days')}</div><div class="sub">${data.memory.vectors} ${t('dashboard.memory.vectors')} · ${data.memory.todayEntries} ${t('dashboard.memory.today')}</div></div>
614
+ <div class="card"><h3>${icon('plug', 14)} ${t('dashboard.plugins')}</h3><div class="value">${data.plugins}</div><div class="sub">${t('dashboard.plugins.loaded')}</div></div>
615
+ <div class="card"><h3>${icon('wrench', 14)} ${t('dashboard.mcp')}</h3><div class="value">${data.mcp}</div><div class="sub">${t('dashboard.mcp.servers')}</div></div>
616
+ <div class="card"><h3>${icon('users', 14)} ${t('dashboard.users')}</h3><div class="value">${data.users}</div><div class="sub">${t('dashboard.users.profiles')}</div></div>
617
+ `;
618
+ document.getElementById('model-badge').textContent = data.model.name;
619
+ }
620
+
621
+ // ── Models / Providers ──────────────────────────────────
622
+ async function loadModels() {
623
+ const [modelsRes, setupRes] = await Promise.all([
624
+ fetch(API + '/api/models'),
625
+ fetch(API + '/api/providers/setup'),
626
+ ]);
627
+ const modelsData = await modelsRes.json();
628
+ const setupData = await setupRes.json();
629
+
630
+ const sel = document.getElementById('chat-model');
631
+ if (sel) {
632
+ sel.innerHTML = modelsData.models.map(m =>
633
+ `<option value="${m.key}" ${m.active ? 'selected' : ''}>${m.name}</option>`
634
+ ).join('');
635
+ }
636
+
637
+ let html = `<div style="margin-bottom:20px"><h3 style="font-size:1em;margin-bottom:4px;display:flex;align-items:center;gap:8px">${icon('bot', 20)} ${t('models.title')}</h3><div class="sub">${t('models.desc')}</div></div>`;
638
+
639
+ for (const p of setupData.providers) {
640
+ const statusBadge = p.hasKey
641
+ ? `<span class="badge badge-green">${icon('circle-check', 12)} ${t('models.key.set')}</span>`
642
+ : (p.free ? `<span class="badge badge-yellow">${icon('zap', 12)} ${t('models.free')}</span>` : `<span class="badge badge-red">${icon('circle-x', 12)} ${t('models.key.none')}</span>`);
643
+
644
+ html += `<div class="card setup-card" style="margin-bottom:16px">
645
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
646
+ <span style="font-size:1.5em">${p.icon}</span>
647
+ <div style="flex:1">
648
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0">${p.name}</h3>
649
+ <div class="sub">${p.description}</div>
650
+ </div>
651
+ ${statusBadge}
652
+ </div>`;
653
+
654
+ html += `<details style="margin-bottom:12px"><summary style="cursor:pointer;color:var(--accent2);font-size:0.82em;font-weight:500;display:flex;align-items:center;gap:4px">${icon('clipboard', 14)} ${t('models.setup.guide')}</summary><ol style="margin:8px 0 0 16px;color:var(--fg2);font-size:0.82em;line-height:1.6">`;
655
+ for (const step of p.setupSteps) html += `<li>${step}</li>`;
656
+ if (p.signupUrl) html += `<li><a href="${p.signupUrl}" target="_blank" style="color:var(--accent2)">${p.signupUrl}</a></li>`;
657
+ html += `</ol></details>`;
658
+
659
+ if (p.envKey) {
660
+ html += `<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
661
+ <input type="password" id="key-${p.id}" placeholder="${t('models.key.placeholder')}" value="${p.keyPreview}" style="flex:1;background:var(--bg3);border:1px solid var(--glass-border);border-radius:6px;padding:8px 12px;color:var(--fg);font:inherit;font-size:0.85em;font-family:monospace;outline:none">
662
+ <button class="btn btn-sm" onclick="saveProviderKey('${p.id}')">${icon('save', 12)} ${t('models.key.save')}</button>
663
+ <button class="btn btn-sm btn-outline" onclick="testProviderKey('${p.id}')">${icon('test-tube', 12)} ${t('models.key.test')}</button>
664
+ </div>
665
+ <div id="key-result-${p.id}" style="font-size:0.78em;margin-bottom:8px"></div>`;
666
+ }
667
+
668
+ if (p.hasKey && p.id !== 'claude-sdk' && p.id !== 'ollama') {
669
+ html += `<details id="live-models-${p.id}" style="margin-bottom:8px">
670
+ <summary onclick="loadLiveModels('${p.id}')" style="cursor:pointer;color:var(--accent2);font-size:0.82em;font-weight:500;display:flex;align-items:center;gap:4px">${icon('search', 14)} ${t('models.live.title')}</summary>
671
+ <div id="live-models-list-${p.id}" style="margin-top:8px;max-height:300px;overflow-y:auto;font-size:0.82em">${t('models.live.loading')}</div>
672
+ </details>`;
673
+ }
674
+
675
+ html += `<div style="border-top:1px solid var(--glass-border);padding-top:8px;margin-top:4px">`;
676
+ for (const m of p.modelsActive) {
677
+ const isActive = m.active;
678
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:0.85em">
679
+ <span style="width:20px;text-align:center">${isActive ? icon('circle-check', 14, 'style="color:var(--green)"') : (m.registered ? icon('circle-dot', 14) : icon('circle-dot', 14, 'style="opacity:0.3"'))}</span>
680
+ <span style="flex:1;font-family:monospace">${m.name} <span style="color:var(--fg2)">(${m.model})</span></span>
681
+ ${isActive ? `<span class="badge badge-green">${t('active')}</span>` : `<button class="btn btn-sm btn-outline" onclick="switchModel('${m.key}')">${t('models.activate')}</button>`}
682
+ </div>`;
683
+ }
684
+ html += `</div></div>`;
685
+ }
686
+
687
+ // Custom Models section
688
+ html += `<div class="card" style="margin-bottom:16px">
689
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
690
+ <span style="display:flex">${icon('wrench', 24)}</span>
691
+ <div style="flex:1">
692
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0">${t('models.custom')}</h3>
693
+ <div class="sub">${t('models.custom.desc')}</div>
694
+ </div>
695
+ </div>`;
696
+
697
+ if (setupData.customModels.length > 0) {
698
+ for (const cm of setupData.customModels) {
699
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:0.85em;border-bottom:1px solid var(--glass-border)">
700
+ <span style="font-family:monospace;flex:1">${cm.name} <span style="color:var(--fg2)">(${cm.model})</span></span>
701
+ <span class="badge">${cm.baseUrl}</span>
702
+ <button class="btn btn-sm btn-outline" onclick="switchModel('${cm.key}')">${t('models.activate')}</button>
703
+ <button class="btn btn-sm btn-outline" style="color:var(--red)" onclick="removeCustomModel('${cm.key}')">${icon('x', 12)}</button>
704
+ </div>`;
705
+ }
706
+ }
707
+
708
+ html += `<button class="btn btn-sm" style="margin-top:12px" onclick="showAddCustomModel()">${icon('plus', 14)} ${t('models.custom.add')}</button>
709
+ <div id="custom-model-form" style="display:none;margin-top:12px;padding:12px;background:var(--bg3);border-radius:8px">
710
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
711
+ <input id="cm-key" placeholder="${t('models.custom.key')}" style="background:var(--bg);border:1px solid var(--bg4);border-radius:6px;padding:8px;color:var(--fg);font:inherit;font-size:0.82em">
712
+ <input id="cm-name" placeholder="${t('models.custom.name')}" style="background:var(--bg);border:1px solid var(--bg4);border-radius:6px;padding:8px;color:var(--fg);font:inherit;font-size:0.82em">
713
+ <input id="cm-model" placeholder="${t('models.custom.model')}" style="background:var(--bg);border:1px solid var(--bg4);border-radius:6px;padding:8px;color:var(--fg);font:inherit;font-size:0.82em">
714
+ <input id="cm-url" placeholder="${t('models.custom.url')}" style="background:var(--bg);border:1px solid var(--bg4);border-radius:6px;padding:8px;color:var(--fg);font:inherit;font-size:0.82em">
715
+ <input id="cm-apikey-env" placeholder="${t('models.custom.envkey')}" style="background:var(--bg);border:1px solid var(--bg4);border-radius:6px;padding:8px;color:var(--fg);font:inherit;font-size:0.82em">
716
+ <input id="cm-apikey" type="password" placeholder="${t('models.custom.apikey')}" style="background:var(--bg);border:1px solid var(--bg4);border-radius:6px;padding:8px;color:var(--fg);font:inherit;font-size:0.82em">
717
+ </div>
718
+ <div style="display:flex;gap:8px">
719
+ <button class="btn btn-sm" onclick="addCustomModel()">${icon('save', 12)} ${t('save')}</button>
720
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('custom-model-form').style.display='none'">${t('cancel')}</button>
721
+ </div>
722
+ </div>
723
+ </div>`;
724
+
725
+ // Fallback chain
726
+ html += `<div class="card">
727
+ <h3 style="font-size:0.85em;text-transform:none;margin-bottom:8px;display:flex;align-items:center;gap:6px">${icon('layers', 16)} ${t('models.fallback')}</h3>
728
+ <div class="sub" style="margin-bottom:12px">${t('models.fallback.desc')}</div>
729
+ <div id="fallback-list" style="font-size:0.85em">${t('loading')}</div>
730
+ </div>`;
731
+
732
+ document.getElementById('models-setup').innerHTML = html;
733
+ loadFallbackOrder();
734
+ }
735
+
736
+ async function loadFallbackOrder() {
737
+ try {
738
+ const res = await fetch(API + '/api/fallback');
739
+ const data = await res.json();
740
+ const container = document.getElementById('fallback-list');
741
+ if (!container) return;
742
+
743
+ const primary = data.order?.primary || data.activeProvider || '(not set)';
744
+ const chain = data.order?.fallbacks || [];
745
+ const healthArr = data.health || [];
746
+ const health = {};
747
+ healthArr.forEach(h => { health[h.key] = h; });
748
+
749
+ let html = `<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg3);border-radius:6px;margin-bottom:6px">
750
+ ${icon('crown', 16)}
751
+ <span style="flex:1;font-family:monospace;font-weight:600">${primary}</span>
752
+ <span class="badge badge-green">${t('models.fallback.primary')}</span>
753
+ ${health[primary] ? `<span style="font-size:0.78em;color:${health[primary].healthy ? 'var(--green)' : 'var(--red)'}">● ${health[primary].healthy ? t('models.fallback.healthy') : t('models.fallback.unhealthy')}</span>` : ''}
754
+ </div>`;
755
+
756
+ if (chain.length === 0) {
757
+ html += `<div style="padding:8px 12px;color:var(--fg2);font-style:italic">${t('models.fallback.none')}</div>`;
758
+ }
759
+
760
+ chain.forEach((key, i) => {
761
+ const h = health[key];
762
+ const healthDot = h ? `<span style="font-size:0.78em;color:${h.healthy ? 'var(--green)' : 'var(--red)'}">● ${h.healthy ? t('models.fallback.healthy') : t('models.fallback.unhealthy')}</span>` : '';
763
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--bg2);border-radius:6px;margin-bottom:4px">
764
+ <span style="color:var(--fg2);min-width:20px;text-align:center">${i + 1}.</span>
765
+ <span style="flex:1;font-family:monospace">${key}</span>
766
+ ${healthDot}
767
+ <button class="btn btn-sm btn-outline" style="padding:2px 8px;font-size:0.8em" onclick="moveFallback('${key}','up')" ${i === 0 ? 'disabled style="opacity:0.3;padding:2px 8px;font-size:0.8em"' : ''}>${icon('arrow-up', 12)}</button>
768
+ <button class="btn btn-sm btn-outline" style="padding:2px 8px;font-size:0.8em" onclick="moveFallback('${key}','down')" ${i === chain.length - 1 ? 'disabled style="opacity:0.3;padding:2px 8px;font-size:0.8em"' : ''}>${icon('arrow-down', 12)}</button>
769
+ <button class="btn btn-sm btn-outline" style="padding:2px 8px;font-size:0.8em;color:var(--red)" onclick="removeFallback('${key}')">${icon('x', 12)}</button>
770
+ </div>`;
771
+ });
772
+
773
+ const allProviders = ['claude-sdk','groq','openai','google','nvidia-llama-3.3-70b','nvidia-kimi-k2.5','openrouter'];
774
+ const available = allProviders.filter(p => p !== primary && !chain.includes(p));
775
+ if (available.length > 0) {
776
+ html += `<div style="margin-top:8px;display:flex;gap:8px;align-items:center">
777
+ <select id="fallback-add-select" style="flex:1;background:var(--bg3);border:1px solid var(--glass-border);border-radius:6px;padding:6px 10px;color:var(--fg);font:inherit;font-size:0.85em">
778
+ ${available.map(p => `<option value="${p}">${p}</option>`).join('')}
779
+ </select>
780
+ <button class="btn btn-sm" onclick="addFallback()">${icon('plus', 12)} ${t('models.fallback.add')}</button>
781
+ </div>`;
782
+ }
783
+
784
+ container.innerHTML = html;
785
+ } catch (err) {
786
+ const container = document.getElementById('fallback-list');
787
+ if (container) container.innerHTML = `<span style="color:var(--red)">${t('error')}: ${err.message}</span>`;
788
+ }
789
+ }
790
+
791
+ async function moveFallback(key, direction) {
792
+ await fetch(API + '/api/fallback/move', {
793
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
794
+ body: JSON.stringify({ key, direction }),
795
+ });
796
+ loadFallbackOrder();
797
+ }
798
+
799
+ async function removeFallback(key) {
800
+ const res = await fetch(API + '/api/fallback');
801
+ const data = await res.json();
802
+ const primary = data.order?.primary || data.activeProvider;
803
+ const newChain = (data.order?.fallbacks || []).filter(k => k !== key);
804
+ await fetch(API + '/api/fallback', {
805
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
806
+ body: JSON.stringify({ primary, fallbacks: newChain }),
807
+ });
808
+ toast(t('models.fallback.removed', { key }));
809
+ loadFallbackOrder();
810
+ }
811
+
812
+ async function addFallback() {
813
+ const sel = document.getElementById('fallback-add-select');
814
+ if (!sel) return;
815
+ const key = sel.value;
816
+ const res = await fetch(API + '/api/fallback');
817
+ const data = await res.json();
818
+ const primary = data.order?.primary || data.activeProvider;
819
+ const newChain = [...(data.order?.fallbacks || []), key];
820
+ await fetch(API + '/api/fallback', {
821
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
822
+ body: JSON.stringify({ primary, fallbacks: newChain }),
823
+ });
824
+ toast(t('models.fallback.added', { key }));
825
+ loadFallbackOrder();
826
+ }
827
+
828
+ const _liveModelsCache = {};
829
+ async function loadLiveModels(providerId) {
830
+ const container = document.getElementById('live-models-list-' + providerId);
831
+ if (!container) return;
832
+ if (_liveModelsCache[providerId] && Date.now() - _liveModelsCache[providerId].ts < 60000) {
833
+ renderLiveModels(providerId, _liveModelsCache[providerId].models, container);
834
+ return;
835
+ }
836
+ container.innerHTML = `<span style="color:var(--fg2)">${t('models.live.loading')}</span>`;
837
+ try {
838
+ const res = await fetch(API + '/api/providers/live-models?id=' + providerId);
839
+ const data = await res.json();
840
+ if (!data.ok || !data.models?.length) {
841
+ container.innerHTML = `<span style="color:var(--fg2)">${t('models.live.none')}</span>`;
842
+ return;
843
+ }
844
+ _liveModelsCache[providerId] = { models: data.models, ts: Date.now() };
845
+ renderLiveModels(providerId, data.models, container);
846
+ } catch (err) {
847
+ container.innerHTML = `<span style="color:var(--red)">${t('error')}: ${err.message}</span>`;
848
+ }
849
+ }
850
+
851
+ function renderLiveModels(providerId, models, container) {
852
+ const countInfo = models.length > 20 ? ` <span style="color:var(--fg2)">(${t('models.live.count', { count: models.length })})</span>` : '';
853
+ let html = `<div style="margin-bottom:6px;font-weight:500">${t('models.live.available')}${countInfo}:</div>`;
854
+ html += '<div style="display:flex;flex-direction:column;gap:2px">';
855
+ for (const m of models) {
856
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;border-radius:4px;background:var(--bg2)">
857
+ <span style="flex:1;font-family:monospace;font-size:0.9em" title="${m.name}">${m.id}</span>
858
+ ${m.name !== m.id ? `<span style="color:var(--fg2);font-size:0.85em">${m.name}</span>` : ''}
859
+ <button class="btn btn-sm btn-outline" style="padding:1px 8px;font-size:0.78em" onclick="activateLiveModel('${providerId}','${m.id}','${(m.name||m.id).replace(/'/g,"\\'")}')"> ${t('models.activate')}</button>
860
+ </div>`;
861
+ }
862
+ html += '</div>';
863
+ container.innerHTML = html;
864
+ }
865
+
866
+ async function activateLiveModel(providerId, modelId, modelName) {
867
+ const baseUrls = {
868
+ anthropic: 'https://api.anthropic.com/v1/', openai: 'https://api.openai.com/v1',
869
+ google: 'https://generativelanguage.googleapis.com/v1beta/openai', groq: 'https://api.groq.com/openai/v1',
870
+ nvidia: 'https://integrate.api.nvidia.com/v1', openrouter: 'https://openrouter.ai/api/v1',
871
+ };
872
+ const apiKeyEnvs = {
873
+ anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', google: 'GOOGLE_API_KEY',
874
+ groq: 'GROQ_API_KEY', nvidia: 'NVIDIA_API_KEY', openrouter: 'OPENROUTER_API_KEY',
875
+ };
876
+ const key = modelId.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
877
+ const res = await fetch(API + '/api/providers/add-custom', {
878
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
879
+ body: JSON.stringify({ key, name: modelName || modelId, model: modelId, baseUrl: baseUrls[providerId] || '', apiKeyEnv: apiKeyEnvs[providerId] || '', type: 'openai-compatible' }),
880
+ });
881
+ const data = await res.json();
882
+ if (data.ok) {
883
+ await fetch(API + '/api/models/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
884
+ toast(`${modelName || modelId} ${t('models.activated')}`);
885
+ loadModels();
886
+ } else {
887
+ toast(data.error || t('models.activate.error'), 'error');
888
+ }
889
+ }
890
+
891
+ async function switchModel(key) {
892
+ await fetch(API + '/api/models/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
893
+ loadModels(); loadDashboard();
894
+ toast(t('models.switched'));
895
+ }
896
+
897
+ async function saveProviderKey(providerId) {
898
+ const input = document.getElementById('key-' + providerId);
899
+ const apiKey = input.value.trim();
900
+ if (!apiKey || apiKey.includes('...')) { toast(t('models.key.fill'), 'error'); return; }
901
+ const res = await fetch(API + '/api/providers/set-key', {
902
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
903
+ body: JSON.stringify({ providerId, apiKey }),
904
+ });
905
+ const data = await res.json();
906
+ toast(data.ok ? t('models.key.saved') : data.error, data.ok ? 'success' : 'error');
907
+ }
908
+
909
+ async function testProviderKey(providerId) {
910
+ const input = document.getElementById('key-' + providerId);
911
+ const apiKey = input?.value?.trim() || '';
912
+ const useStored = !apiKey || apiKey.includes('...');
913
+ const resultDiv = document.getElementById('key-result-' + providerId);
914
+ resultDiv.innerHTML = `<span style="color:var(--fg2)">${t('models.key.testing')}</span>`;
915
+ const res = await fetch(API + '/api/providers/test-key', {
916
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
917
+ body: JSON.stringify({ providerId, apiKey: useStored ? '__USE_STORED__' : apiKey }),
918
+ });
919
+ const data = await res.json();
920
+ resultDiv.innerHTML = data.ok
921
+ ? `<span style="color:var(--green)">${icon('circle-check', 12)} ${t('models.key.works')}</span>`
922
+ : `<span style="color:var(--red)">${icon('circle-x', 12)} ${data.error}</span>`;
923
+ }
924
+
925
+ function showAddCustomModel() { document.getElementById('custom-model-form').style.display = ''; }
926
+
927
+ async function addCustomModel() {
928
+ const model = {
929
+ key: document.getElementById('cm-key').value.trim(),
930
+ name: document.getElementById('cm-name').value.trim(),
931
+ model: document.getElementById('cm-model').value.trim(),
932
+ baseUrl: document.getElementById('cm-url').value.trim(),
933
+ apiKeyEnv: document.getElementById('cm-apikey-env').value.trim(),
934
+ apiKey: document.getElementById('cm-apikey').value.trim(),
935
+ type: 'openai-compatible', supportsStreaming: true,
936
+ };
937
+ if (!model.key || !model.name || !model.model || !model.baseUrl) {
938
+ toast(t('models.custom.fill'), 'error'); return;
939
+ }
940
+ const res = await fetch(API + '/api/providers/add-custom', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(model) });
941
+ const data = await res.json();
942
+ if (data.ok) {
943
+ toast(t('models.custom.added'));
944
+ document.getElementById('custom-model-form').style.display = 'none';
945
+ loadModels();
946
+ } else {
947
+ toast(data.error, 'error');
948
+ }
949
+ }
950
+
951
+ async function removeCustomModel(key) {
952
+ if (!confirm(t('models.custom.remove', { key }))) return;
953
+ await fetch(API + '/api/providers/remove-custom', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
954
+ toast(t('models.custom.removed'));
955
+ loadModels();
956
+ }
957
+
958
+ // ── Memory ──────────────────────────────────────────────
959
+ async function loadMemory() {
960
+ const res = await fetch(API + '/api/memory');
961
+ const data = await res.json();
962
+ const sel = document.getElementById('memory-file');
963
+ sel.innerHTML = `<option value="MEMORY.md">${icon('file-text', 14)} ${t('memory.longterm')}</option>`;
964
+ data.dailyFiles.forEach(f => { sel.innerHTML += `<option value="${f}">${icon('calendar', 14)} ${f}</option>`; });
965
+ document.getElementById('memory-editor').value = data.longTermMemory;
966
+ }
967
+
968
+ async function loadMemoryFile() {
969
+ const file = document.getElementById('memory-file').value;
970
+ const url = file === 'MEMORY.md' ? '/api/memory' : '/api/memory/' + file;
971
+ const res = await fetch(API + url);
972
+ const data = await res.json();
973
+ document.getElementById('memory-editor').value = data.content || data.longTermMemory || '';
974
+ }
975
+
976
+ async function saveMemoryFile() {
977
+ const file = document.getElementById('memory-file').value;
978
+ const content = document.getElementById('memory-editor').value;
979
+ const res = await fetch(API + '/api/memory/save', {
980
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
981
+ body: JSON.stringify({ file, content }),
982
+ });
983
+ const data = await res.json();
984
+ toast(data.ok ? t('memory.saved') : data.error, data.ok ? 'success' : 'error');
985
+ }
986
+
987
+ // ── Sessions ────────────────────────────────────────────
988
+ async function loadSessions() {
989
+ const res = await fetch(API + '/api/sessions');
990
+ const data = await res.json();
991
+ const list = document.getElementById('sessions-list');
992
+ if (data.sessions.length === 0) {
993
+ list.innerHTML = `<div class="card"><h3>${t('sessions.none')}</h3><div class="sub">${t('sessions.none.desc')}</div></div>`;
994
+ return;
995
+ }
996
+ list.innerHTML = data.sessions.map(s => {
997
+ const dur = Math.floor((s.lastActivity - s.startedAt) / 60000);
998
+ return `<div class="list-item"><div class="icon">${icon('message-circle', 18)}</div><div class="info">
999
+ <div class="name">${s.name}${s.username ? ' @'+s.username : ''}</div>
1000
+ <div class="desc">${s.messageCount} ${t('sessions.msgs')} · ${s.toolUseCount} ${t('sessions.tools')} · $${s.totalCost.toFixed(4)} · ${dur}min</div>
1001
+ </div><span class="badge ${s.isProcessing ? 'badge-yellow' : 'badge-green'}">${s.isProcessing ? t('sessions.active') : t('sessions.idle')}</span></div>`;
1002
+ }).join('');
1003
+ }
1004
+
1005
+ // ── Plugins ─────────────────────────────────────────────
1006
+ async function loadPlugins() {
1007
+ const [pluginsRes, mcpRes, skillsRes] = await Promise.all([
1008
+ fetch(API + '/api/plugins'),
1009
+ fetch(API + '/api/mcp').catch(() => ({ json: () => ({ servers: [], tools: [], config: { servers: {} } }) })),
1010
+ fetch(API + '/api/skills').catch(() => ({ json: () => ({ skills: [] }) })),
1011
+ ]);
1012
+ const pluginsData = await pluginsRes.json();
1013
+ const mcpData = await mcpRes.json();
1014
+ const skillsData = await skillsRes.json();
1015
+
1016
+ let html = '';
1017
+
1018
+ // ── Plugins Section ──
1019
+ html += `<div style="margin-bottom:24px">
1020
+ <h3 style="font-size:1em;margin-bottom:8px;display:flex;align-items:center;gap:8px">${icon('plug', 18)} ${t('plugins.none').replace('Keine ','').replace('No ','') || 'Plugins'}</h3>`;
1021
+ if (pluginsData.plugins.length === 0) {
1022
+ html += `<div class="card"><div class="sub">${t('plugins.none.desc')}</div></div>`;
1023
+ } else {
1024
+ html += pluginsData.plugins.map(p => `<div class="list-item"><div class="icon">${icon('plug', 18)}</div><div class="info">
1025
+ <div class="name">${p.name} <span class="badge">${p.version}</span></div>
1026
+ <div class="desc">${p.description}${p.commands.length ? ' · '+p.commands.join(', ') : ''}</div>
1027
+ </div></div>`).join('');
1028
+ }
1029
+ html += '</div>';
1030
+
1031
+ // ── MCP Servers Section ──
1032
+ html += `<div style="margin-bottom:24px">
1033
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
1034
+ <h3 style="font-size:1em;display:flex;align-items:center;gap:8px;flex:1">${icon('server', 18)} MCP Servers</h3>
1035
+ <button class="btn btn-sm" onclick="showAddMCP()">${icon('plus', 14)} ${t('models.fallback.add')}</button>
1036
+ <button class="btn btn-sm btn-outline" onclick="discoverMCP()">${icon('search', 14)} Auto-Discover</button>
1037
+ </div>`;
1038
+
1039
+ if (mcpData.servers.length === 0) {
1040
+ html += `<div class="card"><div class="sub">No MCP servers configured. Add one or use Auto-Discover.</div></div>`;
1041
+ } else {
1042
+ for (const s of mcpData.servers) {
1043
+ const statusBadge = s.connected
1044
+ ? `<span class="badge badge-green">${icon('check', 12)} Connected · ${s.tools} tools</span>`
1045
+ : `<span class="badge badge-red">${icon('x', 12)} Disconnected</span>`;
1046
+ html += `<div class="list-item">
1047
+ <div class="icon">${icon('server', 18)}</div>
1048
+ <div class="info"><div class="name">${escapeHtml(s.name)}</div></div>
1049
+ ${statusBadge}
1050
+ <button class="btn btn-sm btn-outline" style="color:var(--red);padding:2px 8px" onclick="removeMCP('${escapeHtml(s.name)}')">${icon('trash-2', 14)}</button>
1051
+ </div>`;
1052
+ }
1053
+ }
1054
+
1055
+ // Add MCP form (hidden by default)
1056
+ html += `<div id="mcp-add-form" style="display:none;margin-top:12px;padding:16px;background:var(--bg2);border:1px solid var(--glass-border);border-radius:var(--radius)">
1057
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
1058
+ <input id="mcp-name" placeholder="Server name (e.g. filesystem)" class="input">
1059
+ <input id="mcp-command" placeholder="Command (e.g. npx)" class="input">
1060
+ <input id="mcp-args" placeholder="Args (comma-separated, e.g. -y,@modelcontextprotocol/server-filesystem,/tmp)" class="input" style="grid-column:1/-1">
1061
+ <input id="mcp-url" placeholder="Or HTTP URL (for remote servers)" class="input" style="grid-column:1/-1">
1062
+ </div>
1063
+ <div style="display:flex;gap:8px">
1064
+ <button class="btn btn-sm" onclick="addMCP()">${icon('save', 14)} ${t('save')}</button>
1065
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('mcp-add-form').style.display='none'">${t('cancel')}</button>
1066
+ </div>
1067
+ </div>`;
1068
+
1069
+ // Discovery results area
1070
+ html += `<div id="mcp-discover-results" style="margin-top:8px"></div>`;
1071
+ html += '</div>';
1072
+
1073
+ // ── Skills Section ──
1074
+ html += `<div style="margin-bottom:24px">
1075
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
1076
+ <h3 style="font-size:1em;display:flex;align-items:center;gap:8px;flex:1">${icon('sparkles', 18)} Skills</h3>
1077
+ <button class="btn btn-sm" onclick="showAddSkill()">${icon('plus', 14)} ${t('cron.create')}</button>
1078
+ </div>`;
1079
+
1080
+ if (skillsData.skills.length === 0) {
1081
+ html += `<div class="card"><div class="sub">No skills installed. Create one to add specialized knowledge.</div></div>`;
1082
+ } else {
1083
+ for (const s of skillsData.skills) {
1084
+ html += `<div class="list-item">
1085
+ <div class="icon">${icon('sparkles', 18)}</div>
1086
+ <div class="info">
1087
+ <div class="name">${escapeHtml(s.name)} <span class="badge">${s.category}</span></div>
1088
+ <div class="desc">${escapeHtml(s.description || '')} · Triggers: ${s.triggers.join(', ')}</div>
1089
+ </div>
1090
+ <button class="btn btn-sm btn-outline" onclick="editSkill('${escapeHtml(s.id)}')">${icon('edit', 14)}</button>
1091
+ <button class="btn btn-sm btn-outline" style="color:var(--red);padding:2px 8px" onclick="deleteSkill('${escapeHtml(s.id)}')">${icon('trash-2', 14)}</button>
1092
+ </div>`;
1093
+ }
1094
+ }
1095
+
1096
+ // Add Skill form (hidden)
1097
+ html += `<div id="skill-add-form" style="display:none;margin-top:12px;padding:16px;background:var(--bg2);border:1px solid var(--glass-border);border-radius:var(--radius)">
1098
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
1099
+ <input id="skill-id" placeholder="Unique ID (e.g. video-creation)" class="input">
1100
+ <input id="skill-name" placeholder="Display name" class="input">
1101
+ <input id="skill-desc" placeholder="Short description" class="input">
1102
+ <input id="skill-triggers" placeholder="Trigger keywords (comma-separated)" class="input">
1103
+ <input id="skill-category" placeholder="Category (e.g. media, code, data)" class="input">
1104
+ <select id="skill-priority" class="input"><option value="3">Priority: Normal (3)</option><option value="5">High (5)</option><option value="1">Low (1)</option></select>
1105
+ </div>
1106
+ <textarea id="skill-content" class="editor" style="min-height:200px" placeholder="Skill content (instructions, workflows, best practices)..."></textarea>
1107
+ <div style="display:flex;gap:8px;margin-top:8px">
1108
+ <button class="btn btn-sm" onclick="createSkill()">${icon('save', 14)} ${t('save')}</button>
1109
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('skill-add-form').style.display='none'">${t('cancel')}</button>
1110
+ </div>
1111
+ </div>`;
1112
+ html += '</div>';
1113
+
1114
+ document.getElementById('plugins-list').innerHTML = html;
1115
+ }
1116
+
1117
+ // ── MCP Management Functions ────────────────────────────
1118
+
1119
+ function showAddMCP() { document.getElementById('mcp-add-form').style.display = ''; }
1120
+
1121
+ async function addMCP() {
1122
+ const name = document.getElementById('mcp-name').value.trim();
1123
+ const command = document.getElementById('mcp-command').value.trim();
1124
+ const argsStr = document.getElementById('mcp-args').value.trim();
1125
+ const url = document.getElementById('mcp-url').value.trim();
1126
+ if (!name) { toast('Name required', 'error'); return; }
1127
+ const args = argsStr ? argsStr.split(',').map(a => a.trim()) : [];
1128
+ const res = await fetch(API + '/api/mcp/add', {
1129
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1130
+ body: JSON.stringify({ name, command: command || undefined, args: args.length ? args : undefined, url: url || undefined }),
1131
+ });
1132
+ const data = await res.json();
1133
+ if (data.ok) { toast('MCP server added. Restart needed.'); document.getElementById('mcp-add-form').style.display = 'none'; loadPlugins(); }
1134
+ else toast(data.error, 'error');
1135
+ }
1136
+
1137
+ async function removeMCP(name) {
1138
+ if (!confirm(`Remove MCP server "${name}"?`)) return;
1139
+ await fetch(API + '/api/mcp/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
1140
+ toast('Removed'); loadPlugins();
1141
+ }
1142
+
1143
+ async function discoverMCP() {
1144
+ const el = document.getElementById('mcp-discover-results');
1145
+ el.innerHTML = `<div class="sub" style="padding:8px">${icon('search', 14)} Scanning system for MCP servers...</div>`;
1146
+ const res = await fetch(API + '/api/mcp/discover');
1147
+ const data = await res.json();
1148
+ if (!data.discovered?.length) { el.innerHTML = `<div class="sub" style="padding:8px">No MCP servers found on this system.</div>`; return; }
1149
+ el.innerHTML = `<div style="padding:8px;font-size:0.85em"><strong>Found ${data.discovered.length} MCP server(s):</strong></div>` +
1150
+ data.discovered.map(d => `<div class="list-item" style="padding:8px 12px">
1151
+ <div class="info"><div class="name">${escapeHtml(d.name)} <span class="badge">${d.source}</span></div>
1152
+ <div class="desc">${d.command} ${d.args.join(' ')}</div></div>
1153
+ <button class="btn btn-sm" onclick="installDiscoveredMCP('${escapeHtml(d.name)}','${escapeHtml(d.command)}','${escapeHtml(d.args.join(','))}')">${icon('plus', 14)} Add</button>
1154
+ </div>`).join('');
1155
+ }
1156
+
1157
+ async function installDiscoveredMCP(name, command, argsStr) {
1158
+ const args = argsStr.split(',').filter(Boolean);
1159
+ const res = await fetch(API + '/api/mcp/add', {
1160
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1161
+ body: JSON.stringify({ name, command, args }),
1162
+ });
1163
+ const data = await res.json();
1164
+ if (data.ok) { toast(`${name} added!`); loadPlugins(); }
1165
+ else toast(data.error, 'error');
1166
+ }
1167
+
1168
+ // ── Skills Management Functions ─────────────────────────
1169
+
1170
+ function showAddSkill() { document.getElementById('skill-add-form').style.display = ''; }
1171
+
1172
+ async function createSkill() {
1173
+ const skill = {
1174
+ id: document.getElementById('skill-id').value.trim(),
1175
+ name: document.getElementById('skill-name').value.trim(),
1176
+ description: document.getElementById('skill-desc').value.trim(),
1177
+ triggers: document.getElementById('skill-triggers').value.trim(),
1178
+ category: document.getElementById('skill-category').value.trim(),
1179
+ priority: parseInt(document.getElementById('skill-priority').value) || 3,
1180
+ content: document.getElementById('skill-content').value,
1181
+ };
1182
+ if (!skill.id || !skill.name) { toast('ID and name required', 'error'); return; }
1183
+ const res = await fetch(API + '/api/skills/create', {
1184
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1185
+ body: JSON.stringify(skill),
1186
+ });
1187
+ const data = await res.json();
1188
+ if (data.ok) { toast('Skill created!'); document.getElementById('skill-add-form').style.display = 'none'; loadPlugins(); }
1189
+ else toast(data.error, 'error');
1190
+ }
1191
+
1192
+ async function editSkill(id) {
1193
+ const res = await fetch(API + `/api/skills/detail/${id}`);
1194
+ const data = await res.json();
1195
+ if (!data.ok) { toast('Skill not found', 'error'); return; }
1196
+ const s = data.skill;
1197
+ // Populate form
1198
+ document.getElementById('skill-id').value = s.id;
1199
+ document.getElementById('skill-name').value = s.name;
1200
+ document.getElementById('skill-desc').value = s.description || '';
1201
+ document.getElementById('skill-triggers').value = s.triggers.join(', ');
1202
+ document.getElementById('skill-category').value = s.category || '';
1203
+ document.getElementById('skill-priority').value = String(s.priority || 3);
1204
+ document.getElementById('skill-content').value = s.content || '';
1205
+ document.getElementById('skill-add-form').style.display = '';
1206
+ }
1207
+
1208
+ async function deleteSkill(id) {
1209
+ if (!confirm(`Delete skill "${id}"?`)) return;
1210
+ const res = await fetch(API + '/api/skills/delete', {
1211
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1212
+ body: JSON.stringify({ id }),
1213
+ });
1214
+ const data = await res.json();
1215
+ if (data.ok) { toast('Skill deleted'); loadPlugins(); }
1216
+ else toast(data.error, 'error');
1217
+ }
1218
+
1219
+ // ── Users ───────────────────────────────────────────────
1220
+ const PLATFORM_ICONS = { telegram: 'send', whatsapp: 'message-circle', discord: 'signal', signal: 'shield', webui: 'globe', web: 'globe' };
1221
+
1222
+ function timeAgo(ts) {
1223
+ if (!ts) return '—';
1224
+ const diff = Date.now() - ts;
1225
+ if (diff < 60000) return t('users.just.now');
1226
+ if (diff < 3600000) return t('users.min.ago', { n: Math.floor(diff/60000) });
1227
+ if (diff < 86400000) return t('users.hrs.ago', { n: Math.floor(diff/3600000) });
1228
+ return new Date(ts).toLocaleDateString(getLang() === 'de' ? 'de-DE' : 'en-US', { day:'numeric', month:'short', year:'numeric' });
1229
+ }
1230
+
1231
+ async function loadUsers() {
1232
+ const res = await fetch(API + '/api/users');
1233
+ const data = await res.json();
1234
+ const el = document.getElementById('users-list');
1235
+
1236
+ if (data.users.length === 0) {
1237
+ el.innerHTML = `<div class="card"><h3>${t('users.none')}</h3><div class="sub">${t('users.none.desc')}</div></div>`;
1238
+ return;
1239
+ }
1240
+
1241
+ el.innerHTML = data.users.map(u => {
1242
+ const platformIconName = PLATFORM_ICONS[u.lastPlatform] || 'globe';
1243
+ const platformName = u.lastPlatform ? u.lastPlatform.charAt(0).toUpperCase() + u.lastPlatform.slice(1) : t('users.platform.unknown');
1244
+ const lastMsg = u.lastMessage ? `<div class="user-last-msg">"${escapeHtml(u.lastMessage)}"</div>` : '';
1245
+ const sessionInfo = u.session ? `
1246
+ <div class="user-session-info">
1247
+ ${u.session.isProcessing ? `<span class="badge badge-yellow">${icon('clock', 10)} ${t('users.processing')}</span>` : ''}
1248
+ ${u.session.hasActiveQuery ? `<span class="badge badge-yellow">${icon('refresh-cw', 10)} ${t('users.query.active')}</span>` : ''}
1249
+ ${u.session.queuedMessages > 0 ? `<span class="badge badge-blue">${icon('mail', 10)} ${t('users.in.queue', { count: u.session.queuedMessages })}</span>` : ''}
1250
+ <span title="${t('users.cost')}">${icon('zap', 10)} $${u.session.totalCost.toFixed(4)}</span>
1251
+ <span title="${t('users.msgs')}">${icon('message-square', 10)} ${u.session.messageCount}</span>
1252
+ <span title="${t('users.tools')}">${icon('wrench', 10)} ${u.session.toolUseCount}</span>
1253
+ <span title="${t('users.history')}">${icon('list', 10)} ${u.session.historyLength}</span>
1254
+ <span title="${t('users.effort')}">${icon('brain', 10)} ${u.session.effort}</span>
1255
+ </div>` : `<div class="user-session-info"><span class="sub">${t('users.no.session')}</span></div>`;
1256
+
1257
+ const killBtn = u.isOwner ? '' : `<button class="btn btn-danger btn-sm" onclick="killUser(${u.userId}, '${escapeHtml(u.name)}')" title="${t('delete')}">${icon('trash-2', 12)}</button>`;
1258
+
1259
+ return `<div class="card user-card" style="margin-bottom:12px">
1260
+ <div style="display:flex;align-items:flex-start;gap:12px">
1261
+ <div class="icon" style="font-size:1.6em;min-width:36px;text-align:center">${u.isOwner ? icon('crown', 28) : icon('user', 28)}</div>
1262
+ <div style="flex:1;min-width:0">
1263
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
1264
+ <strong>${escapeHtml(u.name)}</strong>
1265
+ ${u.username ? `<span class="sub">@${escapeHtml(u.username)}</span>` : ''}
1266
+ <span class="badge badge-${u.session ? 'green' : 'gray'}" style="font-size:0.7em">${u.session ? t('online') : t('offline')}</span>
1267
+ ${killBtn}
1268
+ </div>
1269
+ <div class="sub" style="margin-top:4px;display:flex;align-items:center;gap:4px">
1270
+ ${icon(platformIconName, 12)} ${platformName} · ${t('users.messages.total', { n: u.totalMessages })} · ${t('users.last.active')}: ${timeAgo(u.lastActive)}
1271
+ </div>
1272
+ ${lastMsg}
1273
+ ${sessionInfo}
1274
+ </div>
1275
+ </div>
1276
+ </div>`;
1277
+ }).join('');
1278
+ }
1279
+
1280
+ async function killUser(userId, name) {
1281
+ if (!confirm(t('users.kill.confirm', { name }))) return;
1282
+ try {
1283
+ const res = await fetch(API + `/api/users/${userId}`, { method: 'DELETE' });
1284
+ const data = await res.json();
1285
+ if (data.ok) {
1286
+ const summary = data.deleted.length > 0 ? t('users.deleted.summary', { items: data.deleted.join(', ') }) : t('users.nothing');
1287
+ alert(`${t('users.deleted')}\n\n${summary}`);
1288
+ loadUsers();
1289
+ } else {
1290
+ alert(`${t('error')}: ${data.error || 'Unknown'}`);
1291
+ }
1292
+ } catch (e) {
1293
+ alert(`${t('error')}: ${e.message}`);
1294
+ }
1295
+ }
1296
+
1297
+ // ── Platforms ────────────────────────────────────────────
1298
+ async function loadPlatforms() {
1299
+ const res = await fetch(API + '/api/platforms/setup');
1300
+ const data = await res.json();
1301
+
1302
+ let html = `<div style="margin-bottom:20px"><h3 style="font-size:1em;margin-bottom:4px;display:flex;align-items:center;gap:8px">${icon('smartphone', 20)} ${t('platforms.title')}</h3><div class="sub">${t('platforms.desc')}</div></div>`;
1303
+
1304
+ for (const p of data.platforms) {
1305
+ let statusBadge;
1306
+ if (p.configured && p.depsInstalled) {
1307
+ statusBadge = `<span class="badge badge-green" id="badge-${p.id}">${icon('circle-check', 12)} ${t('platforms.ready')}</span>`;
1308
+ } else if (p.configured && !p.depsInstalled) {
1309
+ statusBadge = `<span class="badge badge-yellow" id="badge-${p.id}">${icon('package', 12)} ${t('platforms.deps.missing')}</span>`;
1310
+ } else {
1311
+ statusBadge = `<span class="badge badge-red" id="badge-${p.id}">${t('platforms.not.configured')}</span>`;
1312
+ }
1313
+
1314
+ html += `<div class="card setup-card" style="margin-bottom:16px">
1315
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
1316
+ <span style="font-size:1.8em">${p.icon}</span>
1317
+ <div style="flex:1">
1318
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0">${p.name}</h3>
1319
+ <div class="sub">${p.description}</div>
1320
+ </div>
1321
+ ${statusBadge}
1322
+ </div>`;
1323
+
1324
+ html += `<details ${p.configured ? '' : 'open'} style="margin-bottom:12px"><summary style="cursor:pointer;color:var(--accent2);font-size:0.82em;font-weight:500;display:flex;align-items:center;gap:4px">${icon('clipboard', 14)} ${t('platforms.setup.guide')}</summary><ol style="margin:8px 0 0 16px;color:var(--fg2);font-size:0.82em;line-height:1.6">`;
1325
+ for (const step of p.setupSteps) html += `<li>${step}</li>`;
1326
+ if (p.setupUrl) html += `<li><a href="${p.setupUrl}" target="_blank" style="color:var(--accent2)">${p.setupUrl}</a></li>`;
1327
+ html += `</ol></details>`;
1328
+
1329
+ html += `<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:8px">`;
1330
+ for (const v of p.envVars) {
1331
+ if (v.type === 'toggle') {
1332
+ const checked = p.values[v.key] === 'true' ? 'checked' : '';
1333
+ html += `<label style="display:flex;align-items:center;gap:8px;font-size:0.85em;cursor:pointer">
1334
+ <input type="checkbox" id="pv-${p.id}-${v.key}" ${checked} style="width:18px;height:18px;accent-color:var(--accent)">
1335
+ <span>${v.label}</span>
1336
+ </label>`;
1337
+ } else {
1338
+ html += `<div style="display:flex;gap:8px;align-items:center">
1339
+ <label style="font-size:0.78em;color:var(--fg2);min-width:120px">${v.label}</label>
1340
+ <input type="${v.secret ? 'password' : 'text'}" id="pv-${p.id}-${v.key}" placeholder="${v.placeholder}" value="${p.values[v.key] || ''}" style="flex:1;background:var(--bg3);border:1px solid var(--glass-border);border-radius:6px;padding:8px 12px;color:var(--fg);font:inherit;font-size:0.85em;font-family:monospace;outline:none">
1341
+ </div>`;
1342
+ }
1343
+ }
1344
+ html += `</div>`;
1345
+
1346
+ html += `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
1347
+ <button class="btn btn-sm" onclick="savePlatform('${p.id}')">${icon('save', 12)} ${t('platforms.save')}</button>`;
1348
+ if (p.npmPackages && !p.depsInstalled) {
1349
+ html += `<button class="btn btn-sm btn-outline" onclick="installPlatformDeps('${p.id}')">${icon('package', 12)} ${t('platforms.install.deps')}</button>`;
1350
+ }
1351
+ if (p.configured) {
1352
+ html += `<button class="btn btn-sm btn-outline" onclick="testPlatformConnection('${p.id}')">${icon('test-tube', 12)} ${t('platforms.test')}</button>`;
1353
+ html += `<button class="btn btn-sm btn-outline" style="color:var(--red)" onclick="disablePlatform('${p.id}')">${t('platforms.disable')}</button>`;
1354
+ }
1355
+ html += `<span id="platform-live-${p.id}" style="font-size:0.78em;margin-left:4px"></span>`;
1356
+ html += `</div>`;
1357
+
1358
+ if (p.id === 'whatsapp' && p.configured && p.depsInstalled) {
1359
+ html += `<div id="wa-qr-area" style="margin-top:12px;padding:12px;background:var(--bg3);border-radius:8px">
1360
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
1361
+ <span id="wa-status-dot" style="font-size:1.2em">${icon('clock', 16)}</span>
1362
+ <span id="wa-status-text" style="font-size:0.85em;color:var(--fg2)">${t('wa.status.loading')}</span>
1363
+ <div style="flex:1"></div>
1364
+ <button class="btn btn-sm btn-outline" onclick="checkWhatsAppStatus()">${icon('refresh-cw', 12)} ${t('wa.check.status')}</button>
1365
+ <button class="btn btn-sm btn-outline" style="color:var(--red);font-size:0.78em" onclick="disconnectWhatsApp()">${icon('plug', 12)} ${t('wa.disconnect')}</button>
1366
+ </div>
1367
+ <div id="wa-qr-container" style="display:none;text-align:center;padding:16px;background:#fff;border-radius:8px;margin-top:8px">
1368
+ <canvas id="wa-qr-canvas" style="image-rendering:pixelated"></canvas>
1369
+ <div style="color:#333;font-size:0.82em;margin-top:8px">${t('wa.scan.qr')}</div>
1370
+ </div>
1371
+ </div>`;
1372
+ }
1373
+
1374
+ if (p.id === 'whatsapp' && (p.configured || p.depsInstalled)) {
1375
+ html += `<div style="margin-top:16px;border-top:1px solid var(--glass-border);padding-top:12px">
1376
+ <details id="wa-groups-details">
1377
+ <summary style="cursor:pointer;font-weight:600;font-size:0.9em;display:flex;align-items:center;gap:8px">
1378
+ ${icon('message-circle', 16)} ${t('wa.groups')}
1379
+ <span style="font-size:0.75em;color:var(--fg2);font-weight:normal" id="wa-groups-badge"></span>
1380
+ </summary>
1381
+ <div id="wa-groups-content" style="margin-top:12px"><div style="color:var(--fg2);font-size:0.85em">${t('wa.groups.loading')}</div></div>
1382
+ </details>
1383
+ </div>`;
1384
+ }
1385
+
1386
+ html += `<div id="platform-result-${p.id}" style="font-size:0.78em;margin-top:6px"></div></div>`;
1387
+ }
1388
+
1389
+ document.getElementById('platforms-setup').innerHTML = html;
1390
+ if (document.getElementById('wa-groups-content')) loadWAGroups();
1391
+
1392
+ // Load statuses
1393
+ loadPlatformStatuses();
1394
+ if (document.getElementById('wa-qr-area')) checkWhatsAppStatus();
1395
+ }
1396
+
1397
+ async function savePlatform(platformId) {
1398
+ const platform = document.querySelectorAll(`[id^="pv-${platformId}-"]`);
1399
+ const values = {};
1400
+ platform.forEach(el => {
1401
+ const key = el.id.replace(`pv-${platformId}-`, '');
1402
+ values[key] = el.type === 'checkbox' ? (el.checked ? 'true' : '') : el.value.trim();
1403
+ });
1404
+ const res = await fetch(API + '/api/platforms/configure', {
1405
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1406
+ body: JSON.stringify({ platformId, values }),
1407
+ });
1408
+ const data = await res.json();
1409
+ const resultDiv = document.getElementById('platform-result-' + platformId);
1410
+ if (data.ok) {
1411
+ if (data.restartNeeded === false) {
1412
+ toast(t('platforms.saved'));
1413
+ resultDiv.innerHTML = `<span style="color:var(--green)">${icon('circle-check', 12)} ${t('platforms.saved')}</span>`;
1414
+ } else {
1415
+ toast(t('platforms.saved.restart'));
1416
+ resultDiv.innerHTML = `<span style="color:var(--green)">${icon('circle-check', 12)} ${t('platforms.saved.restart')}</span>`;
1417
+ }
1418
+ } else {
1419
+ toast(data.error, 'error');
1420
+ resultDiv.innerHTML = `<span style="color:var(--red)">${icon('circle-x', 12)} ${data.error}</span>`;
1421
+ }
1422
+ }
1423
+
1424
+ async function installPlatformDeps(platformId) {
1425
+ toast(t('platforms.installing'));
1426
+ const resultDiv = document.getElementById('platform-result-' + platformId);
1427
+ resultDiv.innerHTML = `<span style="color:var(--fg2)">${t('loading')}</span>`;
1428
+ const res = await fetch(API + '/api/platforms/install-deps', {
1429
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1430
+ body: JSON.stringify({ platformId }),
1431
+ });
1432
+ const data = await res.json();
1433
+ if (data.ok) {
1434
+ toast(t('platforms.installed'));
1435
+ resultDiv.innerHTML = `<span style="color:var(--green)">${icon('circle-check', 12)} ${t('platforms.installed')}</span>`;
1436
+ loadPlatforms();
1437
+ } else {
1438
+ toast(t('error') + ': ' + data.error, 'error');
1439
+ resultDiv.innerHTML = `<span style="color:var(--red)">${icon('circle-x', 12)} ${data.error}</span>`;
1440
+ }
1441
+ }
1442
+
1443
+ async function disablePlatform(platformId) {
1444
+ if (!confirm(t('platforms.disable.confirm', { id: platformId }))) return;
1445
+ const inputs = document.querySelectorAll(`[id^="pv-${platformId}-"]`);
1446
+ const values = {};
1447
+ inputs.forEach(el => { const key = el.id.replace(`pv-${platformId}-`, ''); values[key] = ''; });
1448
+ await fetch(API + '/api/platforms/configure', {
1449
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1450
+ body: JSON.stringify({ platformId, values }),
1451
+ });
1452
+ toast(t('platforms.disabled'));
1453
+ loadPlatforms();
1454
+ }
1455
+
1456
+ async function testPlatformConnection(platformId) {
1457
+ const el = document.getElementById('platform-live-' + platformId);
1458
+ if (el) el.innerHTML = `${t('platforms.testing')}`;
1459
+ try {
1460
+ const res = await fetch(API + '/api/platforms/test-connection', {
1461
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1462
+ body: JSON.stringify({ platformId }),
1463
+ });
1464
+ const data = await res.json();
1465
+ if (el) {
1466
+ el.innerHTML = data.ok
1467
+ ? `<span style="color:var(--green)">${icon('circle-check', 12)} ${data.info || t('platforms.connected')}</span>`
1468
+ : `<span style="color:var(--red)">${icon('circle-x', 12)} ${data.error || t('error')}</span>`;
1469
+ }
1470
+ } catch (err) {
1471
+ if (el) el.innerHTML = `<span style="color:var(--red)">${icon('circle-x', 12)} ${err.message}</span>`;
1472
+ }
1473
+ }
1474
+
1475
+ async function loadPlatformStatuses() {
1476
+ try {
1477
+ const res = await fetch(API + '/api/platforms/status');
1478
+ const statuses = await res.json();
1479
+ for (const [id, state] of Object.entries(statuses)) {
1480
+ const el = document.getElementById('platform-live-' + id);
1481
+ const badge = document.getElementById('badge-' + id);
1482
+ const s = state;
1483
+
1484
+ if (el && s.status && s.status !== 'not_configured' && s.status !== 'unknown') {
1485
+ let extra = '';
1486
+ if (s.botUsername) extra = ` @${s.botUsername}`;
1487
+ else if (s.botTag) extra = ` ${s.botTag}`;
1488
+ else if (s.guildCount) extra = ` (${s.guildCount} Server)`;
1489
+ else if (s.apiVersion) extra = ` v${s.apiVersion}`;
1490
+ const statusColor = s.status === 'connected' ? 'var(--green)' : (s.status === 'error' || s.status === 'logged_out') ? 'var(--red)' : 'var(--fg2)';
1491
+ const statusIcon = s.status === 'connected' ? icon('circle-check', 12) : s.status === 'error' ? icon('circle-x', 12) : icon('clock', 12);
1492
+ const statusLabel = t(`platforms.status.${s.status.replace('_', '.')}`) || s.status;
1493
+ el.innerHTML = `<span style="color:${statusColor}">${statusIcon} ${statusLabel}${extra}</span>`;
1494
+ }
1495
+
1496
+ if (badge) {
1497
+ if (s.status === 'connected') {
1498
+ badge.className = 'badge badge-green'; badge.innerHTML = `${icon('circle-check', 12)} ${t('platforms.connected')}`;
1499
+ } else if (s.status === 'qr') {
1500
+ badge.className = 'badge badge-yellow'; badge.innerHTML = `${icon('qr-code', 12)} QR`;
1501
+ } else if (s.status === 'connecting') {
1502
+ badge.className = 'badge badge-yellow'; badge.innerHTML = `${icon('clock', 12)} ${t('platforms.status.connecting')}`;
1503
+ } else if (s.status === 'error' || s.status === 'logged_out') {
1504
+ badge.className = 'badge badge-red'; badge.innerHTML = `${icon('circle-x', 12)} ${t('error')}`;
1505
+ } else if (s.status === 'disconnected') {
1506
+ badge.className = 'badge badge-yellow'; badge.innerHTML = `${icon('circle-dot', 12)} ${t('platforms.status.disconnected')}`;
1507
+ }
1508
+ }
1509
+ }
1510
+ } catch { }
1511
+ }
1512
+
1513
+ // ── WhatsApp QR + Status ────────────────────────────────
1514
+ let waStatusInterval = null;
1515
+
1516
+ async function checkWhatsAppStatus() {
1517
+ try {
1518
+ const res = await fetch(API + '/api/whatsapp/status');
1519
+ const state = await res.json();
1520
+ const dot = document.getElementById('wa-status-dot');
1521
+ const text = document.getElementById('wa-status-text');
1522
+ const qrContainer = document.getElementById('wa-qr-container');
1523
+ if (!dot || !text) return;
1524
+
1525
+ const statusMap = {
1526
+ disconnected: [icon('circle-dot', 16), t('platforms.status.disconnected')],
1527
+ connecting: [icon('clock', 16), t('platforms.status.connecting')],
1528
+ qr: [icon('qr-code', 16), t('wa.qr.ready')],
1529
+ connected: [icon('circle-check', 16), `${t('platforms.connected')}${state.connectedAt ? ` (${new Date(state.connectedAt).toLocaleTimeString(getLang() === 'de' ? 'de-DE' : 'en-US')})` : ''}`],
1530
+ logged_out: [icon('circle-x', 16), t('platforms.status.logged.out')],
1531
+ };
1532
+ const [statusIc, label] = statusMap[state.status] || [icon('info', 16), state.status];
1533
+ dot.innerHTML = statusIc;
1534
+ text.textContent = label;
1535
+
1536
+ const badge = document.getElementById('badge-whatsapp');
1537
+ if (badge) {
1538
+ if (state.status === 'connected') { badge.className = 'badge badge-green'; badge.innerHTML = `${icon('circle-check', 12)} ${t('platforms.connected')}`; }
1539
+ else if (state.status === 'qr') { badge.className = 'badge badge-yellow'; badge.innerHTML = `${icon('qr-code', 12)} QR`; }
1540
+ else if (state.status === 'connecting') { badge.className = 'badge badge-yellow'; badge.innerHTML = `${icon('clock', 12)} ...`; }
1541
+ else if (state.status === 'error') { badge.className = 'badge badge-red'; badge.innerHTML = `${icon('circle-x', 12)} ${t('error')}`; }
1542
+ }
1543
+
1544
+ const liveEl = document.getElementById('platform-live-whatsapp');
1545
+ if (liveEl) {
1546
+ const infoStr = state.info ? ` (${state.info})` : '';
1547
+ if (state.status === 'connected') liveEl.innerHTML = `<span style="color:var(--green)">${icon('circle-check', 12)} ${t('platforms.connected')}${infoStr}</span>`;
1548
+ else if (state.status === 'qr') liveEl.innerHTML = `<span style="color:var(--fg2)">${icon('qr-code', 12)} ${t('wa.qr.available')}</span>`;
1549
+ else if (state.status === 'connecting') liveEl.innerHTML = `<span style="color:var(--fg2)">${icon('clock', 12)} ...</span>`;
1550
+ else if (state.status === 'error') liveEl.innerHTML = `<span style="color:var(--red)">${icon('circle-x', 12)} ${state.error || t('error')}</span>`;
1551
+ }
1552
+
1553
+ if (state.status === 'qr' && state.qrString && qrContainer) {
1554
+ qrContainer.style.display = '';
1555
+ renderQrCode(state.qrString);
1556
+ if (!waStatusInterval) waStatusInterval = setInterval(checkWhatsAppStatus, 3000);
1557
+ } else if (state.status === 'connecting') {
1558
+ if (qrContainer) qrContainer.style.display = 'none';
1559
+ if (!waStatusInterval) waStatusInterval = setInterval(checkWhatsAppStatus, 3000);
1560
+ } else {
1561
+ if (qrContainer) qrContainer.style.display = 'none';
1562
+ if (state.status === 'connected' && waStatusInterval) { clearInterval(waStatusInterval); waStatusInterval = null; }
1563
+ }
1564
+ } catch (err) {
1565
+ const text = document.getElementById('wa-status-text');
1566
+ if (text) text.textContent = t('error') + ': ' + err.message;
1567
+ }
1568
+ }
1569
+
1570
+ async function disconnectWhatsApp() {
1571
+ if (!confirm(t('wa.disconnect.confirm'))) return;
1572
+ try {
1573
+ const res = await fetch(API + '/api/whatsapp/disconnect', { method: 'POST' });
1574
+ const data = await res.json();
1575
+ toast(data.ok ? t('wa.disconnected') : data.error, data.ok ? 'success' : 'error');
1576
+ } catch (err) { toast(t('error') + ': ' + err.message, 'error'); }
1577
+ }
1578
+
1579
+ function renderQrCode(text) {
1580
+ const canvas = document.getElementById('wa-qr-canvas');
1581
+ if (!canvas) return;
1582
+ if (typeof QRCode !== 'undefined') { new QRCode(canvas.parentElement, { text, width: 256, height: 256, correctLevel: QRCode.CorrectLevel.M }); return; }
1583
+ if (!window._qrScriptLoaded) {
1584
+ window._qrScriptLoaded = true;
1585
+ const script = document.createElement('script');
1586
+ script.src = 'https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js';
1587
+ script.onload = () => renderQrCodeFromLib(text, canvas);
1588
+ document.head.appendChild(script);
1589
+ } else if (typeof qrcode !== 'undefined') {
1590
+ renderQrCodeFromLib(text, canvas);
1591
+ }
1592
+ }
1593
+
1594
+ function renderQrCodeFromLib(text, canvas) {
1595
+ try {
1596
+ const qr = qrcode(0, 'M'); qr.addData(text); qr.make();
1597
+ const ctx = canvas.getContext('2d');
1598
+ const moduleCount = qr.getModuleCount();
1599
+ const cellSize = Math.max(4, Math.floor(256 / moduleCount));
1600
+ const size = moduleCount * cellSize;
1601
+ canvas.width = size; canvas.height = size;
1602
+ ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, size, size);
1603
+ ctx.fillStyle = '#000000';
1604
+ for (let row = 0; row < moduleCount; row++) {
1605
+ for (let col = 0; col < moduleCount; col++) {
1606
+ if (qr.isDark(row, col)) ctx.fillRect(col * cellSize, row * cellSize, cellSize, cellSize);
1607
+ }
1608
+ }
1609
+ } catch (err) { console.error('QR render error:', err); }
1610
+ }
1611
+
1612
+ // ── Personality ─────────────────────────────────────────
1613
+ async function loadPersonality() {
1614
+ const res = await fetch(API + '/api/soul');
1615
+ const data = await res.json();
1616
+ document.getElementById('soul-editor').value = data.content || '';
1617
+ }
1618
+
1619
+ async function saveSoul() {
1620
+ const content = document.getElementById('soul-editor').value;
1621
+ const res = await fetch(API + '/api/soul/save', {
1622
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1623
+ body: JSON.stringify({ content }),
1624
+ });
1625
+ const data = await res.json();
1626
+ toast(data.ok ? t('personality.saved') : data.error, data.ok ? 'success' : 'error');
1627
+ }
1628
+
1629
+ // ── Settings ────────────────────────────────────────────
1630
+ async function loadSettings() {
1631
+ const [envRes, sudoRes] = await Promise.all([
1632
+ fetch(API + '/api/env'),
1633
+ fetch(API + '/api/sudo/status'),
1634
+ ]);
1635
+ const envData = await envRes.json();
1636
+ const sudoData = await sudoRes.json();
1637
+
1638
+ let html = '';
1639
+
1640
+ const sudoIcon = sudoData.configured ? (sudoData.verified ? icon('circle-check', 20, 'style="color:var(--green)"') : icon('circle-alert', 20, 'style="color:var(--yellow)"')) : icon('circle-x', 20, 'style="color:var(--red)"');
1641
+ const sudoStatusText = sudoData.configured
1642
+ ? (sudoData.verified ? t('settings.sudo.active') : t('settings.sudo.configured'))
1643
+ : t('settings.sudo.not.set');
1644
+
1645
+ html += `<div class="card" style="margin-bottom:16px">
1646
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
1647
+ <span style="display:flex">${icon('lock', 24)}</span>
1648
+ <div style="flex:1">
1649
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0">${t('settings.sudo')}</h3>
1650
+ <div class="sub">${t('settings.sudo.desc')}</div>
1651
+ </div>
1652
+ ${sudoIcon}
1653
+ </div>
1654
+ <div style="font-size:0.85em;margin-bottom:12px">
1655
+ <div><strong>${t('settings.sudo.status')}:</strong> ${sudoStatusText}</div>
1656
+ <div><strong>${t('settings.sudo.storage')}:</strong> ${sudoData.storageMethod}</div>
1657
+ <div><strong>${t('settings.sudo.system')}:</strong> ${sudoData.platform} (${sudoData.user})</div>
1658
+ ${sudoData.permissions.accessibility !== null ? `<div><strong>${t('settings.sudo.accessibility')}:</strong> ${sudoData.permissions.accessibility ? icon('circle-check', 14, 'style="color:var(--green)"') : `${icon('circle-x', 14, 'style="color:var(--red)"')} <button class="btn btn-sm btn-outline" onclick="openSysSettings('accessibility')" style="font-size:0.8em;padding:2px 6px">${t('settings.sudo.open')}</button>`}</div>` : ''}
1659
+ ${sudoData.permissions.fullDiskAccess !== null ? `<div><strong>${t('settings.sudo.fda')}:</strong> ${sudoData.permissions.fullDiskAccess ? icon('circle-check', 14, 'style="color:var(--green)"') : `${icon('circle-x', 14, 'style="color:var(--red)"')} <button class="btn btn-sm btn-outline" onclick="openSysSettings('full-disk-access')" style="font-size:0.8em;padding:2px 6px">${t('settings.sudo.open')}</button>`}</div>` : ''}
1660
+ </div>`;
1661
+
1662
+ if (!sudoData.configured) {
1663
+ html += `<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
1664
+ <input type="password" id="sudo-password" placeholder="${t('settings.sudo.password')}" style="flex:1;background:var(--bg3);border:1px solid var(--glass-border);border-radius:6px;padding:8px 12px;color:var(--fg);font:inherit;font-size:0.85em;outline:none">
1665
+ <button class="btn btn-sm" onclick="setupSudo()">${icon('lock', 12)} ${t('settings.sudo.setup')}</button>
1666
+ </div>
1667
+ <div class="sub">${t('settings.sudo.stored.secure')}</div>`;
1668
+ } else {
1669
+ html += `<div style="display:flex;gap:8px;flex-wrap:wrap">
1670
+ <button class="btn btn-sm btn-outline" onclick="verifySudo()">${icon('test-tube', 12)} ${t('settings.sudo.verify')}</button>
1671
+ <button class="btn btn-sm btn-outline" onclick="testSudoCommand()">${icon('zap', 12)} ${t('settings.sudo.test')}</button>
1672
+ ${sudoData.platform === 'darwin' ? `<button class="btn btn-sm btn-outline" onclick="showAdminDialog()">${icon('monitor', 12)} ${t('settings.sudo.admin.dialog')}</button>` : ''}
1673
+ <button class="btn btn-sm btn-outline" style="color:var(--red)" onclick="revokeSudo()">${icon('circle-x', 12)} ${t('settings.sudo.revoke')}</button>
1674
+ </div>`;
1675
+ }
1676
+ html += `<div id="sudo-result" style="font-size:0.78em;margin-top:6px"></div></div>`;
1677
+
1678
+ const envHtml = envData.vars.map(v => `
1679
+ <div class="list-item">
1680
+ <div class="info">
1681
+ <div class="name" style="font-family:monospace;font-size:0.85em">${v.key}</div>
1682
+ <div class="desc">${v.value || '(empty)'}</div>
1683
+ </div>
1684
+ <button class="btn btn-sm btn-outline" onclick="editEnvVar('${v.key}')">${t('edit')}</button>
1685
+ </div>
1686
+ `).join('');
1687
+
1688
+ html += `<div class="card" style="margin-bottom:16px">
1689
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0;margin-bottom:8px;display:flex;align-items:center;gap:6px">${icon('settings', 16)} ${t('settings.env')}</h3>
1690
+ ${envHtml}
1691
+ <div style="margin-top:12px;display:flex;gap:8px">
1692
+ <button class="btn btn-sm" onclick="addEnvVar()">${icon('plus', 14)} ${t('settings.env.add')}</button>
1693
+ </div>
1694
+ </div>`;
1695
+
1696
+ document.getElementById('settings-content').innerHTML = html;
1697
+ }
1698
+
1699
+ async function setupSudo() {
1700
+ const pw = document.getElementById('sudo-password').value;
1701
+ if (!pw) { toast(t('settings.sudo.password'), 'error'); return; }
1702
+ const resultDiv = document.getElementById('sudo-result');
1703
+ resultDiv.innerHTML = `<span style="color:var(--fg2)">${t('loading')}</span>`;
1704
+ const res = await fetch(API + '/api/sudo/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) });
1705
+ const data = await res.json();
1706
+ if (data.ok && data.verified) { toast(t('settings.sudo.setup.ok')); loadSettings(); }
1707
+ else { resultDiv.innerHTML = `<span style="color:var(--red)">${icon('circle-x', 12)} ${data.error || t('error')}</span>`; toast(data.error || t('error'), 'error'); }
1708
+ }
1709
+
1710
+ async function verifySudo() {
1711
+ const resultDiv = document.getElementById('sudo-result');
1712
+ resultDiv.innerHTML = `<span style="color:var(--fg2)">${t('settings.sudo.verifying')}</span>`;
1713
+ const res = await fetch(API + '/api/sudo/verify', { method: 'POST' });
1714
+ const data = await res.json();
1715
+ resultDiv.innerHTML = data.ok
1716
+ ? `<span style="color:var(--green)">${icon('circle-check', 12)} ${t('settings.sudo.verified')}</span>`
1717
+ : `<span style="color:var(--red)">${icon('circle-x', 12)} ${data.error}</span>`;
1718
+ }
1719
+
1720
+ async function testSudoCommand() {
1721
+ const cmd = prompt(t('settings.sudo.test.prompt'), 'whoami');
1722
+ if (!cmd) return;
1723
+ const resultDiv = document.getElementById('sudo-result');
1724
+ resultDiv.innerHTML = `<span style="color:var(--fg2)">${t('settings.sudo.executing')}</span>`;
1725
+ const res = await fetch(API + '/api/sudo/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: cmd }) });
1726
+ const data = await res.json();
1727
+ resultDiv.innerHTML = data.ok
1728
+ ? `<span style="color:var(--green)">${icon('circle-check', 12)} ${t('settings.sudo.output')}:</span><pre style="margin:4px 0;padding:6px;background:var(--bg3);border-radius:4px;font-size:0.9em;overflow-x:auto">${escapeHtml(data.output.slice(0, 500))}</pre>`
1729
+ : `<span style="color:var(--red)">${icon('circle-x', 12)} ${data.error}</span>`;
1730
+ }
1731
+
1732
+ async function revokeSudo() {
1733
+ if (!confirm(t('settings.sudo.revoke.confirm'))) return;
1734
+ await fetch(API + '/api/sudo/revoke', { method: 'POST' });
1735
+ toast(t('settings.sudo.revoked'));
1736
+ loadSettings();
1737
+ }
1738
+
1739
+ async function showAdminDialog() {
1740
+ const reason = prompt(t('settings.sudo.admin.reason'), t('settings.sudo.admin.default.reason'));
1741
+ if (!reason) return;
1742
+ toast(t('settings.sudo.admin.showing'));
1743
+ const res = await fetch(API + '/api/sudo/admin-dialog', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) });
1744
+ const data = await res.json();
1745
+ toast(data.ok ? t('settings.sudo.admin.confirmed') : t('settings.sudo.admin.denied'), data.ok ? 'success' : 'error');
1746
+ }
1747
+
1748
+ async function openSysSettings(pane) {
1749
+ await fetch(API + '/api/sudo/open-settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pane }) });
1750
+ toast(t('settings.sysopen'));
1751
+ }
1752
+
1753
+ function editEnvVar(key) {
1754
+ const value = prompt(t('settings.env.new.prompt', { key }), '');
1755
+ if (value === null) return;
1756
+ fetch(API + '/api/env/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }) })
1757
+ .then(r => r.json()).then(d => {
1758
+ toast(d.ok ? t('settings.env.saved', { key }) : d.error, d.ok ? 'success' : 'error');
1759
+ loadSettings();
1760
+ });
1761
+ }
1762
+
1763
+ function addEnvVar() {
1764
+ const key = prompt(t('settings.env.name.prompt'));
1765
+ if (!key) return;
1766
+ const value = prompt(t('settings.env.value.prompt', { key }));
1767
+ if (value === null) return;
1768
+ fetch(API + '/api/env/set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }) })
1769
+ .then(r => r.json()).then(d => {
1770
+ toast(d.ok ? t('settings.env.added', { key }) : d.error, d.ok ? 'success' : 'error');
1771
+ loadSettings();
1772
+ });
1773
+ }
1774
+
1775
+ // ── Doctor & Backup ─────────────────────────────────────
1776
+ async function repairIssue(action) {
1777
+ const res = await fetch(API + '/api/doctor/repair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) });
1778
+ const data = await res.json();
1779
+ toast(data.ok ? data.message : data.message, data.ok ? 'success' : 'error');
1780
+ loadMaintenance();
1781
+ }
1782
+
1783
+ async function repairAll() {
1784
+ if (!confirm(t('maint.doctor.fix.all.confirm'))) return;
1785
+ const res = await fetch(API + '/api/doctor/repair-all', { method: 'POST' });
1786
+ const data = await res.json();
1787
+ const ok = data.results.filter(r => r.ok).length;
1788
+ const fail = data.results.filter(r => !r.ok).length;
1789
+ toast(`${t('maint.doctor.fixed', { ok })}${fail > 0 ? `, ${t('maint.doctor.failed', { fail })}` : ''}`, fail > 0 ? 'error' : 'success');
1790
+ loadMaintenance();
1791
+ }
1792
+
1793
+ async function createBackup() {
1794
+ const name = prompt(t('maint.backup.name.prompt'), '');
1795
+ toast(t('maint.backup.creating'));
1796
+ const res = await fetch(API + '/api/backups/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || undefined }) });
1797
+ const data = await res.json();
1798
+ if (data.ok) { toast(t('maint.backup.created', { id: data.id, count: data.files.length })); loadMaintenance(); }
1799
+ else { toast(data.error || t('error'), 'error'); }
1800
+ }
1801
+ function createBackupMaint() { createBackup(); }
1802
+
1803
+ async function restoreBackup(id) {
1804
+ if (!confirm(t('maint.backup.restore.confirm', { id }))) return;
1805
+ const res = await fetch(API + '/api/backups/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
1806
+ const data = await res.json();
1807
+ if (data.ok || data.restored?.length > 0) {
1808
+ toast(t('maint.backup.restored', { count: data.restored.length }));
1809
+ if (data.errors?.length > 0) toast(`${data.errors.length} ${t('error')}`, 'error');
1810
+ loadMaintenance();
1811
+ } else {
1812
+ toast(data.errors?.[0] || t('error'), 'error');
1813
+ }
1814
+ }
1815
+
1816
+ async function showBackupFiles(id) {
1817
+ const area = document.getElementById('backup-files-area');
1818
+ if (area.style.display !== 'none' && area.dataset.id === id) { area.style.display = 'none'; return; }
1819
+ const res = await fetch(API + `/api/backups/${id}/files`);
1820
+ const data = await res.json();
1821
+ area.dataset.id = id; area.style.display = '';
1822
+ area.innerHTML = `<div style="font-weight:500;margin-bottom:6px">${icon('clipboard', 14)} ${t('maint.backup.files.in', { id })}:</div>` +
1823
+ data.files.map(f => `<div style="padding:2px 0;color:var(--fg2)">${icon('file-text', 12)} ${f}</div>`).join('') +
1824
+ `<div style="margin-top:8px"><button class="btn btn-sm btn-outline" onclick="document.getElementById('backup-files-area').style.display='none'">${t('close')}</button></div>`;
1825
+ }
1826
+
1827
+ async function deleteBackup(id) {
1828
+ if (!confirm(t('maint.backup.delete.confirm', { id }))) return;
1829
+ await fetch(API + '/api/backups/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
1830
+ toast(t('maint.backup.deleted'));
1831
+ loadMaintenance();
1832
+ }
1833
+
1834
+ // ── PM2 ─────────────────────────────────────────────────
1835
+ async function pm2Action(action) {
1836
+ const dangerous = ['stop'];
1837
+ if (dangerous.includes(action) && !confirm(t('maint.pm2.stop.confirm'))) return;
1838
+ if (action === 'restart' && !confirm(t('maint.pm2.restart.confirm'))) return;
1839
+
1840
+ toast(`PM2 ${action}...`);
1841
+ try {
1842
+ const res = await fetch(API + '/api/pm2/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) });
1843
+ const data = await res.json();
1844
+ if (data.ok) {
1845
+ toast(t('maint.pm2.action.ok', { action }));
1846
+ if (action === 'stop') {
1847
+ document.getElementById('pm2-status').innerHTML = `<span class="badge badge-red">${icon('pause', 12)} ${t('maint.pm2.stop')}</span>`;
1848
+ } else {
1849
+ setTimeout(refreshPM2Status, 2000);
1850
+ if (['restart', 'reload', 'start'].includes(action)) setTimeout(connectWS, 3000);
1851
+ }
1852
+ } else {
1853
+ toast(t('maint.pm2.action.fail', { action }) + ': ' + (data.error || ''), 'error');
1854
+ }
1855
+ } catch (e) {
1856
+ toast(t('maint.pm2.lost'), 'error');
1857
+ document.getElementById('pm2-status').innerHTML = `<span class="badge badge-red">${icon('circle-x', 12)} ${t('maint.pm2.unreachable')}</span>`;
1858
+ }
1859
+ }
1860
+
1861
+ async function refreshPM2Status() {
1862
+ try {
1863
+ const res = await fetch(API + '/api/pm2/status');
1864
+ const data = await res.json();
1865
+ const el = document.getElementById('pm2-status');
1866
+ if (!el) return;
1867
+ if (data.error) { el.innerHTML = `<span class="badge badge-yellow">${icon('alert-triangle', 12)} ${data.error}</span>`; return; }
1868
+
1869
+ const p = data.process;
1870
+ const statusColors = { online: 'green', stopping: 'yellow', stopped: 'red', errored: 'red', launching: 'yellow' };
1871
+ const statusIcons = { online: 'circle-check', stopping: 'clock', stopped: 'circle-x', errored: 'circle-x', launching: 'zap' };
1872
+ const color = statusColors[p.status] || 'gray';
1873
+ const sIcon = statusIcons[p.status] || 'info';
1874
+ const uptime = p.uptime > 0 ? formatDuration(p.uptime) : '—';
1875
+ const mem = p.memory ? (p.memory / 1024 / 1024).toFixed(1) + ' MB' : '—';
1876
+ const cpu = p.cpu !== undefined ? p.cpu + '%' : '—';
1877
+
1878
+ el.innerHTML = `
1879
+ <div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center">
1880
+ <span class="badge badge-${color}">${icon(sIcon, 12)} ${p.status}</span>
1881
+ <span title="Uptime">${icon('clock', 12)} ${uptime}</span>
1882
+ <span title="Memory">${icon('hard-drive', 12)} ${mem}</span>
1883
+ <span title="CPU">${icon('monitor', 12)} ${cpu}</span>
1884
+ <span title="Restarts">${icon('refresh-cw', 12)} ${p.restarts}x</span>
1885
+ <span title="PID">PID: ${p.pid || '—'}</span>
1886
+ <span title="PM2 Name" style="font-family:monospace;color:var(--accent2)">${p.name}</span>
1887
+ </div>`;
1888
+ } catch (e) {
1889
+ const el = document.getElementById('pm2-status');
1890
+ if (el) el.innerHTML = `<span class="badge badge-red">${icon('circle-x', 12)} ${t('maint.pm2.unreachable')}</span>`;
1891
+ }
1892
+ }
1893
+
1894
+ function formatDuration(ms) {
1895
+ const s = Math.floor(ms / 1000);
1896
+ if (s < 60) return s + 's';
1897
+ const m = Math.floor(s / 60);
1898
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
1899
+ const h = Math.floor(m / 60);
1900
+ if (h < 24) return h + 'h ' + (m % 60) + 'm';
1901
+ const d = Math.floor(h / 24);
1902
+ return d + 'd ' + (h % 24) + 'h';
1903
+ }
1904
+
1905
+ async function loadPM2Logs() {
1906
+ const el = document.getElementById('pm2-log-output');
1907
+ if (!el) return;
1908
+ el.textContent = t('loading');
1909
+ try {
1910
+ const res = await fetch(API + '/api/pm2/logs');
1911
+ const data = await res.json();
1912
+ if (data.error) el.textContent = t('error') + ': ' + data.error;
1913
+ else { el.textContent = data.logs || t('no.data'); el.scrollTop = el.scrollHeight; }
1914
+ } catch (e) { el.textContent = t('error'); }
1915
+ }
1916
+
1917
+ async function restartBot() {
1918
+ if (!confirm(t('maint.bot.restart.confirm'))) return;
1919
+ toast(t('maint.bot.restarting'));
1920
+ await fetch(API + '/api/bot/restart', { method: 'POST' });
1921
+ setTimeout(() => { toast(t('maint.bot.reconnecting')); connectWS(); }, 3000);
1922
+ }
1923
+
1924
+ async function reconnectBot() {
1925
+ toast(t('reconnecting'));
1926
+ if (ws) ws.close();
1927
+ setTimeout(connectWS, 500);
1928
+ }
1929
+
1930
+ // ── Files ───────────────────────────────────────────────
1931
+ let currentFilePath = '.';
1932
+
1933
+ async function navigateFiles(dir) {
1934
+ if (dir === '..') {
1935
+ const parts = currentFilePath.split('/').filter(Boolean);
1936
+ parts.pop();
1937
+ currentFilePath = parts.join('/') || '.';
1938
+ } else if (dir !== '.') {
1939
+ currentFilePath = currentFilePath === '.' ? dir : currentFilePath + '/' + dir;
1940
+ }
1941
+
1942
+ const res = await fetch(API + '/api/files?path=' + encodeURIComponent(currentFilePath));
1943
+ const data = await res.json();
1944
+ document.getElementById('file-breadcrumb').innerHTML = `${icon('folder', 14)} /${currentFilePath === '.' ? '' : currentFilePath}`;
1945
+
1946
+ if (data.entries) {
1947
+ document.getElementById('file-editor-area').style.display = 'none';
1948
+ const fileIcons = { ts:'code', js:'code', json:'file-text', md:'file-text', html:'globe', css:'palette', sh:'terminal', py:'code', txt:'file-text', env:'lock' };
1949
+ document.getElementById('file-list').innerHTML = data.entries.map(e => {
1950
+ const ic = e.type === 'dir' ? 'folder' : (fileIcons[e.name.split('.').pop()?.toLowerCase()] || 'file-text');
1951
+ const size = e.type === 'file' ? formatSize(e.size) : '';
1952
+ const fpath = (currentFilePath === '.' ? '' : currentFilePath + '/') + e.name;
1953
+ return `<div class="file-item" onclick="${e.type==='dir' ? `navigateFiles('${e.name}')` : `openFile('${fpath}')`}">
1954
+ <span class="file-icon">${icon(ic, 16)}</span><span class="file-name">${e.name}</span><span class="file-meta">${size}</span></div>`;
1955
+ }).join('');
1956
+ }
1957
+ }
1958
+
1959
+ async function openFile(filePath) {
1960
+ const res = await fetch(API + '/api/files?path=' + encodeURIComponent(filePath));
1961
+ const data = await res.json();
1962
+ if (data.error) { toast(data.error, 'error'); return; }
1963
+ if (data.content !== undefined && data.content !== null) {
1964
+ document.getElementById('file-editor-area').style.display = '';
1965
+ document.getElementById('file-edit-name').textContent = filePath;
1966
+ document.getElementById('file-editor').value = data.content;
1967
+ const lines = data.content.split('\n').length;
1968
+ const sizeStr = data.size ? formatSize(data.size) : '';
1969
+ document.getElementById('file-edit-meta')?.remove();
1970
+ const meta = document.createElement('div');
1971
+ meta.id = 'file-edit-meta';
1972
+ meta.style.cssText = 'font-size:0.75em;color:var(--fg2);margin-top:4px';
1973
+ meta.textContent = `${t('files.lines', { count: lines })} · ${sizeStr}`;
1974
+ document.getElementById('file-editor').parentNode.insertBefore(meta, document.getElementById('file-editor'));
1975
+ } else if (data.binary) {
1976
+ toast(t('files.binary'), 'error');
1977
+ } else {
1978
+ toast(t('files.open.error'), 'error');
1979
+ }
1980
+ }
1981
+
1982
+ async function saveFile() {
1983
+ const path = document.getElementById('file-edit-name').textContent;
1984
+ const content = document.getElementById('file-editor').value;
1985
+ const res = await fetch(API + '/api/files/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }) });
1986
+ const data = await res.json();
1987
+ toast(data.ok ? t('files.saved') : data.error, data.ok ? 'success' : 'error');
1988
+ }
1989
+
1990
+ async function createNewFile() {
1991
+ const name = prompt(t('files.name.prompt'));
1992
+ if (!name) return;
1993
+ const filePath = currentFilePath === '.' ? name : currentFilePath + '/' + name;
1994
+ const res = await fetch(API + '/api/files/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: filePath, content: '' }) });
1995
+ const data = await res.json();
1996
+ if (data.ok) { toast(t('files.created')); navigateFiles('.'); openFile(filePath); }
1997
+ else { toast(data.error || t('files.create.error'), 'error'); }
1998
+ }
1999
+
2000
+ async function deleteFile(filePath) {
2001
+ if (!confirm(t('files.delete.confirm', { path: filePath }))) return;
2002
+ const res = await fetch(API + '/api/files/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: filePath }) });
2003
+ const data = await res.json();
2004
+ if (data.ok) { toast(t('files.deleted')); document.getElementById('file-editor-area').style.display = 'none'; navigateFiles('.'); }
2005
+ else { toast(data.error || t('files.delete.error'), 'error'); }
2006
+ }
2007
+
2008
+ function formatSize(bytes) {
2009
+ if (bytes < 1024) return bytes + ' B';
2010
+ if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
2011
+ return (bytes/1048576).toFixed(1) + ' MB';
2012
+ }
2013
+
2014
+ // ── Cron ────────────────────────────────────────────────
2015
+ async function loadCron() {
2016
+ const res = await fetch(API + '/api/cron');
2017
+ const data = await res.json();
2018
+ const list = document.getElementById('cron-list');
2019
+
2020
+ if (data.jobs.length === 0) {
2021
+ list.innerHTML = `<div class="card"><h3>${t('cron.none')}</h3><div class="sub">${t('cron.none.desc')}</div></div>`;
2022
+ return;
2023
+ }
2024
+
2025
+ const typeIcons = { reminder: 'timer', shell: 'zap', http: 'globe', message: 'message-square', 'ai-query': 'bot' };
2026
+
2027
+ list.innerHTML = data.jobs.map(j => {
2028
+ const statusIcon = j.enabled ? icon('circle-check', 14, 'style="color:var(--green)"') : icon('pause', 14, 'style="color:var(--fg3)"');
2029
+ const errIcon = j.lastError ? ` <span style="color:var(--red)">${icon('alert-triangle', 14)}</span>` : '';
2030
+ const ic = typeIcons[j.type] || 'clipboard';
2031
+ const payload = j.payload.text || j.payload.command || j.payload.url || j.payload.prompt || '';
2032
+ const schedLabel = j.scheduleReadable || j.schedule;
2033
+ const recBadge = j.oneShot
2034
+ ? `<span class="badge badge-yellow">${icon('zap', 10)} ${t('cron.single')}</span>`
2035
+ : `<span class="badge" style="background:var(--accent);color:#fff">${icon('refresh-cw', 10)} ${escapeHtml(schedLabel)}</span>`;
2036
+
2037
+ return `<div class="card" style="margin-bottom:12px">
2038
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
2039
+ ${statusIcon}
2040
+ <span style="font-weight:500;flex:1;display:flex;align-items:center;gap:6px">${icon(ic, 16)} ${escapeHtml(j.name)}${errIcon}</span>
2041
+ ${recBadge}
2042
+ </div>
2043
+ <div id="cron-edit-${j.id}" style="display:none;margin-bottom:10px;padding:12px;background:var(--bg3);border-radius:8px">
2044
+ ${buildScheduleEditor(j.id, j.schedule, j.oneShot)}
2045
+ </div>
2046
+ <div style="font-size:0.82em;color:var(--fg2);margin-bottom:8px">
2047
+ <span>${t('cron.next.run')}: <strong>${j.nextRunFormatted || '—'}</strong></span> ·
2048
+ <span>${t('cron.runs')}: ${j.runCount}</span> ·
2049
+ <span>${t('cron.last.run')}: ${j.lastRunFormatted || t('cron.never')}</span>
2050
+ </div>
2051
+ ${payload ? `<div style="font-size:0.78em;font-family:monospace;color:var(--fg2);padding:6px 8px;background:var(--bg3);border-radius:4px;margin-bottom:8px;word-break:break-all">${escapeHtml(payload.slice(0, 200))}</div>` : ''}
2052
+ ${j.lastError ? `<div style="font-size:0.78em;color:var(--red);margin-bottom:8px">${icon('circle-x', 12)} ${escapeHtml(j.lastError)}</div>` : ''}
2053
+ <div style="display:flex;gap:6px">
2054
+ <button class="btn btn-sm btn-outline" onclick="toggleCronJob('${j.id}')">${j.enabled ? `${icon('pause', 12)} ${t('cron.pause')}` : `${icon('play', 12)} ${t('cron.start')}`}</button>
2055
+ <button class="btn btn-sm btn-outline" onclick="runCronJob('${j.id}')">${icon('play', 12)} ${t('cron.run.now')}</button>
2056
+ <button class="btn btn-sm btn-outline" onclick="editCronSchedule('${j.id}')">${icon('edit', 12)} ${t('cron.edit')}</button>
2057
+ <button class="btn btn-sm btn-outline" style="color:var(--red)" onclick="deleteCronJob('${j.id}')">${icon('trash-2', 12)}</button>
2058
+ </div>
2059
+ </div>`;
2060
+ }).join('');
2061
+ }
2062
+
2063
+ function showCreateCron() {
2064
+ document.getElementById('cron-create-form').style.display = '';
2065
+ const container = document.getElementById('cron-create-schedule-builder');
2066
+ if (container && !container.innerHTML.trim()) {
2067
+ container.innerHTML = buildScheduleEditor(null, '0 8 * * *', false);
2068
+ }
2069
+ }
2070
+
2071
+ async function createCronJob() {
2072
+ const name = document.getElementById('cron-name').value.trim();
2073
+ const type = document.getElementById('cron-type').value;
2074
+ const payloadText = document.getElementById('cron-payload').value.trim();
2075
+ if (!name) { toast(t('cron.name.required'), 'error'); return; }
2076
+
2077
+ const result = fieldsToCron(null);
2078
+ if (!result) return;
2079
+
2080
+ const payload = {};
2081
+ switch (type) {
2082
+ case 'reminder': case 'message': payload.text = payloadText; break;
2083
+ case 'shell': payload.command = payloadText; break;
2084
+ case 'http': payload.url = payloadText; break;
2085
+ case 'ai-query': payload.prompt = payloadText; break;
2086
+ }
2087
+
2088
+ const res = await fetch(API + '/api/cron/create', {
2089
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2090
+ body: JSON.stringify({ name, type, schedule: result.schedule, oneShot: result.oneShot, payload, target: { platform: 'telegram', chatId: 'YOUR_USER_ID' } }),
2091
+ });
2092
+ const data = await res.json();
2093
+ if (data.ok) {
2094
+ toast(t('cron.created'));
2095
+ document.getElementById('cron-create-form').style.display = 'none';
2096
+ document.getElementById('cron-name').value = '';
2097
+ document.getElementById('cron-payload').value = '';
2098
+ document.getElementById('cron-create-schedule-builder').innerHTML = '';
2099
+ loadCron();
2100
+ } else {
2101
+ toast(data.error, 'error');
2102
+ }
2103
+ }
2104
+
2105
+ async function toggleCronJob(id) {
2106
+ await fetch(API + '/api/cron/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
2107
+ loadCron();
2108
+ }
2109
+
2110
+ async function deleteCronJob(id) {
2111
+ if (!confirm(t('cron.delete.confirm'))) return;
2112
+ await fetch(API + '/api/cron/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
2113
+ toast(t('cron.deleted'));
2114
+ loadCron();
2115
+ }
2116
+
2117
+ // ── Schedule Builder ─────────────────────────────────────
2118
+
2119
+ function parseCronToFields(schedule) {
2120
+ const intMatch = schedule.match(/^(\d+)\s*(m|min|h|hr|d|day|s|sec)s?$/i);
2121
+ if (intMatch) {
2122
+ const val = intMatch[1];
2123
+ const u = intMatch[2].toLowerCase();
2124
+ const unit = (u === 'm' || u === 'min') ? 'min' : (u === 'h' || u === 'hr') ? 'h' : (u === 'd' || u === 'day') ? 'd' : 's';
2125
+ return { mode: 'interval', interval: val, intervalUnit: unit, hour: '08', minute: '00', weekdays: [], monthday: '1' };
2126
+ }
2127
+ const parts = schedule.trim().split(/\s+/);
2128
+ if (parts.length === 5) {
2129
+ const [min, hour, day, , wd] = parts;
2130
+ const weekdays = wd !== '*' ? wd.split(',').flatMap(v => {
2131
+ if (v.includes('-')) { const [a,b] = v.split('-').map(Number); const r=[]; for(let i=a;i<=b;i++) r.push(String(i)); return r; }
2132
+ return [v];
2133
+ }) : [];
2134
+ let mode = 'daily';
2135
+ if (weekdays.length > 0) mode = 'weekly';
2136
+ if (day !== '*') mode = 'monthly';
2137
+ return { mode, interval: '5', intervalUnit: 'min', hour: hour === '*' ? '08' : hour.padStart(2,'0'), minute: min === '*' ? '00' : min.padStart(2,'0'), weekdays, monthday: day === '*' ? '1' : day };
2138
+ }
2139
+ return { mode: 'daily', interval: '5', intervalUnit: 'min', hour: '08', minute: '00', weekdays: [], monthday: '1' };
2140
+ }
2141
+
2142
+ function fieldsToCron(id) {
2143
+ const pfx = id ? id + '-' : 'create-';
2144
+ const mode = document.querySelector(`input[name="sched-mode-${pfx}"]:checked`)?.value || 'daily';
2145
+ const oneShot = document.querySelector(`input[name="sched-recur-${pfx}"]:checked`)?.value === 'true';
2146
+
2147
+ if (mode === 'interval') {
2148
+ const val = document.getElementById(`sched-interval-${pfx}`)?.value || '5';
2149
+ const unit = document.getElementById(`sched-interval-unit-${pfx}`)?.value || 'min';
2150
+ const unitMap = { min: 'm', h: 'h', d: 'd', s: 's' };
2151
+ return { schedule: val + (unitMap[unit] || 'm'), oneShot };
2152
+ }
2153
+
2154
+ const hour = document.getElementById(`sched-hour-${pfx}`)?.value || '8';
2155
+ const minute = document.getElementById(`sched-minute-${pfx}`)?.value || '0';
2156
+
2157
+ if (mode === 'weekly') {
2158
+ const checks = document.querySelectorAll(`input[name="sched-wd-${pfx}"]:checked`);
2159
+ const days = Array.from(checks).map(c => c.value);
2160
+ if (days.length === 0) { toast(t('cron.schedule.weekday.min'), 'error'); return null; }
2161
+ return { schedule: `${minute} ${hour} * * ${days.join(',')}`, oneShot };
2162
+ }
2163
+
2164
+ if (mode === 'monthly') {
2165
+ const day = document.getElementById(`sched-monthday-${pfx}`)?.value || '1';
2166
+ return { schedule: `${minute} ${hour} ${day} * *`, oneShot };
2167
+ }
2168
+
2169
+ return { schedule: `${minute} ${hour} * * *`, oneShot };
2170
+ }
2171
+
2172
+ function buildScheduleEditor(id, schedule, oneShot, hideButtons) {
2173
+ const f = parseCronToFields(schedule || '0 8 * * *');
2174
+ const pfx = id ? id + '-' : 'create-';
2175
+ const wdNames = t('cron.weekdays').split(',');
2176
+ const modeOptions = [
2177
+ { val: 'interval', label: `${icon('timer', 14)} ${t('cron.schedule.interval')}` },
2178
+ { val: 'daily', label: `${icon('calendar', 14)} ${t('cron.schedule.daily')}` },
2179
+ { val: 'weekly', label: `${icon('calendar', 14)} ${t('cron.schedule.weekly')}` },
2180
+ { val: 'monthly', label: `${icon('calendar', 14)} ${t('cron.schedule.monthly')}` },
2181
+ ];
2182
+
2183
+ return `
2184
+ <div style="margin-bottom:10px">
2185
+ <div style="font-size:0.82em;color:var(--fg2);margin-bottom:6px;font-weight:500">${t('cron.schedule.repeat')}</div>
2186
+ <div style="display:flex;gap:4px;flex-wrap:wrap">
2187
+ ${modeOptions.map(o => `<label style="display:flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:0.82em;background:${f.mode===o.val?'var(--accent)':'var(--bg2)'};color:${f.mode===o.val?'#fff':'var(--fg)'}">
2188
+ <input type="radio" name="sched-mode-${pfx}" value="${o.val}" ${f.mode===o.val?'checked':''} onchange="toggleSchedFields('${pfx}')" style="display:none"> ${o.label}
2189
+ </label>`).join('')}
2190
+ </div>
2191
+ </div>
2192
+
2193
+ <div id="sched-interval-row-${pfx}" style="display:${f.mode==='interval'?'flex':'none'};gap:6px;align-items:center;margin-bottom:8px">
2194
+ <span style="font-size:0.82em;color:var(--fg2)">${t('cron.schedule.every')}</span>
2195
+ <input id="sched-interval-${pfx}" type="number" min="1" value="${f.interval}" class="input" style="width:60px;text-align:center">
2196
+ <select id="sched-interval-unit-${pfx}" class="input" style="width:auto">
2197
+ <option value="s" ${f.intervalUnit==='s'?'selected':''}>${t('cron.units.seconds')}</option>
2198
+ <option value="min" ${f.intervalUnit==='min'?'selected':''}>${t('cron.units.minutes')}</option>
2199
+ <option value="h" ${f.intervalUnit==='h'?'selected':''}>${t('cron.units.hours')}</option>
2200
+ <option value="d" ${f.intervalUnit==='d'?'selected':''}>${t('cron.units.days')}</option>
2201
+ </select>
2202
+ </div>
2203
+
2204
+ <div id="sched-time-row-${pfx}" style="display:${f.mode!=='interval'?'flex':'none'};gap:6px;align-items:center;margin-bottom:8px">
2205
+ <span style="font-size:0.82em;color:var(--fg2)">${t('cron.schedule.at')}</span>
2206
+ <input id="sched-hour-${pfx}" type="number" min="0" max="23" value="${f.hour}" class="input" style="width:50px;text-align:center">
2207
+ <span style="font-size:1.1em;font-weight:600">:</span>
2208
+ <input id="sched-minute-${pfx}" type="number" min="0" max="59" value="${f.minute}" class="input" style="width:50px;text-align:center">
2209
+ <span style="font-size:0.82em;color:var(--fg2)">${t('cron.schedule.oclock')}</span>
2210
+ </div>
2211
+
2212
+ <div id="sched-wd-row-${pfx}" style="display:${f.mode==='weekly'?'flex':'none'};gap:4px;flex-wrap:wrap;margin-bottom:8px">
2213
+ ${wdNames.map((d,i) => `<label style="display:flex;align-items:center;gap:2px;padding:4px 8px;border-radius:6px;cursor:pointer;font-size:0.82em;background:${f.weekdays.includes(String(i))?'var(--accent)':'var(--bg2)'};color:${f.weekdays.includes(String(i))?'#fff':'var(--fg)'}">
2214
+ <input type="checkbox" name="sched-wd-${pfx}" value="${i}" ${f.weekdays.includes(String(i))?'checked':''} onchange="this.parentElement.style.background=this.checked?'var(--accent)':'var(--bg2)';this.parentElement.style.color=this.checked?'#fff':'var(--fg)'" style="display:none"> ${d}
2215
+ </label>`).join('')}
2216
+ </div>
2217
+
2218
+ <div id="sched-md-row-${pfx}" style="display:${f.mode==='monthly'?'flex':'none'};gap:6px;align-items:center;margin-bottom:8px">
2219
+ <span style="font-size:0.82em;color:var(--fg2)">${t('cron.schedule.onday')}</span>
2220
+ <input id="sched-monthday-${pfx}" type="number" min="1" max="31" value="${f.monthday}" class="input" style="width:55px;text-align:center">
2221
+ <span style="font-size:0.82em;color:var(--fg2)">${t('cron.schedule.ofmonth')}</span>
2222
+ </div>
2223
+
2224
+ <div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
2225
+ <span style="font-size:0.82em;color:var(--fg2)">${t('cron.schedule.type')}:</span>
2226
+ <label style="font-size:0.82em;cursor:pointer"><input type="radio" name="sched-recur-${pfx}" value="false" ${!oneShot?'checked':''}> ${icon('refresh-cw', 12)} ${t('cron.recurring')}</label>
2227
+ <label style="font-size:0.82em;cursor:pointer"><input type="radio" name="sched-recur-${pfx}" value="true" ${oneShot?'checked':''}> ${icon('zap', 12)} ${t('cron.single')}</label>
2228
+ </div>
2229
+
2230
+ ${id ? `<div style="display:flex;gap:6px">
2231
+ <button class="btn btn-sm" onclick="saveCronSchedule('${id}')">${icon('save', 12)} ${t('save')}</button>
2232
+ <button class="btn btn-sm btn-outline" onclick="document.getElementById('cron-edit-${id}').style.display='none'">${t('cancel')}</button>
2233
+ </div>` : ''}`;
2234
+ }
2235
+
2236
+ function toggleSchedFields(pfx) {
2237
+ const mode = document.querySelector(`input[name="sched-mode-${pfx}"]:checked`)?.value || 'daily';
2238
+ document.getElementById('sched-interval-row-' + pfx).style.display = mode === 'interval' ? 'flex' : 'none';
2239
+ document.getElementById('sched-time-row-' + pfx).style.display = mode !== 'interval' ? 'flex' : 'none';
2240
+ document.getElementById('sched-wd-row-' + pfx).style.display = mode === 'weekly' ? 'flex' : 'none';
2241
+ document.getElementById('sched-md-row-' + pfx).style.display = mode === 'monthly' ? 'flex' : 'none';
2242
+ }
2243
+
2244
+ function editCronSchedule(id) {
2245
+ const el = document.getElementById('cron-edit-' + id);
2246
+ el.style.display = el.style.display === 'none' ? '' : 'none';
2247
+ }
2248
+
2249
+ async function saveCronSchedule(id) {
2250
+ const result = fieldsToCron(id);
2251
+ if (!result) return;
2252
+ const res = await fetch(API + '/api/cron/update', {
2253
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2254
+ body: JSON.stringify({ id, schedule: result.schedule, oneShot: result.oneShot }),
2255
+ });
2256
+ const data = await res.json();
2257
+ if (data.ok) { toast(t('cron.updated')); loadCron(); }
2258
+ else toast(data.error || t('error'), 'error');
2259
+ }
2260
+
2261
+ async function runCronJob(id) {
2262
+ toast(t('cron.executing'));
2263
+ const res = await fetch(API + '/api/cron/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
2264
+ const data = await res.json();
2265
+ if (data.error) toast(data.error, 'error');
2266
+ else toast(t('cron.executed'));
2267
+ loadCron();
2268
+ }
2269
+
2270
+ // ── Tools ───────────────────────────────────────────────
2271
+ let allTools = [];
2272
+
2273
+ async function loadTools() {
2274
+ const res = await fetch(API + '/api/tools');
2275
+ const data = await res.json();
2276
+ allTools = data.tools || [];
2277
+ document.getElementById('tools-count').textContent = t('tools.count', { count: allTools.length });
2278
+ renderTools(allTools);
2279
+ }
2280
+
2281
+ function filterTools() {
2282
+ const q = document.getElementById('tools-search').value.toLowerCase();
2283
+ const filtered = q ? allTools.filter(t => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)) : allTools;
2284
+ document.getElementById('tools-count').textContent = t('tools.count', { count: filtered.length });
2285
+ renderTools(filtered);
2286
+ }
2287
+
2288
+ function renderTools(tools) {
2289
+ const categories = {};
2290
+ tools.forEach(tl => {
2291
+ const cat = categorize(tl.name);
2292
+ if (!categories[cat]) categories[cat] = [];
2293
+ categories[cat].push(tl);
2294
+ });
2295
+
2296
+ const catIcons = {
2297
+ 'system': 'monitor', 'email': 'mail', 'automation': 'sparkles',
2298
+ 'pdf': 'file-text', 'dev': 'code', 'network': 'globe',
2299
+ 'media': 'image', 'clipboard': 'clipboard', 'files': 'folder', 'other': 'hammer'
2300
+ };
2301
+
2302
+ let html = '';
2303
+ for (const [cat, catTools] of Object.entries(categories)) {
2304
+ const catIcon = catIcons[cat] || 'hammer';
2305
+ html += `<div style="margin-bottom:20px"><h3 style="font-size:0.85em;color:var(--fg2);margin-bottom:8px;display:flex;align-items:center;gap:6px">${icon(catIcon, 16)} ${t('tools.cat.' + cat)}</h3>`;
2306
+ html += catTools.map(tl => {
2307
+ const params = Object.keys(tl.parameters || {});
2308
+ const paramBadges = params.map(p => `<span class="badge" style="font-size:0.65em">${p}</span>`).join(' ');
2309
+ return `<div class="list-item" style="cursor:pointer" onclick="runTool('${escapeHtml(tl.name)}')">
2310
+ <div class="info">
2311
+ <div class="name" style="font-family:monospace;font-size:0.85em">${tl.name} ${paramBadges}</div>
2312
+ <div class="desc">${tl.description}</div>
2313
+ </div>
2314
+ <button class="btn btn-sm btn-outline" onclick="event.stopPropagation();runTool('${escapeHtml(tl.name)}')">${icon('play', 12)}</button>
2315
+ </div>`;
2316
+ }).join('');
2317
+ html += '</div>';
2318
+ }
2319
+ document.getElementById('tools-list').innerHTML = html || `<div class="card"><h3>${t('tools.none')}</h3><div class="sub">${t('tools.none.desc')}</div></div>`;
2320
+ }
2321
+
2322
+ function categorize(name) {
2323
+ if (['run_shell','sudo_command','system_info','volume_set','brightness_set','bluetooth_control','wifi_status','say_text','notify','process_list','kill_process'].includes(name)) return 'system';
2324
+ if (name.startsWith('email_')) return 'email';
2325
+ if (['osascript','osascript_js','cliclick_type','cliclick_click','cliclick_key'].includes(name)) return 'automation';
2326
+ if (name.startsWith('pdf_') || name.includes('_to_pdf')) return 'pdf';
2327
+ if (['git_status','git_commit','pm2_status','pm2_restart','pm2_logs','ssh_command','docker_ps'].includes(name)) return 'dev';
2328
+ if (['web_fetch','network_check','open_url'].includes(name)) return 'network';
2329
+ if (['image_convert','image_resize','ffmpeg_convert','whisper_transcribe','screenshot'].includes(name)) return 'media';
2330
+ if (['clipboard_get','clipboard_set'].includes(name)) return 'clipboard';
2331
+ if (['find_files','disk_usage','open_file','calendar_today','calendar_upcoming'].includes(name)) return 'files';
2332
+ return 'other';
2333
+ }
2334
+
2335
+ function runTool(name) {
2336
+ const tool = allTools.find(t => t.name === name);
2337
+ if (!tool) return;
2338
+ const params = Object.entries(tool.parameters || {});
2339
+ if (params.length === 0) { executeTool(name, {}); return; }
2340
+ const values = {};
2341
+ for (const [key, def] of params) {
2342
+ const val = prompt(`${key}: ${def.description}`, '');
2343
+ if (val === null) return;
2344
+ if (val) values[key] = val;
2345
+ }
2346
+ executeTool(name, values);
2347
+ }
2348
+
2349
+ async function executeTool(name, params) {
2350
+ toast(t('tools.running', { name }));
2351
+ try {
2352
+ const res = await fetch(API + '/api/tools/execute', {
2353
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2354
+ body: JSON.stringify({ name, params }),
2355
+ });
2356
+ const data = await res.json();
2357
+ if (data.error) { toast(data.error, 'error'); return; }
2358
+ const output = data.output || '(no output)';
2359
+ document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
2360
+ document.querySelector('[data-page="terminal"]').classList.add('active');
2361
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
2362
+ document.getElementById('page-terminal').classList.add('active');
2363
+ document.getElementById('page-title').innerHTML = `${icon('terminal', 18)} ${t('nav.terminal')}`;
2364
+ const termOutput = document.getElementById('terminal-output');
2365
+ termOutput.innerHTML += `<div class="term-cmd">${icon('wrench', 12)} ${escapeHtml(name)} ${JSON.stringify(params)}</div>`;
2366
+ termOutput.innerHTML += `<div>${escapeHtml(output)}</div>`;
2367
+ termOutput.scrollTop = termOutput.scrollHeight;
2368
+ toast(t('tools.executed'));
2369
+ } catch (err) {
2370
+ toast(t('error') + ': ' + err.message, 'error');
2371
+ }
2372
+ }
2373
+
2374
+ // ── Terminal ────────────────────────────────────────────
2375
+ const termHist = []; let termIdx = -1;
2376
+ async function runCommand() {
2377
+ const input = document.getElementById('terminal-input');
2378
+ const output = document.getElementById('terminal-output');
2379
+ const cmd = input.value.trim();
2380
+ if (!cmd) return;
2381
+ termHist.unshift(cmd); termIdx = -1; input.value = '';
2382
+ output.innerHTML += `<div class="term-cmd">$ ${escapeHtml(cmd)}</div>`;
2383
+ try {
2384
+ const res = await fetch(API + '/api/terminal', {
2385
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2386
+ body: JSON.stringify({ command: cmd }),
2387
+ });
2388
+ const data = await res.json();
2389
+ if (data.output) output.innerHTML += `<div class="${data.exitCode ? 'term-err' : ''}">${escapeHtml(data.output)}</div>`;
2390
+ } catch (err) { output.innerHTML += `<div class="term-err">${t('error')}: ${err.message}</div>`; }
2391
+ output.scrollTop = output.scrollHeight;
2392
+ }
2393
+
2394
+ document.getElementById('terminal-input').addEventListener('keydown', (e) => {
2395
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runCommand(); }
2396
+ if (e.key === 'ArrowUp') { e.preventDefault(); if (termIdx < termHist.length-1) e.target.value = termHist[++termIdx]; }
2397
+ if (e.key === 'ArrowDown') { e.preventDefault(); termIdx > 0 ? e.target.value = termHist[--termIdx] : (termIdx=-1, e.target.value=''); }
2398
+ });
2399
+
2400
+ // ── Maintenance ─────────────────────────────────────────
2401
+ async function loadMaintenance() {
2402
+ const [doctorRes, backupRes] = await Promise.all([
2403
+ fetch(API + '/api/doctor'),
2404
+ fetch(API + '/api/backups'),
2405
+ ]);
2406
+ const doctorData = await doctorRes.json();
2407
+ const backupData = await backupRes.json();
2408
+
2409
+ let html = '';
2410
+
2411
+ const healthIcon = doctorData.healthy ? icon('circle-check', 24, 'style="color:var(--green)"') : (doctorData.errorCount > 0 ? icon('circle-x', 24, 'style="color:var(--red)"') : icon('circle-alert', 24, 'style="color:var(--yellow)"'));
2412
+ html += `<div class="card" style="margin-bottom:16px">
2413
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
2414
+ ${healthIcon}
2415
+ <div style="flex:1">
2416
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0;display:flex;align-items:center;gap:6px">${icon('stethoscope', 18)} ${t('maint.doctor')}</h3>
2417
+ <div class="sub">${t('maint.doctor.errors', { errors: doctorData.errorCount, warns: doctorData.warnCount })}</div>
2418
+ </div>
2419
+ <button class="btn btn-sm btn-outline" onclick="loadMaintenance()">${icon('refresh-cw', 12)} ${t('maint.doctor.check')}</button>
2420
+ ${doctorData.errorCount > 0 ? `<button class="btn btn-sm" onclick="repairAll()">${icon('wrench', 12)} ${t('maint.doctor.fix.all')}</button>` : ''}
2421
+ </div>`;
2422
+
2423
+ for (const issue of doctorData.issues) {
2424
+ const sevIcons = { error: icon('circle-x', 14), warning: icon('alert-triangle', 14), info: icon('info', 14) };
2425
+ const colors = { error: 'var(--red)', warning: 'var(--yellow)', info: 'var(--fg2)' };
2426
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:0.85em;border-top:1px solid var(--glass-border)">
2427
+ <span style="color:${colors[issue.severity]}">${sevIcons[issue.severity]}</span>
2428
+ <span style="flex:1"><strong>${issue.category}:</strong> ${issue.message}</span>
2429
+ ${issue.fixAction ? `<button class="btn btn-sm btn-outline" onclick="repairIssue('${issue.fixAction}')" title="${issue.fix || ''}">${icon('wrench', 12)} Fix</button>` : ''}
2430
+ </div>`;
2431
+ }
2432
+ html += `</div>`;
2433
+
2434
+ // Backup & Restore
2435
+ html += `<div class="card" style="margin-bottom:16px">
2436
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
2437
+ <span style="display:flex">${icon('hard-drive', 24)}</span>
2438
+ <div style="flex:1">
2439
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0">${t('maint.backup')}</h3>
2440
+ <div class="sub">${t('maint.backup.desc')}</div>
2441
+ </div>
2442
+ <button class="btn btn-sm" onclick="createBackupMaint()">${icon('save', 12)} ${t('maint.backup.create')}</button>
2443
+ </div>`;
2444
+
2445
+ if (backupData.backups.length > 0) {
2446
+ for (const b of backupData.backups) {
2447
+ const date = new Date(b.createdAt).toLocaleString(getLang() === 'de' ? 'de-DE' : 'en-US');
2448
+ const size = b.size < 1024 ? b.size + ' B' : (b.size / 1024).toFixed(1) + ' KB';
2449
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-top:1px solid var(--glass-border);font-size:0.85em">
2450
+ ${icon('package', 16)}
2451
+ <div style="flex:1">
2452
+ <div style="font-weight:500;font-family:monospace">${b.id}</div>
2453
+ <div style="color:var(--fg2);font-size:0.82em">${date} · ${b.fileCount} ${t('maint.backup.files')} · ${size}</div>
2454
+ </div>
2455
+ <button class="btn btn-sm btn-outline" onclick="showBackupFiles('${b.id}')">${icon('clipboard', 12)} ${t('maint.backup.files')}</button>
2456
+ <button class="btn btn-sm btn-outline" onclick="restoreBackup('${b.id}')">${icon('refresh-cw', 12)} ${t('maint.backup.restore')}</button>
2457
+ <button class="btn btn-sm btn-outline" style="color:var(--red)" onclick="deleteBackup('${b.id}')">${icon('trash-2', 12)}</button>
2458
+ </div>`;
2459
+ }
2460
+ } else {
2461
+ html += `<div style="font-size:0.85em;color:var(--fg2);padding:8px 0;border-top:1px solid var(--glass-border)">${t('maint.backup.none')}</div>`;
2462
+ }
2463
+ html += `<div id="backup-files-area" style="display:none;margin-top:8px;padding:8px;background:var(--bg3);border-radius:6px;font-size:0.82em"></div></div>`;
2464
+
2465
+ // PM2
2466
+ html += `<div class="card" style="margin-bottom:16px">
2467
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
2468
+ <span style="display:flex">${icon('settings', 24)}</span>
2469
+ <div style="flex:1">
2470
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0">${t('maint.pm2')}</h3>
2471
+ <div class="sub">${t('maint.pm2.desc')}</div>
2472
+ </div>
2473
+ <button class="btn btn-sm btn-outline" onclick="refreshPM2Status()">${icon('refresh-cw', 12)} ${t('maint.pm2.status')}</button>
2474
+ </div>
2475
+ <div id="pm2-status" style="margin-bottom:12px;font-size:0.85em;color:var(--fg2)">${t('maint.pm2.loading')}</div>
2476
+ <div style="display:flex;gap:8px;flex-wrap:wrap">
2477
+ <button class="btn btn-sm" onclick="pm2Action('restart')">${icon('refresh-cw', 12)} ${t('maint.pm2.restart')}</button>
2478
+ <button class="btn btn-sm btn-outline" onclick="pm2Action('reload')">${icon('refresh-cw', 12)} ${t('maint.pm2.reload')}</button>
2479
+ <button class="btn btn-sm btn-danger" onclick="pm2Action('stop')">${icon('pause', 12)} ${t('maint.pm2.stop')}</button>
2480
+ <button class="btn btn-sm" style="background:var(--green)" onclick="pm2Action('start')">${icon('play', 12)} ${t('maint.pm2.start')}</button>
2481
+ <button class="btn btn-sm btn-outline" onclick="pm2Action('flush')">${icon('trash-2', 12)} ${t('maint.pm2.flush')}</button>
2482
+ </div>
2483
+ <div id="pm2-logs" style="display:none;margin-top:12px"></div>
2484
+ </div>`;
2485
+
2486
+ // Logs
2487
+ html += `<div class="card" style="margin-bottom:16px">
2488
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
2489
+ <span style="display:flex">${icon('clipboard', 24)}</span>
2490
+ <div style="flex:1">
2491
+ <h3 style="font-size:0.95em;text-transform:none;letter-spacing:0">${t('maint.logs')}</h3>
2492
+ <div class="sub">${t('maint.logs.desc')}</div>
2493
+ </div>
2494
+ <button class="btn btn-sm btn-outline" onclick="loadPM2Logs()">${icon('refresh-cw', 12)} ${t('maint.logs.refresh')}</button>
2495
+ </div>
2496
+ <div id="pm2-log-output" style="background:var(--bg);border-radius:6px;padding:10px;font-family:monospace;font-size:0.75em;max-height:300px;overflow-y:auto;white-space:pre-wrap;color:var(--fg2);border:1px solid var(--glass-border)">${t('maint.logs.load')}</div>
2497
+ </div>`;
2498
+
2499
+ document.getElementById('maintenance-content').innerHTML = html;
2500
+ refreshPM2Status();
2501
+ if (window._pm2RefreshInterval) clearInterval(window._pm2RefreshInterval);
2502
+ window._pm2RefreshInterval = setInterval(() => {
2503
+ if (document.getElementById('pm2-status')) refreshPM2Status();
2504
+ else clearInterval(window._pm2RefreshInterval);
2505
+ }, 10_000);
2506
+ }
2507
+
2508
+ // ── WhatsApp Groups Management ──────────────────────────
2509
+ let _waGroupsCache = null;
2510
+ let _waRulesCache = null;
2511
+
2512
+ async function loadWAGroups() {
2513
+ const container = document.getElementById('wa-groups-content');
2514
+ if (!container) return;
2515
+ container.innerHTML = `<div style="color:var(--fg2);font-size:0.85em">${t('wa.groups.loading')}</div>`;
2516
+
2517
+ const [groupsRes, rulesRes] = await Promise.all([
2518
+ fetch(API + '/api/whatsapp/groups').then(r => r.json()).catch(() => ({ groups: [], error: 'Unreachable' })),
2519
+ fetch(API + '/api/whatsapp/group-rules').then(r => r.json()).catch(() => ({ rules: [] })),
2520
+ ]);
2521
+
2522
+ _waGroupsCache = groupsRes.groups || [];
2523
+ _waRulesCache = rulesRes.rules || [];
2524
+ const activeCount = _waRulesCache.filter(r => r.enabled).length;
2525
+
2526
+ const badge = document.getElementById('wa-groups-badge');
2527
+ if (badge) badge.textContent = activeCount > 0 ? `(${t('wa.groups.active', { count: activeCount })})` : '';
2528
+
2529
+ if (groupsRes.error && _waGroupsCache.length === 0) {
2530
+ container.innerHTML = `<div style="color:var(--fg2);font-size:0.85em;padding:8px 0">${t('wa.not.connected')}</div>`;
2531
+ return;
2532
+ }
2533
+
2534
+ const rulesMap = {};
2535
+ for (const r of _waRulesCache) rulesMap[r.groupId] = r;
2536
+
2537
+ let html = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
2538
+ <div style="flex:1;font-size:0.8em;color:var(--fg2)">${t('wa.groups.count', { count: _waGroupsCache.length })} · ${t('wa.groups.active', { count: activeCount })}</div>
2539
+ <button class="btn btn-sm btn-outline" style="font-size:0.75em" onclick="loadWAGroups()">${icon('refresh-cw', 12)}</button>
2540
+ </div>`;
2541
+
2542
+ const sorted = [..._waGroupsCache].sort((a, b) => {
2543
+ const aRule = rulesMap[a.id]; const bRule = rulesMap[b.id];
2544
+ if (aRule?.enabled && !bRule?.enabled) return -1;
2545
+ if (!aRule?.enabled && bRule?.enabled) return 1;
2546
+ if (aRule && !bRule) return -1;
2547
+ if (!aRule && bRule) return 1;
2548
+ return a.name.localeCompare(b.name);
2549
+ });
2550
+
2551
+ for (const g of sorted) {
2552
+ const rule = rulesMap[g.id];
2553
+ const isEnabled = rule?.enabled;
2554
+ const statusIcon = isEnabled ? icon('circle-check', 14, 'style="color:var(--green)"') : icon('circle-dot', 14, 'style="opacity:0.3"');
2555
+ const allowedCount = rule?.allowedParticipants?.length || 0;
2556
+ const accessLabel = !rule ? t('wa.groups.no.config') :
2557
+ isEnabled ? (allowedCount > 0 ? t('wa.groups.allowed', { count: allowedCount }) : t('wa.groups.all.allowed')) :
2558
+ t('disabled');
2559
+ const approvalLabel = rule?.requireApproval !== false ? t('wa.approval') : t('wa.auto');
2560
+
2561
+ html += `<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--glass-border)">
2562
+ ${statusIcon}
2563
+ <div style="flex:1;min-width:0">
2564
+ <div style="font-weight:500;font-size:0.85em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(g.name)}</div>
2565
+ <div style="font-size:0.72em;color:var(--fg2)">${accessLabel}${isEnabled ? ' · ' + approvalLabel : ''}</div>
2566
+ </div>
2567
+ <button class="btn btn-sm ${isEnabled ? '' : 'btn-outline'}" style="font-size:0.75em;padding:4px 8px" onclick="toggleWAGroup('${g.id}', '${escapeHtml(g.name)}', ${!isEnabled})">
2568
+ ${isEnabled ? icon('pause', 12) : icon('play', 12)}
2569
+ </button>
2570
+ <button class="btn btn-sm btn-outline" style="font-size:0.75em;padding:4px 8px" onclick="configureWAGroup('${g.id}', '${escapeHtml(g.name)}')">${icon('settings', 12)}</button>
2571
+ </div>`;
2572
+ }
2573
+
2574
+ container.innerHTML = html;
2575
+ }
2576
+
2577
+ async function toggleWAGroup(groupId, groupName, enable) {
2578
+ await fetch(API + '/api/whatsapp/group-rules', {
2579
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2580
+ body: JSON.stringify({ groupId, groupName, enabled: enable }),
2581
+ });
2582
+ toast(enable ? t('wa.groups.enabled', { name: groupName }) : t('wa.groups.disabled', { name: groupName }));
2583
+ loadWAGroups();
2584
+ }
2585
+
2586
+ async function configureWAGroup(groupId, groupName) {
2587
+ const container = document.getElementById('wa-groups-content');
2588
+ if (!container) return;
2589
+
2590
+ const rule = _waRulesCache?.find(r => r.groupId === groupId) || {};
2591
+ container.innerHTML = `<div style="color:var(--fg2);font-size:0.85em">${t('loading')}</div>`;
2592
+ const res = await fetch(API + `/api/whatsapp/groups/${encodeURIComponent(groupId)}/participants`);
2593
+ const { participants } = await res.json();
2594
+
2595
+ const allowed = new Set(rule.allowedParticipants || []);
2596
+ const requireMention = rule.requireMention !== false;
2597
+ const allowMedia = rule.allowMedia !== false;
2598
+ const requireApproval = rule.requireApproval !== false;
2599
+
2600
+ let html = `
2601
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
2602
+ <button class="btn btn-sm btn-outline" style="font-size:0.75em;padding:3px 8px" onclick="loadWAGroups()">${icon('arrow-left', 12)}</button>
2603
+ <span style="font-weight:600;font-size:0.9em">${escapeHtml(groupName)}</span>
2604
+ <span style="font-size:0.75em;color:var(--fg2)">${t('wa.groups.participants', { count: participants.length })}</span>
2605
+ </div>
2606
+
2607
+ <div style="display:flex;flex-direction:column;gap:6px;margin-bottom:12px;padding:10px;background:var(--bg3);border-radius:6px">
2608
+ <label style="display:flex;align-items:center;gap:6px;font-size:0.82em;cursor:pointer">
2609
+ <input type="checkbox" id="wa-require-mention" ${requireMention ? 'checked' : ''}>
2610
+ <span>${t('wa.groups.mention.required')}</span>
2611
+ </label>
2612
+ <label style="display:flex;align-items:center;gap:6px;font-size:0.82em;cursor:pointer">
2613
+ <input type="checkbox" id="wa-allow-media" ${allowMedia ? 'checked' : ''}>
2614
+ <span>${icon('paperclip', 12)} ${t('wa.groups.media')}</span>
2615
+ </label>
2616
+ <label style="display:flex;align-items:center;gap:6px;font-size:0.82em;cursor:pointer">
2617
+ <input type="checkbox" id="wa-require-approval" ${requireApproval ? 'checked' : ''}>
2618
+ <span>${icon('lock', 12)} ${t('wa.groups.approval')}</span>
2619
+ </label>
2620
+ </div>
2621
+
2622
+ <div style="margin-bottom:8px">
2623
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
2624
+ <span style="font-weight:500;font-size:0.85em">${icon('users', 14)} ${t('wa.groups.allowed.contacts')}</span>
2625
+ <span style="flex:1"></span>
2626
+ <button class="btn btn-sm btn-outline" style="font-size:0.7em;padding:2px 6px" onclick="waSelectAll(true)">${t('wa.groups.select.all')}</button>
2627
+ <button class="btn btn-sm btn-outline" style="font-size:0.7em;padding:2px 6px" onclick="waSelectAll(false)">${t('wa.groups.select.none')}</button>
2628
+ </div>
2629
+ <div style="font-size:0.72em;color:var(--fg2);margin-bottom:6px">${t('wa.groups.no.selection')}</div>
2630
+ <div id="wa-participants" style="max-height:250px;overflow-y:auto">`;
2631
+
2632
+ for (const p of participants) {
2633
+ const checked = allowed.has(p.id) || allowed.has(p.number) ? 'checked' : '';
2634
+ const adminBadge = p.isAdmin ? ` <span style="background:var(--accent);color:var(--bg);padding:0 4px;border-radius:3px;font-size:0.7em">${t('wa.groups.admin')}</span>` : '';
2635
+ html += `<label style="display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid var(--glass-border);cursor:pointer;font-size:0.82em" class="wa-participant">
2636
+ <input type="checkbox" data-pid="${p.id}" data-number="${p.number}" ${checked}>
2637
+ <span style="flex:1">${escapeHtml(p.name)}${adminBadge}</span>
2638
+ <span style="color:var(--fg2);font-size:0.75em;font-family:monospace">+${p.number}</span>
2639
+ </label>`;
2640
+ }
2641
+
2642
+ html += `</div></div>
2643
+ <div style="display:flex;gap:6px;margin-top:10px">
2644
+ <button class="btn btn-sm" onclick="saveWAGroupConfig('${groupId}', '${escapeHtml(groupName)}')">${icon('save', 12)} ${t('save')}</button>
2645
+ <button class="btn btn-sm btn-outline" onclick="loadWAGroups()">${t('cancel')}</button>
2646
+ ${rule.groupId ? `<button class="btn btn-sm btn-outline" style="color:var(--red);margin-left:auto" onclick="deleteWAGroupRule('${groupId}')">${icon('trash-2', 12)}</button>` : ''}
2647
+ </div>`;
2648
+
2649
+ container.innerHTML = html;
2650
+ }
2651
+
2652
+ function waSelectAll(selectAll) {
2653
+ document.querySelectorAll('#wa-participants input[type=checkbox]').forEach(cb => cb.checked = selectAll);
2654
+ }
2655
+
2656
+ async function saveWAGroupConfig(groupId, groupName) {
2657
+ const requireMention = document.getElementById('wa-require-mention').checked;
2658
+ const allowMedia = document.getElementById('wa-allow-media').checked;
2659
+ const requireApproval = document.getElementById('wa-require-approval').checked;
2660
+ const allowedParticipants = [];
2661
+ const participantNames = {};
2662
+ document.querySelectorAll('#wa-participants input[type=checkbox]:checked').forEach(cb => {
2663
+ const pid = cb.dataset.pid;
2664
+ allowedParticipants.push(pid);
2665
+ const label = cb.closest('label');
2666
+ const name = label?.querySelector('span')?.textContent?.trim() || pid;
2667
+ participantNames[pid] = name;
2668
+ });
2669
+
2670
+ await fetch(API + '/api/whatsapp/group-rules', {
2671
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2672
+ body: JSON.stringify({ groupId, groupName, enabled: true, allowedParticipants, participantNames, requireMention, allowMedia, requireApproval }),
2673
+ });
2674
+ toast(t('wa.groups.configured', { name: groupName }));
2675
+ loadWAGroups();
2676
+ }
2677
+
2678
+ async function deleteWAGroupRule(groupId) {
2679
+ if (!confirm(t('wa.groups.rule.delete.confirm'))) return;
2680
+ await fetch(API + `/api/whatsapp/group-rules/${encodeURIComponent(groupId)}`, { method: 'DELETE' });
2681
+ toast(t('wa.groups.rule.deleted'));
2682
+ loadWAGroups();
2683
+ }
2684
+
2685
+ // ── Command Palette (Cmd+K) ─────────────────────────────
2686
+ let _cmdPaletteIdx = 0;
2687
+ let _cmdPaletteItems = [];
2688
+
2689
+ function openCommandPalette() {
2690
+ const overlay = document.getElementById('cmd-palette-overlay');
2691
+ overlay.style.display = 'flex';
2692
+ const input = document.getElementById('cmd-palette-input');
2693
+ input.value = '';
2694
+ input.focus();
2695
+ renderCommandPaletteResults('');
2696
+ }
2697
+
2698
+ function closeCommandPalette() {
2699
+ document.getElementById('cmd-palette-overlay').style.display = 'none';
2700
+ }
2701
+
2702
+ function getCommandPaletteItems() {
2703
+ const pages = [
2704
+ { id: 'chat', icon: 'message-square', label: t('nav.chat'), action: () => navigateTo('chat') },
2705
+ { id: 'dashboard', icon: 'layout-dashboard', label: t('nav.dashboard'), action: () => navigateTo('dashboard') },
2706
+ { id: 'models', icon: 'bot', label: t('nav.models'), action: () => navigateTo('models') },
2707
+ { id: 'personality', icon: 'palette', label: t('nav.personality'), action: () => navigateTo('personality') },
2708
+ { id: 'memory', icon: 'brain', label: t('nav.memory'), action: () => navigateTo('memory') },
2709
+ { id: 'sessions', icon: 'clipboard', label: t('nav.sessions'), action: () => navigateTo('sessions') },
2710
+ { id: 'files', icon: 'folder', label: t('nav.files'), action: () => navigateTo('files') },
2711
+ { id: 'cron', icon: 'timer', label: t('nav.cron'), action: () => navigateTo('cron') },
2712
+ { id: 'tools', icon: 'hammer', label: t('nav.tools'), action: () => navigateTo('tools') },
2713
+ { id: 'plugins', icon: 'plug', label: t('nav.plugins'), action: () => navigateTo('plugins') },
2714
+ { id: 'platforms', icon: 'smartphone', label: t('nav.platforms'), action: () => navigateTo('platforms') },
2715
+ { id: 'users', icon: 'users', label: t('nav.users'), action: () => navigateTo('users') },
2716
+ { id: 'terminal', icon: 'terminal', label: t('nav.terminal'), action: () => navigateTo('terminal') },
2717
+ { id: 'maintenance', icon: 'stethoscope', label: t('nav.maintenance'), action: () => navigateTo('maintenance') },
2718
+ { id: 'settings', icon: 'settings', label: t('nav.settings'), action: () => navigateTo('settings') },
2719
+ ];
2720
+
2721
+ const actions = [
2722
+ { id: 'reset', icon: 'refresh-cw', label: t('cmd.action.reset'), kbd: navigator.platform?.includes('Mac') ? '⌘N' : 'Ctrl+N', action: () => resetChat() },
2723
+ { id: 'theme', icon: 'sun', label: t('cmd.action.theme'), action: () => toggleTheme() },
2724
+ { id: 'export', icon: 'download', label: t('cmd.action.export'), kbd: navigator.platform?.includes('Mac') ? '⌘⇧E' : 'Ctrl+Shift+E', action: () => exportChat() },
2725
+ { id: 'lang', icon: 'languages', label: t('cmd.action.lang'), action: () => toggleLang() },
2726
+ ];
2727
+
2728
+ return { pages, actions };
2729
+ }
2730
+
2731
+ function renderCommandPaletteResults(query) {
2732
+ const { pages, actions } = getCommandPaletteItems();
2733
+ const q = query.toLowerCase().trim();
2734
+
2735
+ const filteredPages = q ? pages.filter(p => p.label.toLowerCase().includes(q) || p.id.includes(q)) : pages;
2736
+ const filteredActions = q ? actions.filter(a => a.label.toLowerCase().includes(q) || a.id.includes(q)) : actions;
2737
+
2738
+ _cmdPaletteItems = [...filteredPages, ...filteredActions];
2739
+ _cmdPaletteIdx = 0;
2740
+
2741
+ const container = document.getElementById('cmd-palette-results');
2742
+
2743
+ if (_cmdPaletteItems.length === 0) {
2744
+ container.innerHTML = `<div class="cmd-palette-empty">${t('cmd.no.results')}</div>`;
2745
+ return;
2746
+ }
2747
+
2748
+ let html = '';
2749
+ if (filteredPages.length > 0) {
2750
+ html += `<div class="cmd-palette-section">${t('cmd.goto')}</div>`;
2751
+ filteredPages.forEach((p, i) => {
2752
+ html += `<div class="cmd-palette-item${i === 0 ? ' selected' : ''}" data-idx="${i}" onclick="executeCmdPaletteItem(${i})" onmouseenter="selectCmdPaletteItem(${i})">
2753
+ ${icon(p.icon, 16)} <span>${p.label}</span>
2754
+ </div>`;
2755
+ });
2756
+ }
2757
+ if (filteredActions.length > 0) {
2758
+ html += `<div class="cmd-palette-section">${t('cmd.actions')}</div>`;
2759
+ filteredActions.forEach((a, j) => {
2760
+ const idx = filteredPages.length + j;
2761
+ html += `<div class="cmd-palette-item${idx === 0 && filteredPages.length === 0 ? ' selected' : ''}" data-idx="${idx}" onclick="executeCmdPaletteItem(${idx})" onmouseenter="selectCmdPaletteItem(${idx})">
2762
+ ${icon(a.icon, 16)} <span>${a.label}</span>
2763
+ ${a.kbd ? `<kbd>${a.kbd}</kbd>` : ''}
2764
+ </div>`;
2765
+ });
2766
+ }
2767
+
2768
+ container.innerHTML = html;
2769
+ }
2770
+
2771
+ function selectCmdPaletteItem(idx) {
2772
+ _cmdPaletteIdx = idx;
2773
+ document.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
2774
+ el.classList.toggle('selected', i === idx);
2775
+ });
2776
+ }
2777
+
2778
+ function executeCmdPaletteItem(idx) {
2779
+ const item = _cmdPaletteItems[idx];
2780
+ if (item) {
2781
+ closeCommandPalette();
2782
+ item.action();
2783
+ }
2784
+ }
2785
+
2786
+ function navigateTo(page) {
2787
+ const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
2788
+ if (navItem) navItem.click();
2789
+ }
2790
+
2791
+ // Command palette keyboard navigation
2792
+ document.getElementById('cmd-palette-input').addEventListener('input', (e) => {
2793
+ renderCommandPaletteResults(e.target.value);
2794
+ });
2795
+
2796
+ document.getElementById('cmd-palette-input').addEventListener('keydown', (e) => {
2797
+ if (e.key === 'Escape') { closeCommandPalette(); return; }
2798
+ if (e.key === 'ArrowDown') {
2799
+ e.preventDefault();
2800
+ selectCmdPaletteItem(Math.min(_cmdPaletteIdx + 1, _cmdPaletteItems.length - 1));
2801
+ }
2802
+ if (e.key === 'ArrowUp') {
2803
+ e.preventDefault();
2804
+ selectCmdPaletteItem(Math.max(_cmdPaletteIdx - 1, 0));
2805
+ }
2806
+ if (e.key === 'Enter') {
2807
+ e.preventDefault();
2808
+ executeCmdPaletteItem(_cmdPaletteIdx);
2809
+ }
2810
+ });
2811
+
2812
+ // ── Theme Toggle ────────────────────────────────────────
2813
+ function toggleTheme() {
2814
+ const body = document.documentElement;
2815
+ const current = body.getAttribute('data-theme');
2816
+ body.setAttribute('data-theme', current === 'light' ? '' : 'light');
2817
+ localStorage.setItem('theme', current === 'light' ? 'dark' : 'light');
2818
+ }
2819
+ if (localStorage.getItem('theme') === 'light') document.documentElement.setAttribute('data-theme', 'light');
2820
+
2821
+ // ── Setup Wizard ────────────────────────────────────────
2822
+ let _wizardStep = 1;
2823
+ let _wizardData = { botToken: '', allowedUsers: '', primaryProvider: '', apiKey: '', apiKeyEnv: '', webPassword: '' };
2824
+ let _wizardBotInfo = null;
2825
+
2826
+ const WIZARD_PROVIDERS = [
2827
+ { id: 'groq', name: 'Groq', desc: 'Llama 3.3 70B — fast & free', envKey: 'GROQ_API_KEY', key: 'groq', free: true, recommended: true, signupUrl: 'https://console.groq.com/keys' },
2828
+ { id: 'google', name: 'Google Gemini', desc: 'Gemini 2.5 Flash — free tier', envKey: 'GOOGLE_API_KEY', key: 'gemini-2.5-flash', free: true, signupUrl: 'https://aistudio.google.com/apikey' },
2829
+ { id: 'nvidia', name: 'NVIDIA NIM', desc: 'Llama 3.3 70B — free tier', envKey: 'NVIDIA_API_KEY', key: 'nvidia-llama-3.3-70b', free: true, signupUrl: 'https://build.nvidia.com' },
2830
+ { id: 'anthropic', name: 'Anthropic', desc: 'Claude Opus / Sonnet / Haiku', envKey: 'ANTHROPIC_API_KEY', key: 'claude-opus', signupUrl: 'https://console.anthropic.com' },
2831
+ { id: 'openai', name: 'OpenAI', desc: 'GPT-4o, GPT-4.1, o3', envKey: 'OPENAI_API_KEY', key: 'gpt-4o', signupUrl: 'https://platform.openai.com/api-keys' },
2832
+ { id: 'openrouter', name: 'OpenRouter', desc: '200+ models, one API key', envKey: 'OPENROUTER_API_KEY', key: 'openrouter', signupUrl: 'https://openrouter.ai/keys' },
2833
+ { id: 'claude-sdk', name: 'Claude Agent SDK', desc: 'Full agent + tools (CLI login)', envKey: '', key: 'claude-sdk', premium: true },
2834
+ { id: 'ollama', name: 'Ollama (Local)', desc: 'Run models locally, no API key', envKey: '', key: 'ollama', free: true, signupUrl: 'https://ollama.com/download' },
2835
+ ];
2836
+
2837
+ async function checkSetupNeeded() {
2838
+ try {
2839
+ const res = await fetch(API + '/api/setup-check');
2840
+ const data = await res.json();
2841
+ if (!data.isComplete) { showWizard(data); return true; }
2842
+ } catch {}
2843
+ return false;
2844
+ }
2845
+
2846
+ function showWizard(setupData) {
2847
+ document.getElementById('wizard-overlay').style.display = 'flex';
2848
+ // Pre-fill from existing config
2849
+ if (setupData?.steps?.telegram?.botToken) _wizardData.botToken = '(already set)';
2850
+ if (setupData?.steps?.telegram?.allowedUsers) _wizardData.allowedUsers = '(already set)';
2851
+ _wizardStep = 1;
2852
+ if (setupData?.steps?.telegram?.done) _wizardStep = 2;
2853
+ if (setupData?.steps?.provider?.done) _wizardStep = 3;
2854
+ renderWizardStep();
2855
+ }
2856
+
2857
+ function renderWizardStep() {
2858
+ const header = document.getElementById('wizard-header');
2859
+ const body = document.getElementById('wizard-body');
2860
+ const footer = document.getElementById('wizard-footer');
2861
+
2862
+ // Header with step dots
2863
+ header.innerHTML = `
2864
+ <h1>${icon('bot', 28)} ${t('wizard.title')}</h1>
2865
+ <div class="wizard-subtitle">${t('wizard.subtitle')}</div>
2866
+ <div class="wizard-steps">
2867
+ ${[1,2,3].map(n => `<div class="wizard-step-dot ${n === _wizardStep ? 'active' : n < _wizardStep ? 'done' : ''}"></div>`).join('')}
2868
+ </div>
2869
+ <div style="font-size:0.75em;color:var(--fg3);margin-top:8px">${t('wizard.step', { n: _wizardStep, total: 3 })}</div>
2870
+ `;
2871
+
2872
+ if (_wizardStep === 1) renderWizardStep1(body, footer);
2873
+ else if (_wizardStep === 2) renderWizardStep2(body, footer);
2874
+ else if (_wizardStep === 3) renderWizardStep3(body, footer);
2875
+ }
2876
+
2877
+ function renderWizardStep1(body, footer) {
2878
+ body.innerHTML = `
2879
+ <h2>${icon('send', 20)} ${t('wizard.step1.title')}</h2>
2880
+ <div class="wizard-desc">${t('wizard.step1.desc')}</div>
2881
+ <div style="background:var(--bg3);border-radius:var(--radius-sm);padding:14px;margin-bottom:16px;font-size:0.85em;line-height:1.8">
2882
+ ${t('wizard.step1.instruction1')} <a href="https://t.me/BotFather" target="_blank" style="color:var(--accent2);font-weight:600">@BotFather</a><br>
2883
+ ${t('wizard.step1.instruction2')}<br>
2884
+ ${t('wizard.step1.instruction3')}
2885
+ </div>
2886
+ <div class="wizard-field">
2887
+ <label>Bot Token</label>
2888
+ <div style="display:flex;gap:8px">
2889
+ <input id="wiz-token" type="text" placeholder="${t('wizard.step1.token.placeholder')}" value="${_wizardData.botToken.startsWith('(') ? '' : _wizardData.botToken}" style="flex:1;font-family:monospace">
2890
+ <button class="btn btn-sm" onclick="validateWizardToken()">${icon('check', 14)} ${t('wizard.step1.token.validate')}</button>
2891
+ </div>
2892
+ <div id="wiz-token-result"></div>
2893
+ </div>
2894
+ <div class="wizard-field">
2895
+ <label>${t('wizard.step1.userid')}</label>
2896
+ <input id="wiz-userid" type="text" placeholder="${t('wizard.step1.userid.placeholder')}" value="${_wizardData.allowedUsers.startsWith('(') ? '' : _wizardData.allowedUsers}" style="font-family:monospace">
2897
+ <div class="wizard-hint">${t('wizard.step1.userid.help')} <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a></div>
2898
+ </div>
2899
+ `;
2900
+ footer.innerHTML = `
2901
+ <button class="btn btn-outline" onclick="document.getElementById('wizard-overlay').style.display='none'">${t('wizard.skip')}</button>
2902
+ <button class="btn" onclick="wizardNext()" id="wiz-next-btn">${icon('chevron-right', 16)} ${t('wizard.next')}</button>
2903
+ `;
2904
+ }
2905
+
2906
+ async function validateWizardToken() {
2907
+ const token = document.getElementById('wiz-token').value.trim();
2908
+ const el = document.getElementById('wiz-token-result');
2909
+ if (!token) { el.innerHTML = ''; return; }
2910
+ el.innerHTML = `<div class="wizard-validation" style="color:var(--fg2)">${icon('refresh-cw', 14)} ...</div>`;
2911
+ try {
2912
+ const res = await fetch(API + '/api/validate-bot-token', {
2913
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
2914
+ body: JSON.stringify({ token }),
2915
+ });
2916
+ const data = await res.json();
2917
+ if (data.ok) {
2918
+ _wizardBotInfo = data.bot;
2919
+ _wizardData.botToken = token;
2920
+ el.innerHTML = `<div class="wizard-validation success">${icon('check', 14)} ${t('wizard.step1.token.valid')} <strong>@${data.bot.username}</strong> (${data.bot.firstName})</div>`;
2921
+ } else {
2922
+ el.innerHTML = `<div class="wizard-validation error">${icon('x', 14)} ${t('wizard.step1.token.invalid')}: ${data.error}</div>`;
2923
+ }
2924
+ } catch (e) {
2925
+ el.innerHTML = `<div class="wizard-validation error">${icon('alert-triangle', 14)} ${e.message}</div>`;
2926
+ }
2927
+ }
2928
+
2929
+ function renderWizardStep2(body, footer) {
2930
+ const selected = _wizardData.primaryProvider;
2931
+ body.innerHTML = `
2932
+ <h2>${icon('bot', 20)} ${t('wizard.step2.title')}</h2>
2933
+ <div class="wizard-desc">${t('wizard.step2.desc')}</div>
2934
+ <div class="wizard-provider-grid">
2935
+ ${WIZARD_PROVIDERS.map(p => `
2936
+ <div class="wizard-provider-card ${selected === p.id ? 'selected' : ''}" onclick="selectWizardProvider('${p.id}')" data-provider="${p.id}">
2937
+ <div class="provider-name">${p.name}</div>
2938
+ <div class="provider-desc">${p.desc}</div>
2939
+ ${p.free ? `<span class="provider-badge free">${t('wizard.step2.free')}</span>` : ''}
2940
+ ${p.recommended ? `<span class="provider-badge recommended">${t('wizard.step2.recommended')}</span>` : ''}
2941
+ ${p.premium ? `<span class="provider-badge" style="background:rgba(253,203,110,0.15);color:var(--yellow)">${t('wizard.step2.premium')}</span>` : ''}
2942
+ </div>
2943
+ `).join('')}
2944
+ </div>
2945
+ <div id="wiz-provider-config" style="display:${selected ? 'block' : 'none'}"></div>
2946
+ `;
2947
+ if (selected) renderWizardProviderConfig(selected);
2948
+ footer.innerHTML = `
2949
+ <button class="btn btn-outline" onclick="_wizardStep=1;renderWizardStep()">${icon('arrow-left', 14)} ${t('wizard.back')}</button>
2950
+ <button class="btn" onclick="wizardNext()">${icon('chevron-right', 16)} ${t('wizard.next')}</button>
2951
+ `;
2952
+ }
2953
+
2954
+ function selectWizardProvider(id) {
2955
+ _wizardData.primaryProvider = id;
2956
+ document.querySelectorAll('.wizard-provider-card').forEach(c => c.classList.toggle('selected', c.dataset.provider === id));
2957
+ const provider = WIZARD_PROVIDERS.find(p => p.id === id);
2958
+ const cfg = document.getElementById('wiz-provider-config');
2959
+ cfg.style.display = 'block';
2960
+ renderWizardProviderConfig(id);
2961
+ }
2962
+
2963
+ function renderWizardProviderConfig(id) {
2964
+ const provider = WIZARD_PROVIDERS.find(p => p.id === id);
2965
+ const cfg = document.getElementById('wiz-provider-config');
2966
+ if (!provider) { cfg.innerHTML = ''; return; }
2967
+
2968
+ if (provider.id === 'claude-sdk') {
2969
+ cfg.innerHTML = `<div style="background:var(--bg3);border-radius:var(--radius-sm);padding:14px;font-size:0.85em;line-height:1.8">
2970
+ <strong>Claude Agent SDK</strong> — requires Claude Max subscription ($20/month)<br>
2971
+ 1. <code style="background:rgba(0,0,0,0.2);padding:2px 6px;border-radius:4px">npm install -g @anthropic-ai/claude-code</code><br>
2972
+ 2. <code style="background:rgba(0,0,0,0.2);padding:2px 6px;border-radius:4px">claude login</code><br>
2973
+ No API key needed — uses CLI auth.
2974
+ </div>`;
2975
+ _wizardData.apiKeyEnv = '';
2976
+ _wizardData.apiKey = '';
2977
+ return;
2978
+ }
2979
+ if (provider.id === 'ollama') {
2980
+ cfg.innerHTML = `<div style="background:var(--bg3);border-radius:var(--radius-sm);padding:14px;font-size:0.85em;line-height:1.8">
2981
+ <strong>Ollama</strong> — runs locally on your machine<br>
2982
+ 1. Install: <a href="https://ollama.com/download" target="_blank" style="color:var(--accent2)">ollama.com/download</a><br>
2983
+ 2. <code style="background:rgba(0,0,0,0.2);padding:2px 6px;border-radius:4px">ollama pull llama3.2</code><br>
2984
+ No API key needed.
2985
+ </div>`;
2986
+ _wizardData.apiKeyEnv = '';
2987
+ _wizardData.apiKey = '';
2988
+ return;
2989
+ }
2990
+
2991
+ cfg.innerHTML = `
2992
+ <div class="wizard-field">
2993
+ <label>${provider.name} API Key</label>
2994
+ <input id="wiz-apikey" type="password" placeholder="${t('wizard.step2.key.placeholder')}" value="${_wizardData.apiKey}" style="font-family:monospace">
2995
+ <div class="wizard-hint">
2996
+ ${provider.signupUrl ? `<a href="${provider.signupUrl}" target="_blank">${provider.signupUrl}</a>` : ''}
2997
+ ${provider.free ? ` — ${t('wizard.step2.free')}!` : ''}
2998
+ </div>
2999
+ </div>
3000
+ `;
3001
+ _wizardData.apiKeyEnv = provider.envKey;
3002
+ }
3003
+
3004
+ function renderWizardStep3(body, footer) {
3005
+ body.innerHTML = `
3006
+ <h2>${icon('check', 20)} ${t('wizard.step3.title')}</h2>
3007
+ <div class="wizard-desc">${t('wizard.step3.desc')}</div>
3008
+ <div class="wizard-field">
3009
+ <label>${t('wizard.step3.password')}</label>
3010
+ <input id="wiz-password" type="password" placeholder="${t('wizard.step3.password.placeholder')}" value="${_wizardData.webPassword}">
3011
+ <div class="wizard-hint">${t('wizard.step3.password.help')}</div>
3012
+ </div>
3013
+ <div style="background:var(--bg3);border-radius:var(--radius-sm);padding:16px;margin-top:16px">
3014
+ <h3 style="font-size:0.85em;margin-bottom:10px;display:flex;align-items:center;gap:6px">${icon('list', 16)} Summary</h3>
3015
+ <div style="font-size:0.82em;color:var(--fg2);display:flex;flex-direction:column;gap:6px">
3016
+ <div style="display:flex;gap:8px">${icon('send', 14)} <strong>Telegram:</strong> ${_wizardBotInfo ? `@${_wizardBotInfo.username}` : (_wizardData.botToken ? 'Token set' : 'Not configured')}</div>
3017
+ <div style="display:flex;gap:8px">${icon('user', 14)} <strong>User ID:</strong> ${_wizardData.allowedUsers || 'Not set'}</div>
3018
+ <div style="display:flex;gap:8px">${icon('bot', 14)} <strong>Provider:</strong> ${WIZARD_PROVIDERS.find(p=>p.id===_wizardData.primaryProvider)?.name || 'Not selected'}</div>
3019
+ </div>
3020
+ </div>
3021
+ `;
3022
+ footer.innerHTML = `
3023
+ <button class="btn btn-outline" onclick="_wizardStep=2;renderWizardStep()">${icon('arrow-left', 14)} ${t('wizard.back')}</button>
3024
+ <button class="btn btn-success" onclick="finishWizard()" style="min-width:160px">${icon('check', 16)} ${t('wizard.finish')}</button>
3025
+ `;
3026
+ }
3027
+
3028
+ function wizardNext() {
3029
+ if (_wizardStep === 1) {
3030
+ const token = document.getElementById('wiz-token')?.value?.trim();
3031
+ const userId = document.getElementById('wiz-userid')?.value?.trim();
3032
+ if (token) _wizardData.botToken = token;
3033
+ if (userId) _wizardData.allowedUsers = userId;
3034
+ if (!_wizardData.botToken || _wizardData.botToken.startsWith('(')) {
3035
+ toast(t('wizard.step1.token.invalid'), 'error'); return;
3036
+ }
3037
+ if (!_wizardData.allowedUsers || _wizardData.allowedUsers.startsWith('(')) {
3038
+ toast(t('wizard.step1.userid') + ' required', 'error'); return;
3039
+ }
3040
+ }
3041
+ if (_wizardStep === 2) {
3042
+ if (!_wizardData.primaryProvider) { toast('Please select a provider', 'error'); return; }
3043
+ const provider = WIZARD_PROVIDERS.find(p => p.id === _wizardData.primaryProvider);
3044
+ const keyInput = document.getElementById('wiz-apikey');
3045
+ if (keyInput) _wizardData.apiKey = keyInput.value.trim();
3046
+ if (provider?.envKey && !_wizardData.apiKey) { toast('API key required', 'error'); return; }
3047
+ }
3048
+ _wizardStep++;
3049
+ renderWizardStep();
3050
+ }
3051
+
3052
+ async function finishWizard() {
3053
+ const pw = document.getElementById('wiz-password')?.value?.trim();
3054
+ if (pw) _wizardData.webPassword = pw;
3055
+
3056
+ const provider = WIZARD_PROVIDERS.find(p => p.id === _wizardData.primaryProvider);
3057
+ const payload = {
3058
+ botToken: _wizardData.botToken.startsWith('(') ? undefined : _wizardData.botToken,
3059
+ allowedUsers: _wizardData.allowedUsers.startsWith('(') ? undefined : _wizardData.allowedUsers,
3060
+ primaryProvider: provider?.key || _wizardData.primaryProvider,
3061
+ apiKey: _wizardData.apiKey || undefined,
3062
+ apiKeyEnv: _wizardData.apiKeyEnv || undefined,
3063
+ webPassword: _wizardData.webPassword || undefined,
3064
+ };
3065
+
3066
+ try {
3067
+ const res = await fetch(API + '/api/setup-wizard', {
3068
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
3069
+ body: JSON.stringify(payload),
3070
+ });
3071
+ const data = await res.json();
3072
+ if (data.ok) {
3073
+ document.getElementById('wizard-overlay').style.display = 'none';
3074
+ toast(t('wizard.finish.success'), 'success');
3075
+ // Show restart notice
3076
+ const overlay = document.createElement('div');
3077
+ overlay.style.cssText = 'position:fixed;inset:0;background:var(--bg);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px';
3078
+ overlay.innerHTML = `
3079
+ <div style="font-size:1.2em;font-weight:600;display:flex;align-items:center;gap:10px">${icon('check', 24)} ${t('wizard.finish.success')}</div>
3080
+ <div style="color:var(--fg2);font-size:0.9em">${t('wizard.finish.restart')}</div>
3081
+ <div style="color:var(--fg3);font-size:0.82em;margin-top:8px">Or restart from the Maintenance page after refresh.</div>
3082
+ `;
3083
+ document.body.appendChild(overlay);
3084
+ } else {
3085
+ toast(data.error || 'Setup failed', 'error');
3086
+ }
3087
+ } catch (e) {
3088
+ toast(e.message, 'error');
3089
+ }
3090
+ }
3091
+
3092
+ // ── Init ────────────────────────────────────────────────
3093
+ initUI();
3094
+ initDragDrop();
3095
+ restoreChatFromStorage();
3096
+ if (chatMessages.length > 0) scrollToBottom();
3097
+ connectWS();
3098
+
3099
+ // Check if first-run setup is needed
3100
+ checkSetupNeeded().then(needed => {
3101
+ if (!needed) { loadDashboard(); loadModels(); }
3102
+ });