/* ═══════════════════════════════════════════════════ i18n – extensible locale registry To add a new language, append an entry to LOCALES and add a matching key in `strings`. ═══════════════════════════════════════════════════ */ const LOCALES = [ { id: "en", label: "English" }, { id: "zh-cn", label: "简体中文" }, ]; const strings = { en: { docTitle: "ESP-Claw Settings", pageTitle: "Device Settings", pageSubtitle: "Configure credentials, runtime settings, and manage FATFS storage on your ESP device.", tabConfig: "Configuration", tabMemory: "Memory", tabFiles: "File Manager", statusLoading: "Loading\u2026", statusOnline: "Wi-Fi Connected", statusOffline: "Wi-Fi Offline", sectionWifi: "Wi-Fi Settings", wifiSsid: "Wi-Fi SSID", wifiPassword: "Wi-Fi Password", sectionLlm: "LLM Settings", llmProvider: "LLM Provider", llmProviderOpenai: "OpenAI", llmProviderQwen: "Qwen Compatible", llmProviderDeepSeek: "DeepSeek", llmProviderAnthropic: "Anthropic", llmProviderCustom: "Custom", llmApiKey: "API Key", llmModel: "Model", llmTimeout: "Timeout (ms)", llmTimeoutPlaceholder: "120000", llmAdvanced: "LLM Advanced Options", llmBackend: "Backend Type", llmBackendPlaceholder: "openai_compatible / anthropic / custom", llmProfile: "Profile", llmProfilePlaceholder: "openai / qwen_compatible / custom_openai_compatible / anthropic", llmBaseUrl: "Base URL", llmBaseUrlPlaceholder: "https://api.openai.com/v1", llmAuthType: "Auth Type", llmAuthTypePlaceholder: "bearer / api-key / none", sectionIm: "Instant Messaging (IM)", imWechatTitle: "WeChat", imFeishuTitle: "Feishu", wechatLoginDesc: "QR Code Sign-in", qqAppId: "QQ App ID", qqAppSecret: "QQ App Secret", feishuAppId: "Feishu App ID", feishuAppSecret: "Feishu App Secret", tgBotToken: "Telegram Bot Token", wechatToken: "WeChat Token", wechatBaseUrl: "WeChat Base URL", wechatBaseUrlPlaceholder: "https://ilinkai.weixin.qq.com", wechatCdnBaseUrl: "WeChat CDN Base URL", wechatCdnBaseUrlPlaceholder: "https://novac2c.cdn.weixin.qq.com/c2c", wechatAccountId: "WeChat Account ID", wechatAccountIdPlaceholder: "default", wechatLogin: "WeChat Login", wechatLoginGenerate: "Generate QR", wechatLoginCancel: "Cancel", wechatLoginStatus: "Status: idle", wechatLoginNote: "Generate a QR code here to obtain wechat_token automatically. The token is saved to NVS after confirmation. Restart the device to apply the new login.", wechatLoginOpenLink: "Open login link", sectionSearch: "Search (Optional)", searchNote: "If set, ESP-Claw can search online. Tip: Built-in weather skill also relies on online search.", searchBraveKey: "Brave Search API Key", searchTavilyKey: "Tavily API Key", sectionCapabilities: "Capability Groups", sectionAdvanced: "Advanced Settings", enabledCapGroups: "Enabled Capability Groups", enabledCapGroupsHelp: "Choose which compiled capability groups can be used after boot, and which enabled groups stay visible to the LLM. Changes take effect after restart.", capabilitySelectAll: "Select All", capabilityClearAll: "Clear All", capabilityLlmDefaults: "Restore LLM Set", capabilityLlmClear: "Clear LLM", capabilityLoading: "Loading capabilities...", capabilityLoadError: "Failed to load capabilities", capabilitySelectedSummary: "{enabled} enabled, {llm} LLM-visible, total {total}", capabilityEnabled: "Enabled", capabilityLlmAlwaysVisible: "LLM Always Visible", enabledLuaModules: "Enabled Lua Modules", enabledLuaModulesHelp: "Choose which compiled Lua modules are registered when cap_lua starts. Changes take effect after restart.", luaModuleSelectAll: "Select All", luaModuleClearAll: "Clear All", luaModuleLoading: "Loading Lua modules...", luaModuleLoadError: "Failed to load Lua modules", luaModuleSelectedSummary: "{enabled} enabled, total {total}", luaModuleCapabilityRequired: "Enable the cap_lua capability group above before Lua module settings can take effect.", timezone: "Timezone", timezonePlaceholder: "e.g. CST-8 (Beijing) or UTC0", timezoneHelp: "POSIX TZ string only. The sign is INVERTED relative to common UTC notation: Beijing (UTC+8) must be written as 'CST-8'; New York (UTC-5) is 'EST5'. Names like 'Asia/Shanghai' will NOT work — please convert manually.", saveBtn: "Save Changes", saveSuccess: "Settings saved", saveError: "Failed to save settings", memoryTitle: "Memory Files", memoryDescription: "Manage the device memory files.", memoryCardLongTerm: "Long-term Memory", memoryCardSoul: "Soul", memoryCardIdentity: "Identity", memoryCardUserInfo: "User Info", memoryMissing: "Long-term memory file does not exist yet.", memoryRefreshAll: "Refresh All", memoryRefresh: "Refresh", memoryAuxMissing: "Memory file does not exist yet.", memoryRefreshed: "Memory reloaded", memoryLoadError: "Failed to load memory", memorySaveError: "Failed to save memory", configNote: "Changes are stored in NVS. Choose an LLM provider first, then fill the shared API key and model fields. Restart the device after updating Wi-Fi, Feishu, core LLM, capability, or Lua module settings.", fileRefresh: "Refresh", fileUpDir: "Up One Level", fileNewFolder: "New folder name", fileCreateFolder: "Create Folder", fileUploadPath: "/example.txt", fileChoose: "Choose File", fileNoFileSelected: "No file selected", fileUpload: "Upload", fileColName: "Name", fileColType: "Type", fileColSize: "Size", fileColActions: "Actions", fileTypeFolder: "Folder", fileTypeFile: "File", fileEmpty: "This folder is empty.", fileOpen: "Open", fileEdit: "Edit", filePreview: "Preview", fileDownload: "Download", fileDelete: "Delete", fileDevMode: "Dev Mode", fileDevModeRequired: "Enable Dev Mode to modify files.", fileDevModeConfirm: "After entering Dev Mode, you can upload, modify, or delete files. Incorrect changes may cause the system to fail, reboot endlessly, or behave unexpectedly. Please proceed carefully.", fileDeleteConfirm: "Delete {path}?", fileUploadComplete: "Upload completed", fileFolderCreated: "Folder created", fileDeleteComplete: "Delete completed", fileEditorTitle: "Edit File", fileEditorRefresh: "Refresh", fileEditorClose: "Close", fileEditorSave: "Save", fileEditorSaved: "File saved", fileEditorLoadError: "Failed to load file", fileEditorSaveError: "Failed to save file", fileSelectAndPath: "Select a file and provide a target path that starts with /.", fileFolderNameRequired: "Enter a folder name.", }, "zh-cn": { docTitle: "ESP-Claw 设置", pageTitle: "设备设置", pageSubtitle: "配置凭据、运行时设置,并管理 ESP 设备上的 FATFS 存储。", tabConfig: "配置管理", tabMemory: "记忆管理", tabFiles: "文件管理", statusLoading: "加载中\u2026", statusOnline: "Wi-Fi 已连接", statusOffline: "Wi-Fi 离线", sectionWifi: "Wi-Fi 设置", wifiSsid: "Wi-Fi 名称 (SSID)", wifiPassword: "Wi-Fi 密码", sectionLlm: "LLM 设置", llmProvider: "LLM 提供商", llmProviderOpenai: "OpenAI", llmProviderQwen: "Qwen 兼容", llmProviderDeepSeek: "DeepSeek", llmProviderAnthropic: "Anthropic", llmProviderCustom: "自定义", llmApiKey: "API 密钥", llmModel: "模型", llmTimeout: "超时 (毫秒)", llmTimeoutPlaceholder: "120000", llmAdvanced: "LLM 高级选项", llmBackend: "后端类型", llmBackendPlaceholder: "openai_compatible / anthropic / custom", llmProfile: "配置文件", llmProfilePlaceholder: "openai / qwen_compatible / custom_openai_compatible / anthropic", llmBaseUrl: "Base URL", llmBaseUrlPlaceholder: "https://api.openai.com/v1", llmAuthType: "认证类型", llmAuthTypePlaceholder: "bearer / api-key / none", sectionIm: "即时通讯 (IM)", imWechatTitle: "微信", imFeishuTitle: "飞书", wechatLoginDesc: "二维码登录", qqAppId: "QQ App ID", qqAppSecret: "QQ App Secret", feishuAppId: "飞书 App ID", feishuAppSecret: "飞书 App Secret", tgBotToken: "Telegram Bot Token", wechatToken: "微信 Token", wechatBaseUrl: "微信 Base URL", wechatBaseUrlPlaceholder: "https://ilinkai.weixin.qq.com", wechatCdnBaseUrl: "微信 CDN Base URL", wechatCdnBaseUrlPlaceholder: "https://novac2c.cdn.weixin.qq.com/c2c", wechatAccountId: "微信账号 ID", wechatAccountIdPlaceholder: "默认", wechatLogin: "微信登录", wechatLoginGenerate: "生成二维码", wechatLoginCancel: "取消", wechatLoginStatus: "状态:空闲", wechatLoginNote: "在此生成二维码以自动获取 wechat_token。确认后令牌将保存至 NVS。重启设备以应用新的登录。", wechatLoginOpenLink: "打开登录链接", sectionSearch: "搜索(可选)", searchNote: "如填写,ESP-Claw 可在运行中检索在线资源。提示:系统自带的天气 Skill 也依赖在线搜索功能。", searchBraveKey: "Brave Search API Key", searchTavilyKey: "Tavily API Key", sectionCapabilities: "Capability Group", sectionAdvanced: "高级设置", enabledCapGroups: "启用的 Capability Group", enabledCapGroupsHelp: "选择设备启动后允许使用哪些已编译 capability group,以及哪些启用后的 group 对 LLM 始终可见。修改后需重启生效。", capabilitySelectAll: "全选", capabilityClearAll: "清空", capabilityLlmDefaults: "恢复 LLM 集合", capabilityLlmClear: "清空 LLM", capabilityLoading: "正在加载 capability...", capabilityLoadError: "加载 capability 列表失败", capabilitySelectedSummary: "已启用 {enabled},LLM 可见 {llm},总计 {total}", capabilityEnabled: "启用", capabilityLlmAlwaysVisible: "LLM 始终可见", enabledLuaModules: "启用的 Lua 模块", enabledLuaModulesHelp: "选择 cap_lua 启动时要注册哪些已编译 Lua 模块。修改后需重启生效。", luaModuleSelectAll: "全选", luaModuleClearAll: "清空", luaModuleLoading: "正在加载 Lua 模块...", luaModuleLoadError: "加载 Lua 模块列表失败", luaModuleSelectedSummary: "已启用 {enabled},总计 {total}", luaModuleCapabilityRequired: "需先在上方启用 cap_lua capability group,Lua 模块配置才会生效。", timezone: "时区", timezonePlaceholder: "例如 CST-8(北京时间)或 UTC0", timezoneHelp: "仅接受 POSIX TZ 字符串,符号与日常 UTC 表示相反。北京时间(UTC+8)应写作 “CST-8”,纽约(UTC-5)写作 “EST5”。不支持 “Asia/Shanghai” 这种 IANA 名称,请自行换算。", saveBtn: "保存更改", saveSuccess: "设置已保存", saveError: "保存设置失败", memoryTitle: "记忆文件", memoryDescription: "管理记忆文件。", memoryCardLongTerm: "长期记忆", memoryCardSoul: "灵魂", memoryCardIdentity: "身份", memoryCardUserInfo: "用户信息", memoryMissing: "长期记忆文件尚不存在。", memoryRefreshAll: "全部刷新", memoryRefresh: "刷新", memoryAuxMissing: "记忆文件尚不存在。", memoryRefreshed: "记忆已重新加载", memoryLoadError: "加载记忆失败", memorySaveError: "保存记忆失败", configNote: "更改存储在 NVS 中。请先选择 LLM 提供商,然后填写 API 密钥和模型字段。更新 Wi-Fi、飞书、核心 LLM、capability 或 Lua 模块设置后请重启设备。", fileRefresh: "刷新", fileUpDir: "返回上级", fileNewFolder: "新文件夹名称", fileCreateFolder: "创建文件夹", fileUploadPath: "/example.txt", fileChoose: "选择文件", fileNoFileSelected: "未选择文件", fileUpload: "上传", fileColName: "名称", fileColType: "类型", fileColSize: "大小", fileColActions: "操作", fileTypeFolder: "文件夹", fileTypeFile: "文件", fileEmpty: "此文件夹为空。", fileOpen: "打开", fileEdit: "编辑", filePreview: "预览", fileDownload: "下载", fileDelete: "删除", fileDevMode: "开发者模式", fileDevModeRequired: "请先启用开发者模式再修改文件。", fileDevModeConfirm: "进入开发者模式后,你可以上传、修改或删除文件。不正确的增删改可能导致系统无法正常运行、无限重启等问题。请注意合理修改。", fileDeleteConfirm: "确定删除 {path}?", fileUploadComplete: "上传完成", fileFolderCreated: "文件夹已创建", fileDeleteComplete: "删除完成", fileEditorTitle: "编辑文件", fileEditorRefresh: "刷新", fileEditorClose: "退出", fileEditorSave: "保存", fileEditorSaved: "文件已保存", fileEditorLoadError: "加载文件失败", fileEditorSaveError: "保存文件失败", fileSelectAndPath: "请选择文件并提供以 / 开头的目标路径。", fileFolderNameRequired: "请输入文件夹名称。", }, }; /* ── Current language & device state ── */ let currentLang = "en"; let wifiConnected = null; // null = unknown/loading, true/false = fetched function t(key) { const table = strings[currentLang] || strings["en"]; return table[key] ?? (strings["en"][key] || key); } function formatTemplate(key, vars) { let value = t(key); Object.entries(vars).forEach(([name, replacement]) => { value = value.replaceAll(`{${name}}`, String(replacement)); }); return value; } function detectLang() { const params = new URLSearchParams(window.location.search); const fromUrl = params.get("lang"); if (fromUrl && strings[fromUrl]) return fromUrl; const saved = localStorage.getItem("esp-claw-lang"); if (saved && strings[saved]) return saved; const nav = (navigator.language || "").toLowerCase(); if (nav.startsWith("zh")) return "zh-cn"; return "en"; } function applyI18n() { document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = t(el.dataset.i18n); }); document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { el.placeholder = t(el.dataset.i18nPlaceholder); }); document.querySelectorAll("[data-i18n-title]").forEach((el) => { document.title = t(el.dataset.i18nTitle); }); document.querySelectorAll("[data-i18n-option]").forEach((el) => { el.textContent = t(el.dataset.i18nOption); }); document.documentElement.lang = currentLang === "zh-cn" ? "zh-CN" : "en"; document.title = t("docTitle"); refreshStatusText(); } function refreshStatusText() { const el = document.getElementById("status-text"); if (wifiConnected === null) { el.textContent = t("statusLoading"); } else { el.textContent = wifiConnected ? t("statusOnline") : t("statusOffline"); } } function buildLocaleMenu() { const menu = document.getElementById("locale-menu"); menu.innerHTML = ""; LOCALES.forEach((loc) => { const li = document.createElement("li"); const a = document.createElement("a"); a.textContent = loc.label; a.href = "#"; a.setAttribute("data-lang", loc.id); if (loc.id === currentLang) a.classList.add("is-current"); a.addEventListener("click", (e) => { e.preventDefault(); setLang(loc.id); document.getElementById("lang-switcher").removeAttribute("open"); }); li.appendChild(a); menu.appendChild(li); }); const label = LOCALES.find((l) => l.id === currentLang); document.getElementById("current-locale-label").textContent = label ? label.label : currentLang; } function setLang(lang) { if (!strings[lang]) return; currentLang = lang; localStorage.setItem("esp-claw-lang", lang); applyI18n(); buildLocaleMenu(); renderCapabilityCheckboxes(); renderFileRows(lastFileEntries); renderMemoryState(); updateFileDevModeUI(); } /* ═══════════════════════════════════════════════════ Tab switching ═══════════════════════════════════════════════════ */ function initTabs() { document.querySelectorAll(".tab-btn").forEach((btn) => { btn.addEventListener("click", () => { document .querySelectorAll(".tab-btn") .forEach((b) => b.classList.remove("active")); document .querySelectorAll(".tab-panel") .forEach((p) => p.classList.remove("active")); btn.classList.add("active"); const panel = document.getElementById("tab-" + btn.dataset.tab); if (panel) panel.classList.add("active"); if (btn.dataset.tab === "memory") { loadMemory().catch((err) => showBanner("memoryBanner", err.message || t("memoryLoadError"), true) ); } }); }); } /* ═══════════════════════════════════════════════════ Config – NVS fields ═══════════════════════════════════════════════════ */ const configFields = [ "wifi_ssid", "wifi_password", "llm_api_key", "llm_backend_type", "llm_profile", "llm_model", "llm_base_url", "llm_auth_type", "llm_timeout_ms", "qq_app_id", "qq_app_secret", "feishu_app_id", "feishu_app_secret", "tg_bot_token", "wechat_token", "wechat_base_url", "wechat_cdn_base_url", "wechat_account_id", "search_brave_key", "search_tavily_key", "time_timezone", ]; const CAPABILITY_NONE_SENTINEL = "__none__"; let capabilityItems = []; let pendingEnabledCapGroups = ""; let pendingLlmVisibleCapGroups = ""; let luaModuleItems = []; let pendingEnabledLuaModules = ""; const llmProviderPresets = { openai: { llm_backend_type: "openai_compatible", llm_profile: "openai", llm_base_url: "https://api.openai.com/v1", llm_auth_type: "bearer", }, qwen: { llm_backend_type: "openai_compatible", llm_profile: "qwen_compatible", llm_base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", llm_auth_type: "bearer", }, deepseek: { llm_backend_type: "openai_compatible", llm_profile: "custom_openai_compatible", llm_base_url: "https://api.deepseek.com", llm_auth_type: "bearer", }, anthropic: { llm_backend_type: "anthropic", llm_profile: "anthropic", llm_base_url: "https://api.anthropic.com/v1", llm_auth_type: "none", }, }; const llmProviderDefaultModels = { openai: "gpt-5.4", qwen: "qwen3.6-plus", deepseek: "deepseek-v4-pro", anthropic: "claude-sonnet-4-6", }; /* ── Banner helpers ── */ function showBanner(id, message, isError = false) { const banner = document.getElementById(id); banner.textContent = message; banner.classList.remove("success", "error"); banner.classList.add(isError ? "error" : "success"); banner.classList.add("visible"); } function hideBanner(id) { const banner = document.getElementById(id); banner.classList.remove("visible", "success", "error"); } /* ── Config form read / fill ── */ function readConfigForm() { const payload = {}; configFields.forEach((field) => { const input = document.getElementById(field); payload[field] = input ? input.value.trim() : ""; }); payload.enabled_cap_groups = serializeCapabilitySelection(); payload.llm_visible_cap_groups = serializeLlmVisibleSelection(); payload.enabled_lua_modules = serializeLuaModuleSelection(); return payload; } function fillConfigForm(data) { configFields.forEach((field) => { const input = document.getElementById(field); if (input && typeof data[field] === "string") { input.value = data[field]; } }); pendingEnabledCapGroups = typeof data.enabled_cap_groups === "string" ? data.enabled_cap_groups : ""; pendingLlmVisibleCapGroups = typeof data.llm_visible_cap_groups === "string" ? data.llm_visible_cap_groups : ""; pendingEnabledLuaModules = typeof data.enabled_lua_modules === "string" ? data.enabled_lua_modules : ""; renderCapabilityCheckboxes(); renderLuaModuleCheckboxes(); syncProviderPreset(); } function getSelectionSet( serialized, items, idKey, selectAllIfEmpty, defaultPredicate = null ) { const selected = new Set(); const raw = (serialized || "").trim(); if (!raw) { items.forEach((item) => { if (selectAllIfEmpty || (defaultPredicate && defaultPredicate(item))) { selected.add(item[idKey]); } }); return selected; } raw.split(",").forEach((token) => { const itemId = token.trim(); if (!itemId || itemId === CAPABILITY_NONE_SENTINEL || itemId === "none") { return; } selected.add(itemId); }); return selected; } function getCapabilitySelectionSet(serialized, selectAllIfEmpty, useDefaultLlmIfEmpty) { return getSelectionSet( serialized, capabilityItems, "group_id", selectAllIfEmpty, useDefaultLlmIfEmpty ? (item) => item.default_llm_visible : null ); } function getLuaModuleSelectionSet(serialized, selectAllIfEmpty) { return getSelectionSet(serialized, luaModuleItems, "module_id", selectAllIfEmpty); } function updateCapabilitySelectionSummary() { const summary = document.getElementById("capabilitySelectionSummary"); const enabledCount = document.querySelectorAll( '#capabilityCheckboxList input[data-role="enabled"]:checked' ).length; const llmCount = document.querySelectorAll( '#capabilityCheckboxList input[data-role="llm-visible"]:checked' ).length; if (!summary) return; if (!capabilityItems.length) { summary.textContent = t("capabilityLoading"); return; } summary.textContent = formatTemplate("capabilitySelectedSummary", { enabled: enabledCount, llm: llmCount, total: capabilityItems.length, }); } function isLuaCapabilityEnabled() { return !!document.querySelector( '#capabilityCheckboxList input[data-role="enabled"][value="cap_lua"]:checked' ); } function updateLuaModuleAvailability() { const disabled = !isLuaCapabilityEnabled(); const list = document.getElementById("luaModuleCheckboxList"); const note = document.getElementById("luaModuleCapabilityNote"); const selectAllButton = document.getElementById("luaModuleSelectAllButton"); const clearAllButton = document.getElementById("luaModuleClearAllButton"); if (list) { list.classList.toggle("is-disabled", disabled); } if (note) { note.classList.toggle("hidden", !disabled); } [selectAllButton, clearAllButton].forEach((button) => { if (button) { button.disabled = disabled || !luaModuleItems.length; } }); document .querySelectorAll('#luaModuleCheckboxList input[data-role="enabled"]') .forEach((input) => { input.disabled = disabled; }); } function updateLuaModuleSelectionSummary() { const summary = document.getElementById("luaModuleSelectionSummary"); const enabledCount = document.querySelectorAll( '#luaModuleCheckboxList input[data-role="enabled"]:checked' ).length; if (!summary) return; if (!luaModuleItems.length) { summary.textContent = t("luaModuleLoading"); return; } summary.textContent = formatTemplate("luaModuleSelectedSummary", { enabled: enabledCount, total: luaModuleItems.length, }); } function renderCapabilityCheckboxes() { const list = document.getElementById("capabilityCheckboxList"); if (!list) return; list.innerHTML = ""; if (!capabilityItems.length) { updateCapabilitySelectionSummary(); return; } const enabled = getCapabilitySelectionSet(pendingEnabledCapGroups, true, false); const llmVisible = getCapabilitySelectionSet( pendingLlmVisibleCapGroups, false, true ); capabilityItems.forEach((item) => { const label = document.createElement("label"); label.className = "capability-item"; const meta = document.createElement("div"); meta.className = "capability-meta"; const title = document.createElement("div"); title.className = "capability-title"; title.textContent = item.display_name || item.group_id; const id = document.createElement("div"); id.className = "capability-id"; id.textContent = item.group_id; meta.appendChild(title); meta.appendChild(id); const controls = document.createElement("div"); controls.className = "capability-controls"; const enabledToggle = document.createElement("label"); enabledToggle.className = "capability-toggle"; const enabledInput = document.createElement("input"); enabledInput.type = "checkbox"; enabledInput.value = item.group_id; enabledInput.dataset.role = "enabled"; enabledInput.checked = enabled.has(item.group_id); enabledToggle.appendChild(enabledInput); enabledToggle.appendChild(document.createTextNode(t("capabilityEnabled"))); const llmToggle = document.createElement("label"); llmToggle.className = "capability-toggle"; const llmInput = document.createElement("input"); llmInput.type = "checkbox"; llmInput.value = item.group_id; llmInput.dataset.role = "llm-visible"; llmInput.checked = enabled.has(item.group_id) && llmVisible.has(item.group_id); llmInput.disabled = !enabledInput.checked; llmToggle.appendChild(llmInput); llmToggle.appendChild( document.createTextNode(t("capabilityLlmAlwaysVisible")) ); enabledInput.addEventListener("change", () => { if (!enabledInput.checked) { llmInput.checked = false; llmInput.disabled = true; } else { llmInput.disabled = false; } updateCapabilitySelectionSummary(); updateLuaModuleAvailability(); }); llmInput.addEventListener("change", updateCapabilitySelectionSummary); controls.appendChild(enabledToggle); controls.appendChild(llmToggle); meta.appendChild(controls); label.appendChild(meta); list.appendChild(label); }); updateCapabilitySelectionSummary(); updateLuaModuleAvailability(); } function serializeCapabilitySelection() { const checked = Array.from( document.querySelectorAll('#capabilityCheckboxList input[type="checkbox"]:checked') ).map((input) => input.value); if (capabilityItems.length && checked.length === capabilityItems.length) { return ""; } if (checked.length === 0) { return CAPABILITY_NONE_SENTINEL; } return checked.join(","); } function setAllCapabilityCheckboxes(checked) { document .querySelectorAll('#capabilityCheckboxList input[data-role="enabled"]') .forEach((input) => { input.checked = checked; }); document .querySelectorAll('#capabilityCheckboxList input[data-role="llm-visible"]') .forEach((input) => { input.disabled = !checked; if (!checked) { input.checked = false; } }); updateCapabilitySelectionSummary(); updateLuaModuleAvailability(); } function serializeLlmVisibleSelection() { const checked = Array.from( document.querySelectorAll( '#capabilityCheckboxList input[data-role="llm-visible"]:checked' ) ).map((input) => input.value); const defaultSelected = capabilityItems .filter((item) => item.default_llm_visible) .map((item) => item.group_id); if ( checked.length === defaultSelected.length && checked.every((groupId) => defaultSelected.includes(groupId)) ) { return ""; } if (checked.length === 0) { return CAPABILITY_NONE_SENTINEL; } return checked.join(","); } function setLlmVisibleSelection(mode) { const enabled = new Set( Array.from( document.querySelectorAll('#capabilityCheckboxList input[data-role="enabled"]:checked') ).map((input) => input.value) ); document .querySelectorAll('#capabilityCheckboxList input[data-role="llm-visible"]') .forEach((input) => { const isEnabled = enabled.has(input.value); input.disabled = !isEnabled; if (!isEnabled) { input.checked = false; return; } if (mode === "defaults") { const item = capabilityItems.find((entry) => entry.group_id === input.value); input.checked = !!item?.default_llm_visible; } else if (mode === "clear") { input.checked = false; } }); updateCapabilitySelectionSummary(); } function renderLuaModuleCheckboxes() { const list = document.getElementById("luaModuleCheckboxList"); if (!list) return; list.innerHTML = ""; if (!luaModuleItems.length) { updateLuaModuleSelectionSummary(); updateLuaModuleAvailability(); return; } const enabled = getLuaModuleSelectionSet(pendingEnabledLuaModules, true); luaModuleItems.forEach((item) => { const label = document.createElement("label"); label.className = "capability-item"; const meta = document.createElement("div"); meta.className = "capability-meta"; const title = document.createElement("div"); title.className = "capability-title"; title.textContent = item.display_name || item.module_id; const id = document.createElement("div"); id.className = "capability-id"; id.textContent = item.module_id; const controls = document.createElement("div"); controls.className = "capability-controls"; const enabledToggle = document.createElement("label"); enabledToggle.className = "capability-toggle"; const enabledInput = document.createElement("input"); enabledInput.type = "checkbox"; enabledInput.value = item.module_id; enabledInput.dataset.role = "enabled"; enabledInput.checked = enabled.has(item.module_id); enabledInput.addEventListener("change", updateLuaModuleSelectionSummary); enabledToggle.appendChild(enabledInput); enabledToggle.appendChild(document.createTextNode(t("capabilityEnabled"))); controls.appendChild(enabledToggle); meta.appendChild(title); meta.appendChild(id); meta.appendChild(controls); label.appendChild(meta); list.appendChild(label); }); updateLuaModuleSelectionSummary(); updateLuaModuleAvailability(); } function serializeLuaModuleSelection() { if (!luaModuleItems.length) { return pendingEnabledLuaModules || ""; } const checked = Array.from( document.querySelectorAll('#luaModuleCheckboxList input[data-role="enabled"]:checked') ).map((input) => input.value); if (luaModuleItems.length && checked.length === luaModuleItems.length) { return ""; } if (checked.length === 0) { return CAPABILITY_NONE_SENTINEL; } return checked.join(","); } function setAllLuaModuleCheckboxes(checked) { const disabled = !isLuaCapabilityEnabled(); document .querySelectorAll('#luaModuleCheckboxList input[data-role="enabled"]') .forEach((input) => { input.checked = checked; input.disabled = disabled; }); updateLuaModuleSelectionSummary(); } /* ── LLM provider preset logic ── */ function detectProviderPreset() { const backend = document.getElementById("llm_backend_type")?.value.trim(); const profile = document.getElementById("llm_profile")?.value.trim(); const baseUrl = document.getElementById("llm_base_url")?.value.trim(); const authType = document.getElementById("llm_auth_type")?.value.trim(); const match = Object.entries(llmProviderPresets).find( ([, preset]) => preset.llm_backend_type === backend && preset.llm_profile === profile && preset.llm_base_url === baseUrl && preset.llm_auth_type === authType ); return match ? match[0] : "custom"; } function syncProviderPreset() { const select = document.getElementById("llm_provider_preset"); if (select) select.value = detectProviderPreset(); } function applyProviderPreset(presetKey) { const preset = llmProviderPresets[presetKey]; if (!preset) { syncProviderPreset(); return; } Object.entries(preset).forEach(([field, value]) => { const input = document.getElementById(field); if (input) input.value = value; }); const modelInput = document.getElementById("llm_model"); if (modelInput && llmProviderDefaultModels[presetKey]) { modelInput.value = llmProviderDefaultModels[presetKey]; } syncProviderPreset(); } /* ═══════════════════════════════════════════════════ API calls ═══════════════════════════════════════════════════ */ async function loadStatus() { try { const response = await fetch("/api/status", { cache: "no-store" }); const data = await response.json(); const badge = document.getElementById("status-badge"); const bar = document.getElementById("status-bar"); wifiConnected = !!data.wifi_connected; if (wifiConnected) { badge.classList.remove("offline"); badge.classList.add("online"); bar.classList.add("is-online"); } else { badge.classList.remove("online"); badge.classList.add("offline"); bar.classList.remove("is-online"); } refreshStatusText(); if (data.ip) { document.getElementById("info-ip").textContent = "IP: " + data.ip; document.getElementById("info-ip").style.display = ""; document.getElementById("meta-sep-ip").style.display = ""; } if (data.storage_base_path) { document.getElementById("info-storage").textContent = "Storage: " + data.storage_base_path; document.getElementById("info-storage").style.display = ""; document.getElementById("meta-sep-storage").style.display = ""; } } catch (err) { showBanner("configBanner", err.message || t("statusLoading"), true); } } async function loadConfig() { hideBanner("configBanner"); const response = await fetch("/api/config", { cache: "no-store" }); if (!response.ok) throw new Error("Failed to load settings"); fillConfigForm(await response.json()); } async function loadCapabilities() { const response = await fetch("/api/capabilities", { cache: "no-store" }); if (!response.ok) throw new Error(t("capabilityLoadError")); const data = await response.json(); capabilityItems = Array.isArray(data.items) ? data.items : []; renderCapabilityCheckboxes(); } async function loadLuaModules() { const response = await fetch("/api/lua-modules", { cache: "no-store" }); if (!response.ok) throw new Error(t("luaModuleLoadError")); const data = await response.json(); luaModuleItems = Array.isArray(data.items) ? data.items : []; renderLuaModuleCheckboxes(); } const WECHAT_FIELDS = [ "wechat_token", "wechat_base_url", "wechat_cdn_base_url", "wechat_account_id", ]; async function reloadWechatFields() { const response = await fetch("/api/config", { cache: "no-store" }); if (!response.ok) return; const data = await response.json(); WECHAT_FIELDS.forEach((field) => { const input = document.getElementById(field); if (input && typeof data[field] === "string") { input.value = data[field]; } }); } async function saveConfig() { const button = document.getElementById("saveConfigButton"); button.disabled = true; hideBanner("configBanner"); try { const response = await fetch("/api/config", { method: "POST", cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify(readConfigForm()), }); const responseText = await response.text(); let result = {}; if (responseText) { try { result = JSON.parse(responseText); } catch (parseError) { if (response.ok) throw parseError; result = { error: responseText }; } } if (!response.ok) throw new Error(result.error || t("saveError")); showBanner("configBanner", result.message || t("saveSuccess")); syncProviderPreset(); } catch (err) { showBanner("configBanner", err.message, true); } finally { button.disabled = false; } } /* ═══════════════════════════════════════════════════ WeChat Login ═══════════════════════════════════════════════════ */ let wechatLoginPollTimer = null; function stopWechatLoginPolling() { if (wechatLoginPollTimer) { clearTimeout(wechatLoginPollTimer); wechatLoginPollTimer = null; } } function renderWechatLoginStatus(data) { const qrCanvas = document.getElementById("wechatLoginQr"); const qrLink = document.getElementById("wechatLoginQrLink"); const meta = document.getElementById("wechatLoginMeta"); if (data.qr_data_url) { if (typeof window.renderWechatQr === "function") { try { window.renderWechatQr(qrCanvas, data.qr_data_url); } catch (error) { console.error("Failed to render WeChat QR:", error); } } qrCanvas.classList.remove("hidden"); qrLink.href = data.qr_data_url; qrLink.textContent = t("wechatLoginOpenLink"); qrLink.classList.remove("hidden"); } else { const ctx = qrCanvas.getContext("2d"); if (ctx) { ctx.clearRect(0, 0, qrCanvas.width, qrCanvas.height); } qrCanvas.classList.add("hidden"); qrLink.removeAttribute("href"); qrLink.textContent = ""; qrLink.classList.add("hidden"); } meta.textContent = data.status ? (currentLang === "zh-cn" ? "状态:" : "Status: ") + data.status : t("wechatLoginStatus"); if (data.message) { showBanner("wechatLoginBanner", data.message); } else { hideBanner("wechatLoginBanner"); } if (data.completed && data.persisted) { reloadWechatFields().catch(() => {}); } } async function pollWechatLoginStatus() { try { const response = await fetch("/api/wechat/login/status", { cache: "no-store", }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to fetch WeChat login status"); renderWechatLoginStatus(data); if (data.active || (data.completed && !data.persisted)) { wechatLoginPollTimer = setTimeout(pollWechatLoginStatus, 1500); } else { stopWechatLoginPolling(); } } catch (err) { showBanner("wechatLoginBanner", err.message, true); stopWechatLoginPolling(); } } async function startWechatLogin() { const button = document.getElementById("wechatLoginStartButton"); button.disabled = true; hideBanner("wechatLoginBanner"); try { const response = await fetch("/api/wechat/login/start", { method: "POST", cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ account_id: document.getElementById("wechat_account_id").value.trim(), force: true, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to start WeChat login"); renderWechatLoginStatus(data); stopWechatLoginPolling(); wechatLoginPollTimer = setTimeout(pollWechatLoginStatus, 1000); } catch (err) { showBanner("wechatLoginBanner", err.message, true); } finally { button.disabled = false; } } async function cancelWechatLogin() { try { const response = await fetch("/api/wechat/login/cancel", { method: "POST", cache: "no-store", }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to cancel WeChat login"); renderWechatLoginStatus({ status: currentLang === "zh-cn" ? "已取消" : "cancelled", message: data.message || (currentLang === "zh-cn" ? "已取消微信登录。" : "WeChat login cancelled."), qr_data_url: "", completed: false, persisted: false, }); stopWechatLoginPolling(); } catch (err) { showBanner("wechatLoginBanner", err.message, true); } } /* ═══════════════════════════════════════════════════ File Manager ═══════════════════════════════════════════════════ */ let currentPath = "/"; let lastFileEntries = []; let fileDevModeEnabled = false; const EDITABLE_FILE_EXTENSIONS = [".md", ".json", ".txt", ".jsonb", ".jsonc", ".lua"]; const MEMORY_FILES = [ { id: "memoryEditor", missingId: "memoryMissingState", path: "/memory/memory.md", editable: false, }, { id: "soulEditor", missingId: "soulMissingState", path: "/memory/soul.md", editable: true }, { id: "identityEditor", missingId: "identityMissingState", path: "/memory/identity.md", editable: true }, { id: "userEditor", missingId: "userMissingState", path: "/memory/user.md", editable: true }, ]; let memoryLoaded = false; let memoryContents = {}; let memoryFilePresence = {}; let activeEditorPath = ""; let activeEditorSource = ""; function isFileDevModeEnabled() { return fileDevModeEnabled; } function applyFileEditorAccessMode() { const textarea = document.getElementById("fileEditorTextarea"); const saveButton = document.getElementById("saveFileEditorButton"); if (!textarea || !saveButton) return; const previewOnly = activeEditorSource === "files" && !isFileDevModeEnabled(); textarea.readOnly = previewOnly; saveButton.classList.toggle("hidden", previewOnly); saveButton.disabled = previewOnly; } function updateFileDevModeUI() { const createRow = document.getElementById("fileCreateRow"); const uploadRow = document.getElementById("fileUploadRow"); const toggleButton = document.getElementById("devModeButton"); const enabled = isFileDevModeEnabled(); if (createRow) { createRow.classList.toggle("hidden", !enabled); } if (uploadRow) { uploadRow.classList.toggle("hidden", !enabled); } if (toggleButton) { toggleButton.classList.toggle("active", enabled); toggleButton.setAttribute("aria-pressed", enabled ? "true" : "false"); } applyFileEditorAccessMode(); renderFileRows(lastFileEntries); } function setFileDevMode(enabled) { fileDevModeEnabled = !!enabled; updateFileDevModeUI(); } function humanSize(value) { if (value < 1024) return value + " B"; if (value < 1024 * 1024) return (value / 1024).toFixed(1) + " KB"; return (value / (1024 * 1024)).toFixed(1) + " MB"; } function parentPath(path) { if (path === "/") return "/"; const parts = path.split("/").filter(Boolean); parts.pop(); return parts.length ? "/" + parts.join("/") : "/"; } function joinPath(base, name) { return base === "/" ? "/" + name : base + "/" + name; } function isEditableFile(path) { const lowerPath = (path || "").toLowerCase(); return EDITABLE_FILE_EXTENSIONS.some((ext) => lowerPath.endsWith(ext)); } function createActionSlot(node = null, placeholderText = "") { const slot = document.createElement("span"); slot.className = "action-slot"; if (node) { slot.appendChild(node); } else { slot.classList.add("placeholder"); slot.textContent = placeholderText; } return slot; } function renderFileRows(entries) { lastFileEntries = entries; const tbody = document.getElementById("fileTableBody"); tbody.innerHTML = ""; if (!entries.length) { const row = document.createElement("tr"); const td = document.createElement("td"); td.setAttribute("colspan", "4"); td.textContent = t("fileEmpty"); td.style.color = "var(--text-muted)"; row.appendChild(td); tbody.appendChild(row); return; } entries .sort( (a, b) => Number(b.is_dir) - Number(a.is_dir) || a.name.localeCompare(b.name) ) .forEach((entry) => { const row = document.createElement("tr"); const tdName = document.createElement("td"); tdName.textContent = entry.name; row.appendChild(tdName); const tdType = document.createElement("td"); tdType.textContent = entry.is_dir ? t("fileTypeFolder") : t("fileTypeFile"); row.appendChild(tdType); const tdSize = document.createElement("td"); tdSize.textContent = entry.is_dir ? "-" : humanSize(entry.size || 0); row.appendChild(tdSize); const tdActions = document.createElement("td"); tdActions.className = "actions"; let editControl = null; let middleControl = null; if (entry.is_dir) { const openBtn = document.createElement("button"); openBtn.className = "link-btn"; openBtn.textContent = t("fileOpen"); openBtn.onclick = () => { currentPath = entry.path; loadFiles().catch((err) => showBanner("fileBanner", err.message, true) ); }; middleControl = openBtn; } else { if (isEditableFile(entry.path)) { const editBtn = document.createElement("button"); editBtn.className = "link-btn"; editBtn.textContent = isFileDevModeEnabled() ? t("fileEdit") : t("filePreview"); editBtn.onclick = () => { openFileEditor(entry.path).catch((err) => showBanner("fileBanner", err.message || t("fileEditorLoadError"), true) ); }; editControl = editBtn; } const dlLink = document.createElement("a"); dlLink.href = "/files" + entry.path; dlLink.textContent = t("fileDownload"); dlLink.className = "link-btn"; dlLink.target = "_blank"; middleControl = dlLink; } const delBtn = document.createElement("button"); delBtn.className = "link-btn danger"; delBtn.textContent = t("fileDelete"); delBtn.disabled = !isFileDevModeEnabled(); delBtn.onclick = async () => { if (!isFileDevModeEnabled()) return; const msg = t("fileDeleteConfirm").replace("{path}", entry.path); if (!window.confirm(msg)) return; await deletePath(entry.path); }; const editPlaceholder = entry.is_dir ? t("fileTypeFolder") : t("fileEdit"); tdActions.appendChild(createActionSlot(editControl, editPlaceholder)); tdActions.appendChild(createActionSlot(middleControl)); tdActions.appendChild(createActionSlot(delBtn)); row.appendChild(tdActions); tbody.appendChild(row); }); } async function loadFiles() { hideBanner("fileBanner"); document.getElementById("currentPath").textContent = currentPath; const response = await fetch( "/api/files?path=" + encodeURIComponent(currentPath), { cache: "no-store" } ); if (!response.ok) throw new Error((await response.text()) || "Failed to load file list"); const data = await response.json(); currentPath = data.path || "/"; document.getElementById("currentPath").textContent = currentPath; renderFileRows(data.entries || []); } async function uploadFile() { if (!isFileDevModeEnabled()) { showBanner("fileBanner", t("fileDevModeRequired"), true); return; } const pathInput = document.getElementById("uploadPathInput"); const fileInput = document.getElementById("uploadFileInput"); const button = document.getElementById("uploadButton"); const file = fileInput.files[0]; const relativePath = pathInput.value.trim() || (file ? joinPath(currentPath, file.name) : ""); if (!file || !relativePath.startsWith("/")) { showBanner("fileBanner", t("fileSelectAndPath"), true); return; } button.disabled = true; hideBanner("fileBanner"); try { const response = await fetch( "/api/files/upload?path=" + encodeURIComponent(relativePath), { method: "POST", cache: "no-store", body: file } ); if (!response.ok) throw new Error(await response.text()); pathInput.value = ""; fileInput.value = ""; document.getElementById("selectedFileName").textContent = t("fileNoFileSelected"); showBanner("fileBanner", t("fileUploadComplete")); await loadFiles(); } catch (err) { showBanner("fileBanner", err.message || "Upload failed", true); } finally { button.disabled = false; } } async function createFolder() { if (!isFileDevModeEnabled()) { showBanner("fileBanner", t("fileDevModeRequired"), true); return; } const input = document.getElementById("newFolderInput"); const name = input.value.trim(); if (!name) { showBanner("fileBanner", t("fileFolderNameRequired"), true); return; } try { const response = await fetch("/api/files/mkdir", { method: "POST", cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: joinPath(currentPath, name) }), }); if (!response.ok) throw new Error(await response.text()); input.value = ""; showBanner("fileBanner", t("fileFolderCreated")); await loadFiles(); } catch (err) { showBanner("fileBanner", err.message || "Failed to create folder", true); } } async function deletePath(path) { if (!isFileDevModeEnabled()) { showBanner("fileBanner", t("fileDevModeRequired"), true); return; } try { const response = await fetch( "/api/files?path=" + encodeURIComponent(path), { method: "DELETE", cache: "no-store" } ); if (!response.ok) throw new Error(await response.text()); showBanner("fileBanner", t("fileDeleteComplete")); await loadFiles(); } catch (err) { showBanner("fileBanner", err.message || "Delete failed", true); } } /* ═══════════════════════════════════════════════════ File Editor ═══════════════════════════════════════════════════ */ function openFileEditorModal() { document.getElementById("fileEditorModal").classList.remove("hidden"); document.body.style.overflow = "hidden"; } function closeFileEditorModal() { document.getElementById("fileEditorModal").classList.add("hidden"); document.body.style.overflow = ""; const shouldRefreshMemory = activeEditorSource === "memory"; activeEditorPath = ""; activeEditorSource = ""; hideBanner("fileEditorBanner"); if (shouldRefreshMemory) { refreshMemory({ showSuccessBanner: false }).catch((err) => showBanner("memoryBanner", err.message || t("memoryLoadError"), true) ); } } async function loadFileIntoEditor(path, options = {}) { const { allowMissing = false } = options; const response = await fetch("/files" + path, { cache: "no-store" }); if (!response.ok) { if (allowMissing && response.status === 404) { document.getElementById("fileEditorPath").textContent = path; document.getElementById("fileEditorTextarea").value = ""; activeEditorPath = path; return; } throw new Error((await response.text()) || t("fileEditorLoadError")); } const content = await response.text(); document.getElementById("fileEditorPath").textContent = path; document.getElementById("fileEditorTextarea").value = content; activeEditorPath = path; } async function openFileEditor(path) { hideBanner("fileEditorBanner"); activeEditorSource = "files"; await loadFileIntoEditor(path); applyFileEditorAccessMode(); openFileEditorModal(); } async function openMemoryEditor(path) { hideBanner("fileEditorBanner"); activeEditorSource = "memory"; await loadFileIntoEditor(path, { allowMissing: true }); applyFileEditorAccessMode(); openFileEditorModal(); } async function refreshFileEditor() { const button = document.getElementById("refreshFileEditorButton"); if (!activeEditorPath) return; button.disabled = true; hideBanner("fileEditorBanner"); try { await loadFileIntoEditor(activeEditorPath, { allowMissing: activeEditorSource === "memory", }); } catch (err) { showBanner("fileEditorBanner", err.message || t("fileEditorLoadError"), true); } finally { button.disabled = false; } } async function saveFileEditor() { const button = document.getElementById("saveFileEditorButton"); const textarea = document.getElementById("fileEditorTextarea"); if (!activeEditorPath) return; if (activeEditorSource === "files" && !isFileDevModeEnabled()) { showBanner("fileEditorBanner", t("fileDevModeRequired"), true); return; } button.disabled = true; hideBanner("fileEditorBanner"); try { const response = await fetch( "/api/files/upload?path=" + encodeURIComponent(activeEditorPath), { method: "POST", cache: "no-store", body: new Blob([textarea.value], { type: "text/plain; charset=utf-8" }), } ); if (!response.ok) { throw new Error((await response.text()) || t("fileEditorSaveError")); } showBanner("fileEditorBanner", t("fileEditorSaved")); await loadFiles(); } catch (err) { showBanner("fileEditorBanner", err.message || t("fileEditorSaveError"), true); } finally { button.disabled = false; } } /* ═══════════════════════════════════════════════════ Memory Manager ═══════════════════════════════════════════════════ */ function renderMemoryState() { MEMORY_FILES.forEach((file) => { const editor = document.getElementById(file.id); const missing = file.missingId ? document.getElementById(file.missingId) : null; const exists = !!memoryFilePresence[file.path]; const editButton = document.querySelector(`.memory-edit-btn[data-path="${file.path}"]`); if (editor) { editor.value = memoryContents[file.path] || ""; editor.readOnly = true; editor.classList.toggle("hidden", !exists); } if (missing) { missing.classList.toggle("hidden", exists); } if (editButton) { const showEdit = !!file.editable && exists; editButton.classList.toggle("hidden", !showEdit); editButton.disabled = !showEdit; } }); } async function loadMemory() { hideBanner("memoryBanner"); const dirResponse = await fetch("/api/files?path=" + encodeURIComponent("/memory"), { cache: "no-store", }); if (!dirResponse.ok) { if (dirResponse.status === 404) { memoryContents = {}; memoryFilePresence = {}; MEMORY_FILES.forEach((file) => { memoryContents[file.path] = ""; memoryFilePresence[file.path] = false; }); memoryLoaded = true; renderMemoryState(); return; } throw new Error((await dirResponse.text()) || t("memoryLoadError")); } const dirData = await dirResponse.json(); const entries = dirData.entries || []; const entryNames = new Set(entries.map((entry) => entry.name)); memoryContents = {}; memoryFilePresence = {}; for (const file of MEMORY_FILES) { const fileName = file.path.split("/").pop(); const exists = entryNames.has(fileName); memoryFilePresence[file.path] = exists; if (!exists) { memoryContents[file.path] = ""; continue; } const response = await fetch("/files" + file.path, { cache: "no-store", }); if (!response.ok) { throw new Error((await response.text()) || t("memoryLoadError")); } memoryContents[file.path] = await response.text(); } memoryLoaded = true; renderMemoryState(); } async function refreshMemory(options = {}) { const { showSuccessBanner = true } = options; const button = document.getElementById("refreshMemoryButton"); button.disabled = true; hideBanner("memoryBanner"); try { await loadMemory(); if (showSuccessBanner) { showBanner("memoryBanner", t("memoryRefreshed")); } } catch (err) { showBanner("memoryBanner", err.message || t("memoryLoadError"), true); throw err; } finally { button.disabled = false; } } async function refreshMemoryFile(path) { hideBanner("memoryBanner"); const file = MEMORY_FILES.find((item) => item.path === path); if (!file) return; try { const response = await fetch("/files" + file.path, { cache: "no-store" }); if (response.status === 404) { memoryFilePresence[file.path] = false; memoryContents[file.path] = ""; } else if (!response.ok) { throw new Error((await response.text()) || t("memoryLoadError")); } else { memoryFilePresence[file.path] = true; memoryContents[file.path] = await response.text(); } renderMemoryState(); } catch (err) { showBanner("memoryBanner", err.message || t("memoryLoadError"), true); } } /* ═══════════════════════════════════════════════════ Event binding ═══════════════════════════════════════════════════ */ function bindEvents() { initTabs(); document .getElementById("capabilitySelectAllButton") .addEventListener("click", () => setAllCapabilityCheckboxes(true)); document .getElementById("capabilityClearAllButton") .addEventListener("click", () => setAllCapabilityCheckboxes(false)); document .getElementById("capabilityLlmSelectDefaultsButton") .addEventListener("click", () => setLlmVisibleSelection("defaults")); document .getElementById("capabilityLlmClearAllButton") .addEventListener("click", () => setLlmVisibleSelection("clear")); document .getElementById("luaModuleSelectAllButton") .addEventListener("click", () => setAllLuaModuleCheckboxes(true)); document .getElementById("luaModuleClearAllButton") .addEventListener("click", () => setAllLuaModuleCheckboxes(false)); document.getElementById("saveConfigButton").addEventListener("click", saveConfig); document.getElementById("llm_provider_preset").addEventListener("change", (e) => { applyProviderPreset(e.target.value); }); ["llm_backend_type", "llm_profile", "llm_base_url", "llm_auth_type"].forEach( (field) => { const input = document.getElementById(field); if (input) input.addEventListener("input", syncProviderPreset); } ); document .getElementById("wechatLoginStartButton") .addEventListener("click", startWechatLogin); document .getElementById("wechatLoginCancelButton") .addEventListener("click", cancelWechatLogin); document.getElementById("refreshFilesButton").addEventListener("click", () => loadFiles().catch((err) => showBanner("fileBanner", err.message, true)) ); document.getElementById("devModeButton").addEventListener("click", () => { const nextEnabled = !isFileDevModeEnabled(); if (nextEnabled && !window.confirm(t("fileDevModeConfirm"))) { return; } setFileDevMode(nextEnabled); }); document.getElementById("upDirButton").addEventListener("click", () => { currentPath = parentPath(currentPath); loadFiles().catch((err) => showBanner("fileBanner", err.message, true)); }); document.getElementById("uploadButton").addEventListener("click", uploadFile); document.getElementById("refreshMemoryButton").addEventListener("click", refreshMemory); document.querySelectorAll(".memory-refresh-btn").forEach((button) => { button.addEventListener("click", () => refreshMemoryFile(button.dataset.path)); }); document.querySelectorAll(".memory-edit-btn").forEach((button) => { button.addEventListener("click", () => { openMemoryEditor(button.dataset.path).catch((err) => showBanner("memoryBanner", err.message || t("fileEditorLoadError"), true) ); }); }); document .getElementById("closeFileEditorButton") .addEventListener("click", closeFileEditorModal); document .getElementById("refreshFileEditorButton") .addEventListener("click", refreshFileEditor); document .getElementById("saveFileEditorButton") .addEventListener("click", saveFileEditor); document.getElementById("fileEditorModal").addEventListener("click", (e) => { if (e.target.id === "fileEditorModal") { closeFileEditorModal(); } }); document.getElementById("chooseFileButton").addEventListener("click", () => { document.getElementById("uploadFileInput").click(); }); document.getElementById("createFolderButton").addEventListener("click", createFolder); document.getElementById("uploadFileInput").addEventListener("change", (e) => { const file = e.target.files[0]; const nameSpan = document.getElementById("selectedFileName"); if (file) { document.getElementById("uploadPathInput").value = joinPath( currentPath, file.name ); nameSpan.textContent = file.name; } else { nameSpan.textContent = t("fileNoFileSelected"); } }); } /* ═══════════════════════════════════════════════════ Bootstrap ═══════════════════════════════════════════════════ */ async function bootstrap() { fileDevModeEnabled = false; capabilityItems = []; pendingEnabledCapGroups = ""; pendingLlmVisibleCapGroups = ""; luaModuleItems = []; pendingEnabledLuaModules = ""; currentLang = detectLang(); applyI18n(); buildLocaleMenu(); bindEvents(); updateFileDevModeUI(); try { await loadStatus(); } catch (err) { showBanner("configBanner", err.message || "Failed to load device status", true); } try { await loadCapabilities(); } catch (err) { showBanner("configBanner", err.message || t("capabilityLoadError"), true); } try { await loadLuaModules(); } catch (err) { showBanner("configBanner", err.message || t("luaModuleLoadError"), true); } try { await loadConfig(); } catch (err) { showBanner("configBanner", err.message || "Failed to load settings", true); } try { await pollWechatLoginStatus(); } catch (err) { showBanner( "wechatLoginBanner", err.message || "Failed to load WeChat login status", true ); } try { await loadFiles(); } catch (err) { showBanner("fileBanner", err.message || "Failed to load file list", true); } if (document.querySelector('.tab-btn[data-tab="memory"]')?.classList.contains("active")) { try { await loadMemory(); } catch (err) { showBanner("memoryBanner", err.message || t("memoryLoadError"), true); } } else { renderMemoryState(); } } bootstrap().catch((err) => { showBanner("configBanner", err.message || "Failed to initialize the page", true); });