levix-bot 3.3.0-rc.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +13 -9
  2. package/app.cjs +49 -46
  3. package/package.json +9 -1
  4. package/public/dashboard/assets/index-BBBXWzM0.js +391 -0
  5. package/public/dashboard/assets/index-pYaivHON.css +1 -0
  6. package/public/dashboard/index.html +19 -0
  7. package/public/dashboard.css +391 -66
  8. package/public/dashboard.js +593 -42
  9. package/scheduler.cjs +9 -14
  10. package/src/auth/use-database-auth-state.js +2 -4
  11. package/src/bootstrap/core.js +2 -5
  12. package/src/bootstrap/panel.js +10 -5
  13. package/src/cli.js +3 -5
  14. package/src/commands/autoschedule.cjs +1 -4
  15. package/src/commands/blacklist.cjs +22 -20
  16. package/src/commands/block.cjs +2 -4
  17. package/src/commands/calc.cjs +9 -16
  18. package/src/commands/digit.cjs +97 -0
  19. package/src/commands/gemini.cjs +147 -161
  20. package/src/commands/group/add.cjs +7 -16
  21. package/src/commands/group/antiSpam.cjs +5 -11
  22. package/src/commands/group/antilink.cjs +34 -28
  23. package/src/commands/group/approveall.cjs +3 -11
  24. package/src/commands/group/clearwarns.cjs +2 -5
  25. package/src/commands/group/demote.cjs +3 -8
  26. package/src/commands/group/kick.cjs +12 -15
  27. package/src/commands/group/media.cjs +17 -24
  28. package/src/commands/group/members.cjs +1 -2
  29. package/src/commands/group/promote.cjs +4 -12
  30. package/src/commands/group/removeall.cjs +2 -5
  31. package/src/commands/group/setpp.cjs +1 -1
  32. package/src/commands/group/setrules.cjs +2 -8
  33. package/src/commands/group/setwarn.cjs +4 -2
  34. package/src/commands/group/tagadmins.cjs +2 -5
  35. package/src/commands/group/warn.cjs +26 -24
  36. package/src/commands/group/warns.cjs +1 -2
  37. package/src/commands/group/welcome.cjs +13 -17
  38. package/src/commands/group.cjs +7 -10
  39. package/src/commands/help.cjs +6 -12
  40. package/src/commands/lang.cjs +104 -0
  41. package/src/commands/listschedules.cjs +2 -6
  42. package/src/commands/loop.cjs +6 -13
  43. package/src/commands/memory.cjs +19 -32
  44. package/src/commands/mod.cjs +97 -35
  45. package/src/commands/perm.cjs +18 -56
  46. package/src/commands/ping.cjs +1 -5
  47. package/src/commands/poll.cjs +1 -1
  48. package/src/commands/rand.cjs +2 -5
  49. package/src/commands/schedule.cjs +4 -8
  50. package/src/commands/score.cjs +8 -2
  51. package/src/commands/setprefix.cjs +2 -2
  52. package/src/commands/shortlink.cjs +7 -15
  53. package/src/commands/shutdown.cjs +1 -1
  54. package/src/commands/sticker.cjs +222 -0
  55. package/src/commands/stt.cjs +136 -68
  56. package/src/commands/todo.cjs +9 -15
  57. package/src/commands/tts.cjs +10 -16
  58. package/src/commands/unblock.cjs +2 -6
  59. package/src/commands/weather.cjs +5 -12
  60. package/src/config/ai-persona.md +5 -3
  61. package/src/config/available-models.json +1156 -0
  62. package/src/config/baileys.config.js +2 -1
  63. package/src/config/brand.esm.js +1 -10
  64. package/src/config/constants.js +1 -1
  65. package/src/config/defaults.cjs +66 -66
  66. package/src/config/lock.cjs +2 -2
  67. package/src/config/modelCapabilities.cjs +172 -0
  68. package/src/config/runtime-config.cjs +8 -13
  69. package/src/config/secrets.cjs +18 -10
  70. package/src/config/settings.cjs +94 -10
  71. package/src/config/vault.cjs +107 -0
  72. package/src/core/connection.js +13 -12
  73. package/src/core/events.js +41 -23
  74. package/src/core/proxy.js +7 -7
  75. package/src/core/session.js +91 -48
  76. package/src/core/socket.js +12 -15
  77. package/src/db/db.cjs +14 -6
  78. package/src/db/store.cjs +221 -87
  79. package/src/domain/caddy.js +1 -1
  80. package/src/domain/command.js +40 -19
  81. package/src/domain/detect.js +1 -1
  82. package/src/domain/system.js +2 -5
  83. package/src/handlers/command.handler.js +22 -43
  84. package/src/handlers/group.handler.js +13 -13
  85. package/src/handlers/message.handler.js +11 -21
  86. package/src/index.js +8 -4
  87. package/src/middleware/antispam.middleware.js +46 -23
  88. package/src/middleware/blacklist.middleware.js +14 -9
  89. package/src/middleware/forward-tracking.middleware.js +3 -2
  90. package/src/middleware/permissions.middleware.js +7 -10
  91. package/src/panel/login-throttle.cjs +43 -0
  92. package/src/panel/session-auth.cjs +17 -0
  93. package/src/routes/dashboard.api.esm.js +394 -72
  94. package/src/services/aiAgent.cjs +136 -49
  95. package/src/services/aiProviders.cjs +193 -47
  96. package/src/services/aiRouter.cjs +137 -0
  97. package/src/services/aiTools.cjs +305 -128
  98. package/src/services/llmDiscovery.cjs +481 -0
  99. package/src/services/modelRegistry.cjs +412 -0
  100. package/src/utils/datetime.cjs +2 -2
  101. package/src/utils/lid-helper.esm.js +7 -7
  102. package/src/utils/logger.cjs +1 -1
  103. package/src/utils/markdownParser.cjs +159 -0
  104. package/src/utils/memory.cjs +43 -13
  105. package/src/utils/messageContent.cjs +40 -0
  106. package/src/utils/normalizeJid.esm.js +7 -7
  107. package/src/utils/numerals.cjs +75 -0
  108. package/src/utils/permissions.esm.js +10 -15
  109. package/src/utils/providerUrl.cjs +281 -0
  110. package/src/utils/recurrence.cjs +7 -11
  111. package/src/utils/sendBotMessage.esm.js +4 -14
  112. package/src/utils/statusMessage.cjs +5 -13
  113. package/src/utils/storage.cjs +10 -0
  114. package/src/utils/storage.esm.js +11 -0
  115. package/src/utils/textDecode.cjs +4 -9
  116. package/src/utils/thumbnail.cjs +8 -14
  117. package/src/utils/typing.esm.js +12 -12
  118. package/views/dashboard.ejs +134 -106
@@ -121,6 +121,234 @@
121
121
  });
122
122
  }
123
123
 
124
+ // --- i18n -----------------------------------------------------------------
125
+
126
+ const LANG_KEY = "levix_lang";
127
+ let currentLang = "en";
128
+
129
+ const I18N = {
130
+ en: {
131
+ overview: "Overview",
132
+ connection: "Connection",
133
+ commands: "Commands",
134
+ ai: "AI & memory",
135
+ groups: "Groups",
136
+ roles: "Roles",
137
+ data: "Tables",
138
+ data_label: "Data",
139
+ control: "Control",
140
+ settings: "Settings",
141
+ workspace: "Workspace",
142
+ settings_sub: "Configure your bot options and AI models. Changes take effect immediately.",
143
+ settings_tab_ai: "AI Assistant",
144
+ settings_tab_general: "General",
145
+ settings_tab_security: "Security",
146
+ settings_tab_whatsapp: "WhatsApp Proxy",
147
+ settings_tab_media: "Media & Storage",
148
+ pw_heading: "Control panel password",
149
+ pw_current: "Current password",
150
+ pw_next: "New password",
151
+ pw_change_btn: "Change password",
152
+ pw_hint: "This password protects your dashboard. It is stored securely as a hash.",
153
+ ai_wizard_title: "AI Provider Setup",
154
+ ai_wizard_sub: "Choose which provider powers your assistant's chat responses.",
155
+ provider_gemini: "Google Gemini",
156
+ provider_gemini_desc: "Free key, native multimodal vision, and Google Search grounding.",
157
+ provider_groq: "Groq",
158
+ provider_groq_desc: "Ultra-fast Llama 3 & Whisper STT inference.",
159
+ provider_openai: "OpenAI / Compatible",
160
+ provider_openai_desc: "GPT-4o, OpenRouter, Together AI, or any compatible endpoint.",
161
+ provider_ollama: "Ollama (Local)",
162
+ provider_ollama_desc: "Self-hosted models running locally on your hardware.",
163
+ provider_anthropic: "Anthropic Claude",
164
+ provider_anthropic_desc: "Claude 3.5 & Sonnet reasoning models.",
165
+ apply_preset: "Apply Preset",
166
+ active_provider: "Active Provider",
167
+ api_key: "API Key",
168
+ base_url: "Base URL",
169
+ model_name: "Model Name",
170
+ capabilities_title: "Model Capabilities & Media",
171
+ capabilities_sub: "Configure vision inspection and voice speech-to-text.",
172
+ vision_toggle: "Vision & Image Analysis",
173
+ vision_toggle_hint: "Allows the AI to inspect and understand photos sent in messages.",
174
+ stt_title: "Voice Transcription (STT)",
175
+ stt_hint: "Which engine transcribes voice messages when using !stt.",
176
+ agent_mode_toggle: "Autonomous Agent Mode",
177
+ agent_mode_hint: "Enables multi-turn tool calling (web search, memory, bot actions).",
178
+ google_search_toggle: "Google Search Grounding",
179
+ google_search_hint: "Allows Gemini to look up current live web data.",
180
+ advanced_btn: "Advanced AI Settings…",
181
+ ai_advanced_title: "Advanced AI Settings",
182
+ save: "Save",
183
+ saved: "Saved",
184
+ saved_here: "saved here",
185
+ default_val: "default",
186
+ restart_to_apply: "restart to apply",
187
+ not_set: "not set",
188
+ cleared: "Cleared",
189
+ clear: "Clear",
190
+ done: "Done",
191
+ close: "Close",
192
+ supports_vision_badge: "Vision Supported",
193
+ supports_audio_badge: "Audio/STT Ready",
194
+ recommended: "Recommended",
195
+ detected: "Detected",
196
+ connected: "Connected",
197
+ disconnected: "Disconnected",
198
+ idle: "Idle",
199
+ starting: "Starting…",
200
+ linking: "Linking…",
201
+ reconnecting: "Reconnecting…",
202
+ unlink: "Unlink",
203
+ start_bot: "Start",
204
+ stop_bot: "Stop",
205
+ reconnect_bot: "Reconnect to apply",
206
+ },
207
+ ar: {
208
+ overview: "نظرة عامة",
209
+ connection: "الاتصال",
210
+ commands: "الأوامر",
211
+ ai: "الذكاء الاصطناعي والذاكرة",
212
+ groups: "المجموعات",
213
+ roles: "الصلاحيات",
214
+ data: "الجداول والبيانات",
215
+ data_label: "البيانات",
216
+ control: "التحكم",
217
+ settings: "الإعدادات",
218
+ workspace: "مساحة العمل",
219
+ settings_sub: "لوحة التحكم المركزية لضبط إعدادات البوت والذكاء الاصطناعي. تُطبق التغييرات فوراً.",
220
+ settings_tab_ai: "الذكاء الاصطناعي",
221
+ settings_tab_general: "عام",
222
+ settings_tab_security: "الأمان",
223
+ settings_tab_whatsapp: "بروكسي واتساب",
224
+ settings_tab_media: "الوسائط والتخزين",
225
+ pw_heading: "كلمة مرور لوحة التحكم",
226
+ pw_current: "كلمة المرور الحالية",
227
+ pw_next: "كلمة المرور الجديدة",
228
+ pw_change_btn: "تغيير كلمة المرور",
229
+ pw_hint: "هذه الكلمة تحمي حساب واتساب المربوط. تُحفظ بشكل مشفر تماماً.",
230
+ ai_wizard_title: "إعداد مزود الذكاء الاصطناعي",
231
+ ai_wizard_sub: "اختر المزود والموديل الذي يشغّل محادثات المساعد الخاص بك.",
232
+ provider_gemini: "جوجل جيميني",
233
+ provider_gemini_desc: "مفتاح مجاني مع دعم فائق لقراءة الصور والبحث المباشر.",
234
+ provider_groq: "جروك (Groq)",
235
+ provider_groq_desc: "سرعة خارقة في الرد وتفريغ الصوت بموديلات Llama و Whisper.",
236
+ provider_openai: "OpenAI / متوافق",
237
+ provider_openai_desc: "موديلات GPT-4o أو OpenRouter أو Together ومختلف المزودات.",
238
+ provider_ollama: "أولاما (محلي)",
239
+ provider_ollama_desc: "موديلات تعمل محلياً على جهازك دون اتصال خارجي.",
240
+ provider_anthropic: "أنثروبيك كلود",
241
+ provider_anthropic_desc: "موديلات Claude 3.5 & Sonnet لقدرات تحليلية متقدمة.",
242
+ apply_preset: "تطبيق الإعداد الجاهز",
243
+ active_provider: "المزود النشط",
244
+ api_key: "مفتاح API",
245
+ base_url: "رابط الخدمة (Base URL)",
246
+ model_name: "اسم الموديل",
247
+ capabilities_title: "قدرات الموديل ومعالجة الوسائط",
248
+ capabilities_sub: "تحديد ما إذا كان الموديل سيتعامل مع الصور والتسجيلات الصوتية.",
249
+ vision_toggle: "تحليل وقراءة الصور (Vision)",
250
+ vision_toggle_hint: "يسمح للذكاء الاصطناعي برؤية وفهم الصور والمستندات المرسلة في الشات.",
251
+ stt_title: "تفريغ الصوتيات إلى نص (STT)",
252
+ stt_hint: "المحرك المستخدم في تحويل الرسائل الصوتية لنصوص عند طلب !stt.",
253
+ agent_mode_toggle: "وضع الوكيل الذكي (Agent Mode)",
254
+ agent_mode_hint: "تفعيل البحث بالإنترنت، تسجيل الملاحظات في الذاكرة، وأدوات المجموعات.",
255
+ google_search_toggle: "البحث الحي عبر جوجل",
256
+ google_search_hint: "يتيح لموديل جيميني جلب أحدث المعلومات والأخبار الحية من محرك بحث جوجل.",
257
+ advanced_btn: "إعدادات متقدمة للذكاء الاصطناعي…",
258
+ ai_advanced_title: "إعدادات متقدمة للذكاء الاصطناعي",
259
+ save: "حفظ",
260
+ saved: "تم الحفظ بنجاح",
261
+ saved_here: "محفوظ هنا",
262
+ default_val: "افتراضي",
263
+ restart_to_apply: "إعادة التشغيل للتطبيق",
264
+ not_set: "غير محدد",
265
+ cleared: "تم الحذف",
266
+ clear: "مسح",
267
+ done: "تم",
268
+ close: "إغلاق",
269
+ supports_vision_badge: "يدعم تحليل الصور",
270
+ supports_audio_badge: "يدعم تفريغ الصوتيات",
271
+ recommended: "موصى به",
272
+ detected: "تم اكتشافه",
273
+ connected: "متصل",
274
+ disconnected: "غير متصل",
275
+ idle: "خامل",
276
+ starting: "جاري البدء…",
277
+ linking: "جاري الربط…",
278
+ reconnecting: "جاري إعادة الاتصال…",
279
+ unlink: "إلغاء الربط",
280
+ start_bot: "تشغيل",
281
+ stop_bot: "إيقاف",
282
+ reconnect_bot: "إعادة الاتصال لتطبيق التغييرات",
283
+ },
284
+ };
285
+
286
+ function t(key) {
287
+ return I18N[currentLang]?.[key] || I18N.en[key] || key;
288
+ }
289
+
290
+ function applyLang(lang) {
291
+ currentLang = lang === "ar" ? "ar" : "en";
292
+ document.documentElement.setAttribute("lang", currentLang);
293
+ document.documentElement.setAttribute("dir", currentLang === "ar" ? "rtl" : "ltr");
294
+ if (currentLang === "ar") {
295
+ document.documentElement.classList.add("lang-ar");
296
+ } else {
297
+ document.documentElement.classList.remove("lang-ar");
298
+ }
299
+
300
+ const langTag = $("#lang-tag");
301
+ if (langTag) {
302
+ langTag.textContent = currentLang === "ar" ? "EN" : "عربي";
303
+ }
304
+ const langBtn = $("#lang-btn");
305
+ if (langBtn) {
306
+ const title = currentLang === "ar" ? "Switch to English" : "التبديل إلى العربية";
307
+ langBtn.setAttribute("title", title);
308
+ langBtn.setAttribute("aria-label", title);
309
+ }
310
+
311
+ try {
312
+ localStorage.setItem(LANG_KEY, currentLang);
313
+ } catch {}
314
+
315
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
316
+ const key = el.getAttribute("data-i18n");
317
+ const val = t(key);
318
+ if (val) el.textContent = val;
319
+ });
320
+
321
+ document.querySelectorAll("[data-i18n-title]").forEach((el) => {
322
+ const key = el.getAttribute("data-i18n-title");
323
+ const val = t(key);
324
+ if (val) el.setAttribute("title", val);
325
+ });
326
+
327
+ if (currentView && VIEWS[currentView]) {
328
+ $("#page-title").textContent = t(currentView) || VIEWS[currentView].title;
329
+ }
330
+ }
331
+
332
+ function initLang() {
333
+ let chosen = "en";
334
+ try {
335
+ const saved = localStorage.getItem(LANG_KEY);
336
+ if (saved === "ar" || saved === "en") chosen = saved;
337
+ else {
338
+ const nav = (navigator.languages && navigator.languages[0]) || navigator.language || "";
339
+ if (nav.toLowerCase().startsWith("ar")) chosen = "ar";
340
+ }
341
+ } catch {}
342
+ applyLang(chosen);
343
+
344
+ $("#lang-btn")?.addEventListener("click", () => {
345
+ applyLang(currentLang === "ar" ? "en" : "ar");
346
+ if (currentView && VIEWS[currentView]?.load) {
347
+ VIEWS[currentView].load().catch(() => {});
348
+ }
349
+ });
350
+ }
351
+
124
352
  // --- routing ------------------------------------------------------------
125
353
 
126
354
  const VIEWS = {};
@@ -149,7 +377,7 @@
149
377
  if (active) link.setAttribute("aria-current", "page");
150
378
  else link.removeAttribute("aria-current");
151
379
  });
152
- $("#page-title").textContent = VIEWS[target].title;
380
+ $("#page-title").textContent = I18N[currentLang]?.[target] || VIEWS[target].title;
153
381
  setSidebar(false);
154
382
 
155
383
  VIEWS[target].load();
@@ -233,12 +461,12 @@
233
461
  setStatus(stats.connection);
234
462
 
235
463
  const cards = [
236
- ["Groups", stats.totalGroups, "Communities", '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>'],
237
- ["Commands", stats.commandCount, "Available", '<svg viewBox="0 0 24 24"><path d="m5 7 5 5-5 5M12 17h7"/></svg>'],
238
- ["Open debts", stats.totalDebts, "Unsettled", '<svg viewBox="0 0 24 24"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2zM9 8h6M9 12h6"/></svg>'],
239
- ["Warnings", stats.totalWarnings, "Issued", '<svg viewBox="0 0 24 24"><path d="M12 4 3 20h18zM12 9v5M12 17h.01"/></svg>'],
240
- ["Notes", stats.totalNotes, "Saved", '<svg viewBox="0 0 24 24"><path d="M5 3h14v18H5zM8 8h8M8 12h8M8 16h5"/></svg>'],
241
- ["Todo lists", stats.totalTodos, "Tracked", '<svg viewBox="0 0 24 24"><path d="m5 7 2 2 4-4M13 7h6M5 14l2 2 4-4M13 14h6"/></svg>'],
464
+ ["Groups", stats.totalGroups, "Communities", '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>'],
465
+ ["Commands", stats.commandCount, "Available", '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>'],
466
+ ["Open debts", stats.totalDebts, "Unsettled", '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" x2="12" y1="2" y2="22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>'],
467
+ ["Warnings", stats.totalWarnings, "Issued", '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>'],
468
+ ["Notes", stats.totalNotes, "Saved", '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>'],
469
+ ["Todo lists", stats.totalTodos, "Tracked", '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11 3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>'],
242
470
  ];
243
471
 
244
472
  $("#stat-cards").innerHTML = cards
@@ -1209,33 +1437,36 @@
1209
1437
 
1210
1438
  // --- settings -----------------------------------------------------------
1211
1439
 
1440
+ let activeSettingsTab = "ai";
1441
+
1212
1442
  function settingField(setting) {
1443
+ if (!setting) return "";
1213
1444
  const id = `set-${setting.key}`;
1214
1445
  const source =
1215
1446
  setting.source === "dashboard"
1216
- ? '<span class="source dashboard">saved here</span>'
1217
- : '<span class="source">default</span>';
1447
+ ? '<span class="source dashboard">' + t("saved_here") + '</span>'
1448
+ : '<span class="source">' + t("default_val") + '</span>';
1218
1449
  const restart = setting.restart
1219
- ? '<span class="source">restart to apply</span>'
1450
+ ? '<span class="source">' + t("restart_to_apply") + '</span>'
1220
1451
  : "";
1221
1452
 
1222
1453
  let control;
1223
1454
  if (setting.type === "secret") {
1224
1455
  control = `
1225
1456
  <div class="inline">
1226
- <input type="password" id="${id}" placeholder="${setting.configured ? "•••••••• (set)" : "not set"}"
1457
+ <input type="password" id="${id}" placeholder="${setting.configured ? "•••••••• (set)" : t("not_set")}"
1227
1458
  autocomplete="new-password"${setting.configured ? ' data-configured="1"' : ""} />
1228
- <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
1229
- ${setting.configured ? `<button class="btn btn-sm btn-danger" type="button" data-clear-setting="${esc(setting.key)}">Clear</button>` : ""}
1459
+ <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">${t("save")}</button>
1460
+ ${setting.configured ? `<button class="btn btn-sm btn-danger" type="button" data-clear-setting="${esc(setting.key)}">${t("clear")}</button>` : ""}
1230
1461
  </div>`;
1231
1462
  } else if (setting.type === "bool") {
1232
1463
  control = `
1233
1464
  <div class="inline">
1234
1465
  <label class="switch">
1235
- <input type="checkbox" id="${id}" ${setting.value ? "checked" : ""} />
1466
+ <input type="checkbox" id="${id}" ${setting.value ? "checked" : ""} data-auto-save="${esc(setting.key)}" />
1236
1467
  <span class="track"></span>
1237
1468
  </label>
1238
- <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
1469
+ <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">${t("save")}</button>
1239
1470
  </div>`;
1240
1471
  } else if (Array.isArray(setting.choices) && setting.choices.length) {
1241
1472
  control = `
@@ -1248,7 +1479,7 @@
1248
1479
  )
1249
1480
  .join("")}
1250
1481
  </select>
1251
- <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
1482
+ <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">${t("save")}</button>
1252
1483
  </div>`;
1253
1484
  } else {
1254
1485
  control = `
@@ -1257,12 +1488,12 @@
1257
1488
  value="${esc(setting.value)}"
1258
1489
  ${setting.min !== null ? `min="${setting.min}"` : ""}
1259
1490
  ${setting.max !== null ? `max="${setting.max}"` : ""} />
1260
- <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">Save</button>
1491
+ <button class="btn btn-sm" type="button" data-save-setting="${esc(setting.key)}">${t("save")}</button>
1261
1492
  </div>`;
1262
1493
  }
1263
1494
 
1264
1495
  return `
1265
- <div class="setting-item">
1496
+ <div class="setting-item" id="item-${esc(setting.key)}">
1266
1497
  <div class="inline setting-label-row">
1267
1498
  <label for="${id}" class="setting-label">${esc(setting.label)}</label>
1268
1499
  ${source}${restart}
@@ -1272,28 +1503,260 @@
1272
1503
  </div>`;
1273
1504
  }
1274
1505
 
1506
+ function renderAiSettingsTab(container, byKey) {
1507
+ const currentProvider = byKey.get("ai_provider")?.value || "gemini";
1508
+ const openaiBaseUrl = String(byKey.get("openai_base_url")?.value || "");
1509
+ const activeModel =
1510
+ currentProvider === "gemini"
1511
+ ? byKey.get("gemini_model")?.value
1512
+ : currentProvider === "openai"
1513
+ ? byKey.get("openai_model")?.value
1514
+ : byKey.get("anthropic_model")?.value;
1515
+
1516
+ const mStr = String(activeModel || "").toLowerCase();
1517
+ const supportsVision =
1518
+ currentProvider === "gemini" ||
1519
+ currentProvider === "anthropic" ||
1520
+ mStr.includes("vision") ||
1521
+ mStr.includes("vl") ||
1522
+ mStr.includes("llava") ||
1523
+ mStr.includes("4o") ||
1524
+ mStr.includes("pixtral") ||
1525
+ mStr.includes("qwen") ||
1526
+ mStr.includes("llama-3.2-11b") ||
1527
+ mStr.includes("llama-3.2-90b");
1528
+
1529
+ let activeCard = currentProvider;
1530
+ if (currentProvider === "openai") {
1531
+ if (openaiBaseUrl.includes("groq.com")) activeCard = "groq";
1532
+ else if (openaiBaseUrl.includes("11434") || openaiBaseUrl.includes("ollama")) activeCard = "ollama";
1533
+ else activeCard = "openai";
1534
+ }
1535
+
1536
+ let providerFieldsHtml = "";
1537
+ if (currentProvider === "gemini") {
1538
+ const keys = ["gemini_api_key", "gemini_model"];
1539
+ providerFieldsHtml = keys.map((k) => settingField(byKey.get(k))).join("");
1540
+ } else if (currentProvider === "openai") {
1541
+ const keys = ["openai_api_key", "openai_base_url", "openai_model"];
1542
+ providerFieldsHtml = `
1543
+ <div class="preset-chips">
1544
+ <span class="hint" style="align-self:center; font-weight:600;">${t("apply_preset")}:</span>
1545
+ <button class="preset-chip" type="button" data-preset="groq">⚡ Groq (Llama 3.3)</button>
1546
+ <button class="preset-chip" type="button" data-preset="openai">🧠 OpenAI (GPT-4o)</button>
1547
+ <button class="preset-chip" type="button" data-preset="ollama">🦙 Ollama (Local)</button>
1548
+ <button class="preset-chip" type="button" data-preset="openrouter">🌐 OpenRouter</button>
1549
+ </div>
1550
+ ${keys.map((k) => settingField(byKey.get(k))).join("")}
1551
+ `;
1552
+ } else if (currentProvider === "anthropic") {
1553
+ const keys = ["anthropic_api_key", "anthropic_model"];
1554
+ providerFieldsHtml = keys.map((k) => settingField(byKey.get(k))).join("");
1555
+ }
1556
+
1557
+ const capKeys = ["bot_language", "ai_vision_enabled", "ai_stt_provider"];
1558
+ if (
1559
+ byKey.get("ai_stt_provider")?.value === "openai" ||
1560
+ (byKey.get("ai_stt_provider")?.value === "auto" && currentProvider === "openai")
1561
+ ) {
1562
+ capKeys.push("openai_stt_model");
1563
+ }
1564
+ capKeys.push("ai_agent");
1565
+ if (currentProvider === "gemini") {
1566
+ capKeys.push("ai_google_search");
1567
+ }
1568
+ const capFieldsHtml = capKeys.map((k) => settingField(byKey.get(k))).join("");
1569
+
1570
+ container.innerHTML = `
1571
+ <!-- Step 1: Provider Selection Cards -->
1572
+ <div class="card">
1573
+ <div class="card-head">
1574
+ <div>
1575
+ <h2>${t("ai_wizard_title")}</h2>
1576
+ <div class="hint">${t("ai_wizard_sub")}</div>
1577
+ </div>
1578
+ </div>
1579
+
1580
+ <div class="provider-grid">
1581
+ <div class="provider-card ${activeCard === "gemini" ? "active" : ""}" data-pick-provider="gemini">
1582
+ <div class="provider-card-head">
1583
+ <div class="provider-title"><span>🌟</span> ${t("provider_gemini")}</div>
1584
+ <span class="provider-active-badge">${t("active_provider")}</span>
1585
+ </div>
1586
+ <div class="provider-desc">${t("provider_gemini_desc")}</div>
1587
+ </div>
1588
+
1589
+ <div class="provider-card ${activeCard === "groq" ? "active" : ""}" data-pick-provider="groq">
1590
+ <div class="provider-card-head">
1591
+ <div class="provider-title"><span>⚡</span> ${t("provider_groq")}</div>
1592
+ <span class="provider-active-badge">${t("active_provider")}</span>
1593
+ </div>
1594
+ <div class="provider-desc">${t("provider_groq_desc")}</div>
1595
+ </div>
1596
+
1597
+ <div class="provider-card ${activeCard === "openai" ? "active" : ""}" data-pick-provider="openai">
1598
+ <div class="provider-card-head">
1599
+ <div class="provider-title"><span>🧠</span> ${t("provider_openai")}</div>
1600
+ <span class="provider-active-badge">${t("active_provider")}</span>
1601
+ </div>
1602
+ <div class="provider-desc">${t("provider_openai_desc")}</div>
1603
+ </div>
1604
+
1605
+ <div class="provider-card ${activeCard === "ollama" ? "active" : ""}" data-pick-provider="ollama">
1606
+ <div class="provider-card-head">
1607
+ <div class="provider-title"><span>🦙</span> ${t("provider_ollama")}</div>
1608
+ <span class="provider-active-badge">${t("active_provider")}</span>
1609
+ </div>
1610
+ <div class="provider-desc">${t("provider_ollama_desc")}</div>
1611
+ </div>
1612
+
1613
+ <div class="provider-card ${activeCard === "anthropic" ? "active" : ""}" data-pick-provider="anthropic">
1614
+ <div class="provider-card-head">
1615
+ <div class="provider-title"><span>🔮</span> ${t("provider_anthropic")}</div>
1616
+ <span class="provider-active-badge">${t("active_provider")}</span>
1617
+ </div>
1618
+ <div class="provider-desc">${t("provider_anthropic_desc")}</div>
1619
+ </div>
1620
+ </div>
1621
+ </div>
1622
+
1623
+ <!-- Step 2: Credentials & Model for Active Provider -->
1624
+ <div class="card">
1625
+ <div class="card-head">
1626
+ <h2>🔑 ${t("active_provider")}: ${esc(currentProvider.toUpperCase())} ${activeCard !== currentProvider ? `(${esc(activeCard.toUpperCase())})` : ""}</h2>
1627
+ </div>
1628
+ ${providerFieldsHtml}
1629
+ </div>
1630
+
1631
+ <!-- Step 3: Model Capabilities & Media Toggles (Point 3) -->
1632
+ <div class="card">
1633
+ <div class="card-head">
1634
+ <div>
1635
+ <h2>🎛️ ${t("capabilities_title")}</h2>
1636
+ <div class="hint">${t("capabilities_sub")}</div>
1637
+ </div>
1638
+ </div>
1639
+ ${capFieldsHtml}
1640
+ <div style="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line-soft); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
1641
+ <button class="btn btn-secondary" id="open-ai-advanced" type="button">
1642
+ ⚙️ ${t("advanced_btn")}
1643
+ </button>
1644
+ </div>
1645
+ </div>
1646
+ `;
1647
+
1648
+ const advContainer = $("#ai-advanced-fields");
1649
+ if (advContainer) {
1650
+ const advKeys = [
1651
+ "ai_max_tool_steps",
1652
+ "ai_tool_timeout_ms",
1653
+ "ai_max_history",
1654
+ "memory_context_chars",
1655
+ "gemini_stt_model",
1656
+ "gemini_image_model",
1657
+ "google_search_api_key",
1658
+ "google_search_cx",
1659
+ ];
1660
+ advContainer.innerHTML = advKeys
1661
+ .map((k) => byKey.get(k))
1662
+ .filter(Boolean)
1663
+ .map(settingField)
1664
+ .join("");
1665
+ }
1666
+ }
1667
+
1275
1668
  VIEWS.settings = {
1276
1669
  title: "Settings",
1277
1670
  load: guard(async () => {
1278
- const { settings } = await api("/settings");
1671
+ const { settings, prefix } = await api("/settings");
1672
+ const byKey = new Map(settings.map((s) => [s.key, s]));
1279
1673
 
1280
- const groups = new Map();
1281
- for (const setting of settings) {
1282
- if (!groups.has(setting.group)) {
1283
- groups.set(setting.group, { label: setting.groupLabel, items: [] });
1284
- }
1285
- groups.get(setting.group).items.push(setting);
1674
+ $$(".settings-tab").forEach((b) => {
1675
+ const isSelected = b.dataset.tab === activeSettingsTab;
1676
+ b.classList.toggle("active", isSelected);
1677
+ b.setAttribute("aria-selected", isSelected ? "true" : "false");
1678
+ });
1679
+
1680
+ const secContent = $("#settings-tab-security");
1681
+ if (secContent) {
1682
+ secContent.style.display = activeSettingsTab === "security" ? "block" : "none";
1286
1683
  }
1287
1684
 
1288
- $("#settings-list").innerHTML = [...groups.values()]
1289
- .map(
1290
- (group) => `
1291
- <div class="card">
1292
- <div class="card-head"><h2>${esc(group.label)}</h2></div>
1293
- ${group.items.map(settingField).join("")}
1294
- </div>`
1295
- )
1296
- .join("");
1685
+ const listEl = $("#settings-list");
1686
+ if (!listEl) return;
1687
+
1688
+ if (activeSettingsTab === "ai") {
1689
+ renderAiSettingsTab(listEl, byKey);
1690
+ } else if (activeSettingsTab === "general") {
1691
+ const generalKeys = ["bot_timezone", "bot_min_delay_ms", "bot_max_delay_ms"];
1692
+ const items = generalKeys.map((k) => byKey.get(k)).filter(Boolean);
1693
+ listEl.innerHTML = `
1694
+ <div class="card" style="margin-bottom: 20px;">
1695
+ <div class="card-head"><h2>⌨️ Command Prefix (بادئة الأوامر)</h2></div>
1696
+ <div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px;">
1697
+ <input type="text" id="gen-prefix-input" maxlength="3" class="field" style="max-width: 90px; font-family: monospace; font-size: 1.15rem; text-align: center;" value="${escapeHtml(prefix || "!")}">
1698
+ <button type="button" class="btn btn-primary" id="gen-prefix-save">Save Prefix</button>
1699
+ <span class="hint" style="margin: 0;">e.g. <code>!help</code> or <code>/help</code></span>
1700
+ </div>
1701
+ </div>
1702
+ <div class="card">
1703
+ <div class="card-head"><h2>⚙️ ${t("settings_tab_general")}</h2></div>
1704
+ ${items.map(settingField).join("")}
1705
+ </div>`;
1706
+
1707
+ const pInput = $("#gen-prefix-input");
1708
+ const pSave = $("#gen-prefix-save");
1709
+ if (pSave && pInput) {
1710
+ pSave.addEventListener("click", guard(async () => {
1711
+ const val = pInput.value.trim();
1712
+ if (!val || val.length > 3) {
1713
+ toast("Prefix must be 1 to 3 characters", "bad");
1714
+ return;
1715
+ }
1716
+ await api("/settings", { method: "PATCH", body: { key: "prefix", value: val } });
1717
+ toast(`Prefix updated to "${val}"`, "ok");
1718
+ }));
1719
+ }
1720
+ }
1721
+ } else if (activeSettingsTab === "security") {
1722
+ const serverKeys = ["port", "bind_address", "trust_proxy", "public_domain", "dashboard_origin"];
1723
+ const items = serverKeys.map((k) => byKey.get(k)).filter(Boolean);
1724
+ listEl.innerHTML = `
1725
+ <div class="card">
1726
+ <div class="card-head"><h2>🌐 ${t("settings_tab_security")} &amp; Server</h2></div>
1727
+ ${items.map(settingField).join("")}
1728
+ </div>`;
1729
+ } else if (activeSettingsTab === "whatsapp") {
1730
+ const proxyKeys = [
1731
+ "whatsapp_proxy_enabled",
1732
+ "whatsapp_proxy_protocol",
1733
+ "whatsapp_proxy_host",
1734
+ "whatsapp_proxy_port",
1735
+ "whatsapp_proxy_username",
1736
+ "whatsapp_proxy_password",
1737
+ ];
1738
+ const items = proxyKeys.map((k) => byKey.get(k)).filter(Boolean);
1739
+ listEl.innerHTML = `
1740
+ <div class="card">
1741
+ <div class="card-head"><h2>🌐 ${t("settings_tab_whatsapp")}</h2></div>
1742
+ ${items.map(settingField).join("")}
1743
+ </div>`;
1744
+ } else if (activeSettingsTab === "media") {
1745
+ const mediaKeys = [
1746
+ "thumbnail_remote",
1747
+ "thumbnail_remote_max_mb",
1748
+ "thumbnail_remote_timeout_ms",
1749
+ "ffmpeg_path",
1750
+ "forward_score_ttl_days",
1751
+ "openweathermap_api_key",
1752
+ ];
1753
+ const items = mediaKeys.map((k) => byKey.get(k)).filter(Boolean);
1754
+ listEl.innerHTML = `
1755
+ <div class="card">
1756
+ <div class="card-head"><h2>📁 ${t("settings_tab_media")}</h2></div>
1757
+ ${items.map(settingField).join("")}
1758
+ </div>`;
1759
+ }
1297
1760
  }),
1298
1761
  };
1299
1762
 
@@ -1310,18 +1773,107 @@
1310
1773
  })
1311
1774
  );
1312
1775
 
1313
- $("#settings-list").addEventListener("click", (event) => {
1776
+ // Tab switcher
1777
+ $("#settings-nav-tabs")?.addEventListener("click", (e) => {
1778
+ const tabBtn = e.target.closest("[data-tab]");
1779
+ if (!tabBtn) return;
1780
+ activeSettingsTab = tabBtn.dataset.tab;
1781
+ VIEWS.settings.load();
1782
+ });
1783
+
1784
+ // Modal triggers
1785
+ document.addEventListener("click", (e) => {
1786
+ if (e.target.closest("#open-ai-advanced")) {
1787
+ const modal = $("#ai-advanced-modal");
1788
+ if (modal) modal.style.display = "flex";
1789
+ } else if (e.target.closest("#ai-advanced-close") || e.target.closest("#ai-advanced-done")) {
1790
+ const modal = $("#ai-advanced-modal");
1791
+ if (modal) modal.style.display = "none";
1792
+ }
1793
+ });
1794
+
1795
+ // Provider Card Picker
1796
+ document.addEventListener("click", (e) => {
1797
+ const card = e.target.closest("[data-pick-provider]");
1798
+ if (!card) return;
1799
+ const pick = card.dataset.pickProvider;
1800
+ guard(async () => {
1801
+ if (pick === "groq") {
1802
+ await api("/settings", { method: "PATCH", body: { key: "ai_provider", value: "openai" } });
1803
+ await api("/settings", { method: "PATCH", body: { key: "openai_base_url", value: "https://api.groq.com/openai/v1" } });
1804
+ await api("/settings", { method: "PATCH", body: { key: "openai_model", value: "llama-3.3-70b-versatile" } });
1805
+ await api("/settings", { method: "PATCH", body: { key: "openai_stt_model", value: "whisper-large-v3" } });
1806
+ await api("/settings", { method: "PATCH", body: { key: "ai_vision_enabled", value: true } });
1807
+ } else if (pick === "ollama") {
1808
+ await api("/settings", { method: "PATCH", body: { key: "ai_provider", value: "openai" } });
1809
+ await api("/settings", { method: "PATCH", body: { key: "openai_base_url", value: "http://127.0.0.1:11434/v1" } });
1810
+ await api("/settings", { method: "PATCH", body: { key: "openai_model", value: "llama3.2" } });
1811
+ } else if (pick === "openai") {
1812
+ await api("/settings", { method: "PATCH", body: { key: "ai_provider", value: "openai" } });
1813
+ await api("/settings", { method: "PATCH", body: { key: "openai_base_url", value: "https://api.openai.com/v1" } });
1814
+ await api("/settings", { method: "PATCH", body: { key: "openai_model", value: "gpt-4o-mini" } });
1815
+ } else if (pick === "gemini") {
1816
+ await api("/settings", { method: "PATCH", body: { key: "ai_provider", value: "gemini" } });
1817
+ } else if (pick === "anthropic") {
1818
+ await api("/settings", { method: "PATCH", body: { key: "ai_provider", value: "anthropic" } });
1819
+ await api("/settings", { method: "PATCH", body: { key: "anthropic_model", value: "claude-sonnet-4-5" } });
1820
+ }
1821
+ toast(t("saved"), "ok");
1822
+ VIEWS.settings.load();
1823
+ })();
1824
+ });
1825
+
1826
+ // Preset chips
1827
+ document.addEventListener("click", (e) => {
1828
+ const chip = e.target.closest("[data-preset]");
1829
+ if (!chip) return;
1830
+ const preset = chip.dataset.preset;
1831
+ guard(async () => {
1832
+ if (preset === "groq") {
1833
+ await api("/settings", { method: "PATCH", body: { key: "openai_base_url", value: "https://api.groq.com/openai/v1" } });
1834
+ await api("/settings", { method: "PATCH", body: { key: "openai_model", value: "llama-3.3-70b-versatile" } });
1835
+ await api("/settings", { method: "PATCH", body: { key: "openai_stt_model", value: "whisper-large-v3" } });
1836
+ await api("/settings", { method: "PATCH", body: { key: "ai_vision_enabled", value: true } });
1837
+ } else if (preset === "openai") {
1838
+ await api("/settings", { method: "PATCH", body: { key: "openai_base_url", value: "https://api.openai.com/v1" } });
1839
+ await api("/settings", { method: "PATCH", body: { key: "openai_model", value: "gpt-4o-mini" } });
1840
+ } else if (preset === "ollama") {
1841
+ await api("/settings", { method: "PATCH", body: { key: "openai_base_url", value: "http://127.0.0.1:11434/v1" } });
1842
+ await api("/settings", { method: "PATCH", body: { key: "openai_model", value: "llama3.2" } });
1843
+ } else if (preset === "openrouter") {
1844
+ await api("/settings", { method: "PATCH", body: { key: "openai_base_url", value: "https://openrouter.ai/api/v1" } });
1845
+ await api("/settings", { method: "PATCH", body: { key: "openai_model", value: "google/gemini-2.5-flash" } });
1846
+ }
1847
+ toast(t("saved"), "ok");
1848
+ VIEWS.settings.load();
1849
+ })();
1850
+ });
1851
+
1852
+ // Auto-save switches on change
1853
+ document.addEventListener("change", (e) => {
1854
+ const autoSaveKey = e.target?.dataset?.autoSave;
1855
+ if (autoSaveKey && e.target.type === "checkbox") {
1856
+ const key = autoSaveKey;
1857
+ const value = e.target.checked;
1858
+ guard(async () => {
1859
+ await api("/settings", { method: "PATCH", body: { key, value } });
1860
+ toast(t("saved"), "ok");
1861
+ VIEWS.settings.load();
1862
+ if (key.startsWith("whatsapp_proxy_")) loadStats().catch(() => {});
1863
+ })();
1864
+ }
1865
+ });
1866
+
1867
+ // Button-based save and clear
1868
+ document.addEventListener("click", (event) => {
1314
1869
  const save = event.target.closest("[data-save-setting]");
1315
1870
  const clear = event.target.closest("[data-clear-setting]");
1316
1871
  if (!save && !clear) return;
1317
1872
 
1318
1873
  const key = save ? save.dataset.saveSetting : clear.dataset.clearSetting;
1319
1874
  const input = document.getElementById(`set-${key}`);
1875
+ if (!input) return;
1320
1876
 
1321
- // An already-set secret with an empty box means "leave it alone", not
1322
- // "erase it". The box is always empty — the server never sends the value
1323
- // back — so saving the field as-is would wipe a working password every
1324
- // time somebody edited the host next to it. Clear is the explicit way.
1325
1877
  if (save && input.dataset.configured === "1" && input.value === "") {
1326
1878
  toast("Left unchanged. Use Clear to remove it.");
1327
1879
  return;
@@ -1335,10 +1887,8 @@
1335
1887
 
1336
1888
  guard(async () => {
1337
1889
  await api("/settings", { method: "PATCH", body: { key, value } });
1338
- toast(clear ? "Cleared" : "Saved", "ok");
1890
+ toast(clear ? t("cleared") : t("saved"), "ok");
1339
1891
  VIEWS.settings.load();
1340
- // A proxy change cannot reach a socket that is already open; the
1341
- // Connection screen is where that is offered, so refresh its state.
1342
1892
  if (key.startsWith("whatsapp_proxy_")) loadStats().catch(() => {});
1343
1893
  })();
1344
1894
  });
@@ -1382,6 +1932,7 @@
1382
1932
  }
1383
1933
 
1384
1934
  initTheme();
1935
+ initLang();
1385
1936
  initRouting();
1386
1937
  initConnectionActions();
1387
1938
  {