@memtensor/memos-cloud-openclaw-plugin 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1454 +1,1454 @@
1
- const APP = {
2
- token: __CONFIG_UI_TOKEN__,
3
- fieldGroups: __FIELD_GROUPS__,
4
- fieldDefinitions: __FIELD_DEFINITIONS__,
5
- };
6
-
7
- const knownKeys = new Set(APP.fieldDefinitions.map((field) => field.key));
8
- let remoteState = null;
9
- let updateStatus = null;
10
- let updateStatusChecked = false;
11
- let updateStatusLoading = false;
12
- let draft = null;
13
- let baselineSnapshot = "";
14
-
15
-
16
-
17
- let externalRefreshQueued = false;
18
- let heartbeatBootId = "";
19
- let heartbeatAssetRevision = "";
20
- let heartbeatReloadQueued = false;
21
- let authRecoveryInProgress = false;
22
- let authRecoveryReloadQueued = false;
23
- let authRecoveryRetryAt = 0;
24
- let activeSectionId = "";
25
- let activeSectionObserver = null;
26
- let navCollapsed = false;
27
- const navCollapseMedia = window.matchMedia("(max-width: 720px)");
28
-
29
- const elements = {
30
- eyebrowText: document.getElementById("eyebrowText"),
31
- heroTitle: document.getElementById("heroTitle"),
32
- heroSubtitle: document.getElementById("heroSubtitle"),
33
- activeConfigTitle: document.getElementById("activeConfigTitle"),
34
- activeConfigText: document.getElementById("activeConfigText"),
35
- editingModelTitle: document.getElementById("editingModelTitle"),
36
- editingModelText: document.getElementById("editingModelText"),
37
- langLabel: document.getElementById("langLabel"),
38
- languageDropdown: document.getElementById("languageDropdown"),
39
- languageSelectButton: document.getElementById("languageSelectButton"),
40
- languageSelectText: document.getElementById("languageSelectText"),
41
- languageMenu: document.getElementById("languageMenu"),
42
- languageOptionAuto: document.getElementById("languageOptionAuto"),
43
- languageOptionEn: document.getElementById("languageOptionEn"),
44
- languageOptionZh: document.getElementById("languageOptionZh"),
45
- statusRow: document.getElementById("statusRow"),
46
- metaRow: document.getElementById("metaRow"),
47
- updateCheckButton: document.getElementById("updateCheckButton") || document.createElement("button"),
48
- updateNotice: document.getElementById("updateNotice"),
49
- banner: document.getElementById("banner"),
50
- floatingTools: document.getElementById("floatingTools"),
51
- floatingNavLabel: document.getElementById("floatingNavLabel"),
52
- floatingNav: document.getElementById("floatingNav"),
53
- floatingToggleButton: document.getElementById("floatingToggleButton"),
54
- backTopButton: document.getElementById("backTopButton"),
55
- pathBox: document.getElementById("pathBox"),
56
- copyPathButton: document.getElementById("copyPathButton"),
57
- saveButton: document.getElementById("saveButton"),
58
-
59
- reloadButton: document.getElementById("reloadButton"),
60
- layout: document.getElementById("layout"),
61
- overlay: document.getElementById("overlay"),
62
- overlayTitle: document.getElementById("overlayTitle"),
63
- overlayText: document.getElementById("overlayText"),
64
- overlayRefreshButton: document.getElementById("overlayRefreshButton"),
65
- };
66
-
67
- const languagePreferenceKey = "memos-config-ui-language";
68
- let languagePreference = localStorage.getItem(languagePreferenceKey) || "auto";
69
- let languageMenuOpen = false;
70
-
71
- const UI_TEXT = {
72
- en: {
73
- langLabel: "Language",
74
- auto: "Auto",
75
- floatingNavLabel: "Navigate",
76
- collapseNav: "Collapse Navigation",
77
- expandNav: "Expand Navigation",
78
- backToTop: "Back To Top",
79
- eyebrow: "MemOS Cloud OpenClaw Plugin",
80
- heroTitle: "Config",
81
- heroSubtitle:
82
- "This page reads and writes plugins.entries.memos-cloud-openclaw-plugin.config from the active gateway config file and keeps the form synced with on-disk changes.",
83
- activeConfigTitle: "Active Config File",
84
- activeConfigText: "The page automatically follows the current runtime profile and local config path.",
85
- editingModelTitle: "Editing Model",
86
- editingModelText:
87
- "Known plugin fields are rendered as typed controls. Unknown keys stay in the extra JSON section so future custom fields are not lost.",
88
- save: "Save Config",
89
- restart: "Restart Gateway",
90
- reload: "Reload From Disk",
91
- refreshPage: "Refresh Page",
92
- copyPath: "Copy Config Path",
93
- overlayTitle: "Restarting Gateway",
94
- overlayText: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
95
- pluginEnabled: "Plugin Enabled",
96
- enabledDesc:
97
- "Saving with this switch off keeps the entry in the config but disables the plugin. The page will disappear after the gateway restarts because the plugin will stop loading.",
98
- extraTitle: "Extra JSON Fields",
99
- extraDesc: "Any unknown plugin config keys stay here so future custom fields are preserved instead of being dropped.",
100
- extraHelper: "Use plain JSON. Leave empty if you do not need extra keys.",
101
- clear: "Clear",
102
- reset: "Reset",
103
- show: "Show",
104
- hide: "Hide",
105
- enabled: "Enabled",
106
- disabled: "Disabled",
107
- env: "Env",
108
- inherit: "Default",
109
- custom: "Custom",
110
- on: "On",
111
- off: "Off",
112
- empty: "empty",
113
- helperBoolean: "Default means the plugin falls back to env files or runtime defaults.",
114
- helperJson: "Only JSON objects are accepted here.",
115
- helperArray: "One value per line. Empty lines are ignored.",
116
- helperDefault: "Leave this as default to remove the key from plugin config.",
117
- helperEnvValue: "Using env value: ",
118
- helperProjectDefault: "Using project default: ",
119
- helperEmptyValue: "No config value is set for this field.",
120
- errorInteger: "Enter a valid integer.",
121
- errorNumber: "Enter a valid number.",
122
- errorJsonObject: "JSON must be an object.",
123
- errorJsonInvalid: "Invalid JSON.",
124
- bannerExternal: "A newer on-disk config is available. Reload from disk to sync before saving.",
125
- bannerErrors: "Please fix the highlighted field errors before saving.",
126
- bannerWaiting: "Config saved! Please restart the gateway manually to apply changes.",
127
- bannerDirty: "You have unsaved changes.",
128
- bannerInclude: "An $include directive was detected. This page writes overrides to the main config file.",
129
- bannerSynced: "The page synced a newer on-disk config.",
130
- bannerRestarted: "Gateway restart completed and the page is live again.",
131
- bannerHeartbeatRecovered: "Gateway connection restored. Reloading the config page...",
132
- bannerAuthRecovered: "Config session expired, but the gateway is healthy. Reloading this page...",
133
- bannerAuthWaiting: "Config session expired. Waiting for the gateway to finish restarting before retrying...",
134
- bannerAuthFailed: "Config session expired. Refresh this page to reconnect.",
135
- bannerCopied: "The config file path was copied to your clipboard.",
136
- bannerUpdateCommandCopied: "The update command was copied to your clipboard.",
137
- bannerClipboardFailed: "Clipboard access failed. Copy the address from your browser bar instead.",
138
- bannerSaved: "The plugin config was saved.",
139
- bannerRestartLaunched: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
140
- bannerWaitingStop: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
141
- bannerWaitingBack: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
142
- bannerRestartRefreshHint: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
143
- pillPlugin: "Plugin",
144
- pillVersion: "Version",
145
- pillRuntime: "Runtime",
146
- pillEntry: "Entry",
147
- pillPageUrl: "Page URL",
148
- pillRevision: "Config revision",
149
- pillConfigFile: "Config file",
150
- pillInclude: "Include",
151
- checkUpdates: "Check for updates",
152
- checkingUpdates: "Checking...",
153
- updateTitle: "Plugin update available",
154
- updateBody: "Current version {current}; latest version {latest}. Update and restart with:",
155
- updateCopyCommand: "Copy Update And Restart Command",
156
- upToDateTitle: "Already up to date",
157
- upToDateBody: "Current version {current} is the latest version.",
158
- updateCheckFailed: "Version check failed: {error}",
159
- pillEntryPresent: "present",
160
- pillEntryMissing: "missing",
161
- pillConfigFound: "found",
162
- pillConfigCreate: "will be created",
163
- pillIncludeValue: "Found $include; this page writes to the main file only",
164
- },
165
- zh: {
166
- langLabel: "语言",
167
- auto: "跟随浏览器",
168
- floatingNavLabel: "区域导航",
169
- collapseNav: "收起导航",
170
- expandNav: "展开导航",
171
- backToTop: "返回顶部",
172
- eyebrow: "MemOS Cloud OpenClaw Plugin",
173
- heroTitle: "配置页",
174
- heroSubtitle:
175
- "这个页面会直接读取并写回当前 gateway 配置文件中的 plugins.entries.memos-cloud-openclaw-plugin.config,并自动同步磁盘变更。",
176
- activeConfigTitle: "当前配置文件",
177
- activeConfigText: "页面会自动跟随当前宿主运行时,读取对应的本地配置路径。",
178
- editingModelTitle: "编辑方式",
179
- editingModelText: "已知字段会以结构化表单展示;未知字段会保留在额外 JSON 区域,避免未来自定义配置被覆盖丢失。",
180
- save: "保存配置",
181
- restart: "重启 Gateway",
182
- reload: "从磁盘重新加载",
183
- refreshPage: "刷新页面",
184
- copyPath: "复制配置文件路径",
185
- overlayTitle: "正在重启 Gateway",
186
- overlayText: "已请求重启。如果页面没有自动恢复,请过几秒点击刷新重试。",
187
- pluginEnabled: "插件启用状态",
188
- enabledDesc: "关闭后仍会保留插件配置项,但插件会在重启后停用,页面也会随之消失。",
189
- extraTitle: "额外 JSON 字段",
190
- extraDesc: "这里会保留当前 schema 之外的自定义键,避免未来字段在保存时丢失。",
191
- extraHelper: "这里只接受 JSON 对象;如果不需要额外字段可以留空。",
192
- clear: "清空",
193
- reset: "重置",
194
- show: "显示",
195
- hide: "隐藏",
196
- enabled: "启用",
197
- disabled: "停用",
198
- env: "环境变量",
199
- inherit: "默认",
200
- custom: "自定义",
201
- on: "开",
202
- off: "关",
203
- empty: "空",
204
- helperBoolean: "选择默认时,会回退到环境变量或运行时默认值。",
205
- helperJson: "这里只接受 JSON 对象。",
206
- helperArray: "每行一个值,空行会自动忽略。",
207
- helperDefault: "选择默认后会从插件配置里移除这个字段。",
208
- helperEnvValue: "当前使用环境变量值:",
209
- helperProjectDefault: "当前使用项目默认值:",
210
- helperEmptyValue: "这个字段当前没有配置值。",
211
- errorInteger: "请输入有效的整数。",
212
- errorNumber: "请输入有效的数字。",
213
- errorJsonObject: "这里必须填写 JSON 对象。",
214
- errorJsonInvalid: "JSON 格式不正确。",
215
- bannerExternal: "磁盘上的配置已经更新,请先重新加载再决定是否保存。",
216
- bannerErrors: "请先修正高亮字段的错误,再执行保存。",
217
- bannerWaiting: "配置已保存,请手动重启 gateway 以使配置生效。",
218
- bannerDirty: "你有尚未保存的修改。",
219
- bannerInclude: "检测到 $include 指令,本页面会把覆盖项写入主配置文件。",
220
- bannerSynced: "页面已经同步到更新后的磁盘配置。",
221
- bannerRestarted: "Gateway 已重新启动,配置页面也恢复可用。",
222
- bannerHeartbeatRecovered: "Gateway 连接已恢复,正在重新加载配置页面...",
223
- bannerAuthRecovered: "配置页会话已过期,但 Gateway 仍然在线,正在自动刷新页面...",
224
- bannerAuthWaiting: "配置页会话已过期,正在等待 Gateway 完成重启后再恢复...",
225
- bannerAuthFailed: "配置页会话已过期,请刷新页面后重新连接。",
226
- bannerCopied: "配置文件路径已复制到剪贴板。",
227
- bannerUpdateCommandCopied: "更新命令已复制到剪贴板。",
228
- bannerClipboardFailed: "复制失败,请直接从浏览器地址栏复制。",
229
- bannerSaved: "插件配置已保存。",
230
- bannerRestartLaunched: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
231
- bannerWaitingStop: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
232
- bannerWaitingBack: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
233
- bannerRestartRefreshHint: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
234
- pillPlugin: "插件",
235
- pillVersion: "版本",
236
- pillRuntime: "运行时",
237
- pillEntry: "配置项",
238
- pillPageUrl: "页面地址",
239
- pillRevision: "配置标识",
240
- pillConfigFile: "配置文件",
241
- pillInclude: "包含",
242
- checkUpdates: "检查更新",
243
- checkingUpdates: "检查中...",
244
- updateTitle: "插件有新版本",
245
- updateBody: "当前版本 {current};最新版本 {latest}。可使用以下命令更新并重启:",
246
- updateCopyCommand: "复制更新并重启命令",
247
- upToDateTitle: "已是最新版本",
248
- upToDateBody: "当前版本 {current} 已经是最新版本。",
249
- updateCheckFailed: "版本检查失败:{error}",
250
- pillEntryPresent: "已存在",
251
- pillEntryMissing: "不存在",
252
- pillConfigFound: "已找到",
253
- pillConfigCreate: "将自动创建",
254
- pillIncludeValue: "检测到 $include;本页面只会写入主配置文件",
255
- },
256
- };
257
-
258
- const GROUP_TRANSLATIONS = {
259
- zh: {
260
- connection: { title: "连接与鉴权", description: "MemOS 地址、鉴权和身份映射相关配置。" },
261
- session: { title: "会话与召回", description: "会话 ID 策略、召回范围以及上下文注入行为。" },
262
- capture: { title: "写回与存储", description: "控制每轮结束后写回到 MemOS 的内容。" },
263
- agent: { title: "Agent 隔离", description: "多 Agent 隔离、App 元数据与共享权限。" },
264
- filter: { title: "召回过滤器", description: "在记忆注入前,使用模型做二次筛选。" },
265
- advanced: { title: "高级设置", description: "超时、重试、节流和其他底层控制项。" },
266
- },
267
- };
268
-
269
- const FIELD_TRANSLATIONS = {
270
- zh: {
271
- baseUrl: { label: "MemOS 地址", description: "MemOS OpenMem API 的基础地址。" },
272
- apiKey: { label: "MemOS API Key", description: "Token 鉴权密钥。未在此处填写时,将从 .env 文件中读取。" },
273
- userId: { label: "用户 ID", description: "消息添加与记忆查询关联的用户唯一标识符。" },
274
- useDirectSessionUserId: { label: "使用 Direct 会话用户 ID", description: "可用时从 session key 的 direct 段提取用户 ID,覆盖默认 userId。" },
275
- conversationId: { label: "会话 ID 覆盖", description: "消息添加与记忆查询关联的会话唯一标识符;同一 ID 会被视为同一上下文。" },
276
- conversationIdPrefix: { label: "会话前缀", description: "追加到自动生成 conversation_id 前面的文本。" },
277
- conversationIdSuffix: { label: "会话后缀", description: "追加到自动生成 conversation_id 后面的文本。" },
278
- conversationSuffixMode: { label: "后缀模式", description: "决定 /new 是否递增数字后缀。" },
279
- resetOnNew: { label: "/new 时重置", description: "在使用 counter 模式时需要 hooks.internal.enabled。" },
280
- queryPrefix: { label: "查询前缀", description: "附加在 query 前面的文本,用于补充检索上下文。" },
281
- maxQueryChars: { label: "查询最大长度", description: "限制单次查询文本长度,避免 query 过长。" },
282
- recallEnabled: { label: "启用召回", description: "控制 before_agent_start 阶段是否执行记忆召回。" },
283
- recallGlobal: { label: "全局召回", description: "开启后查询不传 conversation_id,不再强调当前会话权重。" },
284
- maxItemChars: { label: "注入项最大长度", description: "限制单条召回记忆注入上下文时保留的字符数。" },
285
- memoryLimitNumber: { label: "记忆数量上限", description: "召回事实记忆的最大条数;默认 9,最大 25。" },
286
- preferenceLimitNumber: { label: "偏好数量上限", description: "召回偏好记忆的最大条数;默认 9,最大 25。" },
287
- includePreference: { label: "包含偏好", description: "是否启用偏好记忆召回。" },
288
- includeToolMemory: { label: "包含工具记忆", description: "是否启用工具记忆召回。" },
289
- toolMemoryLimitNumber: { label: "工具记忆上限", description: "工具记忆返回条数上限,仅在启用工具记忆时生效;默认 6,最大 25。" },
290
- relativity: { label: "相关度阈值", description: "召回相关性阈值,范围 0 到 1;为 0 时不做相关性过滤。" },
291
- filter: { label: "搜索过滤器(JSON)", description: "检索前的过滤条件,支持 agent_id、app_id、时间字段和 info 字段,以及 and/or/gte/lte/gt/lt。" },
292
- knowledgebaseIds: { label: "知识库 ID", description: "限制本次可检索的知识库范围;每行一个 ID,也可填写 all。" },
293
- addEnabled: { label: "启用写回", description: "控制 agent_end 阶段是否添加消息并写入记忆。" },
294
- captureStrategy: { label: "捕获策略", description: "决定写入最后一轮消息,还是写入整段会话消息数组。" },
295
- maxMessageChars: { label: "消息最大长度", description: "限制每条写入消息保留的字符数,用于控制 messages 内容大小。" },
296
- includeAssistant: { label: "包含助手回复", description: "是否把 assistant 回复也写入 messages 数组。" },
297
- tags: { label: "标签", description: "自定义标签列表,用于标记消息主题或分类;每行一个。" },
298
- info: { label: "附加信息(JSON)", description: "自定义结构化元信息,用于记录来源、版本、位置等,并支持后续精确过滤。" },
299
- asyncMode: { label: "异步模式", description: "是否异步添加记忆;开启后会在后台写入,减少调用链阻塞。" },
300
- agentId: { label: "固定 Agent ID", description: "消息或检索关联的 Agent 唯一标识符,用于区分某用户与该 Agent 的专属记忆。" },
301
- multiAgentMode: { label: "多 Agent 模式", description: "按 ctx.agentId 隔离召回与写回数据。" },
302
- allowedAgents: { label: "允许的 Agent 列表", description: "仅允许列表内 agent 执行召回与写回;留空表示允许全部 agent。" },
303
- agentOverrides: { label: "Agent 覆盖配置(JSON)", description: "按 agent 维度覆盖配置。键为 agent id,值为可覆盖字段对象。" },
304
- appId: { label: "App ID", description: "消息或检索关联的应用唯一标识符,用于区分某用户在该 App 下的专属记忆。" },
305
- allowPublic: { label: "允许公开", description: "是否允许把生成的记忆写入公共记忆库;开启后项目中的其他用户也可能检索到。" },
306
- allowKnowledgebaseIds: { label: "允许写入的知识库 ID", description: "消息生成的记忆允许写入的知识库范围;每行一个 ID。" },
307
- recallFilterEnabled: { label: "启用召回过滤器", description: "召回结果在注入前先经过模型二次筛选。" },
308
- recallFilterBaseUrl: { label: "过滤器地址", description: "用于召回过滤的 OpenAI 兼容接口地址。" },
309
- recallFilterApiKey: { label: "过滤器 API Key", description: "召回过滤模型接口所需的 Bearer Token。" },
310
- recallFilterModel: { label: "过滤模型", description: "召回过滤阶段使用的模型名称。" },
311
- recallFilterTimeoutMs: { label: "过滤超时(毫秒)", description: "召回过滤模型请求的超时时间。" },
312
- recallFilterRetries: { label: "过滤重试次数", description: "召回过滤请求失败后的重试次数。" },
313
- recallFilterCandidateLimit: { label: "候选数量上限", description: "每类候选项在过滤前的最大数量。" },
314
- recallFilterMaxItemChars: { label: "过滤项最大长度", description: "送入过滤模型前,单条候选项允许保留的字符数。" },
315
- recallFilterFailOpen: { label: "失败时放行", description: "过滤器失败时回退为不过滤,直接使用原始召回结果。" },
316
- timeoutMs: { label: "MemOS 超时(毫秒)", description: "调用 MemOS API 时使用的超时时间。" },
317
- retries: { label: "MemOS 重试次数", description: "调用 MemOS API 失败时的重试次数。" },
318
- throttleMs: { label: "节流时间(毫秒)", description: "两次写回间隔过短时跳过 add/message。" },
319
- },
320
- };
321
-
322
- function getCurrentLanguage() {
323
- if (languagePreference === "zh" || languagePreference === "en") {
324
- return languagePreference;
325
- }
326
- const browserLanguage = String(navigator.language || "").toLowerCase();
327
- return browserLanguage.startsWith("zh") ? "zh" : "en";
328
- }
329
-
330
- function uiText(key) {
331
- const lang = getCurrentLanguage();
332
- return UI_TEXT[lang][key] ?? UI_TEXT.en[key] ?? key;
333
- }
334
-
335
- function formatUiText(key, values) {
336
- let text = uiText(key);
337
- for (const [name, value] of Object.entries(values || {})) {
338
- text = text.split(`{${name}}`).join(String(value ?? ""));
339
- }
340
- return text;
341
- }
342
-
343
- function localizedGroup(group) {
344
- const lang = getCurrentLanguage();
345
- const translated = GROUP_TRANSLATIONS[lang]?.[group.id];
346
- return translated ? { ...group, ...translated } : group;
347
- }
348
-
349
- function localizedField(definition) {
350
- const lang = getCurrentLanguage();
351
- const translated = FIELD_TRANSLATIONS[lang]?.[definition.key];
352
- return translated ? { ...definition, ...translated } : definition;
353
- }
354
-
355
- function applyLanguageUi() {
356
- document.documentElement.lang = getCurrentLanguage();
357
- // document.title = uiText("heroTitle");
358
- elements.eyebrowText.textContent = uiText("eyebrow");
359
- elements.heroTitle.textContent = uiText("heroTitle");
360
- elements.heroSubtitle.innerHTML = escapeHtml(uiText("heroSubtitle")).replace(
361
- "plugins.entries.memos-cloud-openclaw-plugin.config",
362
- "<code>plugins.entries.memos-cloud-openclaw-plugin.config</code>",
363
- );
364
- elements.activeConfigTitle.textContent = uiText("activeConfigTitle");
365
- elements.activeConfigText.textContent = uiText("activeConfigText");
366
- elements.editingModelTitle.textContent = uiText("editingModelTitle");
367
- elements.editingModelText.textContent = uiText("editingModelText");
368
- elements.langLabel.textContent = uiText("langLabel");
369
- elements.floatingNavLabel.textContent = uiText("floatingNavLabel");
370
- updateFloatingNavToggleUi();
371
- elements.backTopButton.setAttribute("aria-label", uiText("backToTop"));
372
- elements.backTopButton.setAttribute("title", uiText("backToTop"));
373
- elements.copyPathButton.setAttribute("aria-label", uiText("copyPath"));
374
- elements.copyPathButton.setAttribute("title", uiText("copyPath"));
375
- elements.saveButton.textContent = uiText("save");
376
- elements.updateCheckButton.id = "updateCheckButton";
377
- elements.updateCheckButton.type = "button";
378
- elements.updateCheckButton.className = "pill update-check-button";
379
- updateCheckButtonUi();
380
-
381
- elements.reloadButton.textContent = uiText("reload");
382
- elements.overlayTitle.textContent = uiText("overlayTitle");
383
- elements.overlayText.textContent = uiText("overlayText");
384
- elements.overlayRefreshButton.setAttribute("aria-label", uiText("refreshPage"));
385
- elements.overlayRefreshButton.setAttribute("title", uiText("refreshPage"));
386
- elements.languageOptionAuto.textContent = uiText("auto");
387
- elements.languageOptionEn.textContent = "EN";
388
- elements.languageOptionZh.textContent = "中文";
389
- updateLanguageDropdownUi();
390
- renderUpdateNotice();
391
- }
392
-
393
- function updateCheckButtonUi() {
394
- if (!elements.updateCheckButton) return;
395
- const version = remoteState?.pluginVersion || "unknown";
396
- const action = updateStatusLoading ? uiText("checkingUpdates") : uiText("checkUpdates");
397
- elements.updateCheckButton.innerHTML =
398
- "<strong>" +
399
- escapeHtml(uiText("pillVersion")) +
400
- "</strong><span>" +
401
- escapeHtml(version + " · " + action) +
402
- "</span>";
403
- elements.updateCheckButton.setAttribute("aria-label", uiText("pillVersion") + " " + version + ", " + action);
404
- elements.updateCheckButton.setAttribute("title", action);
405
- elements.updateCheckButton.disabled = updateStatusLoading;
406
- }
407
-
408
- function updateFloatingNavToggleUi() {
409
- elements.floatingToggleButton.setAttribute("aria-expanded", navCollapsed ? "false" : "true");
410
- elements.floatingToggleButton.setAttribute("aria-label", navCollapsed ? uiText("expandNav") : uiText("collapseNav"));
411
- elements.floatingToggleButton.setAttribute("title", navCollapsed ? uiText("expandNav") : uiText("collapseNav"));
412
- }
413
-
414
- function setNavCollapsed(nextCollapsed) {
415
- navCollapsed = Boolean(nextCollapsed);
416
- document.body.classList.toggle("nav-collapsed", navCollapsed);
417
- updateFloatingNavToggleUi();
418
- }
419
-
420
- function syncNavCollapseForViewport() {
421
- setNavCollapsed(navCollapseMedia.matches);
422
- }
423
-
424
- function setLanguagePreference(nextLanguage) {
425
- languagePreference = nextLanguage;
426
- localStorage.setItem(languagePreferenceKey, nextLanguage);
427
- applyLanguageUi();
428
- if (draft) {
429
- renderForm();
430
- }
431
- }
432
-
433
- function getLanguageOptionLabel(value) {
434
- if (value === "en") return "EN";
435
- if (value === "zh") return "中文";
436
- return uiText("auto");
437
- }
438
-
439
- function setLanguageMenuOpen(nextOpen) {
440
- languageMenuOpen = nextOpen;
441
- elements.languageDropdown.classList.toggle("open", nextOpen);
442
- elements.languageSelectButton.setAttribute("aria-expanded", nextOpen ? "true" : "false");
443
- }
444
-
445
- function updateLanguageDropdownUi() {
446
- const currentValue = languagePreference;
447
- elements.languageSelectText.textContent = getLanguageOptionLabel(currentValue);
448
-
449
- const options = [
450
- { element: elements.languageOptionAuto, value: "auto" },
451
- { element: elements.languageOptionEn, value: "en" },
452
- { element: elements.languageOptionZh, value: "zh" },
453
- ];
454
-
455
- for (const option of options) {
456
- const active = option.value === currentValue;
457
- option.element.classList.toggle("active", active);
458
- option.element.setAttribute("aria-selected", active ? "true" : "false");
459
- }
460
- }
461
-
462
- function renderFloatingNav(groups) {
463
- elements.floatingNav.innerHTML = "";
464
- for (const group of groups) {
465
- const button = document.createElement("button");
466
- button.type = "button";
467
- button.className = "floating-link" + (activeSectionId === group.id ? " active" : "");
468
- button.textContent = group.title;
469
- button.addEventListener("click", () => {
470
- activeSectionId = group.id;
471
- renderFloatingNav(groups);
472
- document.getElementById(`section-${group.id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
473
- });
474
- elements.floatingNav.appendChild(button);
475
- }
476
- }
477
-
478
- function updateFloatingVisibility() {
479
- const visible = window.scrollY > 240;
480
- elements.backTopButton.classList.toggle("is-visible", visible);
481
- }
482
-
483
- function installSectionObserver(groups) {
484
- if (activeSectionObserver) {
485
- activeSectionObserver.disconnect();
486
- activeSectionObserver = null;
487
- }
488
-
489
- const visibleSections = new Map();
490
- activeSectionObserver = new IntersectionObserver(
491
- (entries) => {
492
- for (const entry of entries) {
493
- const sectionId = entry.target.dataset.sectionId;
494
- if (!sectionId) continue;
495
- if (entry.isIntersecting) {
496
- visibleSections.set(sectionId, entry.intersectionRatio);
497
- } else {
498
- visibleSections.delete(sectionId);
499
- }
500
- }
501
-
502
- if (!visibleSections.size) return;
503
-
504
- let nextActive = activeSectionId;
505
- let maxRatio = -1;
506
- for (const group of groups) {
507
- const ratio = visibleSections.get(group.id);
508
- if (ratio !== undefined && ratio > maxRatio) {
509
- maxRatio = ratio;
510
- nextActive = group.id;
511
- }
512
- }
513
-
514
- if (nextActive && nextActive !== activeSectionId) {
515
- activeSectionId = nextActive;
516
- renderFloatingNav(groups);
517
- }
518
- },
519
- {
520
- rootMargin: "-12% 0px -55% 0px",
521
- threshold: [0.15, 0.3, 0.45, 0.6],
522
- },
523
- );
524
-
525
- for (const group of groups) {
526
- const section = document.getElementById(`section-${group.id}`);
527
- if (section) {
528
- activeSectionObserver.observe(section);
529
- }
530
- }
531
- }
532
-
533
- function splitConfig(config) {
534
- const known = {};
535
- const extra = {};
536
- for (const [key, value] of Object.entries(config || {})) {
537
- if (knownKeys.has(key)) {
538
- known[key] = value;
539
- } else {
540
- extra[key] = value;
541
- }
542
- }
543
- return { known, extra };
544
- }
545
-
546
- function createFieldDraft(definition, hasValue, value, meta = null) {
547
- const inheritedValue = meta?.inheritedValue;
548
- if (definition.type === "boolean") {
549
- return {
550
- mode: hasValue ? (value === false ? "false" : "true") : "inherit",
551
- text: "",
552
- inheritedValue,
553
- inheritedSource: meta?.source || "empty",
554
- uiDefaultValue: meta?.uiDefaultValue,
555
- };
556
- }
557
-
558
- return {
559
- mode: hasValue ? "set" : "inherit",
560
- text: hasValue ? toFieldText(definition, value) : toFieldText(definition, inheritedValue),
561
- inheritedValue,
562
- inheritedSource: meta?.source || "empty",
563
- uiDefaultValue: meta?.uiDefaultValue,
564
- };
565
- }
566
-
567
- function toFieldText(definition, value) {
568
- if (value === undefined || value === null) return "";
569
- if (definition.type === "json") return JSON.stringify(value, null, 2);
570
- if (definition.type === "stringArray") return Array.isArray(value) ? value.join("\n") : "";
571
- return String(value);
572
- }
573
-
574
- function createDraftFromRemote(state) {
575
- const { known, extra } = splitConfig(state.config || {});
576
- const fields = {};
577
-
578
- for (const definition of APP.fieldDefinitions) {
579
- const hasValue = Object.prototype.hasOwnProperty.call(known, definition.key);
580
- fields[definition.key] = createFieldDraft(definition, hasValue, known[definition.key], state.fieldMeta?.[definition.key] || null);
581
- }
582
-
583
- return {
584
- enabled: state.enabled !== false,
585
- fields,
586
- extraText: Object.keys(extra).length > 0 ? JSON.stringify(extra, null, 2) : "",
587
- };
588
- }
589
-
590
- function getDraftSnapshot() {
591
- return JSON.stringify(draft);
592
- }
593
-
594
- function isDirty() {
595
- return draft && getDraftSnapshot() !== baselineSnapshot;
596
- }
597
-
598
- function parseField(definition, fieldDraft) {
599
- if (!fieldDraft) return { value: undefined };
600
-
601
- if (definition.type === "boolean") {
602
- if (fieldDraft.mode === "inherit") return { value: undefined };
603
- return { value: fieldDraft.mode === "true" };
604
- }
605
-
606
- if (fieldDraft.mode === "inherit") {
607
- return { value: undefined };
608
- }
609
-
610
- const text = String(fieldDraft.text || "");
611
-
612
- if (definition.type === "string" || definition.type === "secret" || definition.type === "textarea") {
613
- const trimmed = text.trim();
614
- return { value: trimmed ? text : undefined };
615
- }
616
-
617
- if (definition.type === "integer") {
618
- const trimmed = text.trim();
619
- if (!trimmed) return { value: undefined };
620
- const parsed = Number(trimmed);
621
- if (!Number.isInteger(parsed)) return { error: uiText("errorInteger") };
622
- return { value: parsed };
623
- }
624
-
625
- if (definition.type === "number") {
626
- const trimmed = text.trim();
627
- if (!trimmed) return { value: undefined };
628
- const parsed = Number(trimmed);
629
- if (!Number.isFinite(parsed)) return { error: uiText("errorNumber") };
630
- return { value: parsed };
631
- }
632
-
633
- if (definition.type === "enum") {
634
- const trimmed = text.trim();
635
- return { value: trimmed || undefined };
636
- }
637
-
638
- if (definition.type === "stringArray") {
639
- const lines = text
640
- .split(/\r?\n/)
641
- .map((line) => line.trim())
642
- .filter(Boolean);
643
- return { value: lines.length > 0 ? lines : undefined };
644
- }
645
-
646
- if (definition.type === "json") {
647
- const trimmed = text.trim();
648
- if (!trimmed) return { value: undefined };
649
- try {
650
- const parsed = JSON.parse(trimmed);
651
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
652
- return { error: uiText("errorJsonObject") };
653
- }
654
- return { value: parsed };
655
- } catch {
656
- return { error: uiText("errorJsonInvalid") };
657
- }
658
- }
659
-
660
- return { value: undefined };
661
- }
662
-
663
- function collectDraft() {
664
- const config = {};
665
- const errors = {};
666
-
667
- for (const definition of APP.fieldDefinitions) {
668
- const result = parseField(definition, draft.fields[definition.key]);
669
- if (result.error) {
670
- errors[definition.key] = result.error;
671
- continue;
672
- }
673
- if (result.value !== undefined) {
674
- config[definition.key] = result.value;
675
- }
676
- }
677
-
678
- const extraText = String(draft.extraText || "").trim();
679
- if (extraText) {
680
- try {
681
- const parsed = JSON.parse(extraText);
682
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
683
- errors.__extra = uiText("errorJsonObject");
684
- } else {
685
- Object.assign(config, parsed);
686
- }
687
- } catch {
688
- errors.__extra = uiText("errorJsonInvalid");
689
- }
690
- }
691
-
692
- return { config, errors };
693
- }
694
-
695
- function setBanner(kind, message) {
696
- if (!message) {
697
- elements.banner.className = "banner";
698
- elements.banner.textContent = "";
699
- return;
700
- }
701
- elements.banner.className = "banner show " + kind;
702
- elements.banner.textContent = message;
703
- }
704
-
705
- function setOverlay(visible, title, text) {
706
- elements.overlay.className = visible ? "overlay show" : "overlay";
707
- if (title) elements.overlayTitle.textContent = title;
708
- if (text) elements.overlayText.textContent = text;
709
- }
710
-
711
- function escapeHtml(text) {
712
- return String(text)
713
- .replace(/&/g, "&amp;")
714
- .replace(/</g, "&lt;")
715
- .replace(/>/g, "&gt;")
716
- .replace(/"/g, "&quot;")
717
- .replace(/'/g, "&#39;");
718
- }
719
-
720
- function createPill(className, label, value) {
721
- const div = document.createElement("div");
722
- div.className = "pill " + className;
723
- div.innerHTML = "<strong>" + escapeHtml(label) + "</strong><span>" + escapeHtml(value) + "</span>";
724
- return div;
725
- }
726
-
727
- function renderSegmentButton(label, active, onClick) {
728
- const button = document.createElement("button");
729
- button.type = "button";
730
- button.textContent = label;
731
- button.className = active ? "active" : "";
732
- button.addEventListener("click", onClick);
733
- return button;
734
- }
735
-
736
- function formatInheritedValue(definition, value) {
737
- if (value === undefined || value === null) return uiText("empty");
738
- if (definition.type === "boolean") return value ? uiText("on") : uiText("off");
739
- if (definition.type === "secret") {
740
- const text = String(value || "");
741
- if (!text) return uiText("empty");
742
- if (text.length <= 6) return "••••••";
743
- return `${text.slice(0, 3)}••••${text.slice(-2)}`;
744
- }
745
- if (definition.type === "json") return JSON.stringify(value);
746
- if (definition.type === "stringArray") return Array.isArray(value) ? (value.length ? value.join(", ") : uiText("empty")) : uiText("empty");
747
- const text = String(value);
748
- return text.trim() ? text : uiText("empty");
749
- }
750
-
751
- function booleanStateLabel(mode) {
752
- if (mode === "true") return uiText("on");
753
- if (mode === "false") return uiText("off");
754
- return uiText("inherit");
755
- }
756
-
757
- function helperText(definition) {
758
- const fieldDraft = draft?.fields?.[definition.key];
759
- if (fieldDraft?.mode === "inherit") {
760
- if (fieldDraft.inheritedSource === "env") {
761
- return uiText("helperEnvValue") + formatInheritedValue(definition, fieldDraft.inheritedValue);
762
- }
763
- if (fieldDraft.inheritedSource === "default") {
764
- return uiText("helperProjectDefault") + formatInheritedValue(definition, fieldDraft.inheritedValue);
765
- }
766
- return uiText("helperEmptyValue");
767
- }
768
- if (definition.type === "boolean") return uiText("helperBoolean");
769
- if (definition.type === "json") return uiText("helperJson");
770
- if (definition.type === "stringArray") return uiText("helperArray");
771
- return uiText("helperDefault");
772
- }
773
-
774
- function renderStatus() {
775
- elements.statusRow.innerHTML = "";
776
- elements.metaRow.innerHTML = "";
777
- elements.pathBox.textContent = remoteState ? remoteState.configPath : "";
778
- if (elements.updateCheckButton && !elements.updateCheckButton.parentElement) {
779
- elements.updateCheckButton.remove();
780
- }
781
- if (!remoteState) return;
782
-
783
- elements.statusRow.appendChild(
784
- createPill(remoteState.enabled ? "ok" : "warn", uiText("pillPlugin"), remoteState.enabled ? uiText("enabled") : uiText("disabled")),
785
- );
786
- updateCheckButtonUi();
787
- elements.statusRow.appendChild(elements.updateCheckButton);
788
- elements.statusRow.appendChild(createPill("", uiText("pillRuntime"), remoteState.runtimeDisplayName));
789
- elements.statusRow.appendChild(
790
- createPill(remoteState.entryExists ? "" : "warn", uiText("pillEntry"), remoteState.entryExists ? uiText("pillEntryPresent") : uiText("pillEntryMissing")),
791
- );
792
- elements.metaRow.appendChild(createPill("", uiText("pillPageUrl"), window.location.origin));
793
- elements.metaRow.appendChild(createPill("", uiText("pillRevision"), remoteState.revision));
794
- elements.metaRow.appendChild(
795
- createPill(remoteState.fileExists ? "" : "warn", uiText("pillConfigFile"), remoteState.fileExists ? uiText("pillConfigFound") : uiText("pillConfigCreate")),
796
- );
797
- if (remoteState.hasInclude) {
798
- elements.metaRow.appendChild(
799
- createPill("warn", uiText("pillInclude"), uiText("pillIncludeValue")),
800
- );
801
- }
802
- }
803
-
804
- function renderUpdateNotice() {
805
- if (!elements.updateNotice) return;
806
-
807
- if (updateStatusLoading) {
808
- elements.updateNotice.hidden = false;
809
- elements.updateNotice.className = "update-notice info show";
810
- elements.updateNotice.innerHTML =
811
- '<div class="update-title">' +
812
- escapeHtml(uiText("checkingUpdates")) +
813
- "</div>";
814
- return;
815
- }
816
-
817
- if (!updateStatusChecked || !updateStatus) {
818
- elements.updateNotice.hidden = true;
819
- elements.updateNotice.className = "update-notice";
820
- elements.updateNotice.innerHTML = "";
821
- return;
822
- }
823
-
824
- elements.updateNotice.hidden = false;
825
- elements.updateNotice.className = "update-notice " + (updateStatus.ok === false ? "error show" : updateStatus.updateAvailable ? "show" : "ok show");
826
-
827
- if (updateStatus.updateAvailable) {
828
- const command = getUpdateAndRestartCommand(updateStatus);
829
- elements.updateNotice.innerHTML =
830
- '<div class="update-copy">' +
831
- '<div><div class="update-title">' +
832
- escapeHtml(uiText("updateTitle")) +
833
- '</div><div class="update-body">' +
834
- escapeHtml(formatUiText("updateBody", { current: updateStatus.currentVersion, latest: updateStatus.latestVersion })) +
835
- '</div></div>' +
836
- '<button class="inline-btn update-copy-button" type="button">' +
837
- escapeHtml(uiText("updateCopyCommand")) +
838
- "</button></div>" +
839
- '<code class="update-command">' +
840
- escapeHtml(command) +
841
- "</code>";
842
-
843
- elements.updateNotice.querySelector(".update-copy-button")?.addEventListener("click", () => {
844
- void copyUpdateCommand(command);
845
- });
846
- return;
847
- }
848
-
849
- if (updateStatus.ok === false) {
850
- elements.updateNotice.innerHTML = escapeHtml(formatUiText("updateCheckFailed", { error: updateStatus.error || "Unknown error" }));
851
- return;
852
- }
853
-
854
- elements.updateNotice.innerHTML =
855
- '<div class="update-title">' +
856
- escapeHtml(uiText("upToDateTitle")) +
857
- '</div><div class="update-body">' +
858
- escapeHtml(formatUiText("upToDateBody", { current: updateStatus.currentVersion || remoteState?.pluginVersion || "unknown" })) +
859
- "</div>";
860
- }
861
-
862
- function getUpdateAndRestartCommand(status) {
863
- const cliName = status?.cliName || "openclaw";
864
- const updateCommand = status?.updateCommand || `${cliName} plugins update memos-cloud-openclaw-plugin`;
865
- return `${updateCommand} && ${cliName} gateway restart`;
866
- }
867
-
868
- function renderEnabledField() {
869
- const field = document.createElement("div");
870
- field.className = "field";
871
- field.innerHTML =
872
- '<div class="field-head"><div class="field-title">' +
873
- uiText("pluginEnabled") +
874
- '</div><div class="field-state">' +
875
- (draft.enabled ? uiText("enabled") : uiText("disabled")) +
876
- '</div></div><p class="field-desc">' +
877
- uiText("enabledDesc") +
878
- "</p>";
879
-
880
- const segmented = document.createElement("div");
881
- segmented.className = "segmented";
882
- segmented.appendChild(
883
- renderSegmentButton(uiText("enabled"), draft.enabled, () => {
884
- draft.enabled = true;
885
- renderForm();
886
- }),
887
- );
888
- segmented.appendChild(
889
- renderSegmentButton(uiText("disabled"), !draft.enabled, () => {
890
- draft.enabled = false;
891
- renderForm();
892
- }),
893
- );
894
- field.appendChild(segmented);
895
- return field;
896
- }
897
-
898
- function resetField(key) {
899
- const definition = APP.fieldDefinitions.find((field) => field.key === key);
900
- draft.fields[key] = createFieldDraft(definition, false, undefined, remoteState?.fieldMeta?.[key] || null);
901
- renderForm();
902
- }
903
-
904
- function renderFooter(definition, errorText) {
905
- const footer = document.createElement("div");
906
- footer.className = "field-tools";
907
-
908
- const status = document.createElement("div");
909
- status.className = errorText ? "error" : "helper";
910
- status.textContent = errorText || helperText(definition);
911
- footer.appendChild(status);
912
-
913
- const actions = document.createElement("div");
914
- actions.className = "inline-actions";
915
- const reset = document.createElement("button");
916
- reset.type = "button";
917
- reset.className = "inline-btn";
918
- reset.textContent = uiText("reset");
919
- reset.addEventListener("click", () => resetField(definition.key));
920
- actions.appendChild(reset);
921
- footer.appendChild(actions);
922
-
923
- return footer;
924
- }
925
-
926
- function renderValueControl(definition) {
927
- if (definition.type === "enum") {
928
- const select = document.createElement("select");
929
- select.className = "select";
930
- for (const option of definition.options || []) {
931
- const element = document.createElement("option");
932
- element.value = option.value;
933
- element.textContent = option.label;
934
- if (draft.fields[definition.key].text === option.value) {
935
- element.selected = true;
936
- }
937
- select.appendChild(element);
938
- }
939
- select.addEventListener("change", (event) => {
940
- draft.fields[definition.key].text = event.target.value;
941
- refreshChrome();
942
- });
943
- return select;
944
- }
945
-
946
- if (definition.type === "textarea" || definition.type === "json" || definition.type === "stringArray") {
947
- const textarea = document.createElement("textarea");
948
- textarea.className = definition.type === "json" ? "text-area json-font" : "text-area";
949
- textarea.rows = definition.rows || 5;
950
- textarea.placeholder = definition.placeholder || "";
951
- textarea.value = draft.fields[definition.key].text || "";
952
- textarea.addEventListener("input", (event) => {
953
- draft.fields[definition.key].text = event.target.value;
954
- refreshChrome();
955
- });
956
- return textarea;
957
- }
958
-
959
- const wrap = document.createElement("div");
960
- const input = document.createElement("input");
961
- input.className = "control";
962
- input.type = definition.type === "secret" ? "password" : "text";
963
- input.placeholder = definition.placeholder || "";
964
- input.value = draft.fields[definition.key].text || "";
965
- if (definition.type === "integer" || definition.type === "number") {
966
- input.inputMode = "decimal";
967
- }
968
- input.addEventListener("input", (event) => {
969
- draft.fields[definition.key].text = event.target.value;
970
- refreshChrome();
971
- });
972
- wrap.appendChild(input);
973
-
974
- if (definition.type === "secret") {
975
- const tools = document.createElement("div");
976
- tools.className = "field-tools";
977
- tools.appendChild(document.createElement("div"));
978
-
979
- const actions = document.createElement("div");
980
- actions.className = "inline-actions";
981
- const reveal = document.createElement("button");
982
- reveal.type = "button";
983
- reveal.className = "inline-btn";
984
- reveal.textContent = uiText("show");
985
- reveal.addEventListener("click", () => {
986
- input.type = input.type === "password" ? "text" : "password";
987
- reveal.textContent = input.type === "password" ? uiText("show") : uiText("hide");
988
- });
989
- actions.appendChild(reveal);
990
- tools.appendChild(actions);
991
- wrap.appendChild(tools);
992
- }
993
-
994
- return wrap;
995
- }
996
-
997
- function renderField(definition, errorText) {
998
- const fieldText = localizedField(definition);
999
- const field = document.createElement("div");
1000
- field.className = "field";
1001
- const stateText =
1002
- draft.fields[definition.key].mode === "inherit"
1003
- ? (draft.fields[definition.key].inheritedSource === "env"
1004
- ? uiText("env")
1005
- : draft.fields[definition.key].inheritedSource === "default"
1006
- ? uiText("inherit")
1007
- : uiText("empty"))
1008
- : definition.type === "boolean"
1009
- ? booleanStateLabel(draft.fields[definition.key].mode)
1010
- : uiText("custom");
1011
-
1012
- field.innerHTML =
1013
- '<div class="field-head"><div class="field-title">' +
1014
- escapeHtml(fieldText.label) +
1015
- '</div><div class="field-state">' +
1016
- escapeHtml(stateText) +
1017
- '</div></div><p class="field-desc">' +
1018
- escapeHtml(fieldText.description) +
1019
- "</p>";
1020
-
1021
- if (definition.type === "boolean") {
1022
- const segmented = document.createElement("div");
1023
- segmented.className = "segmented";
1024
- segmented.appendChild(
1025
- renderSegmentButton(uiText("inherit"), draft.fields[definition.key].mode === "inherit", () => {
1026
- draft.fields[definition.key].mode = "inherit";
1027
- renderForm();
1028
- }),
1029
- );
1030
- segmented.appendChild(
1031
- renderSegmentButton(uiText("on"), draft.fields[definition.key].mode === "true", () => {
1032
- draft.fields[definition.key].mode = "true";
1033
- renderForm();
1034
- }),
1035
- );
1036
- segmented.appendChild(
1037
- renderSegmentButton(uiText("off"), draft.fields[definition.key].mode === "false", () => {
1038
- draft.fields[definition.key].mode = "false";
1039
- renderForm();
1040
- }),
1041
- );
1042
- field.appendChild(segmented);
1043
- field.appendChild(renderFooter(definition, errorText));
1044
- return field;
1045
- }
1046
-
1047
- const toggle = document.createElement("div");
1048
- toggle.className = "segmented";
1049
- toggle.appendChild(
1050
- renderSegmentButton(uiText("inherit"), draft.fields[definition.key].mode === "inherit", () => {
1051
- draft.fields[definition.key].mode = "inherit";
1052
- renderForm();
1053
- }),
1054
- );
1055
- toggle.appendChild(
1056
- renderSegmentButton(uiText("custom"), draft.fields[definition.key].mode === "set", () => {
1057
- draft.fields[definition.key].mode = "set";
1058
- renderForm();
1059
- }),
1060
- );
1061
- field.appendChild(toggle);
1062
-
1063
- if (draft.fields[definition.key].mode === "set") {
1064
- field.appendChild(renderValueControl(definition));
1065
- }
1066
-
1067
- field.appendChild(renderFooter(definition, errorText));
1068
- return field;
1069
- }
1070
-
1071
- function renderExtraField(errorText) {
1072
- const field = document.createElement("div");
1073
- field.className = "field";
1074
- field.innerHTML =
1075
- '<div class="field-head"><div class="field-title">' +
1076
- uiText("extraTitle") +
1077
- '</div><div class="field-state">' +
1078
- (String(draft.extraText || "").trim() ? uiText("custom") : uiText("empty")) +
1079
- '</div></div><p class="field-desc">' +
1080
- uiText("extraDesc") +
1081
- "</p>";
1082
-
1083
- const textarea = document.createElement("textarea");
1084
- textarea.className = "text-area json-font";
1085
- textarea.rows = 10;
1086
- textarea.placeholder = '{\n "futureField": true\n}';
1087
- textarea.value = draft.extraText || "";
1088
- textarea.addEventListener("input", (event) => {
1089
- draft.extraText = event.target.value;
1090
- refreshChrome();
1091
- });
1092
- field.appendChild(textarea);
1093
-
1094
- const footer = document.createElement("div");
1095
- footer.className = "field-tools";
1096
- const status = document.createElement("div");
1097
- status.className = errorText ? "error" : "helper";
1098
- status.textContent = errorText || uiText("extraHelper");
1099
- footer.appendChild(status);
1100
-
1101
- const actions = document.createElement("div");
1102
- actions.className = "inline-actions";
1103
- const clear = document.createElement("button");
1104
- clear.type = "button";
1105
- clear.className = "inline-btn";
1106
- clear.textContent = uiText("clear");
1107
- clear.addEventListener("click", () => {
1108
- draft.extraText = "";
1109
- renderForm();
1110
- });
1111
- actions.appendChild(clear);
1112
- footer.appendChild(actions);
1113
- field.appendChild(footer);
1114
-
1115
- return field;
1116
- }
1117
-
1118
- function renderForm() {
1119
- const groups = APP.fieldGroups.map((item) => localizedGroup(item));
1120
- if (!activeSectionId && groups.length > 0) {
1121
- activeSectionId = groups[0].id;
1122
- }
1123
- renderStatus();
1124
- elements.layout.innerHTML = "";
1125
- const collected = collectDraft();
1126
- const hasErrors = Object.keys(collected.errors).length > 0;
1127
-
1128
- for (const group of groups) {
1129
- const card = document.createElement("section");
1130
- card.className = "card";
1131
- card.id = `section-${group.id}`;
1132
- card.dataset.sectionId = group.id;
1133
-
1134
- const head = document.createElement("div");
1135
- head.className = "card-head card-anchor";
1136
- head.innerHTML = "<div><h3>" + escapeHtml(group.title) + "</h3><p>" + escapeHtml(group.description) + "</p></div>";
1137
- card.appendChild(head);
1138
-
1139
- const stack = document.createElement("div");
1140
- stack.className = "stack";
1141
-
1142
- if (group.id === "connection") {
1143
- stack.appendChild(renderEnabledField());
1144
- }
1145
-
1146
- for (const definition of APP.fieldDefinitions.filter((field) => field.group === group.id)) {
1147
- stack.appendChild(renderField(definition, collected.errors[definition.key] || ""));
1148
- }
1149
-
1150
- if (group.id === "advanced") {
1151
- stack.appendChild(renderExtraField(collected.errors.__extra || ""));
1152
- }
1153
-
1154
- card.appendChild(stack);
1155
- elements.layout.appendChild(card);
1156
- }
1157
-
1158
- renderFloatingNav(groups);
1159
- installSectionObserver(groups);
1160
-
1161
- elements.saveButton.disabled = hasErrors || !isDirty();
1162
-
1163
- elements.reloadButton.disabled = !remoteState;
1164
- refreshChrome();
1165
- }
1166
-
1167
- function refreshChrome() {
1168
- const { errors } = collectDraft();
1169
- elements.saveButton.disabled = Object.keys(errors).length > 0 || !isDirty();
1170
-
1171
- elements.reloadButton.disabled = !remoteState;
1172
- if (externalRefreshQueued) {
1173
- setBanner("warn", uiText("bannerExternal"));
1174
- return;
1175
- }
1176
- if (Object.keys(errors).length > 0) {
1177
- setBanner("error", uiText("bannerErrors"));
1178
- return;
1179
- }
1180
-
1181
- if (isDirty()) {
1182
- setBanner("info", uiText("bannerDirty"));
1183
- return;
1184
- }
1185
- if (remoteState && remoteState.hasInclude) {
1186
- setBanner("warn", uiText("bannerInclude"));
1187
- return;
1188
- }
1189
- setBanner("", "");
1190
- }
1191
-
1192
- async function api(path, options = {}) {
1193
- const response = await fetch(path, {
1194
- ...options,
1195
- headers: {
1196
- "Content-Type": "application/json",
1197
- "X-Memos-Config-Token": APP.token,
1198
- ...(options.headers || {}),
1199
- },
1200
- });
1201
- if (!response.ok) {
1202
- const message = (await response.text()) || "Request failed.";
1203
- const error = new Error(message);
1204
- error.status = response.status;
1205
- throw error;
1206
- }
1207
- return response.json();
1208
- }
1209
-
1210
- async function checkHeartbeat() {
1211
- const response = await fetch("/api/heartbeat", {
1212
- method: "GET",
1213
- cache: "no-store",
1214
- });
1215
- if (!response.ok) {
1216
- throw new Error((await response.text()) || "Heartbeat failed.");
1217
- }
1218
- return response.json();
1219
- }
1220
-
1221
- async function loadUpdateStatus(options = {}) {
1222
- const manual = options.manual === true;
1223
- if (manual) {
1224
- updateStatusChecked = true;
1225
- updateStatusLoading = true;
1226
- updateCheckButtonUi();
1227
- renderUpdateNotice();
1228
- }
1229
-
1230
- try {
1231
- updateStatus = await api("/api/update-status" + (options.force ? "?force=1" : ""));
1232
- updateStatusChecked = true;
1233
- } catch (error) {
1234
- if (error?.status === 403) {
1235
- void recoverFromAuthError();
1236
- return;
1237
- }
1238
- updateStatusChecked = true;
1239
- updateStatus = {
1240
- ok: false,
1241
- error: String(error.message || error),
1242
- };
1243
- } finally {
1244
- updateStatusLoading = false;
1245
- updateCheckButtonUi();
1246
- renderUpdateNotice();
1247
- }
1248
- }
1249
-
1250
- function handleHeartbeatState(heartbeat) {
1251
- if (!heartbeat || typeof heartbeat !== "object") return;
1252
-
1253
- const bootChanged = Boolean(heartbeatBootId) && heartbeat.bootId && heartbeat.bootId !== heartbeatBootId;
1254
- const assetChanged =
1255
- Boolean(heartbeatAssetRevision) &&
1256
- heartbeat.assetRevision &&
1257
- heartbeat.assetRevision !== heartbeatAssetRevision;
1258
-
1259
- heartbeatBootId = heartbeat.bootId || heartbeatBootId;
1260
- heartbeatAssetRevision = heartbeat.assetRevision || heartbeatAssetRevision;
1261
-
1262
- if ((!bootChanged && !assetChanged) || heartbeatReloadQueued) return;
1263
-
1264
- if (!isDirty()) {
1265
- heartbeatReloadQueued = true;
1266
- window.location.reload();
1267
- return;
1268
- }
1269
-
1270
- externalRefreshQueued = true;
1271
- refreshChrome();
1272
- }
1273
-
1274
- async function recoverFromAuthError() {
1275
- if (authRecoveryInProgress) return;
1276
-
1277
- authRecoveryInProgress = true;
1278
- authRecoveryRetryAt = Date.now() + 4000;
1279
- try {
1280
- const heartbeat = await checkHeartbeat();
1281
- handleHeartbeatState(heartbeat);
1282
-
1283
- if (authRecoveryReloadQueued || heartbeatReloadQueued) return;
1284
-
1285
- if (heartbeat?.bootId || heartbeat?.assetRevision) {
1286
- authRecoveryReloadQueued = true;
1287
- setBanner("info", uiText("bannerAuthRecovered"));
1288
- setTimeout(() => {
1289
- window.location.reload();
1290
- }, 350);
1291
- return;
1292
- }
1293
-
1294
- setBanner("info", uiText("bannerAuthWaiting"));
1295
- } catch {
1296
- setBanner("error", uiText("bannerAuthFailed"));
1297
- } finally {
1298
- authRecoveryInProgress = false;
1299
- }
1300
- }
1301
-
1302
- async function loadRemote(initial = false) {
1303
- if (authRecoveryReloadQueued || heartbeatReloadQueued || authRecoveryInProgress) {
1304
- return;
1305
- }
1306
-
1307
- if (!initial && authRecoveryRetryAt > Date.now()) {
1308
- return;
1309
- }
1310
-
1311
- try {
1312
- const state = await api("/api/state");
1313
- const previousRevision = remoteState ? remoteState.revision : "";
1314
- remoteState = state;
1315
- authRecoveryInProgress = false;
1316
- authRecoveryReloadQueued = false;
1317
- authRecoveryRetryAt = 0;
1318
- handleHeartbeatState(state);
1319
-
1320
- if (!draft || initial) {
1321
- draft = createDraftFromRemote(state);
1322
- baselineSnapshot = getDraftSnapshot();
1323
- renderForm();
1324
- return;
1325
- }
1326
-
1327
- if (previousRevision && previousRevision !== state.revision) {
1328
- if (!isDirty()) {
1329
- draft = createDraftFromRemote(state);
1330
- baselineSnapshot = getDraftSnapshot();
1331
- externalRefreshQueued = false;
1332
- renderForm();
1333
- setBanner("info", uiText("bannerSynced"));
1334
- return;
1335
- }
1336
- externalRefreshQueued = true;
1337
- refreshChrome();
1338
-
1339
- }
1340
-
1341
- refreshChrome();
1342
- } catch (error) {
1343
-
1344
- if (error?.status === 403) {
1345
- void recoverFromAuthError();
1346
- return;
1347
- }
1348
- setBanner("error", String(error.message || error));
1349
- }
1350
- }
1351
-
1352
- async function saveConfig() {
1353
- const { config, errors } = collectDraft();
1354
- if (Object.keys(errors).length > 0) {
1355
- renderForm();
1356
- return;
1357
- }
1358
-
1359
- try {
1360
- elements.saveButton.disabled = true;
1361
- const result = await api("/api/save", {
1362
- method: "POST",
1363
- body: JSON.stringify({
1364
- enabled: draft.enabled,
1365
- config,
1366
- }),
1367
- });
1368
-
1369
- remoteState = result.state;
1370
- draft = createDraftFromRemote(result.state);
1371
- baselineSnapshot = getDraftSnapshot();
1372
-
1373
-
1374
-
1375
- externalRefreshQueued = false;
1376
- renderForm();
1377
- setOverlay(false, "", "");
1378
- setBanner("info", uiText("bannerSaved"));
1379
- } catch (error) {
1380
- setBanner("error", String(error.message || error));
1381
- refreshChrome();
1382
- }
1383
- }
1384
-
1385
- async function copyConfigPath() {
1386
- try {
1387
- await navigator.clipboard.writeText(remoteState?.configPath || "");
1388
- setBanner("info", uiText("bannerCopied"));
1389
- } catch {
1390
- setBanner("error", uiText("bannerClipboardFailed"));
1391
- }
1392
- }
1393
-
1394
- async function copyUpdateCommand(command) {
1395
- if (!command) return;
1396
- try {
1397
- await navigator.clipboard.writeText(command);
1398
- setBanner("info", uiText("bannerUpdateCommandCopied"));
1399
- } catch {
1400
- setBanner("error", uiText("bannerClipboardFailed"));
1401
- }
1402
- }
1403
-
1404
- elements.languageSelectButton.addEventListener("click", () => {
1405
- setLanguageMenuOpen(!languageMenuOpen);
1406
- });
1407
- for (const option of [elements.languageOptionAuto, elements.languageOptionEn, elements.languageOptionZh]) {
1408
- option.addEventListener("click", () => {
1409
- setLanguagePreference(option.dataset.language || "auto");
1410
- setLanguageMenuOpen(false);
1411
- });
1412
- }
1413
- document.addEventListener("click", (event) => {
1414
- if (!elements.languageDropdown.contains(event.target)) {
1415
- setLanguageMenuOpen(false);
1416
- }
1417
- });
1418
- document.addEventListener("keydown", (event) => {
1419
- if (event.key === "Escape") {
1420
- setLanguageMenuOpen(false);
1421
- }
1422
- });
1423
- elements.saveButton.addEventListener("click", () => void saveConfig());
1424
-
1425
- elements.reloadButton.addEventListener("click", () => {
1426
- if (!remoteState) return;
1427
- draft = createDraftFromRemote(remoteState);
1428
- baselineSnapshot = getDraftSnapshot();
1429
- externalRefreshQueued = false;
1430
- renderForm();
1431
- });
1432
- elements.overlayRefreshButton.addEventListener("click", () => {
1433
- window.location.reload();
1434
- });
1435
- elements.copyPathButton.addEventListener("click", () => void copyConfigPath());
1436
- elements.updateCheckButton?.addEventListener("click", () => {
1437
- void loadUpdateStatus({ manual: true, force: true });
1438
- });
1439
- elements.floatingToggleButton.addEventListener("click", () => {
1440
- setNavCollapsed(!navCollapsed);
1441
- });
1442
- elements.backTopButton.addEventListener("click", () => {
1443
- window.scrollTo({ top: 0, behavior: "smooth" });
1444
- });
1445
-
1446
- syncNavCollapseForViewport();
1447
- applyLanguageUi();
1448
- void loadRemote(true);
1449
- updateFloatingVisibility();
1450
- window.addEventListener("scroll", updateFloatingVisibility, { passive: true });
1451
- navCollapseMedia.addEventListener("change", syncNavCollapseForViewport);
1452
- setInterval(() => {
1453
- void loadRemote(false);
1454
- }, 3000);
1
+ const APP = {
2
+ token: __CONFIG_UI_TOKEN__,
3
+ fieldGroups: __FIELD_GROUPS__,
4
+ fieldDefinitions: __FIELD_DEFINITIONS__,
5
+ };
6
+
7
+ const knownKeys = new Set(APP.fieldDefinitions.map((field) => field.key));
8
+ let remoteState = null;
9
+ let updateStatus = null;
10
+ let updateStatusChecked = false;
11
+ let updateStatusLoading = false;
12
+ let draft = null;
13
+ let baselineSnapshot = "";
14
+
15
+
16
+
17
+ let externalRefreshQueued = false;
18
+ let heartbeatBootId = "";
19
+ let heartbeatAssetRevision = "";
20
+ let heartbeatReloadQueued = false;
21
+ let authRecoveryInProgress = false;
22
+ let authRecoveryReloadQueued = false;
23
+ let authRecoveryRetryAt = 0;
24
+ let activeSectionId = "";
25
+ let activeSectionObserver = null;
26
+ let navCollapsed = false;
27
+ const navCollapseMedia = window.matchMedia("(max-width: 720px)");
28
+
29
+ const elements = {
30
+ eyebrowText: document.getElementById("eyebrowText"),
31
+ heroTitle: document.getElementById("heroTitle"),
32
+ heroSubtitle: document.getElementById("heroSubtitle"),
33
+ activeConfigTitle: document.getElementById("activeConfigTitle"),
34
+ activeConfigText: document.getElementById("activeConfigText"),
35
+ editingModelTitle: document.getElementById("editingModelTitle"),
36
+ editingModelText: document.getElementById("editingModelText"),
37
+ langLabel: document.getElementById("langLabel"),
38
+ languageDropdown: document.getElementById("languageDropdown"),
39
+ languageSelectButton: document.getElementById("languageSelectButton"),
40
+ languageSelectText: document.getElementById("languageSelectText"),
41
+ languageMenu: document.getElementById("languageMenu"),
42
+ languageOptionAuto: document.getElementById("languageOptionAuto"),
43
+ languageOptionEn: document.getElementById("languageOptionEn"),
44
+ languageOptionZh: document.getElementById("languageOptionZh"),
45
+ statusRow: document.getElementById("statusRow"),
46
+ metaRow: document.getElementById("metaRow"),
47
+ updateCheckButton: document.getElementById("updateCheckButton") || document.createElement("button"),
48
+ updateNotice: document.getElementById("updateNotice"),
49
+ banner: document.getElementById("banner"),
50
+ floatingTools: document.getElementById("floatingTools"),
51
+ floatingNavLabel: document.getElementById("floatingNavLabel"),
52
+ floatingNav: document.getElementById("floatingNav"),
53
+ floatingToggleButton: document.getElementById("floatingToggleButton"),
54
+ backTopButton: document.getElementById("backTopButton"),
55
+ pathBox: document.getElementById("pathBox"),
56
+ copyPathButton: document.getElementById("copyPathButton"),
57
+ saveButton: document.getElementById("saveButton"),
58
+
59
+ reloadButton: document.getElementById("reloadButton"),
60
+ layout: document.getElementById("layout"),
61
+ overlay: document.getElementById("overlay"),
62
+ overlayTitle: document.getElementById("overlayTitle"),
63
+ overlayText: document.getElementById("overlayText"),
64
+ overlayRefreshButton: document.getElementById("overlayRefreshButton"),
65
+ };
66
+
67
+ const languagePreferenceKey = "memos-config-ui-language";
68
+ let languagePreference = localStorage.getItem(languagePreferenceKey) || "auto";
69
+ let languageMenuOpen = false;
70
+
71
+ const UI_TEXT = {
72
+ en: {
73
+ langLabel: "Language",
74
+ auto: "Auto",
75
+ floatingNavLabel: "Navigate",
76
+ collapseNav: "Collapse Navigation",
77
+ expandNav: "Expand Navigation",
78
+ backToTop: "Back To Top",
79
+ eyebrow: "MemOS Cloud OpenClaw Plugin",
80
+ heroTitle: "Config",
81
+ heroSubtitle:
82
+ "This page reads and writes plugins.entries.memos-cloud-openclaw-plugin.config from the active gateway config file and keeps the form synced with on-disk changes.",
83
+ activeConfigTitle: "Active Config File",
84
+ activeConfigText: "The page automatically follows the current runtime profile and local config path.",
85
+ editingModelTitle: "Editing Model",
86
+ editingModelText:
87
+ "Known plugin fields are rendered as typed controls. Unknown keys stay in the extra JSON section so future custom fields are not lost.",
88
+ save: "Save Config",
89
+ restart: "Restart Gateway",
90
+ reload: "Reload From Disk",
91
+ refreshPage: "Refresh Page",
92
+ copyPath: "Copy Config Path",
93
+ overlayTitle: "Restarting Gateway",
94
+ overlayText: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
95
+ pluginEnabled: "Plugin Enabled",
96
+ enabledDesc:
97
+ "Saving with this switch off keeps the entry in the config but disables the plugin. The page will disappear after the gateway restarts because the plugin will stop loading.",
98
+ extraTitle: "Extra JSON Fields",
99
+ extraDesc: "Any unknown plugin config keys stay here so future custom fields are preserved instead of being dropped.",
100
+ extraHelper: "Use plain JSON. Leave empty if you do not need extra keys.",
101
+ clear: "Clear",
102
+ reset: "Reset",
103
+ show: "Show",
104
+ hide: "Hide",
105
+ enabled: "Enabled",
106
+ disabled: "Disabled",
107
+ env: "Env",
108
+ inherit: "Default",
109
+ custom: "Custom",
110
+ on: "On",
111
+ off: "Off",
112
+ empty: "empty",
113
+ helperBoolean: "Default means the plugin falls back to env files or runtime defaults.",
114
+ helperJson: "Only JSON objects are accepted here.",
115
+ helperArray: "One value per line. Empty lines are ignored.",
116
+ helperDefault: "Leave this as default to remove the key from plugin config.",
117
+ helperEnvValue: "Using env value: ",
118
+ helperProjectDefault: "Using project default: ",
119
+ helperEmptyValue: "No config value is set for this field.",
120
+ errorInteger: "Enter a valid integer.",
121
+ errorNumber: "Enter a valid number.",
122
+ errorJsonObject: "JSON must be an object.",
123
+ errorJsonInvalid: "Invalid JSON.",
124
+ bannerExternal: "A newer on-disk config is available. Reload from disk to sync before saving.",
125
+ bannerErrors: "Please fix the highlighted field errors before saving.",
126
+ bannerWaiting: "Config saved! Please restart the gateway manually to apply changes.",
127
+ bannerDirty: "You have unsaved changes.",
128
+ bannerInclude: "An $include directive was detected. This page writes overrides to the main config file.",
129
+ bannerSynced: "The page synced a newer on-disk config.",
130
+ bannerRestarted: "Gateway restart completed and the page is live again.",
131
+ bannerHeartbeatRecovered: "Gateway connection restored. Reloading the config page...",
132
+ bannerAuthRecovered: "Config session expired, but the gateway is healthy. Reloading this page...",
133
+ bannerAuthWaiting: "Config session expired. Waiting for the gateway to finish restarting before retrying...",
134
+ bannerAuthFailed: "Config session expired. Refresh this page to reconnect.",
135
+ bannerCopied: "The config file path was copied to your clipboard.",
136
+ bannerUpdateCommandCopied: "The update command was copied to your clipboard.",
137
+ bannerClipboardFailed: "Clipboard access failed. Copy the address from your browser bar instead.",
138
+ bannerSaved: "The plugin config was saved.",
139
+ bannerRestartLaunched: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
140
+ bannerWaitingStop: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
141
+ bannerWaitingBack: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
142
+ bannerRestartRefreshHint: "Restart requested. Refresh this page in a few seconds if it does not recover automatically.",
143
+ pillPlugin: "Plugin",
144
+ pillVersion: "Version",
145
+ pillRuntime: "Runtime",
146
+ pillEntry: "Entry",
147
+ pillPageUrl: "Page URL",
148
+ pillRevision: "Config revision",
149
+ pillConfigFile: "Config file",
150
+ pillInclude: "Include",
151
+ checkUpdates: "Check for updates",
152
+ checkingUpdates: "Checking...",
153
+ updateTitle: "Plugin update available",
154
+ updateBody: "Current version {current}; latest version {latest}. Update and restart with:",
155
+ updateCopyCommand: "Copy Update And Restart Command",
156
+ upToDateTitle: "Already up to date",
157
+ upToDateBody: "Current version {current} is the latest version.",
158
+ updateCheckFailed: "Version check failed: {error}",
159
+ pillEntryPresent: "present",
160
+ pillEntryMissing: "missing",
161
+ pillConfigFound: "found",
162
+ pillConfigCreate: "will be created",
163
+ pillIncludeValue: "Found $include; this page writes to the main file only",
164
+ },
165
+ zh: {
166
+ langLabel: "语言",
167
+ auto: "跟随浏览器",
168
+ floatingNavLabel: "区域导航",
169
+ collapseNav: "收起导航",
170
+ expandNav: "展开导航",
171
+ backToTop: "返回顶部",
172
+ eyebrow: "MemOS Cloud OpenClaw Plugin",
173
+ heroTitle: "配置页",
174
+ heroSubtitle:
175
+ "这个页面会直接读取并写回当前 gateway 配置文件中的 plugins.entries.memos-cloud-openclaw-plugin.config,并自动同步磁盘变更。",
176
+ activeConfigTitle: "当前配置文件",
177
+ activeConfigText: "页面会自动跟随当前宿主运行时,读取对应的本地配置路径。",
178
+ editingModelTitle: "编辑方式",
179
+ editingModelText: "已知字段会以结构化表单展示;未知字段会保留在额外 JSON 区域,避免未来自定义配置被覆盖丢失。",
180
+ save: "保存配置",
181
+ restart: "重启 Gateway",
182
+ reload: "从磁盘重新加载",
183
+ refreshPage: "刷新页面",
184
+ copyPath: "复制配置文件路径",
185
+ overlayTitle: "正在重启 Gateway",
186
+ overlayText: "已请求重启。如果页面没有自动恢复,请过几秒点击刷新重试。",
187
+ pluginEnabled: "插件启用状态",
188
+ enabledDesc: "关闭后仍会保留插件配置项,但插件会在重启后停用,页面也会随之消失。",
189
+ extraTitle: "额外 JSON 字段",
190
+ extraDesc: "这里会保留当前 schema 之外的自定义键,避免未来字段在保存时丢失。",
191
+ extraHelper: "这里只接受 JSON 对象;如果不需要额外字段可以留空。",
192
+ clear: "清空",
193
+ reset: "重置",
194
+ show: "显示",
195
+ hide: "隐藏",
196
+ enabled: "启用",
197
+ disabled: "停用",
198
+ env: "环境变量",
199
+ inherit: "默认",
200
+ custom: "自定义",
201
+ on: "开",
202
+ off: "关",
203
+ empty: "空",
204
+ helperBoolean: "选择默认时,会回退到环境变量或运行时默认值。",
205
+ helperJson: "这里只接受 JSON 对象。",
206
+ helperArray: "每行一个值,空行会自动忽略。",
207
+ helperDefault: "选择默认后会从插件配置里移除这个字段。",
208
+ helperEnvValue: "当前使用环境变量值:",
209
+ helperProjectDefault: "当前使用项目默认值:",
210
+ helperEmptyValue: "这个字段当前没有配置值。",
211
+ errorInteger: "请输入有效的整数。",
212
+ errorNumber: "请输入有效的数字。",
213
+ errorJsonObject: "这里必须填写 JSON 对象。",
214
+ errorJsonInvalid: "JSON 格式不正确。",
215
+ bannerExternal: "磁盘上的配置已经更新,请先重新加载再决定是否保存。",
216
+ bannerErrors: "请先修正高亮字段的错误,再执行保存。",
217
+ bannerWaiting: "配置已保存,请手动重启 gateway 以使配置生效。",
218
+ bannerDirty: "你有尚未保存的修改。",
219
+ bannerInclude: "检测到 $include 指令,本页面会把覆盖项写入主配置文件。",
220
+ bannerSynced: "页面已经同步到更新后的磁盘配置。",
221
+ bannerRestarted: "Gateway 已重新启动,配置页面也恢复可用。",
222
+ bannerHeartbeatRecovered: "Gateway 连接已恢复,正在重新加载配置页面...",
223
+ bannerAuthRecovered: "配置页会话已过期,但 Gateway 仍然在线,正在自动刷新页面...",
224
+ bannerAuthWaiting: "配置页会话已过期,正在等待 Gateway 完成重启后再恢复...",
225
+ bannerAuthFailed: "配置页会话已过期,请刷新页面后重新连接。",
226
+ bannerCopied: "配置文件路径已复制到剪贴板。",
227
+ bannerUpdateCommandCopied: "更新命令已复制到剪贴板。",
228
+ bannerClipboardFailed: "复制失败,请直接从浏览器地址栏复制。",
229
+ bannerSaved: "插件配置已保存。",
230
+ bannerRestartLaunched: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
231
+ bannerWaitingStop: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
232
+ bannerWaitingBack: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
233
+ bannerRestartRefreshHint: "已请求重启。如果页面没有自动恢复,请过几秒刷新重试。",
234
+ pillPlugin: "插件",
235
+ pillVersion: "版本",
236
+ pillRuntime: "运行时",
237
+ pillEntry: "配置项",
238
+ pillPageUrl: "页面地址",
239
+ pillRevision: "配置标识",
240
+ pillConfigFile: "配置文件",
241
+ pillInclude: "包含",
242
+ checkUpdates: "检查更新",
243
+ checkingUpdates: "检查中...",
244
+ updateTitle: "插件有新版本",
245
+ updateBody: "当前版本 {current};最新版本 {latest}。可使用以下命令更新并重启:",
246
+ updateCopyCommand: "复制更新并重启命令",
247
+ upToDateTitle: "已是最新版本",
248
+ upToDateBody: "当前版本 {current} 已经是最新版本。",
249
+ updateCheckFailed: "版本检查失败:{error}",
250
+ pillEntryPresent: "已存在",
251
+ pillEntryMissing: "不存在",
252
+ pillConfigFound: "已找到",
253
+ pillConfigCreate: "将自动创建",
254
+ pillIncludeValue: "检测到 $include;本页面只会写入主配置文件",
255
+ },
256
+ };
257
+
258
+ const GROUP_TRANSLATIONS = {
259
+ zh: {
260
+ connection: { title: "连接与鉴权", description: "MemOS 地址、鉴权和身份映射相关配置。" },
261
+ session: { title: "会话与召回", description: "会话 ID 策略、召回范围以及上下文注入行为。" },
262
+ capture: { title: "写回与存储", description: "控制每轮结束后写回到 MemOS 的内容。" },
263
+ agent: { title: "Agent 隔离", description: "多 Agent 隔离、App 元数据与共享权限。" },
264
+ filter: { title: "召回过滤器", description: "在记忆注入前,使用模型做二次筛选。" },
265
+ advanced: { title: "高级设置", description: "超时、重试、节流和其他底层控制项。" },
266
+ },
267
+ };
268
+
269
+ const FIELD_TRANSLATIONS = {
270
+ zh: {
271
+ baseUrl: { label: "MemOS 地址", description: "MemOS OpenMem API 的基础地址。" },
272
+ apiKey: { label: "MemOS API Key", description: "Token 鉴权密钥。未在此处填写时,将从 .env 文件中读取。" },
273
+ userId: { label: "用户 ID", description: "消息添加与记忆查询关联的用户唯一标识符。" },
274
+ useDirectSessionUserId: { label: "使用 Direct 会话用户 ID", description: "可用时从 session key 的 direct 段提取用户 ID,覆盖默认 userId。" },
275
+ conversationId: { label: "会话 ID 覆盖", description: "消息添加与记忆查询关联的会话唯一标识符;同一 ID 会被视为同一上下文。" },
276
+ conversationIdPrefix: { label: "会话前缀", description: "追加到自动生成 conversation_id 前面的文本。" },
277
+ conversationIdSuffix: { label: "会话后缀", description: "追加到自动生成 conversation_id 后面的文本。" },
278
+ conversationSuffixMode: { label: "后缀模式", description: "决定 /new 是否递增数字后缀。" },
279
+ resetOnNew: { label: "/new 时重置", description: "在使用 counter 模式时需要 hooks.internal.enabled。" },
280
+ queryPrefix: { label: "查询前缀", description: "附加在 query 前面的文本,用于补充检索上下文。" },
281
+ maxQueryChars: { label: "查询最大长度", description: "限制单次查询文本长度,避免 query 过长。" },
282
+ recallEnabled: { label: "启用召回", description: "控制 before_agent_start 阶段是否执行记忆召回。" },
283
+ recallGlobal: { label: "全局召回", description: "开启后查询不传 conversation_id,不再强调当前会话权重。" },
284
+ maxItemChars: { label: "注入项最大长度", description: "限制单条召回记忆注入上下文时保留的字符数。" },
285
+ memoryLimitNumber: { label: "记忆数量上限", description: "召回事实记忆的最大条数;默认 9,最大 25。" },
286
+ preferenceLimitNumber: { label: "偏好数量上限", description: "召回偏好记忆的最大条数;默认 9,最大 25。" },
287
+ includePreference: { label: "包含偏好", description: "是否启用偏好记忆召回。" },
288
+ includeToolMemory: { label: "包含工具记忆", description: "是否启用工具记忆召回。" },
289
+ toolMemoryLimitNumber: { label: "工具记忆上限", description: "工具记忆返回条数上限,仅在启用工具记忆时生效;默认 6,最大 25。" },
290
+ relativity: { label: "相关度阈值", description: "召回相关性阈值,范围 0 到 1;为 0 时不做相关性过滤。" },
291
+ filter: { label: "搜索过滤器(JSON)", description: "检索前的过滤条件,支持 agent_id、app_id、时间字段和 info 字段,以及 and/or/gte/lte/gt/lt。" },
292
+ knowledgebaseIds: { label: "知识库 ID", description: "限制本次可检索的知识库范围;每行一个 ID,也可填写 all。" },
293
+ addEnabled: { label: "启用写回", description: "控制 agent_end 阶段是否添加消息并写入记忆。" },
294
+ captureStrategy: { label: "捕获策略", description: "决定写入最后一轮消息,还是写入整段会话消息数组。" },
295
+ maxMessageChars: { label: "消息最大长度", description: "限制每条写入消息保留的字符数,用于控制 messages 内容大小。" },
296
+ includeAssistant: { label: "包含助手回复", description: "是否把 assistant 回复也写入 messages 数组。" },
297
+ tags: { label: "标签", description: "自定义标签列表,用于标记消息主题或分类;每行一个。" },
298
+ info: { label: "附加信息(JSON)", description: "自定义结构化元信息,用于记录来源、版本、位置等,并支持后续精确过滤。" },
299
+ asyncMode: { label: "异步模式", description: "是否异步添加记忆;开启后会在后台写入,减少调用链阻塞。" },
300
+ agentId: { label: "固定 Agent ID", description: "消息或检索关联的 Agent 唯一标识符,用于区分某用户与该 Agent 的专属记忆。" },
301
+ multiAgentMode: { label: "多 Agent 模式", description: "按 ctx.agentId 隔离召回与写回数据。" },
302
+ allowedAgents: { label: "允许的 Agent 列表", description: "仅允许列表内 agent 执行召回与写回;留空表示允许全部 agent。" },
303
+ agentOverrides: { label: "Agent 覆盖配置(JSON)", description: "按 agent 维度覆盖配置。键为 agent id,值为可覆盖字段对象。" },
304
+ appId: { label: "App ID", description: "消息或检索关联的应用唯一标识符,用于区分某用户在该 App 下的专属记忆。" },
305
+ allowPublic: { label: "允许公开", description: "是否允许把生成的记忆写入公共记忆库;开启后项目中的其他用户也可能检索到。" },
306
+ allowKnowledgebaseIds: { label: "允许写入的知识库 ID", description: "消息生成的记忆允许写入的知识库范围;每行一个 ID。" },
307
+ recallFilterEnabled: { label: "启用召回过滤器", description: "召回结果在注入前先经过模型二次筛选。" },
308
+ recallFilterBaseUrl: { label: "过滤器地址", description: "用于召回过滤的 OpenAI 兼容接口地址。" },
309
+ recallFilterApiKey: { label: "过滤器 API Key", description: "召回过滤模型接口所需的 Bearer Token。" },
310
+ recallFilterModel: { label: "过滤模型", description: "召回过滤阶段使用的模型名称。" },
311
+ recallFilterTimeoutMs: { label: "过滤超时(毫秒)", description: "召回过滤模型请求的超时时间。" },
312
+ recallFilterRetries: { label: "过滤重试次数", description: "召回过滤请求失败后的重试次数。" },
313
+ recallFilterCandidateLimit: { label: "候选数量上限", description: "每类候选项在过滤前的最大数量。" },
314
+ recallFilterMaxItemChars: { label: "过滤项最大长度", description: "送入过滤模型前,单条候选项允许保留的字符数。" },
315
+ recallFilterFailOpen: { label: "失败时放行", description: "过滤器失败时回退为不过滤,直接使用原始召回结果。" },
316
+ timeoutMs: { label: "MemOS 超时(毫秒)", description: "调用 MemOS API 时使用的超时时间。" },
317
+ retries: { label: "MemOS 重试次数", description: "调用 MemOS API 失败时的重试次数。" },
318
+ throttleMs: { label: "节流时间(毫秒)", description: "两次写回间隔过短时跳过 add/message。" },
319
+ },
320
+ };
321
+
322
+ function getCurrentLanguage() {
323
+ if (languagePreference === "zh" || languagePreference === "en") {
324
+ return languagePreference;
325
+ }
326
+ const browserLanguage = String(navigator.language || "").toLowerCase();
327
+ return browserLanguage.startsWith("zh") ? "zh" : "en";
328
+ }
329
+
330
+ function uiText(key) {
331
+ const lang = getCurrentLanguage();
332
+ return UI_TEXT[lang][key] ?? UI_TEXT.en[key] ?? key;
333
+ }
334
+
335
+ function formatUiText(key, values) {
336
+ let text = uiText(key);
337
+ for (const [name, value] of Object.entries(values || {})) {
338
+ text = text.split(`{${name}}`).join(String(value ?? ""));
339
+ }
340
+ return text;
341
+ }
342
+
343
+ function localizedGroup(group) {
344
+ const lang = getCurrentLanguage();
345
+ const translated = GROUP_TRANSLATIONS[lang]?.[group.id];
346
+ return translated ? { ...group, ...translated } : group;
347
+ }
348
+
349
+ function localizedField(definition) {
350
+ const lang = getCurrentLanguage();
351
+ const translated = FIELD_TRANSLATIONS[lang]?.[definition.key];
352
+ return translated ? { ...definition, ...translated } : definition;
353
+ }
354
+
355
+ function applyLanguageUi() {
356
+ document.documentElement.lang = getCurrentLanguage();
357
+ // document.title = uiText("heroTitle");
358
+ elements.eyebrowText.textContent = uiText("eyebrow");
359
+ elements.heroTitle.textContent = uiText("heroTitle");
360
+ elements.heroSubtitle.innerHTML = escapeHtml(uiText("heroSubtitle")).replace(
361
+ "plugins.entries.memos-cloud-openclaw-plugin.config",
362
+ "<code>plugins.entries.memos-cloud-openclaw-plugin.config</code>",
363
+ );
364
+ elements.activeConfigTitle.textContent = uiText("activeConfigTitle");
365
+ elements.activeConfigText.textContent = uiText("activeConfigText");
366
+ elements.editingModelTitle.textContent = uiText("editingModelTitle");
367
+ elements.editingModelText.textContent = uiText("editingModelText");
368
+ elements.langLabel.textContent = uiText("langLabel");
369
+ elements.floatingNavLabel.textContent = uiText("floatingNavLabel");
370
+ updateFloatingNavToggleUi();
371
+ elements.backTopButton.setAttribute("aria-label", uiText("backToTop"));
372
+ elements.backTopButton.setAttribute("title", uiText("backToTop"));
373
+ elements.copyPathButton.setAttribute("aria-label", uiText("copyPath"));
374
+ elements.copyPathButton.setAttribute("title", uiText("copyPath"));
375
+ elements.saveButton.textContent = uiText("save");
376
+ elements.updateCheckButton.id = "updateCheckButton";
377
+ elements.updateCheckButton.type = "button";
378
+ elements.updateCheckButton.className = "pill update-check-button";
379
+ updateCheckButtonUi();
380
+
381
+ elements.reloadButton.textContent = uiText("reload");
382
+ elements.overlayTitle.textContent = uiText("overlayTitle");
383
+ elements.overlayText.textContent = uiText("overlayText");
384
+ elements.overlayRefreshButton.setAttribute("aria-label", uiText("refreshPage"));
385
+ elements.overlayRefreshButton.setAttribute("title", uiText("refreshPage"));
386
+ elements.languageOptionAuto.textContent = uiText("auto");
387
+ elements.languageOptionEn.textContent = "EN";
388
+ elements.languageOptionZh.textContent = "中文";
389
+ updateLanguageDropdownUi();
390
+ renderUpdateNotice();
391
+ }
392
+
393
+ function updateCheckButtonUi() {
394
+ if (!elements.updateCheckButton) return;
395
+ const version = remoteState?.pluginVersion || "unknown";
396
+ const action = updateStatusLoading ? uiText("checkingUpdates") : uiText("checkUpdates");
397
+ elements.updateCheckButton.innerHTML =
398
+ "<strong>" +
399
+ escapeHtml(uiText("pillVersion")) +
400
+ "</strong><span>" +
401
+ escapeHtml(version + " · " + action) +
402
+ "</span>";
403
+ elements.updateCheckButton.setAttribute("aria-label", uiText("pillVersion") + " " + version + ", " + action);
404
+ elements.updateCheckButton.setAttribute("title", action);
405
+ elements.updateCheckButton.disabled = updateStatusLoading;
406
+ }
407
+
408
+ function updateFloatingNavToggleUi() {
409
+ elements.floatingToggleButton.setAttribute("aria-expanded", navCollapsed ? "false" : "true");
410
+ elements.floatingToggleButton.setAttribute("aria-label", navCollapsed ? uiText("expandNav") : uiText("collapseNav"));
411
+ elements.floatingToggleButton.setAttribute("title", navCollapsed ? uiText("expandNav") : uiText("collapseNav"));
412
+ }
413
+
414
+ function setNavCollapsed(nextCollapsed) {
415
+ navCollapsed = Boolean(nextCollapsed);
416
+ document.body.classList.toggle("nav-collapsed", navCollapsed);
417
+ updateFloatingNavToggleUi();
418
+ }
419
+
420
+ function syncNavCollapseForViewport() {
421
+ setNavCollapsed(navCollapseMedia.matches);
422
+ }
423
+
424
+ function setLanguagePreference(nextLanguage) {
425
+ languagePreference = nextLanguage;
426
+ localStorage.setItem(languagePreferenceKey, nextLanguage);
427
+ applyLanguageUi();
428
+ if (draft) {
429
+ renderForm();
430
+ }
431
+ }
432
+
433
+ function getLanguageOptionLabel(value) {
434
+ if (value === "en") return "EN";
435
+ if (value === "zh") return "中文";
436
+ return uiText("auto");
437
+ }
438
+
439
+ function setLanguageMenuOpen(nextOpen) {
440
+ languageMenuOpen = nextOpen;
441
+ elements.languageDropdown.classList.toggle("open", nextOpen);
442
+ elements.languageSelectButton.setAttribute("aria-expanded", nextOpen ? "true" : "false");
443
+ }
444
+
445
+ function updateLanguageDropdownUi() {
446
+ const currentValue = languagePreference;
447
+ elements.languageSelectText.textContent = getLanguageOptionLabel(currentValue);
448
+
449
+ const options = [
450
+ { element: elements.languageOptionAuto, value: "auto" },
451
+ { element: elements.languageOptionEn, value: "en" },
452
+ { element: elements.languageOptionZh, value: "zh" },
453
+ ];
454
+
455
+ for (const option of options) {
456
+ const active = option.value === currentValue;
457
+ option.element.classList.toggle("active", active);
458
+ option.element.setAttribute("aria-selected", active ? "true" : "false");
459
+ }
460
+ }
461
+
462
+ function renderFloatingNav(groups) {
463
+ elements.floatingNav.innerHTML = "";
464
+ for (const group of groups) {
465
+ const button = document.createElement("button");
466
+ button.type = "button";
467
+ button.className = "floating-link" + (activeSectionId === group.id ? " active" : "");
468
+ button.textContent = group.title;
469
+ button.addEventListener("click", () => {
470
+ activeSectionId = group.id;
471
+ renderFloatingNav(groups);
472
+ document.getElementById(`section-${group.id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
473
+ });
474
+ elements.floatingNav.appendChild(button);
475
+ }
476
+ }
477
+
478
+ function updateFloatingVisibility() {
479
+ const visible = window.scrollY > 240;
480
+ elements.backTopButton.classList.toggle("is-visible", visible);
481
+ }
482
+
483
+ function installSectionObserver(groups) {
484
+ if (activeSectionObserver) {
485
+ activeSectionObserver.disconnect();
486
+ activeSectionObserver = null;
487
+ }
488
+
489
+ const visibleSections = new Map();
490
+ activeSectionObserver = new IntersectionObserver(
491
+ (entries) => {
492
+ for (const entry of entries) {
493
+ const sectionId = entry.target.dataset.sectionId;
494
+ if (!sectionId) continue;
495
+ if (entry.isIntersecting) {
496
+ visibleSections.set(sectionId, entry.intersectionRatio);
497
+ } else {
498
+ visibleSections.delete(sectionId);
499
+ }
500
+ }
501
+
502
+ if (!visibleSections.size) return;
503
+
504
+ let nextActive = activeSectionId;
505
+ let maxRatio = -1;
506
+ for (const group of groups) {
507
+ const ratio = visibleSections.get(group.id);
508
+ if (ratio !== undefined && ratio > maxRatio) {
509
+ maxRatio = ratio;
510
+ nextActive = group.id;
511
+ }
512
+ }
513
+
514
+ if (nextActive && nextActive !== activeSectionId) {
515
+ activeSectionId = nextActive;
516
+ renderFloatingNav(groups);
517
+ }
518
+ },
519
+ {
520
+ rootMargin: "-12% 0px -55% 0px",
521
+ threshold: [0.15, 0.3, 0.45, 0.6],
522
+ },
523
+ );
524
+
525
+ for (const group of groups) {
526
+ const section = document.getElementById(`section-${group.id}`);
527
+ if (section) {
528
+ activeSectionObserver.observe(section);
529
+ }
530
+ }
531
+ }
532
+
533
+ function splitConfig(config) {
534
+ const known = {};
535
+ const extra = {};
536
+ for (const [key, value] of Object.entries(config || {})) {
537
+ if (knownKeys.has(key)) {
538
+ known[key] = value;
539
+ } else {
540
+ extra[key] = value;
541
+ }
542
+ }
543
+ return { known, extra };
544
+ }
545
+
546
+ function createFieldDraft(definition, hasValue, value, meta = null) {
547
+ const inheritedValue = meta?.inheritedValue;
548
+ if (definition.type === "boolean") {
549
+ return {
550
+ mode: hasValue ? (value === false ? "false" : "true") : "inherit",
551
+ text: "",
552
+ inheritedValue,
553
+ inheritedSource: meta?.source || "empty",
554
+ uiDefaultValue: meta?.uiDefaultValue,
555
+ };
556
+ }
557
+
558
+ return {
559
+ mode: hasValue ? "set" : "inherit",
560
+ text: hasValue ? toFieldText(definition, value) : toFieldText(definition, inheritedValue),
561
+ inheritedValue,
562
+ inheritedSource: meta?.source || "empty",
563
+ uiDefaultValue: meta?.uiDefaultValue,
564
+ };
565
+ }
566
+
567
+ function toFieldText(definition, value) {
568
+ if (value === undefined || value === null) return "";
569
+ if (definition.type === "json") return JSON.stringify(value, null, 2);
570
+ if (definition.type === "stringArray") return Array.isArray(value) ? value.join("\n") : "";
571
+ return String(value);
572
+ }
573
+
574
+ function createDraftFromRemote(state) {
575
+ const { known, extra } = splitConfig(state.config || {});
576
+ const fields = {};
577
+
578
+ for (const definition of APP.fieldDefinitions) {
579
+ const hasValue = Object.prototype.hasOwnProperty.call(known, definition.key);
580
+ fields[definition.key] = createFieldDraft(definition, hasValue, known[definition.key], state.fieldMeta?.[definition.key] || null);
581
+ }
582
+
583
+ return {
584
+ enabled: state.enabled !== false,
585
+ fields,
586
+ extraText: Object.keys(extra).length > 0 ? JSON.stringify(extra, null, 2) : "",
587
+ };
588
+ }
589
+
590
+ function getDraftSnapshot() {
591
+ return JSON.stringify(draft);
592
+ }
593
+
594
+ function isDirty() {
595
+ return draft && getDraftSnapshot() !== baselineSnapshot;
596
+ }
597
+
598
+ function parseField(definition, fieldDraft) {
599
+ if (!fieldDraft) return { value: undefined };
600
+
601
+ if (definition.type === "boolean") {
602
+ if (fieldDraft.mode === "inherit") return { value: undefined };
603
+ return { value: fieldDraft.mode === "true" };
604
+ }
605
+
606
+ if (fieldDraft.mode === "inherit") {
607
+ return { value: undefined };
608
+ }
609
+
610
+ const text = String(fieldDraft.text || "");
611
+
612
+ if (definition.type === "string" || definition.type === "secret" || definition.type === "textarea") {
613
+ const trimmed = text.trim();
614
+ return { value: trimmed ? text : undefined };
615
+ }
616
+
617
+ if (definition.type === "integer") {
618
+ const trimmed = text.trim();
619
+ if (!trimmed) return { value: undefined };
620
+ const parsed = Number(trimmed);
621
+ if (!Number.isInteger(parsed)) return { error: uiText("errorInteger") };
622
+ return { value: parsed };
623
+ }
624
+
625
+ if (definition.type === "number") {
626
+ const trimmed = text.trim();
627
+ if (!trimmed) return { value: undefined };
628
+ const parsed = Number(trimmed);
629
+ if (!Number.isFinite(parsed)) return { error: uiText("errorNumber") };
630
+ return { value: parsed };
631
+ }
632
+
633
+ if (definition.type === "enum") {
634
+ const trimmed = text.trim();
635
+ return { value: trimmed || undefined };
636
+ }
637
+
638
+ if (definition.type === "stringArray") {
639
+ const lines = text
640
+ .split(/\r?\n/)
641
+ .map((line) => line.trim())
642
+ .filter(Boolean);
643
+ return { value: lines.length > 0 ? lines : undefined };
644
+ }
645
+
646
+ if (definition.type === "json") {
647
+ const trimmed = text.trim();
648
+ if (!trimmed) return { value: undefined };
649
+ try {
650
+ const parsed = JSON.parse(trimmed);
651
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
652
+ return { error: uiText("errorJsonObject") };
653
+ }
654
+ return { value: parsed };
655
+ } catch {
656
+ return { error: uiText("errorJsonInvalid") };
657
+ }
658
+ }
659
+
660
+ return { value: undefined };
661
+ }
662
+
663
+ function collectDraft() {
664
+ const config = {};
665
+ const errors = {};
666
+
667
+ for (const definition of APP.fieldDefinitions) {
668
+ const result = parseField(definition, draft.fields[definition.key]);
669
+ if (result.error) {
670
+ errors[definition.key] = result.error;
671
+ continue;
672
+ }
673
+ if (result.value !== undefined) {
674
+ config[definition.key] = result.value;
675
+ }
676
+ }
677
+
678
+ const extraText = String(draft.extraText || "").trim();
679
+ if (extraText) {
680
+ try {
681
+ const parsed = JSON.parse(extraText);
682
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
683
+ errors.__extra = uiText("errorJsonObject");
684
+ } else {
685
+ Object.assign(config, parsed);
686
+ }
687
+ } catch {
688
+ errors.__extra = uiText("errorJsonInvalid");
689
+ }
690
+ }
691
+
692
+ return { config, errors };
693
+ }
694
+
695
+ function setBanner(kind, message) {
696
+ if (!message) {
697
+ elements.banner.className = "banner";
698
+ elements.banner.textContent = "";
699
+ return;
700
+ }
701
+ elements.banner.className = "banner show " + kind;
702
+ elements.banner.textContent = message;
703
+ }
704
+
705
+ function setOverlay(visible, title, text) {
706
+ elements.overlay.className = visible ? "overlay show" : "overlay";
707
+ if (title) elements.overlayTitle.textContent = title;
708
+ if (text) elements.overlayText.textContent = text;
709
+ }
710
+
711
+ function escapeHtml(text) {
712
+ return String(text)
713
+ .replace(/&/g, "&amp;")
714
+ .replace(/</g, "&lt;")
715
+ .replace(/>/g, "&gt;")
716
+ .replace(/"/g, "&quot;")
717
+ .replace(/'/g, "&#39;");
718
+ }
719
+
720
+ function createPill(className, label, value) {
721
+ const div = document.createElement("div");
722
+ div.className = "pill " + className;
723
+ div.innerHTML = "<strong>" + escapeHtml(label) + "</strong><span>" + escapeHtml(value) + "</span>";
724
+ return div;
725
+ }
726
+
727
+ function renderSegmentButton(label, active, onClick) {
728
+ const button = document.createElement("button");
729
+ button.type = "button";
730
+ button.textContent = label;
731
+ button.className = active ? "active" : "";
732
+ button.addEventListener("click", onClick);
733
+ return button;
734
+ }
735
+
736
+ function formatInheritedValue(definition, value) {
737
+ if (value === undefined || value === null) return uiText("empty");
738
+ if (definition.type === "boolean") return value ? uiText("on") : uiText("off");
739
+ if (definition.type === "secret") {
740
+ const text = String(value || "");
741
+ if (!text) return uiText("empty");
742
+ if (text.length <= 6) return "••••••";
743
+ return `${text.slice(0, 3)}••••${text.slice(-2)}`;
744
+ }
745
+ if (definition.type === "json") return JSON.stringify(value);
746
+ if (definition.type === "stringArray") return Array.isArray(value) ? (value.length ? value.join(", ") : uiText("empty")) : uiText("empty");
747
+ const text = String(value);
748
+ return text.trim() ? text : uiText("empty");
749
+ }
750
+
751
+ function booleanStateLabel(mode) {
752
+ if (mode === "true") return uiText("on");
753
+ if (mode === "false") return uiText("off");
754
+ return uiText("inherit");
755
+ }
756
+
757
+ function helperText(definition) {
758
+ const fieldDraft = draft?.fields?.[definition.key];
759
+ if (fieldDraft?.mode === "inherit") {
760
+ if (fieldDraft.inheritedSource === "env") {
761
+ return uiText("helperEnvValue") + formatInheritedValue(definition, fieldDraft.inheritedValue);
762
+ }
763
+ if (fieldDraft.inheritedSource === "default") {
764
+ return uiText("helperProjectDefault") + formatInheritedValue(definition, fieldDraft.inheritedValue);
765
+ }
766
+ return uiText("helperEmptyValue");
767
+ }
768
+ if (definition.type === "boolean") return uiText("helperBoolean");
769
+ if (definition.type === "json") return uiText("helperJson");
770
+ if (definition.type === "stringArray") return uiText("helperArray");
771
+ return uiText("helperDefault");
772
+ }
773
+
774
+ function renderStatus() {
775
+ elements.statusRow.innerHTML = "";
776
+ elements.metaRow.innerHTML = "";
777
+ elements.pathBox.textContent = remoteState ? remoteState.configPath : "";
778
+ if (elements.updateCheckButton && !elements.updateCheckButton.parentElement) {
779
+ elements.updateCheckButton.remove();
780
+ }
781
+ if (!remoteState) return;
782
+
783
+ elements.statusRow.appendChild(
784
+ createPill(remoteState.enabled ? "ok" : "warn", uiText("pillPlugin"), remoteState.enabled ? uiText("enabled") : uiText("disabled")),
785
+ );
786
+ updateCheckButtonUi();
787
+ elements.statusRow.appendChild(elements.updateCheckButton);
788
+ elements.statusRow.appendChild(createPill("", uiText("pillRuntime"), remoteState.runtimeDisplayName));
789
+ elements.statusRow.appendChild(
790
+ createPill(remoteState.entryExists ? "" : "warn", uiText("pillEntry"), remoteState.entryExists ? uiText("pillEntryPresent") : uiText("pillEntryMissing")),
791
+ );
792
+ elements.metaRow.appendChild(createPill("", uiText("pillPageUrl"), window.location.origin));
793
+ elements.metaRow.appendChild(createPill("", uiText("pillRevision"), remoteState.revision));
794
+ elements.metaRow.appendChild(
795
+ createPill(remoteState.fileExists ? "" : "warn", uiText("pillConfigFile"), remoteState.fileExists ? uiText("pillConfigFound") : uiText("pillConfigCreate")),
796
+ );
797
+ if (remoteState.hasInclude) {
798
+ elements.metaRow.appendChild(
799
+ createPill("warn", uiText("pillInclude"), uiText("pillIncludeValue")),
800
+ );
801
+ }
802
+ }
803
+
804
+ function renderUpdateNotice() {
805
+ if (!elements.updateNotice) return;
806
+
807
+ if (updateStatusLoading) {
808
+ elements.updateNotice.hidden = false;
809
+ elements.updateNotice.className = "update-notice info show";
810
+ elements.updateNotice.innerHTML =
811
+ '<div class="update-title">' +
812
+ escapeHtml(uiText("checkingUpdates")) +
813
+ "</div>";
814
+ return;
815
+ }
816
+
817
+ if (!updateStatusChecked || !updateStatus) {
818
+ elements.updateNotice.hidden = true;
819
+ elements.updateNotice.className = "update-notice";
820
+ elements.updateNotice.innerHTML = "";
821
+ return;
822
+ }
823
+
824
+ elements.updateNotice.hidden = false;
825
+ elements.updateNotice.className = "update-notice " + (updateStatus.ok === false ? "error show" : updateStatus.updateAvailable ? "show" : "ok show");
826
+
827
+ if (updateStatus.updateAvailable) {
828
+ const command = getUpdateAndRestartCommand(updateStatus);
829
+ elements.updateNotice.innerHTML =
830
+ '<div class="update-copy">' +
831
+ '<div><div class="update-title">' +
832
+ escapeHtml(uiText("updateTitle")) +
833
+ '</div><div class="update-body">' +
834
+ escapeHtml(formatUiText("updateBody", { current: updateStatus.currentVersion, latest: updateStatus.latestVersion })) +
835
+ '</div></div>' +
836
+ '<button class="inline-btn update-copy-button" type="button">' +
837
+ escapeHtml(uiText("updateCopyCommand")) +
838
+ "</button></div>" +
839
+ '<code class="update-command">' +
840
+ escapeHtml(command) +
841
+ "</code>";
842
+
843
+ elements.updateNotice.querySelector(".update-copy-button")?.addEventListener("click", () => {
844
+ void copyUpdateCommand(command);
845
+ });
846
+ return;
847
+ }
848
+
849
+ if (updateStatus.ok === false) {
850
+ elements.updateNotice.innerHTML = escapeHtml(formatUiText("updateCheckFailed", { error: updateStatus.error || "Unknown error" }));
851
+ return;
852
+ }
853
+
854
+ elements.updateNotice.innerHTML =
855
+ '<div class="update-title">' +
856
+ escapeHtml(uiText("upToDateTitle")) +
857
+ '</div><div class="update-body">' +
858
+ escapeHtml(formatUiText("upToDateBody", { current: updateStatus.currentVersion || remoteState?.pluginVersion || "unknown" })) +
859
+ "</div>";
860
+ }
861
+
862
+ function getUpdateAndRestartCommand(status) {
863
+ const cliName = status?.cliName || "openclaw";
864
+ const updateCommand = status?.updateCommand || `${cliName} plugins update memos-cloud-openclaw-plugin`;
865
+ return `${updateCommand} && ${cliName} gateway restart`;
866
+ }
867
+
868
+ function renderEnabledField() {
869
+ const field = document.createElement("div");
870
+ field.className = "field";
871
+ field.innerHTML =
872
+ '<div class="field-head"><div class="field-title">' +
873
+ uiText("pluginEnabled") +
874
+ '</div><div class="field-state">' +
875
+ (draft.enabled ? uiText("enabled") : uiText("disabled")) +
876
+ '</div></div><p class="field-desc">' +
877
+ uiText("enabledDesc") +
878
+ "</p>";
879
+
880
+ const segmented = document.createElement("div");
881
+ segmented.className = "segmented";
882
+ segmented.appendChild(
883
+ renderSegmentButton(uiText("enabled"), draft.enabled, () => {
884
+ draft.enabled = true;
885
+ renderForm();
886
+ }),
887
+ );
888
+ segmented.appendChild(
889
+ renderSegmentButton(uiText("disabled"), !draft.enabled, () => {
890
+ draft.enabled = false;
891
+ renderForm();
892
+ }),
893
+ );
894
+ field.appendChild(segmented);
895
+ return field;
896
+ }
897
+
898
+ function resetField(key) {
899
+ const definition = APP.fieldDefinitions.find((field) => field.key === key);
900
+ draft.fields[key] = createFieldDraft(definition, false, undefined, remoteState?.fieldMeta?.[key] || null);
901
+ renderForm();
902
+ }
903
+
904
+ function renderFooter(definition, errorText) {
905
+ const footer = document.createElement("div");
906
+ footer.className = "field-tools";
907
+
908
+ const status = document.createElement("div");
909
+ status.className = errorText ? "error" : "helper";
910
+ status.textContent = errorText || helperText(definition);
911
+ footer.appendChild(status);
912
+
913
+ const actions = document.createElement("div");
914
+ actions.className = "inline-actions";
915
+ const reset = document.createElement("button");
916
+ reset.type = "button";
917
+ reset.className = "inline-btn";
918
+ reset.textContent = uiText("reset");
919
+ reset.addEventListener("click", () => resetField(definition.key));
920
+ actions.appendChild(reset);
921
+ footer.appendChild(actions);
922
+
923
+ return footer;
924
+ }
925
+
926
+ function renderValueControl(definition) {
927
+ if (definition.type === "enum") {
928
+ const select = document.createElement("select");
929
+ select.className = "select";
930
+ for (const option of definition.options || []) {
931
+ const element = document.createElement("option");
932
+ element.value = option.value;
933
+ element.textContent = option.label;
934
+ if (draft.fields[definition.key].text === option.value) {
935
+ element.selected = true;
936
+ }
937
+ select.appendChild(element);
938
+ }
939
+ select.addEventListener("change", (event) => {
940
+ draft.fields[definition.key].text = event.target.value;
941
+ refreshChrome();
942
+ });
943
+ return select;
944
+ }
945
+
946
+ if (definition.type === "textarea" || definition.type === "json" || definition.type === "stringArray") {
947
+ const textarea = document.createElement("textarea");
948
+ textarea.className = definition.type === "json" ? "text-area json-font" : "text-area";
949
+ textarea.rows = definition.rows || 5;
950
+ textarea.placeholder = definition.placeholder || "";
951
+ textarea.value = draft.fields[definition.key].text || "";
952
+ textarea.addEventListener("input", (event) => {
953
+ draft.fields[definition.key].text = event.target.value;
954
+ refreshChrome();
955
+ });
956
+ return textarea;
957
+ }
958
+
959
+ const wrap = document.createElement("div");
960
+ const input = document.createElement("input");
961
+ input.className = "control";
962
+ input.type = definition.type === "secret" ? "password" : "text";
963
+ input.placeholder = definition.placeholder || "";
964
+ input.value = draft.fields[definition.key].text || "";
965
+ if (definition.type === "integer" || definition.type === "number") {
966
+ input.inputMode = "decimal";
967
+ }
968
+ input.addEventListener("input", (event) => {
969
+ draft.fields[definition.key].text = event.target.value;
970
+ refreshChrome();
971
+ });
972
+ wrap.appendChild(input);
973
+
974
+ if (definition.type === "secret") {
975
+ const tools = document.createElement("div");
976
+ tools.className = "field-tools";
977
+ tools.appendChild(document.createElement("div"));
978
+
979
+ const actions = document.createElement("div");
980
+ actions.className = "inline-actions";
981
+ const reveal = document.createElement("button");
982
+ reveal.type = "button";
983
+ reveal.className = "inline-btn";
984
+ reveal.textContent = uiText("show");
985
+ reveal.addEventListener("click", () => {
986
+ input.type = input.type === "password" ? "text" : "password";
987
+ reveal.textContent = input.type === "password" ? uiText("show") : uiText("hide");
988
+ });
989
+ actions.appendChild(reveal);
990
+ tools.appendChild(actions);
991
+ wrap.appendChild(tools);
992
+ }
993
+
994
+ return wrap;
995
+ }
996
+
997
+ function renderField(definition, errorText) {
998
+ const fieldText = localizedField(definition);
999
+ const field = document.createElement("div");
1000
+ field.className = "field";
1001
+ const stateText =
1002
+ draft.fields[definition.key].mode === "inherit"
1003
+ ? (draft.fields[definition.key].inheritedSource === "env"
1004
+ ? uiText("env")
1005
+ : draft.fields[definition.key].inheritedSource === "default"
1006
+ ? uiText("inherit")
1007
+ : uiText("empty"))
1008
+ : definition.type === "boolean"
1009
+ ? booleanStateLabel(draft.fields[definition.key].mode)
1010
+ : uiText("custom");
1011
+
1012
+ field.innerHTML =
1013
+ '<div class="field-head"><div class="field-title">' +
1014
+ escapeHtml(fieldText.label) +
1015
+ '</div><div class="field-state">' +
1016
+ escapeHtml(stateText) +
1017
+ '</div></div><p class="field-desc">' +
1018
+ escapeHtml(fieldText.description) +
1019
+ "</p>";
1020
+
1021
+ if (definition.type === "boolean") {
1022
+ const segmented = document.createElement("div");
1023
+ segmented.className = "segmented";
1024
+ segmented.appendChild(
1025
+ renderSegmentButton(uiText("inherit"), draft.fields[definition.key].mode === "inherit", () => {
1026
+ draft.fields[definition.key].mode = "inherit";
1027
+ renderForm();
1028
+ }),
1029
+ );
1030
+ segmented.appendChild(
1031
+ renderSegmentButton(uiText("on"), draft.fields[definition.key].mode === "true", () => {
1032
+ draft.fields[definition.key].mode = "true";
1033
+ renderForm();
1034
+ }),
1035
+ );
1036
+ segmented.appendChild(
1037
+ renderSegmentButton(uiText("off"), draft.fields[definition.key].mode === "false", () => {
1038
+ draft.fields[definition.key].mode = "false";
1039
+ renderForm();
1040
+ }),
1041
+ );
1042
+ field.appendChild(segmented);
1043
+ field.appendChild(renderFooter(definition, errorText));
1044
+ return field;
1045
+ }
1046
+
1047
+ const toggle = document.createElement("div");
1048
+ toggle.className = "segmented";
1049
+ toggle.appendChild(
1050
+ renderSegmentButton(uiText("inherit"), draft.fields[definition.key].mode === "inherit", () => {
1051
+ draft.fields[definition.key].mode = "inherit";
1052
+ renderForm();
1053
+ }),
1054
+ );
1055
+ toggle.appendChild(
1056
+ renderSegmentButton(uiText("custom"), draft.fields[definition.key].mode === "set", () => {
1057
+ draft.fields[definition.key].mode = "set";
1058
+ renderForm();
1059
+ }),
1060
+ );
1061
+ field.appendChild(toggle);
1062
+
1063
+ if (draft.fields[definition.key].mode === "set") {
1064
+ field.appendChild(renderValueControl(definition));
1065
+ }
1066
+
1067
+ field.appendChild(renderFooter(definition, errorText));
1068
+ return field;
1069
+ }
1070
+
1071
+ function renderExtraField(errorText) {
1072
+ const field = document.createElement("div");
1073
+ field.className = "field";
1074
+ field.innerHTML =
1075
+ '<div class="field-head"><div class="field-title">' +
1076
+ uiText("extraTitle") +
1077
+ '</div><div class="field-state">' +
1078
+ (String(draft.extraText || "").trim() ? uiText("custom") : uiText("empty")) +
1079
+ '</div></div><p class="field-desc">' +
1080
+ uiText("extraDesc") +
1081
+ "</p>";
1082
+
1083
+ const textarea = document.createElement("textarea");
1084
+ textarea.className = "text-area json-font";
1085
+ textarea.rows = 10;
1086
+ textarea.placeholder = '{\n "futureField": true\n}';
1087
+ textarea.value = draft.extraText || "";
1088
+ textarea.addEventListener("input", (event) => {
1089
+ draft.extraText = event.target.value;
1090
+ refreshChrome();
1091
+ });
1092
+ field.appendChild(textarea);
1093
+
1094
+ const footer = document.createElement("div");
1095
+ footer.className = "field-tools";
1096
+ const status = document.createElement("div");
1097
+ status.className = errorText ? "error" : "helper";
1098
+ status.textContent = errorText || uiText("extraHelper");
1099
+ footer.appendChild(status);
1100
+
1101
+ const actions = document.createElement("div");
1102
+ actions.className = "inline-actions";
1103
+ const clear = document.createElement("button");
1104
+ clear.type = "button";
1105
+ clear.className = "inline-btn";
1106
+ clear.textContent = uiText("clear");
1107
+ clear.addEventListener("click", () => {
1108
+ draft.extraText = "";
1109
+ renderForm();
1110
+ });
1111
+ actions.appendChild(clear);
1112
+ footer.appendChild(actions);
1113
+ field.appendChild(footer);
1114
+
1115
+ return field;
1116
+ }
1117
+
1118
+ function renderForm() {
1119
+ const groups = APP.fieldGroups.map((item) => localizedGroup(item));
1120
+ if (!activeSectionId && groups.length > 0) {
1121
+ activeSectionId = groups[0].id;
1122
+ }
1123
+ renderStatus();
1124
+ elements.layout.innerHTML = "";
1125
+ const collected = collectDraft();
1126
+ const hasErrors = Object.keys(collected.errors).length > 0;
1127
+
1128
+ for (const group of groups) {
1129
+ const card = document.createElement("section");
1130
+ card.className = "card";
1131
+ card.id = `section-${group.id}`;
1132
+ card.dataset.sectionId = group.id;
1133
+
1134
+ const head = document.createElement("div");
1135
+ head.className = "card-head card-anchor";
1136
+ head.innerHTML = "<div><h3>" + escapeHtml(group.title) + "</h3><p>" + escapeHtml(group.description) + "</p></div>";
1137
+ card.appendChild(head);
1138
+
1139
+ const stack = document.createElement("div");
1140
+ stack.className = "stack";
1141
+
1142
+ if (group.id === "connection") {
1143
+ stack.appendChild(renderEnabledField());
1144
+ }
1145
+
1146
+ for (const definition of APP.fieldDefinitions.filter((field) => field.group === group.id)) {
1147
+ stack.appendChild(renderField(definition, collected.errors[definition.key] || ""));
1148
+ }
1149
+
1150
+ if (group.id === "advanced") {
1151
+ stack.appendChild(renderExtraField(collected.errors.__extra || ""));
1152
+ }
1153
+
1154
+ card.appendChild(stack);
1155
+ elements.layout.appendChild(card);
1156
+ }
1157
+
1158
+ renderFloatingNav(groups);
1159
+ installSectionObserver(groups);
1160
+
1161
+ elements.saveButton.disabled = hasErrors || !isDirty();
1162
+
1163
+ elements.reloadButton.disabled = !remoteState;
1164
+ refreshChrome();
1165
+ }
1166
+
1167
+ function refreshChrome() {
1168
+ const { errors } = collectDraft();
1169
+ elements.saveButton.disabled = Object.keys(errors).length > 0 || !isDirty();
1170
+
1171
+ elements.reloadButton.disabled = !remoteState;
1172
+ if (externalRefreshQueued) {
1173
+ setBanner("warn", uiText("bannerExternal"));
1174
+ return;
1175
+ }
1176
+ if (Object.keys(errors).length > 0) {
1177
+ setBanner("error", uiText("bannerErrors"));
1178
+ return;
1179
+ }
1180
+
1181
+ if (isDirty()) {
1182
+ setBanner("info", uiText("bannerDirty"));
1183
+ return;
1184
+ }
1185
+ if (remoteState && remoteState.hasInclude) {
1186
+ setBanner("warn", uiText("bannerInclude"));
1187
+ return;
1188
+ }
1189
+ setBanner("", "");
1190
+ }
1191
+
1192
+ async function api(path, options = {}) {
1193
+ const response = await fetch(path, {
1194
+ ...options,
1195
+ headers: {
1196
+ "Content-Type": "application/json",
1197
+ "X-Memos-Config-Token": APP.token,
1198
+ ...(options.headers || {}),
1199
+ },
1200
+ });
1201
+ if (!response.ok) {
1202
+ const message = (await response.text()) || "Request failed.";
1203
+ const error = new Error(message);
1204
+ error.status = response.status;
1205
+ throw error;
1206
+ }
1207
+ return response.json();
1208
+ }
1209
+
1210
+ async function checkHeartbeat() {
1211
+ const response = await fetch("/api/heartbeat", {
1212
+ method: "GET",
1213
+ cache: "no-store",
1214
+ });
1215
+ if (!response.ok) {
1216
+ throw new Error((await response.text()) || "Heartbeat failed.");
1217
+ }
1218
+ return response.json();
1219
+ }
1220
+
1221
+ async function loadUpdateStatus(options = {}) {
1222
+ const manual = options.manual === true;
1223
+ if (manual) {
1224
+ updateStatusChecked = true;
1225
+ updateStatusLoading = true;
1226
+ updateCheckButtonUi();
1227
+ renderUpdateNotice();
1228
+ }
1229
+
1230
+ try {
1231
+ updateStatus = await api("/api/update-status" + (options.force ? "?force=1" : ""));
1232
+ updateStatusChecked = true;
1233
+ } catch (error) {
1234
+ if (error?.status === 403) {
1235
+ void recoverFromAuthError();
1236
+ return;
1237
+ }
1238
+ updateStatusChecked = true;
1239
+ updateStatus = {
1240
+ ok: false,
1241
+ error: String(error.message || error),
1242
+ };
1243
+ } finally {
1244
+ updateStatusLoading = false;
1245
+ updateCheckButtonUi();
1246
+ renderUpdateNotice();
1247
+ }
1248
+ }
1249
+
1250
+ function handleHeartbeatState(heartbeat) {
1251
+ if (!heartbeat || typeof heartbeat !== "object") return;
1252
+
1253
+ const bootChanged = Boolean(heartbeatBootId) && heartbeat.bootId && heartbeat.bootId !== heartbeatBootId;
1254
+ const assetChanged =
1255
+ Boolean(heartbeatAssetRevision) &&
1256
+ heartbeat.assetRevision &&
1257
+ heartbeat.assetRevision !== heartbeatAssetRevision;
1258
+
1259
+ heartbeatBootId = heartbeat.bootId || heartbeatBootId;
1260
+ heartbeatAssetRevision = heartbeat.assetRevision || heartbeatAssetRevision;
1261
+
1262
+ if ((!bootChanged && !assetChanged) || heartbeatReloadQueued) return;
1263
+
1264
+ if (!isDirty()) {
1265
+ heartbeatReloadQueued = true;
1266
+ window.location.reload();
1267
+ return;
1268
+ }
1269
+
1270
+ externalRefreshQueued = true;
1271
+ refreshChrome();
1272
+ }
1273
+
1274
+ async function recoverFromAuthError() {
1275
+ if (authRecoveryInProgress) return;
1276
+
1277
+ authRecoveryInProgress = true;
1278
+ authRecoveryRetryAt = Date.now() + 4000;
1279
+ try {
1280
+ const heartbeat = await checkHeartbeat();
1281
+ handleHeartbeatState(heartbeat);
1282
+
1283
+ if (authRecoveryReloadQueued || heartbeatReloadQueued) return;
1284
+
1285
+ if (heartbeat?.bootId || heartbeat?.assetRevision) {
1286
+ authRecoveryReloadQueued = true;
1287
+ setBanner("info", uiText("bannerAuthRecovered"));
1288
+ setTimeout(() => {
1289
+ window.location.reload();
1290
+ }, 350);
1291
+ return;
1292
+ }
1293
+
1294
+ setBanner("info", uiText("bannerAuthWaiting"));
1295
+ } catch {
1296
+ setBanner("error", uiText("bannerAuthFailed"));
1297
+ } finally {
1298
+ authRecoveryInProgress = false;
1299
+ }
1300
+ }
1301
+
1302
+ async function loadRemote(initial = false) {
1303
+ if (authRecoveryReloadQueued || heartbeatReloadQueued || authRecoveryInProgress) {
1304
+ return;
1305
+ }
1306
+
1307
+ if (!initial && authRecoveryRetryAt > Date.now()) {
1308
+ return;
1309
+ }
1310
+
1311
+ try {
1312
+ const state = await api("/api/state");
1313
+ const previousRevision = remoteState ? remoteState.revision : "";
1314
+ remoteState = state;
1315
+ authRecoveryInProgress = false;
1316
+ authRecoveryReloadQueued = false;
1317
+ authRecoveryRetryAt = 0;
1318
+ handleHeartbeatState(state);
1319
+
1320
+ if (!draft || initial) {
1321
+ draft = createDraftFromRemote(state);
1322
+ baselineSnapshot = getDraftSnapshot();
1323
+ renderForm();
1324
+ return;
1325
+ }
1326
+
1327
+ if (previousRevision && previousRevision !== state.revision) {
1328
+ if (!isDirty()) {
1329
+ draft = createDraftFromRemote(state);
1330
+ baselineSnapshot = getDraftSnapshot();
1331
+ externalRefreshQueued = false;
1332
+ renderForm();
1333
+ setBanner("info", uiText("bannerSynced"));
1334
+ return;
1335
+ }
1336
+ externalRefreshQueued = true;
1337
+ refreshChrome();
1338
+
1339
+ }
1340
+
1341
+ refreshChrome();
1342
+ } catch (error) {
1343
+
1344
+ if (error?.status === 403) {
1345
+ void recoverFromAuthError();
1346
+ return;
1347
+ }
1348
+ setBanner("error", String(error.message || error));
1349
+ }
1350
+ }
1351
+
1352
+ async function saveConfig() {
1353
+ const { config, errors } = collectDraft();
1354
+ if (Object.keys(errors).length > 0) {
1355
+ renderForm();
1356
+ return;
1357
+ }
1358
+
1359
+ try {
1360
+ elements.saveButton.disabled = true;
1361
+ const result = await api("/api/save", {
1362
+ method: "POST",
1363
+ body: JSON.stringify({
1364
+ enabled: draft.enabled,
1365
+ config,
1366
+ }),
1367
+ });
1368
+
1369
+ remoteState = result.state;
1370
+ draft = createDraftFromRemote(result.state);
1371
+ baselineSnapshot = getDraftSnapshot();
1372
+
1373
+
1374
+
1375
+ externalRefreshQueued = false;
1376
+ renderForm();
1377
+ setOverlay(false, "", "");
1378
+ setBanner("info", uiText("bannerSaved"));
1379
+ } catch (error) {
1380
+ setBanner("error", String(error.message || error));
1381
+ refreshChrome();
1382
+ }
1383
+ }
1384
+
1385
+ async function copyConfigPath() {
1386
+ try {
1387
+ await navigator.clipboard.writeText(remoteState?.configPath || "");
1388
+ setBanner("info", uiText("bannerCopied"));
1389
+ } catch {
1390
+ setBanner("error", uiText("bannerClipboardFailed"));
1391
+ }
1392
+ }
1393
+
1394
+ async function copyUpdateCommand(command) {
1395
+ if (!command) return;
1396
+ try {
1397
+ await navigator.clipboard.writeText(command);
1398
+ setBanner("info", uiText("bannerUpdateCommandCopied"));
1399
+ } catch {
1400
+ setBanner("error", uiText("bannerClipboardFailed"));
1401
+ }
1402
+ }
1403
+
1404
+ elements.languageSelectButton.addEventListener("click", () => {
1405
+ setLanguageMenuOpen(!languageMenuOpen);
1406
+ });
1407
+ for (const option of [elements.languageOptionAuto, elements.languageOptionEn, elements.languageOptionZh]) {
1408
+ option.addEventListener("click", () => {
1409
+ setLanguagePreference(option.dataset.language || "auto");
1410
+ setLanguageMenuOpen(false);
1411
+ });
1412
+ }
1413
+ document.addEventListener("click", (event) => {
1414
+ if (!elements.languageDropdown.contains(event.target)) {
1415
+ setLanguageMenuOpen(false);
1416
+ }
1417
+ });
1418
+ document.addEventListener("keydown", (event) => {
1419
+ if (event.key === "Escape") {
1420
+ setLanguageMenuOpen(false);
1421
+ }
1422
+ });
1423
+ elements.saveButton.addEventListener("click", () => void saveConfig());
1424
+
1425
+ elements.reloadButton.addEventListener("click", () => {
1426
+ if (!remoteState) return;
1427
+ draft = createDraftFromRemote(remoteState);
1428
+ baselineSnapshot = getDraftSnapshot();
1429
+ externalRefreshQueued = false;
1430
+ renderForm();
1431
+ });
1432
+ elements.overlayRefreshButton.addEventListener("click", () => {
1433
+ window.location.reload();
1434
+ });
1435
+ elements.copyPathButton.addEventListener("click", () => void copyConfigPath());
1436
+ elements.updateCheckButton?.addEventListener("click", () => {
1437
+ void loadUpdateStatus({ manual: true, force: true });
1438
+ });
1439
+ elements.floatingToggleButton.addEventListener("click", () => {
1440
+ setNavCollapsed(!navCollapsed);
1441
+ });
1442
+ elements.backTopButton.addEventListener("click", () => {
1443
+ window.scrollTo({ top: 0, behavior: "smooth" });
1444
+ });
1445
+
1446
+ syncNavCollapseForViewport();
1447
+ applyLanguageUi();
1448
+ void loadRemote(true);
1449
+ updateFloatingVisibility();
1450
+ window.addEventListener("scroll", updateFloatingVisibility, { passive: true });
1451
+ navCollapseMedia.addEventListener("change", syncNavCollapseForViewport);
1452
+ setInterval(() => {
1453
+ void loadRemote(false);
1454
+ }, 3000);