Files

1894 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═══════════════════════════════════════════════════
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 groupLua 模块配置才会生效。",
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);
});