{"repo": "asphodelusu/sakura-personal", "files": {"tools/settings-tauri/frontend/settings.js": {"3734fc86772ad240e76bdf2e3e2a68d28e35ef55": {"t": 1786068486.9568875, "n": 172747, "h": "8e066722221a3c5c", "c": "const invoke = window.__TAURI__.core.invoke;\n\ndocument.addEventListener(\"contextmenu\", (event) => event.preventDefault());\n\nconst fields = {\n characterSelect: document.getElementById(\"characterSelect\"),\n characterImportButton: document.getElementById(\"characterImportButton\"),\n ttsVoiceImportButton: document.getElementById(\"ttsVoiceImportButton\"),\n characterExportButton: document.getElementById(\"characterExportButton\"),\n characterEditorButton: document.getElementById(\"characterEditorButton\"),\n characterArchiveHint: document.getElementById(\"characterArchiveHint\"),\n portraitScale: document.getElementById(\"portraitScale\"),\n controlPanelWidth: document.getElementById(\"controlPanelWidth\"),\n bubbleHeight: document.getElementById(\"bubbleHeight\"),\n controlPanelOffset: document.getElementById(\"controlPanelOffset\"),\n inputBarOffset: document.getElementById(\"inputBarOffset\"),\n enabled: document.getElementById(\"enabled\"),\n proactiveCooldownSeconds: document.getElementById(\"proactiveCooldownSeconds\"),\n proactiveTimerSeconds: document.getElementById(\"proactiveTimerSeconds\"),\n proactiveAdaptiveMin: document.getElementById(\"proactiveAdaptiveMin\"),\n proactiveAdaptiveMax: document.getElementById(\"proactiveAdaptiveMax\"),\n proactiveIdleThreshold: document.getElementById(\"proactiveIdleThreshold\"),\n proactiveMinSilence: document.getElementById(\"proactiveMinSilence\"),\n proactiveWindowSwitch: document.getElementById(\"proactiveWindowSwitch\"),\n proactiveWindowSwitchCooldown: document.getElementById(\"proactiveWindowSwitchCooldown\"),\n proactiveFocusSettle: document.getElementById(\"proactiveFocusSettle\"),\n proactiveMaxEdge: document.getElementById(\"proactiveMaxEdge\"),\n proactiveBlockedProcesses: document.getElementById(\"proactiveBlockedProcesses\"),\n proactiveBlockedTitles: document.getElementById(\"proactiveBlockedTitles\"),\n windowsMcp: document.getElementById(\"windowsMcp\"),\n agentSteps: document.getElementById(\"agentSteps\"),\n toolCallsPerStep: document.getElementById(\"toolCallsPerStep\"),\n toolCallsPerTurn: document.getElementById(\"toolCallsPerTurn\"),\n providerStatusStrip: document.getElementById(\"providerStatusStrip\"),\n providerSearch: document.getElementById(\"providerSearch\"),\n addProviderButton: document.getElementById(\"addProviderButton\"),\n providerList: document.getElementById(\"providerList\"),\n providerDetail: document.getElementById(\"providerDetail\"),\n modelSlots: document.getElementById(\"modelSlots\"),\n apiTimeout: document.getElementById(\"apiTimeout\"),\n apiTemperature: document.getElementById(\"apiTemperature\"),\n apiTopPEnabled: document.getElementById(\"apiTopPEnabled\"),\n apiTopP: document.getElementById(\"apiTopP\"),\n apiMaxTokensEnabled: document.getElementById(\"apiMaxTokensEnabled\"),\n apiMaxTokens: document.getElementById(\"apiMaxTokens\"),\n ttsEnabled: document.getElementById(\"ttsEnabled\"),\n ttsProvider: document.getElementById(\"ttsProvider\"),\n ttsApiUrl: document.getElementById(\"ttsApiUrl\"),\n ttsWorkDir: document.getElementById(\"ttsWorkDir\"),\n ttsPythonPath: document.getElementById(\"ttsPythonPath\"),\n ttsConfigPath: document.getElementById(\"ttsConfigPath\"),\n ttsBundleNoticeRow: document.getElementById(\"ttsBundleNoticeRow\"),\n ttsBundleNotice: document.getElementById(\"ttsBundleNotice\"),\n ttsResourceCard: document.getElementById(\"ttsResourceCard\"),\n ttsTestButton: document.getElementById(\"ttsTestButton\"),\n ttsTimeout: document.getElementById(\"ttsTimeout\"),\n themeColors: document.getElementById(\"themeColors\"),\n visualEffectMode: document.getElementById(\"visualEffectMode\"),\n themeAiButton: document.getElementById(\"themeAiButton\"),\n resetThemeButton: document.getElementById(\"resetThemeButton\"),\n launchAtLogin: document.getElementById(\"launchAtLogin\"),\n debugLogEnabled: document.getElementById(\"debugLogEnabled\"),\n debugBodyEnabled: document.getElementById(\"debugBodyEnabled\"),\n debugFileEnabled: document.getElementById(\"debugFileEnabled\"),\n stageDebugOverlay: document.getElementById(\"stageDebugOverlay\"),\n stageCollisionMask: document.getElementById(\"stageCollisionMask\"),\n subtitleTypingInterval: document.getElementById(\"subtitleTypingInterval\"),\n replySegmentPause: document.getElementById(\"replySegmentPause\"),\n bubbleAutoHide: document.getElementById(\"bubbleAutoHide\"),\n bubbleAutoHideDelay: document.getElementById(\"bubbleAutoHideDelay\"),\n backchannelEnabled: document.getElementById(\"backchannelEnabled\"),\n backchannelMode: document.getElementById(\"backchannelMode\"),\n backchannelDelay: document.getElementById(\"backchannelDelay\"),\n backchannelProbability: document.getElementById(\"backchannelProbability\"),\n backchannelTtsEnabled: document.getElementById(\"backchannelTtsEnabled\"),\n backchannelResourceCard: document.getElementById(\"backchannelResourceCard\"),\n memoryTriggerTurns: document.getElementById(\"memoryTriggerTurns\"),\n progressiveMemory: document.getElementById(\"progressiveMemory\"),\n memoryModelResourceCard: document.getElementById(\"memoryModelResourceCard\"),\n memoryRerankResourceCard: document.getElementById(\"memoryRerankResourceCard\"),\n speechFontSize: document.getElementById(\"speechFontSize\"),\n nameFontSize: document.getElementById(\"nameFontSize\"),\n inputFontSize: document.getElementById(\"inputFontSize\"),\n buttonFontSize: document.getElementById(\"buttonFontSize\"),\n memoryStatusStrip: document.getElementById(\"memoryStatusStrip\"),\n memorySearch: document.getElementById(\"memorySearch\"),\n memoryLayerFilter: document.getElementById(\"memoryLayerFilter\"),\n memorySort: document.getElementById(\"memorySort\"),\n memoryAddButton: document.getElementById(\"memoryAddButton\"),\n memoryRefreshButton: document.getElementById(\"memoryRefreshButton\"),\n memoryList: document.getElementById(\"memoryList\"),\n memoryContent: document.getElementById(\"memoryContent\"),\n memoryLayer: document.getElementById(\"memoryLayer\"),\n memoryCategory: document.getElementById(\"memoryCategory\"),\n memorySource: document.getElementById(\"memorySource\"),\n memoryImportance: document.getElementById(\"memoryImportance\"),\n memoryConfidence: document.getElementById(\"memoryConfidence\"),\n memoryMeta: document.getElementById(\"memoryMeta\"),\n memorySaveButton: document.getElementById(\"memorySaveButton\"),\n memoryRevertButton: document.getElementById(\"memoryRevertButton\"),\n memoryDeleteButton: document.getElementById(\"memoryDeleteButton\"),\n pluginStatusStrip: document.getElementById(\"pluginStatusStrip\"),\n pluginSearch: document.getElementById(\"pluginSearch\"),\n pluginStatusFilter: document.getElementById(\"pluginStatusFilter\"),\n pluginPermissionFilter: document.getElementById(\"pluginPermissionFilter\"),\n pluginList: document.getElementById(\"pluginList\"),\n pluginDetail: document.getElementById(\"pluginDetail\"),\n tokenEstimate: document.getElementById(\"tokenEstimate\"),\n errorText: document.getElementById(\"errorText\"),\n onboardingHead: document.getElementById(\"onboardingHead\"),\n onboardingCharacterStep: document.getElementById(\"onboardingCharacterStep\"),\n onboardingProviderStep: document.getElementById(\"onboardingProviderStep\"),\n onboardingCompleteStep: document.getElementById(\"onboardingCompleteStep\"),\n onboardingBackButton: document.getElementById(\"onboardingBackButton\"),\n saveButton: document.getElementById(\"saveButton\"),\n applyButton: document.getElementById(\"applyButton\"),\n cancelButton: document.getElementById(\"cancelButton\"),\n pageHead: document.querySelector(\".page-head\"),\n pageTitle: document.getElementById(\"pageTitle\"),\n pageSubtitle: document.getElementById(\"pageSubtitle\"),\n navItems: Array.from(document.querySelectorAll(\".nav-item[data-page]\")),\n pages: {\n character: document.getElementById(\"page-character\"),\n privacy: document.getElementById(\"page-privacy\"),\n appearance: document.getElementById(\"page-appearance\"),\n providers: document.getElementById(\"page-providers\"),\n model: document.getElementById(\"page-model\"),\n voice: document.getElementById(\"page-voice\"),\n interaction: document.getElementById(\"page-interaction\"),\n tools: document.getElementById(\"page-tools\"),\n plugins: document.getElementById(\"page-plugins\"),\n system: document.getElementById(\"page-system\"),\n memory: document.getElementById(\"page-memory\"),\n },\n};\n\nlet request = null;\nlet lastTtsProvider = \"\";\nlet themeChanged = false;\n// 「未保存改动」基线:load() 末尾拍下 collectSettings() 的 JSON 快照,之后任意输入都与它比对。\nlet settingsBaseline = null;\n// 程序化关窗(保存/取消)前置真,避免关窗拦截器把正常关闭误判成「放弃改动」。\nlet bypassCloseGuard = false;\nlet memoryRetryTimer = null;\nlet characterArchiveBusy = false;\nlet onboardingStep = \"character\";\nconst characterExportOptions = [\n {\n kind: \"full\",\n label: \"完整包 (.char)\",\n description: \"导出角色配置和可携带语音模型,适合完整迁移。\",\n requiresVoice: true,\n },\n {\n kind: \"card\",\n label: \"单角色包 (.char)\",\n description: \"只导出角色配置,不包含语音模型。\",\n requiresVoice: false,\n },\n {\n kind: \"voice\",\n label: \"语音包 (.voice)\",\n description: \"只导出当前角色的可携带 TTS 模型。\",\n requiresVoice: true,\n },\n];\nconst memoryState = {\n entries: [],\n selectedId: \"\",\n loading: false,\n loaded: false,\n status: \"idle\",\n message: \"\",\n draft: null,\n};\nconst pluginState = {\n selectedId: \"\",\n enabledById: {},\n initialEnabledById: {},\n settingsValues: {},\n initialSettingsValues: {},\n actionBusyKey: \"\",\n};\nconst resourceState = {\n snapshot: null,\n pollTimer: null,\n ttsBundleKey: \"\",\n seenTaskFinishes: {},\n};\n\nconst themeVars = {\n primary_color: \"--sakura-primary\",\n primary_hover_color: \"--sakura-primary-hover\",\n accent_color: \"--sakura-accent\",\n text_color: \"--sakura-text\",\n secondary_text_color: \"--sakura-secondary-text\",\n muted_text_color: \"--sakura-muted-text\",\n page_background_color: \"--sakura-page-bg\",\n panel_background_color: \"--sakura-panel-bg\",\n input_background_color: \"--sakura-input-bg\",\n bubble_background_color: \"--sakura-bubble-bg\",\n border_color: \"--sakura-border\",\n};\n\nconst reduceMotionQuery = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\") || null;\n\nlet activeThemeField = \"\";\nlet themeEditor = {};\n\nfunction setError(message) {\n fields.errorText.textContent = message || \"\";\n}\n\n// 反馈分流:错误常驻 footer 红字(role=alert)走 setError;成功/信息走右下角 toast,自动消失。\nconst toastStack = document.getElementById(\"toastStack\");\n\nfunction notify(message, type = \"info\") {\n const text = String(message ?? \"\").trim();\n if (!text) {\n return;\n }\n if (type === \"error\") {\n setError(text);\n return;\n }\n setError(\"\");\n if (!toastStack) {\n return;\n }\n const toast = document.createElement(\"div\");\n toast.className = `toast is-${type}`;\n toast.setAttribute(\"role\", \"status\");\n toast.textContent = text;\n toastStack.append(toast);\n const remove = () => {\n toast.classList.add(\"is-leaving\");\n window.setTimeout(() => toast.remove(), 220);\n };\n window.setTimeout(remove, 2600);\n toast.addEventListener(\"click\", remove);\n}\n\n// ---------- 未保存改动追踪 ----------\nfunction settingsSnapshot() {\n try {\n return JSON.stringify(collectSettings());\n } catch {\n return settingsBaseline;\n }\n}\n\nfunction computeDirty() {\n return Boolean(request) && settingsBaseline !== null && settingsSnapshot() !== settingsBaseline;\n}\n\nfunction refreshDirty() {\n const dirty = computeDirty();\n document.body.classList.toggle(\"is-dirty\", dirty);\n fields.saveButton.classList.toggle(\"has-changes\", dirty);\n}\n\nlet dirtyTimer = null;\nlet submissionBusy = false;\nconst submissionDisabledStates = new Map();\n\nfunction setSubmissionBusy(busy) {\n submissionBusy = Boolean(busy);\n document.body.classList.toggle(\"is-submitting\", submissionBusy);\n document.querySelectorAll(\"input, select, textarea, button\").forEach((control) => {\n if (submissionBusy) {\n if (!submissionDisabledStates.has(control)) {\n submissionDisabledStates.set(control, control.disabled);\n }\n control.disabled = true;\n return;\n }\n if (submissionDisabledStates.has(control)) {\n control.disabled = submissionDisabledStates.get(control);\n submissionDisabledStates.delete(control);\n }\n });\n}\n\nfunction scheduleDirty() {\n if (settingsBaseline === null || submissionBusy) {\n return;\n }\n window.clearTimeout(dirtyTimer);\n dirtyTimer = window.setTimeout(refreshDirty, 150);\n}\n\n// 关窗/取消前若有未保存改动则二次确认,返回是否放行。\nasync function confirmDiscard() {\n if (!computeDirty()) {\n return true;\n }\n return confirmAction(\"有未保存的改动,确定放弃并关闭吗?\", {\n title: \"放弃改动\",\n confirmText: \"放弃\",\n cancelText: \"返回\",\n danger: true,\n });\n}\n\nasync function closeSettingsWindow() {\n bypassCloseGuard = true;\n try {\n await invoke(\"cancel_settings\");\n return;\n } catch (error) {\n const current = window.__TAURI__?.window?.getCurrentWindow?.();\n if (current?.close) {\n await current.close();\n return;\n }\n throw error;\n }\n}\n\nlet closeRequestInFlight = false;\nasync function requestCancelClose() {\n if (closeRequestInFlight) {\n return;\n }\n closeRequestInFlight = true;\n try {\n if (!(await confirmDiscard())) {\n return;\n }\n await closeSettingsWindow();\n } catch (error) {\n bypassCloseGuard = false;\n setError(String(error));\n } finally {\n closeRequestInFlight = false;\n }\n}\n\nfunction markInvalid(input, invalid) {\n if (input) {\n input.classList.toggle(\"is-invalid\", Boolean(invalid));\n }\n}\n\nfunction setControlDisabled(control, disabled, { row = true } = {}) {\n if (!control) {\n return;\n }\n control.disabled = Boolean(disabled);\n if (row) {\n control.closest(\".setting-row\")?.classList.toggle(\"is-disabled\", Boolean(disabled));\n }\n refreshSelect(control);\n}\n\nfunction syncDesktopMcpControl(mcp) {\n const desktop = mcp.desktop || { supported: true, label: \"Windows MCP\", experimental_text: \"\" };\n const row = fields.windowsMcp.closest(\".setting-row\");\n if (row) {\n row.hidden = !desktop.supported;\n }\n const title = row?.querySelector(\".setting-title\");\n const desc = row?.querySelector(\".setting-desc\");\n if (title && desktop.label) {\n title.textContent = `${desktop.label} 桌面控制`;\n }\n if (desc) {\n const experimental = desktop.experimental_text ? `${desktop.experimental_text}。` : \"\";\n desc.textContent = `${experimental}允许桌宠通过 MCP 操作桌面与应用,修改后需重启 Sakura。`;\n }\n}\n\nfunction clearMemoryRetry() {\n window.clearTimeout(memoryRetryTimer);\n memoryRetryTimer = null;\n}\n\nfunction scheduleMemoryRetry() {\n clearMemoryRetry();\n if (!fields.pages.memory.classList.contains(\"is-active\")) {\n return;\n }\n memoryRetryTimer = window.setTimeout(loadMemories, 1500);\n}\n\nfunction confirmAction(\n message,\n { title = \"确认操作\", confirmText = \"确认\", cancelText = \"取消\", danger = false } = {},\n) {\n return new Promise((resolve) => {\n const overlay = document.createElement(\"div\");\n overlay.className = \"confirm-overlay\";\n const dialog = document.createElement(\"section\");\n dialog.className = \"confirm-dialog\";\n dialog.setAttribute(\"role\", \"dialog\");\n dialog.setAttribute(\"aria-modal\", \"true\");\n const heading = document.createElement(\"h2\");\n heading.textContent = title;\n const body = document.createElement(\"p\");\n body.textContent = message;\n const actions = document.createElement(\"div\");\n actions.className = \"confirm-actions\";\n const cancel = document.createElement(\"button\");\n cancel.type = \"button\";\n cancel.className = \"secondary-button\";\n cancel.textContent = cancelText;\n const confirm = document.createElement(\"button\");\n confirm.type = \"button\";\n if (danger) {\n confirm.className = \"danger-button\";\n }\n confirm.textContent = confirmText;\n actions.append(cancel, confirm);\n dialog.append(heading, body, actions);\n overlay.append(dialog);\n\n function close(value) {\n document.removeEventListener(\"keydown\", onKey, true);\n overlay.remove();\n resolve(value);\n }\n function onKey(event) {\n if (event.key === \"Escape\") {\n close(false);\n }\n }\n overlay.addEventListener(\"click\", (event) => {\n if (event.target === overlay) {\n close(false);\n }\n });\n cancel.addEventListener(\"click\", () => close(false));\n confirm.addEventListener(\"click\", () => close(true));\n document.addEventListener(\"keydown\", onKey, true);\n document.body.append(overlay);\n confirm.focus();\n });\n}\n\nfunction isHexColor(value) {\n return typeof value === \"string\" && /^#[0-9a-fA-F]{6}$/.test(value);\n}\n\nfunction applyTheme(theme) {\n const style = document.documentElement.style;\n Object.entries(themeVars).forEach(([key, cssVar]) => {\n const value = theme?.[key];\n if (isHexColor(value)) {\n style.setProperty(cssVar, value);\n }\n });\n}\n\nfunction runThemeTransition(update) {\n if (reduceMotionQuery?.matches || typeof document.startViewTransition !== \"function\") {\n update();\n return;\n }\n document.documentElement.classList.add(\"is-theme-view-transition\");\n const transition = document.startViewTransition(update);\n transition.finished.finally(() => {\n document.documentElement.classList.remove(\"is-theme-view-transition\");\n });\n}\n\nfunction replayMotion(element, className) {\n if (!element || reduceMotionQuery?.matches) {\n return;\n }\n element.classList.remove(className);\n void element.offsetWidth;\n element.classList.add(className);\n}\n\nfunction markThemeChanged() {\n themeChanged = true;\n applyTheme(collectThemeSettings());\n}\n\n// 自定义下拉框:WebView2 在 Windows 上的原生 (隐藏)承载取值与 change 事件,只把视觉换成可控弹层。\n// 弹层用 position:fixed + getBoundingClientRect 定位,避开 .page-scroll 的 overflow 裁剪。\nfunction enhanceSelect(select) {\n if (!select || select.__customSelect) {\n return;\n }\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"custom-select\";\n const trigger = document.createElement(\"button\");\n trigger.type = \"button\";\n trigger.className = \"custom-select__trigger\";\n const label = document.createElement(\"span\");\n label.className = \"custom-select__label\";\n const caret = document.createElement(\"span\");\n caret.className = \"custom-select__caret\";\n caret.setAttribute(\"aria-hidden\", \"true\");\n trigger.append(label, caret);\n const menu = document.createElement(\"div\");\n menu.className = \"custom-select__menu\";\n menu.setAttribute(\"role\", \"listbox\");\n\n select.parentNode.insertBefore(wrapper, select);\n // menu 不挂在 wrapper 内:打开时才挂到 (见 openMenu),避免被祖先的\n // transform 包含块推偏定位。\n wrapper.append(trigger, select);\n\n function syncTrigger() {\n const option = select.options[select.selectedIndex];\n label.textContent = option ? option.textContent : \"\";\n trigger.disabled = select.disabled;\n }\n\n function buildMenu() {\n menu.textContent = \"\";\n Array.from(select.options).forEach((option) => {\n const item = document.createElement(\"div\");\n item.className = \"custom-select__option\";\n item.setAttribute(\"role\", \"option\");\n item.textContent = option.textContent;\n if (option.value === select.value) {\n item.classList.add(\"is-selected\");\n item.setAttribute(\"aria-selected\", \"true\");\n }\n if (option.disabled) {\n item.classList.add(\"is-disabled\");\n item.setAttribute(\"aria-disabled\", \"true\");\n }\n item.addEventListener(\"click\", () => {\n if (option.disabled) {\n return;\n }\n if (select.value !== option.value) {\n select.value = option.value;\n select.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n syncTrigger();\n closeMenu();\n });\n menu.append(item);\n });\n }\n\n // 弹层挂在 上,按视口坐标定位;下方空间不足且上方更宽裕时向上弹出。\n function positionMenu() {\n const rect = trigger.getBoundingClientRect();\n const maxWidth = Math.max(120, window.innerWidth - 16);\n menu.style.minWidth = `${rect.width}px`;\n menu.style.width = \"max-content\";\n menu.style.maxWidth = `${maxWidth}px`;\n const menuWidth = Math.min(menu.offsetWidth, maxWidth);\n menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8))}px`;\n const menuHeight = menu.offsetHeight;\n const spaceBelow = window.innerHeight - rect.bottom;\n if (spaceBelow < menuHeight + 12 && rect.top > spaceBelow) {\n menu.style.top = `${Math.max(8, rect.top - 6 - menuHeight)}px`;\n } else {\n menu.style.top = `${rect.bottom + 6}px`;\n }\n }\n\n function onDocPointer(event) {\n if (!wrapper.contains(event.target) && !menu.contains(event.target)) {\n closeMenu();\n }\n }\n function onKey(event) {\n if (event.key === \"Escape\") {\n closeMenu();\n }\n }\n function openMenu() {\n if (select.disabled) {\n return;\n }\n buildMenu();\n document.body.appendChild(menu);\n menu.classList.add(\"is-open\");\n positionMenu();\n wrapper.classList.add(\"is-open\");\n document.addEventListener(\"pointerdown\", onDocPointer, true);\n document.addEventListener(\"keydown\", onKey, true);\n window.addEventListener(\"scroll\", closeMenu, true);\n window.addEventListener(\"resize\", closeMenu, true);\n }\n function closeMenu() {\n wrapper.classList.remove(\"is-open\");\n menu.classList.remove(\"is-open\");\n menu.remove();\n document.removeEventListener(\"pointerdown\", onDocPointer, true);\n document.removeEventListener(\"keydown\", onKey, true);\n window.removeEventListener(\"scroll\", closeMenu, true);\n window.removeEventListener(\"resize\", closeMenu, true);\n }\n\n trigger.addEventListener(\"click\", () => {\n wrapper.classList.contains(\"is-open\") ? closeMenu() : openMenu();\n });\n select.addEventListener(\"change\", syncTrigger);\n\n select.__customSelect = { refresh: syncTrigger };\n syncTrigger();\n}\n\nfunction refreshSelect(select) {\n if (select && select.__customSelect) {\n select.__customSelect.refresh();\n }\n}\n\nfunction setNumericBounds(input, bounds) {\n input.min = String(bounds[0]);\n input.max = String(bounds[1]);\n}\n\nfunction clampInt(value, bounds) {\n const number = Number.parseInt(value, 10);\n if (!Number.isFinite(number)) {\n return bounds[0];\n }\n return Math.min(bounds[1], Math.max(bounds[0], number));\n}\n\nfunction clampFloat(value, bounds) {\n const number = Number.parseFloat(value);\n if (!Number.isFinite(number)) {\n return bounds[0];\n }\n return Math.min(bounds[1], Math.max(bounds[0], number));\n}\n\nfunction normalizeColorText(value, fallback) {\n const text = String(value || \"\").trim();\n const prefixed = text.startsWith(\"#\") ? text : `#${text}`;\n return isHexColor(prefixed) ? prefixed.toLowerCase() : fallback;\n}\n\nfunction themeFieldInput(id) {\n return fields.themeColors.querySelector(`[data-theme-field=\"${id}\"]`);\n}\n\nfunction themeFieldLabel(id) {\n return request.theme_fields.find((field) => field.id === id)?.label || id;\n}\n\nfunction themeFieldValue(id) {\n const input = themeFieldInput(id);\n return normalizeColorText(input?.value, request.theme_defaults[id]);\n}\n\nfunction hexToRgb(hex) {\n const value = normalizeColorText(hex, \"#000000\").slice(1);\n return {\n r: Number.parseInt(value.slice(0, 2), 16),\n g: Number.parseInt(value.slice(2, 4), 16),\n b: Number.parseInt(value.slice(4, 6), 16),\n };\n}\n\nfunction componentToHex(value) {\n return Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, \"0\");\n}\n\nfunction rgbToHex({ r, g, b }) {\n return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;\n}\n\nfunction rgbToHsv({ r, g, b }) {\n const red = r / 255;\n const green = g / 255;\n const blue = b / 255;\n const max = Math.max(red, green, blue);\n const min = Math.min(red, green, blue);\n const delta = max - min;\n let h = 0;\n if (delta !== 0) {\n if (max === red) {\n h = ((green - blue) / delta) % 6;\n } else if (max === green) {\n h = (blue - red) / delta + 2;\n } else {\n h = (red - green) / delta + 4;\n }\n h *= 60;\n if (h < 0) {\n h += 360;\n }\n }\n return {\n h,\n s: max === 0 ? 0 : delta / max,\n v: max,\n };\n}\n\nfunction hsvToRgb({ h, s, v }) {\n const chroma = v * s;\n const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));\n const m = v - chroma;\n let red = 0;\n let green = 0;\n let blue = 0;\n if (h < 60) {\n red = chroma; green = x;\n } else if (h < 120) {\n red = x; green = chroma;\n } else if (h < 180) {\n green = chroma; blue = x;\n } else if (h < 240) {\n green = x; blue = chroma;\n } else if (h < 300) {\n red = x; blue = chroma;\n } else {\n red = chroma; blue = x;\n }\n return {\n r: (red + m) * 255,\n g: (green + m) * 255,\n b: (blue + m) * 255,\n };\n}\n\nconst pageMeta = {\n character: { title: \"角色与布局\", subtitle: \"选择陪伴角色与桌宠布局\" },\n appearance: { title: \"外观\", subtitle: \"配色与输入栏视觉效果\" },\n providers: { title: \"供应商\", subtitle: \"管理 API 供应商、密钥与模型\" },\n model: { title: \"模型\", subtitle: \"功能模型分配与高级参数\" },\n voice: { title: \"语音\", subtitle: \"TTS 提供器与语音参数\" },\n interaction: { title: \"交互\", subtitle: \"字幕、气泡与快速接话\" },\n privacy: { title: \"隐私\", subtitle: \"主动屏幕感知、频率与黑名单\" },\n tools: { title: \"工具\", subtitle: \"桌面控制与工具循环上限\" },\n plugins: { title: \"插件\", subtitle: \"启停状态、权限、来源与重启生效预览\" },\n system: { title: \"系统\", subtitle: \"启动、日志与排查工具\" },\n memory: { title: \"记忆\", subtitle: \"查看、编辑、删除长期记忆与常驻档案\" },\n};\n\nfunction showPage(page) {\n Object.entries(fields.pages).forEach(([key, element]) => {\n element.classList.toggle(\"is-active\", key === page);\n });\n fields.navItems.forEach((item) => {\n const active = item.dataset.page === page;\n item.classList.toggle(\"is-active\", active);\n if (active) {\n item.setAttribute(\"aria-current\", \"page\");\n } else {\n item.removeAttribute(\"aria-current\");\n }\n });\n document.querySelector(\".page-scroll\")?.classList.toggle(\n \"is-admin-active\",\n page === \"memory\" || page === \"plugins\" || page === \"providers\",\n );\n if (page !== \"memory\") {\n clearMemoryRetry();\n }\n const meta = pageMeta[page];\n if (meta) {\n fields.pageTitle.textContent = meta.title;\n fields.pageSubtitle.textContent = meta.subtitle;\n replayMotion(fields.pageHead, \"is-switching\");\n }\n // 进入「模型」页时按当前供应商重建槽位选项(供应商可能在另一页被改过)。\n if (page === \"model\" && request) {\n refreshModelSlots();\n }\n if (\n page === \"memory\"\n && !memoryState.loading\n && (!memoryState.loaded || memoryState.status === \"loading\")\n ) {\n loadMemories();\n }\n}\n\nfunction isOnboarding() {\n return Boolean(request?.onboarding);\n}\n\nfunction onboardingChatProfile() {\n const profiles = normalizedProviderProfiles();\n const chat = collectModelSelection().slots.chat || {};\n return profiles.find((profile) => profile.id === chat.profile_id) || null;\n}\n\nfunction onboardingApiReady() {\n const profile = onboardingChatProfile();\n const chat = collectModelSelection().slots.chat || {};\n return Boolean(\n profile\n && profile.base_url\n && profile.api_key\n && chat.model\n && profile.models.includes(chat.model)\n );\n}\n\nfunction updateOnboardingUi() {\n if (!isOnboarding()) {\n return;\n }\n const characterReady = Boolean(selectedCharacter());\n const apiReady = onboardingApiReady();\n const providerActive = onboardingStep === \"providers\";\n fields.onboardingCharacterStep.classList.toggle(\"is-active\", !providerActive);\n fields.onboardingCharacterStep.classList.toggle(\"is-complete\", characterReady);\n fields.onboardingProviderStep.classList.toggle(\"is-active\", providerActive);\n fields.onboardingProviderStep.classList.toggle(\"is-complete\", apiReady);\n fields.onboardingProviderStep.disabled = !characterReady;\n fields.onboardingCompleteStep.classList.toggle(\"is-complete\", characterReady && apiReady);\n fields.onboardingBackButton.hidden = !providerActive;\n fields.saveButton.disabled = characterArchiveBusy || !(characterReady && apiReady);\n}\n\nfunction showOnboardingStep(page) {\n if (!isOnboarding() || (page === \"providers\" && !selectedCharacter())) {\n return;\n }\n onboardingStep = page;\n showPage(page);\n updateOnboardingUi();\n}\n\nfunction initializeOnboarding() {\n const active = isOnboarding();\n document.body.classList.toggle(\"is-onboarding\", active);\n fields.onboardingHead.hidden = !active;\n if (!active) {\n return;\n }\n fields.saveButton.textContent = \"完成并启动 Sakura\";\n showOnboardingStep(selectedCharacter() ? \"providers\" : \"character\");\n}\n\nfunction syncEnabledState() {\n const enabled = fields.enabled.checked;\n const dependent = [\n fields.proactiveCooldownSeconds,\n fields.proactiveTimerSeconds,\n fields.proactiveAdaptiveMin,\n fields.proactiveAdaptiveMax,\n fields.proactiveIdleThreshold,\n fields.proactiveMinSilence,\n fields.proactiveWindowSwitch,\n fields.proactiveWindowSwitchCooldown,\n fields.proactiveFocusSettle,\n fields.proactiveMaxEdge,\n fields.proactiveBlockedProcesses,\n fields.proactiveBlockedTitles,\n ];\n dependent.forEach((el) => setControlDisabled(el, !enabled));\n if (enabled) {\n setControlDisabled(fields.proactiveWindowSwitchCooldown, !fields.proactiveWindowSwitch.checked);\n setControlDisabled(fields.proactiveFocusSettle, !fields.proactiveWindowSwitch.checked);\n }\n updateScreenResolutionEstimate();\n}\n\nfunction updateScreenResolutionEstimate() {\n if (!request) return;\n const maxEdge = Number(fields.proactiveMaxEdge?.value || request.proactive?.max_edge || 1920);\n fields.tokenEstimate.textContent =\n `聚焦窗口截图(最长边约 ${maxEdge}px):约 ${request.estimated_tokens_per_image?.toLocaleString(\"zh-CN\") || \"—\"} token/张。`;\n}\n\nfunction linesToList(text) {\n return String(text || \"\")\n .split(/\\r?\\n/)\n .map((line) => line.trim())\n .filter(Boolean);\n}\n\nfunction listToLines(values) {\n return Array.isArray(values) ? values.join(\"\\n\") : \"\";\n}\n\nfunction syncRuntimeLoopState() {\n if (!request) {\n return;\n }\n const perStep = clampInt(fields.toolCallsPerStep.value, request.limits.max_tool_calls_per_step);\n fields.toolCallsPerTurn.min = String(perStep);\n}\n\nfunction syncDebugLogState() {\n setControlDisabled(fields.debugBodyEnabled, !fields.debugLogEnabled.checked);\n}\n\nfunction syncBubbleState() {\n setControlDisabled(fields.bubbleAutoHideDelay, !fields.bubbleAutoHide.checked);\n}\n\nfunction selectedCharacter() {\n const id = fields.characterSelect.value;\n return request.character.characters.find((item) => item.id === id) || null;\n}\n\nfunction selectedCharacterHasExportableVoice() {\n return Boolean(selectedCharacter()?.has_exportable_voice);\n}\n\nfunction selectedCharacterThemeDefaults() {\n return selectedCharacter()?.default_theme || request.theme_defaults;\n}\n\nfunction selectedCharacterTheme() {\n return selectedCharacter()?.theme || selectedCharacterThemeDefaults();\n}\n\n// 切换角色时跟随载入该角色的最终配色(仅配色,输入栏视觉效果等用户级偏好保留)。\nfunction applySelectedCharacterTheme() {\n setThemeValues(selectedCharacterTheme(), { updateVisualEffect: false, animateTheme: true });\n}\n\nfunction ttsProviderDefaults(provider) {\n return request?.tts?.provider_defaults?.[provider] || {};\n}\n\nfunction ttsDefaultValue(provider, key) {\n return String(ttsProviderDefaults(provider)[key] || \"\");\n}\n\nfunction isBundledTtsProvider(provider) {\n return provider === \"gpt-sovits\" || provider === \"genie-tts\";\n}\n\nfunction normalizeTtsPathText(value) {\n return String(value || \"\").trim().replaceAll(\"/\", \"\\\\\").toLowerCase();\n}\n\nfunction isBundledTtsDefaultPath(value, key) {\n const normalized = normalizeTtsPathText(value);\n return Boolean(normalized) && [\"gpt-sovits\", \"genie-tts\"].some((provider) => (\n normalizeTtsPathText(ttsDefaultValue(provider, key)) === normalized\n ));\n}\n\nfunction isTtsDefaultApiUrl(value) {\n const apiUrl = String(value || \"\").trim();\n return Boolean(apiUrl) && [\"gpt-sovits\", \"genie-tts\", \"custom-gpt-sovits\"].some((provider) => (\n ttsDefaultValue(provider, \"api_url\") === apiUrl\n ));\n}\n\nfunction applyTtsProviderDefaults(previousProvider = lastTtsProvider) {\n const provider = fields.ttsProvider.value;\n const defaults = ttsProviderDefaults(provider);\n const apiUrl = fields.ttsApiUrl.value.trim();\n const oldApiUrl = ttsDefaultValue(previousProvider, \"api_url\");\n const newApiUrl = String(defaults.api_url || \"\");\n if (newApiUrl && (!apiUrl || apiUrl === oldApiUrl || isTtsDefaultApiUrl(apiUrl))) {\n fields.ttsApiUrl.value = newApiUrl;\n }\n if (isBundledTtsProvider(provider)) {\n fields.ttsWorkDir.value = String(defaults.work_dir || \"\");\n fields.ttsPythonPath.value = String(defaults.python_path || \"\");\n fields.ttsConfigPath.value = \"\";\n } else if (provider === \"custom-gpt-sovits\") {\n if (isBundledTtsDefaultPath(fields.ttsWorkDir.value, \"work_dir\")) {\n fields.ttsWorkDir.value = \"\";\n }\n if (isBundledTtsDefaultPath(fields.ttsPythonPath.value, \"python_path\")) {\n fields.ttsPythonPath.value = \"\";\n }\n fields.ttsConfigPath.value = \"\";\n }\n lastTtsProvider = provider;\n}\n\nfunction syncTtsBundleNotice() {\n const provider = fields.ttsProvider.value;\n const notice = isBundledTtsProvider(provider) ? String(ttsProviderDefaults(provider).notice || \"\") : \"\";\n fields.ttsBundleNotice.textContent = notice;\n fields.ttsBundleNoticeRow.hidden = !notice;\n}\n\nfunction syncTtsState() {\n const character = selectedCharacter();\n const hasVoice = character ? Boolean(character.has_voice) : true;\n if (!hasVoice) {\n fields.ttsEnabled.checked = false;\n }\n setControlDisabled(fields.ttsEnabled, !hasVoice);\n const active = fields.ttsEnabled.checked && fields.ttsProvider.value !== \"none\";\n const bundledProvider = isBundledTtsProvider(fields.ttsProvider.value);\n setControlDisabled(fields.ttsApiUrl, !active);\n setControlDisabled(fields.ttsTimeout, !active);\n setControlDisabled(fields.ttsWorkDir, !active || bundledProvider);\n setControlDisabled(fields.ttsPythonPath, !active || bundledProvider);\n fields.ttsWorkDir.readOnly = false;\n fields.ttsPythonPath.readOnly = false;\n fields.ttsConfigPath.disabled = true;\n setControlDisabled(fields.ttsTestButton, !active);\n syncTtsBundleNotice();\n syncBackchannelState({ renderResource: false });\n if (request) {\n renderTtsResourceCard();\n }\n}\n\nfunction syncBackchannelState({ renderResource = true } = {}) {\n const enabled = fields.backchannelEnabled.checked;\n const ttsAvailable = fields.ttsEnabled.checked && !fields.ttsEnabled.disabled && fields.ttsProvider.value !== \"none\";\n setControlDisabled(fields.backchannelMode, !enabled);\n setControlDisabled(fields.backchannelDelay, !enabled);\n setControlDisabled(fields.backchannelProbability, !enabled);\n setControlDisabled(fields.backchannelTtsEnabled, !enabled || !ttsAvailable);\n if (renderResource) {\n renderBackchannelResourceCard();\n }\n}\n\nasync function testTtsSettings() {\n const character = selectedCharacter();\n if (!character) {\n setError(\"请先选择一个角色。\");\n return;\n }\n const original = fields.ttsTestButton.textContent;\n fields.ttsTestButton.disabled = true;\n fields.ttsTestButton.textContent = \"检测中…\";\n setError(\"\");\n try {\n const result = await hostCall(\"tts.test\", {\n character_id: character.id,\n tts: collectTtsSettings(),\n });\n notify(result?.message || \"TTS 服务检测成功。\", \"success\");\n } catch (error) {\n setError(`TTS 检测失败:${error}`);\n } finally {\n fields.ttsTestButton.disabled = false;\n fields.ttsTestButton.textContent = original;\n syncTtsState();\n }\n}\n\nfunction handleTtsProviderChange() {\n resourceState.ttsBundleKey = \"\";\n applyTtsProviderDefaults(lastTtsProvider);\n syncTtsState();\n}\n\nfunction syncApiAdvancedState() {\n setControlDisabled(fields.apiTopP, !fields.apiTopPEnabled.checked, { row: false });\n setControlDisabled(fields.apiMaxTokens, !fields.apiMaxTokensEnabled.checked, { row: false });\n}\n\nfunction renderCharacters() {\n fields.characterSelect.textContent = \"\";\n request.character.characters.forEach((character) => {\n const option = document.createElement(\"option\");\n option.value = character.id;\n option.textContent = character.display_name || character.id;\n fields.characterSelect.append(option);\n });\n fields.characterSelect.value = request.character.current_character_id;\n syncCharacterArchiveState();\n}\n\nfunction syncCharacterArchiveState() {\n if (!request) {\n return;\n }\n const character = selectedCharacter();\n const hasCharacter = Boolean(character);\n fields.characterSelect.disabled = characterArchiveBusy || !request.character.characters.length;\n fields.characterImportButton.disabled = characterArchiveBusy;\n fields.ttsVoiceImportButton.disabled = characterArchiveBusy || !hasCharacter;\n fields.characterExportButton.disabled = characterArchiveBusy || !hasCharacter;\n fields.characterEditorButton.disabled = characterArchiveBusy;\n fields.saveButton.disabled = characterArchiveBusy;\n fields.applyButton.disabled = characterArchiveBusy;\n fields.cancelButton.disabled = characterArchiveBusy;\n fields.characterArchiveHint.textContent = characterArchiveBusy\n ? \"角色包处理中...\"\n : (hasCharacter ? \"管理 Sakura .char 与 .voice 文件。\" : \"先导入一个 Sakura .char 角色包。\");\n refreshSelect(fields.characterSelect);\n updateOnboardingUi();\n}\n\nfunction setCharacterArchiveBusy(busy) {\n characterArchiveBusy = Boolean(busy);\n syncCharacterArchiveState();\n}\n\nfunction renderThemeControls() {\n fields.themeColors.textContent = \"\";\n activeThemeField = activeThemeField || request.theme_fields[0]?.id || \"\";\n\n request.theme_fields.forEach(({ id, label }) => {\n const row = document.createElement(\"div\");\n row.className = \"form-row theme-color-row\";\n row.dataset.themeRole = id;\n const rowLabel = document.createElement(\"label\");\n rowLabel.htmlFor = `theme-${id}`;\n rowLabel.textContent = label;\n const controls = document.createElement(\"div\");\n controls.className = \"theme-color-control\";\n\n const swatchButton = document.createElement(\"button\");\n swatchButton.type = \"button\";\n swatchButton.className = \"theme-color-swatch\";\n swatchButton.dataset.themeSwatch = id;\n swatchButton.title = \"调整颜色\";\n swatchButton.addEventListener(\"click\", () => openThemeColorPopover(id, swatchButton));\n\n const textInput = document.createElement(\"input\");\n textInput.id = `theme-${id}`;\n textInput.type = \"text\";\n textInput.maxLength = 7;\n textInput.placeholder = \"#RRGGBB\";\n textInput.dataset.themeField = id;\n textInput.addEventListener(\"input\", () => {\n syncThemeRole(id);\n if (id === activeThemeField) {\n syncThemeEditor();\n }\n markThemeChanged();\n });\n\n controls.append(swatchButton, textInput);\n row.append(rowLabel, controls);\n fields.themeColors.append(row);\n });\n\n fields.themeColors.append(buildThemeEditor());\n request.theme_fields.forEach(({ id }) => syncThemeRole(id));\n selectThemeField(activeThemeField, { open: false });\n\n fields.visualEffectMode.textContent = \"\";\n const currentMode = request.theme.visual_effect_mode;\n const modes = [...request.visual_effect_modes];\n if (!modes.some((mode) => mode.id === currentMode)) {\n modes.push({ id: currentMode, label: currentMode });\n }\n modes.forEach((mode) => {\n const option = document.createElement(\"option\");\n option.value = mode.id;\n option.textContent = mode.label;\n fields.visualEffectMode.append(option);\n });\n}\n\nfunction buildThemeEditor() {\n const editor = document.createElement(\"dialog\");\n editor.className = \"theme-color-popover\";\n editor.hidden = true;\n\n const head = document.createElement(\"div\");\n head.className = \"theme-editor-head\";\n const swatch = document.createElement(\"div\");\n swatch.className = \"theme-editor-swatch\";\n const title = document.createElement(\"div\");\n title.className = \"theme-editor-title\";\n const label = document.createElement(\"strong\");\n const key = document.createElement(\"span\");\n title.append(label, key);\n head.append(swatch, title);\n\n const hexRow = document.createElement(\"label\");\n hexRow.className = \"theme-editor-field\";\n hexRow.textContent = \"HEX\";\n const hex = document.createElement(\"input\");\n hex.type = \"text\";\n hex.maxLength = 7;\n hex.placeholder = \"#RRGGBB\";\n hex.addEventListener(\"input\", () => {\n const color = normalizeColorText(hex.value, \"\");\n markInvalid(hex, !color);\n if (color) {\n updateActiveThemeColor(color);\n }\n });\n hexRow.append(hex);\n\n const rgb = document.createElement(\"div\");\n rgb.className = \"theme-rgb-row\";\n const rgbInputs = [\"R\", \"G\", \"B\"].map((name) => {\n const field = document.createElement(\"label\");\n field.textContent = name;\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.min = \"0\";\n input.max = \"255\";\n input.step = \"1\";\n input.addEventListener(\"input\", updateThemeFromRgbInputs);\n field.append(input);\n rgb.append(field);\n return input;\n });\n\n const svPad = document.createElement(\"div\");\n svPad.className = \"theme-sv-pad\";\n const svPointer = document.createElement(\"span\");\n svPointer.className = \"theme-picker-pointer\";\n svPad.append(svPointer);\n svPad.addEventListener(\"pointerdown\", updateThemeFromSvPointer);\n svPad.addEventListener(\"pointermove\", (event) => {\n if (event.buttons & 1) {\n updateThemeFromSvPointer(event);\n }\n });\n\n const hue = document.createElement(\"div\");\n hue.className = \"theme-hue-strip\";\n const huePointer = document.createElement(\"span\");\n huePointer.className = \"theme-hue-pointer\";\n hue.append(huePointer);\n hue.addEventListener(\"pointerdown\", updateThemeFromHuePointer);\n hue.addEventListener(\"pointermove\", (event) => {\n if (event.buttons & 1) {\n updateThemeFromHuePointer(event);\n }\n });\n\n const actions = document.createElement(\"div\");\n actions.className = \"theme-editor-actions\";\n const pick = document.createElement(\"button\");\n pick.type = \"button\";\n pick.className = \"secondary-button\";\n pick.textContent = \"取色\";\n pick.addEventListener(\"click\", pickActiveThemeColor);\n const done = document.createElement(\"button\");\n done.type = \"button\";\n done.className = \"primary-button\";\n done.textContent = \"完成\";\n done.addEventListener(\"click\", closeThemeColorPopover);\n actions.append(pick, done);\n\n editor.append(head, svPad, hue, hexRow, rgb, actions);\n themeEditor = {\n root: editor,\n swatch,\n label,\n key,\n hex,\n rgbInputs,\n svPad,\n svPointer,\n hue,\n huePointer,\n pick,\n };\n return editor;\n}\n\nfunction syncThemeRole(id) {\n const input = themeFieldInput(id);\n const color = normalizeColorText(input?.value, \"\");\n const fallback = themeFieldValue(id);\n const row = fields.themeColors.querySelector(`[data-theme-role=\"${id}\"]`);\n const swatch = fields.themeColors.querySelector(`[data-theme-swatch=\"${id}\"]`);\n if (row) {\n row.classList.toggle(\"is-active\", id === activeThemeField);\n row.classList.toggle(\"is-invalid\", Boolean(input?.value) && !color);\n }\n if (swatch) {\n swatch.style.backgroundColor = color || fallback;\n }\n}\n\nfunction selectThemeField(id, options = {}) {\n if (!request.theme_fields.some((field) => field.id === id)) {\n activeThemeField = request.theme_fields[0]?.id || \"\";\n } else {\n activeThemeField = id;\n }\n request.theme_fields.forEach(({ id: fieldId }) => syncThemeRole(fieldId));\n syncThemeEditor();\n if (options.open !== false) {\n openThemeColorPopover(activeThemeField, fields.themeColors.querySelector(`[data-theme-swatch=\"${activeThemeField}\"]`));\n }\n}\n\nfunction syncThemeEditor() {\n if (!themeEditor.root || !activeThemeField) {\n return;\n }\n const color = themeFieldValue(activeThemeField);\n const rgb = hexToRgb(color);\n const hsv = rgbToHsv(rgb);\n themeEditor.root.style.setProperty(\"--theme-editor-color\", color);\n themeEditor.root.style.setProperty(\"--theme-editor-hue\", `${hsv.h}deg`);\n themeEditor.swatch.style.background = color;\n themeEditor.label.textContent = themeFieldLabel(activeThemeField);\n themeEditor.key.textContent = activeThemeField;\n themeEditor.hex.value = color;\n markInvalid(themeEditor.hex, false);\n [rgb.r, rgb.g, rgb.b].forEach((value, index) => {\n themeEditor.rgbInputs[index].value = String(value);\n });\n themeEditor.svPointer.style.left = `${hsv.s * 100}%`;\n themeEditor.svPointer.style.top = `${(1 - hsv.v) * 100}%`;\n themeEditor.huePointer.style.left = `${(hsv.h / 360) * 100}%`;\n}\n\nfunction openThemeColorPopover(id) {\n selectThemeField(id, { open: false });\n const popover = themeEditor.root;\n if (!popover) {\n return;\n }\n popover.hidden = false;\n if (!popover.open) {\n popover.showModal();\n }\n themeEditor.hex.focus();\n document.addEventListener(\"keydown\", closeThemePopoverOnEscape, true);\n}\n\nfunction closeThemeColorPopover() {\n if (themeEditor.root) {\n if (themeEditor.root.open) {\n themeEditor.root.close();\n }\n themeEditor.root.hidden = true;\n }\n document.removeEventListener(\"keydown\", closeThemePopoverOnEscape, true);\n}\n\nfunction closeThemePopoverOnEscape(event) {\n if (event.key === \"Escape\") {\n closeThemeColorPopover();\n }\n}\n\nfunction updateActiveThemeColor(color) {\n const normalized = normalizeColorText(color, \"\");\n const input = themeFieldInput(activeThemeField);\n if (!normalized || !input) {\n return;\n }\n input.value = normalized;\n syncThemeRole(activeThemeField);\n syncThemeEditor();\n markThemeChanged();\n}\n\nfunction updateThemeFromRgbInputs() {\n if (!themeEditor.rgbInputs?.length) {\n return;\n }\n if (themeEditor.rgbInputs.some((input) => input.value === \"\")) {\n return;\n }\n const [r, g, b] = themeEditor.rgbInputs.map((input) => (\n Math.min(255, Math.max(0, Number.parseInt(input.value, 10) || 0))\n ));\n updateActiveThemeColor(rgbToHex({ r, g, b }));\n}\n\nfunction updateThemeFromSvPointer(event) {\n const rect = themeEditor.svPad.getBoundingClientRect();\n const x = Math.min(rect.width, Math.max(0, event.clientX - rect.left));\n const y = Math.min(rect.height, Math.max(0, event.clientY - rect.top));\n const hsv = rgbToHsv(hexToRgb(themeFieldValue(activeThemeField)));\n updateActiveThemeColor(rgbToHex(hsvToRgb({\n h: hsv.h,\n s: rect.width ? x / rect.width : 0,\n v: rect.height ? 1 - (y / rect.height) : 0,\n })));\n}\n\nfunction updateThemeFromHuePointer(event) {\n const rect = themeEditor.hue.getBoundingClientRect();\n const x = Math.min(rect.width, Math.max(0, event.clientX - rect.left));\n const hsv = rgbToHsv(hexToRgb(themeFieldValue(activeThemeField)));\n updateActiveThemeColor(rgbToHex(hsvToRgb({\n h: rect.width ? (x / rect.width) * 360 : 0,\n s: hsv.s,\n v: hsv.v,\n })));\n}\n\nasync function pickActiveThemeColor() {\n if (!activeThemeField) {\n return;\n }\n themeEditor.pick.disabled = true;\n setError(\"\");\n try {\n closeThemeColorPopover();\n const result = await hostCall(\"theme.pick_screen_color\");\n if (result?.cancelled) {\n return;\n }\n const color = normalizeColorText(result?.color, \"\");\n if (!color) {\n throw new Error(\"取色结果无效。\");\n }\n updateActiveThemeColor(color);\n } catch (error) {\n setError(`屏幕取色失败:${error}`);\n } finally {\n themeEditor.pick.disabled = false;\n }\n}\n\nfunction setThemeValues(theme, options = {}) {\n const updateVisualEffect = options.updateVisualEffect !== false;\n const animateTheme = options.animateTheme === true;\n const update = () => {\n request.theme_fields.forEach(({ id }) => {\n const textInput = themeFieldInput(id);\n const color = normalizeColorText(theme[id], request.theme_defaults[id]);\n if (textInput) {\n textInput.value = color;\n }\n syncThemeRole(id);\n });\n if (updateVisualEffect && theme.visual_effect_mode) {\n fields.visualEffectMode.value = theme.visual_effect_mode;\n refreshSelect(fields.visualEffectMode);\n }\n applyTheme({\n ...theme,\n visual_effect_mode: fields.visualEffectMode.value || request.theme.visual_effect_mode,\n });\n syncThemeEditor();\n };\n if (animateTheme) {\n runThemeTransition(update);\n return;\n }\n update();\n}\n\nasync function generateAiTheme() {\n const character = selectedCharacter();\n if (!character) {\n setError(\"请先选择一个角色。\");\n return;\n }\n const original = fields.themeAiButton.textContent;\n fields.themeAiButton.disabled = true;\n fields.themeAiButton.textContent = \"生成中…\";\n setError(\"\");\n try {\n const result = await hostCall(\"theme.generate_ai\", { character_id: character.id });\n if (!result?.theme) {\n throw new Error(\"AI 返回的主题格式无效。\");\n }\n setThemeValues(result.theme, { animateTheme: true });\n themeChanged = true;\n notify(\"AI 配色已生成。\", \"success\");\n } catch (error) {\n setError(`AI 配色失败,已保留当前配色:${error}`);\n } finally {\n fields.themeAiButton.disabled = false;\n fields.themeAiButton.textContent = original;\n }\n}\n\nfunction makeProfileId() {\n if (window.crypto?.randomUUID) {\n return window.crypto.randomUUID();\n }\n return `profile-${Date.now()}`;\n}\n\n// 供应商页改为状态驱动的主从结构:providerState.profiles 是唯一数据源,\n// 「供应商」页与「模型」页的槽位都从它派生(参照 pluginState/memoryState)。\nconst providerState = { profiles: [], selectedId: \"\", search: \"\" };\nconst inheritedSlotManualSelections = {};\nconst PROVIDER_FIELD_PLACEHOLDERS = {\n base_url: \"通常以 /v1 结尾\",\n api_key: \"通常以 sk- 开头\",\n};\n\n// 内置预设:选中即预填 Base URL 与图标,其余走「自定义」。\nconst PROVIDER_PRESETS = [\n {\n key: \"deepseek\",\n label: \"DeepSeek\",\n base_url: \"https://api.deepseek.com/v1\",\n host: \"api.deepseek.com\",\n iconUrl: \"./assets/providers/deepseek.svg\",\n },\n];\n\nfunction initializeProviderState() {\n providerState.profiles = (request.api.profiles || []).map((profile) => ({\n id: profile.id || makeProfileId(),\n alias: profile.alias || profile.id || \"供应商\",\n base_url: profile.base_url || \"\",\n api_key: profile.api_key || \"\",\n models: Array.isArray(profile.models) ? profile.models.map(String) : [],\n }));\n providerState.selectedId = providerState.profiles[0]?.id || \"\";\n}\n\nfunction providerHost(url) {\n const text = String(url || \"\").trim();\n if (!text) {\n return \"\";\n }\n try {\n return new URL(text).host;\n } catch {\n return text.replace(/^https?:\\/\\//, \"\").split(\"/\")[0];\n }\n}\n\nfunction presetForProfile(profile) {\n const host = providerHost(profile.base_url);\n const alias = String(profile.alias || \"\").toLowerCase();\n return (\n PROVIDER_PRESETS.find((preset) => preset.host === host || preset.label.toLowerCase() === alias)\n || null\n );\n}\n\nfunction filteredProviders() {\n const query = providerState.search.trim().toLowerCase();\n if (!query) {\n return providerState.profiles;\n }\n return providerState.profiles.filter((profile) =>\n [profile.alias, profile.base_url, ...(profile.models || [])]\n .join(\" \")\n .toLowerCase()\n .includes(query),\n );\n}\n\nfunction renderProviderPage() {\n renderProviderStatus();\n renderProviderList();\n renderProviderDetail();\n}\n\nfunction renderProviderStatus() {\n const items = providerState.profiles;\n const configured = items.filter(\n (profile) => (profile.base_url || \"\").trim() && (profile.api_key || \"\").trim(),\n ).length;\n const totalModels = items.reduce((sum, profile) => sum + (profile.models || []).length, 0);\n renderStrip(fields.providerStatusStrip, [\n { label: \"供应商\", value: items.length },\n { label: \"已配置\", value: configured },\n { label: \"模型\", value: totalModels },\n ]);\n}\n\n// 填充头像:优先用图标资源(如 DeepSeek SVG),其次 emoji,最后名称首字母。\nfunction applyAvatar(avatar, { iconUrl, icon, initial } = {}) {\n avatar.textContent = \"\";\n avatar.classList.remove(\"is-initial\");\n if (iconUrl) {\n const img = document.createElement(\"img\");\n img.className = \"provider-avatar-img\";\n img.src = iconUrl;\n img.alt = \"\";\n avatar.append(img);\n } else if (icon) {\n avatar.textContent = icon;\n } else {\n avatar.classList.add(\"is-initial\");\n avatar.textContent = (initial || \"?\").trim().charAt(0).toUpperCase() || \"?\";\n }\n}\n\nfunction providerAvatar(profile) {\n const avatar = document.createElement(\"span\");\n avatar.className = \"provider-avatar\";\n const preset = presetForProfile(profile);\n applyAvatar(avatar, {\n iconUrl: preset?.iconUrl,\n icon: preset?.icon,\n initial: profile.alias || \"?\",\n });\n return avatar;\n}\n\nfunction renderProviderList() {\n fields.providerList.textContent = \"\";\n const profiles = filteredProviders();\n if (!profiles.length) {\n const empty = document.createElement(\"div\");\n empty.className = \"empty-state\";\n if (providerState.profiles.length) {\n empty.textContent = \"没有匹配的供应商。\";\n } else {\n const text = document.createElement(\"p\");\n text.className = \"empty-state-text\";\n text.textContent = \"还没有供应商,先添加一个开始配置 API。\";\n const cta = document.createElement(\"button\");\n cta.type = \"button\";\n cta.className = \"primary-button\";\n cta.textContent = \"添加供应商\";\n cta.addEventListener(\"click\", openAddProviderChooser);\n empty.append(text, cta);\n }\n fields.providerList.append(empty);\n return;\n }\n profiles.forEach((profile) => {\n const card = document.createElement(\"div\");\n card.className = \"provider-card\";\n card.classList.toggle(\"is-selected\", profile.id === providerState.selectedId);\n card.addEventListener(\"click\", () => {\n providerState.selectedId = profile.id;\n renderProviderPage();\n });\n const body = document.createElement(\"div\");\n body.className = \"provider-card-body\";\n const title = document.createElement(\"strong\");\n title.textContent = profile.alias || profile.id;\n const meta = document.createElement(\"span\");\n meta.className = \"card-meta\";\n meta.textContent = providerHost(profile.base_url) || \"未设置 Base URL\";\n body.append(title, meta);\n const count = document.createElement(\"span\");\n count.className = \"provider-count\";\n count.textContent = `${(profile.models || []).length} 个模型`;\n card.append(providerAvatar(profile), body, count);\n fields.providerList.append(card);\n });\n}\n\nfunction renderProviderDetail() {\n const detail = fields.providerDetail;\n detail.textContent = \"\";\n const profile = providerState.profiles.find((item) => item.id === providerState.selectedId);\n if (!profile) {\n const empty = document.createElement(\"p\");\n empty.className = \"empty-state\";\n empty.textContent = \"选择左侧供应商查看与编辑配置。\";\n detail.append(empty);\n return;\n }\n const title = document.createElement(\"h2\");\n title.textContent = profile.alias || profile.id;\n detail.append(\n title,\n providerField(profile, \"alias\", \"名称\", \"text\"),\n providerField(profile, \"base_url\", \"Base URL\", \"text\"),\n providerField(profile, \"api_key\", \"API Key\", \"password\"),\n renderProviderModels(profile),\n );\n const actions = document.createElement(\"div\");\n actions.className = \"detail-actions\";\n const testButton = document.createElement(\"button\");\n testButton.type = \"button\";\n testButton.className = \"secondary-button\";\n testButton.textContent = \"测试连接\";\n testButton.addEventListener(\"click\", () => testProvider(profile, testButton));\n const removeButton = document.createElement(\"button\");\n removeButton.type = \"button\";\n removeButton.className = \"danger-button\";\n removeButton.textContent = \"删除供应商\";\n removeButton.addEventListener(\"click\", () => removeProvider(profile));\n actions.append(testButton, removeButton);\n detail.append(actions);\n}\n\nfunction providerField(profile, key, label, type) {\n const row = document.createElement(\"div\");\n row.className = \"form-row\";\n const labelEl = document.createElement(\"label\");\n labelEl.textContent = label;\n const input = document.createElement(\"input\");\n input.type = type === \"password\" ? \"password\" : \"text\";\n input.className = \"wide-input\";\n input.dataset.providerField = key;\n input.value = profile[key] || \"\";\n input.placeholder = PROVIDER_FIELD_PLACEHOLDERS[key] || \"\";\n input.addEventListener(\"input\", () => {\n profile[key] = input.value;\n if (input.value.trim()) {\n markInvalid(input, false);\n }\n if (key === \"alias\" || key === \"base_url\") {\n // 仅刷新左侧卡片与标题,避免重渲详情导致输入框失焦。\n renderProviderStatus();\n renderProviderList();\n if (key === \"alias\") {\n const heading = fields.providerDetail.querySelector(\"h2\");\n if (heading) {\n heading.textContent = input.value.trim() || profile.id;\n }\n }\n } else if (key === \"api_key\") {\n renderProviderStatus();\n }\n updateOnboardingUi();\n });\n row.append(labelEl, input);\n return row;\n}\n\nfunction renderProviderModels(profile) {\n const section = document.createElement(\"div\");\n section.className = \"provider-models\";\n const head = document.createElement(\"div\");\n head.className = \"provider-models-head\";\n const heading = document.createElement(\"h3\");\n heading.textContent = \"模型\";\n const detectButton = document.createElement(\"button\");\n detectButton.type = \"button\";\n detectButton.className = \"secondary-button compact-button\";\n detectButton.textContent = \"自动检测\";\n detectButton.addEventListener(\"click\", () => autoDetectModels(profile, detectButton));\n head.append(heading, detectButton);\n section.append(head);\n\n const list = document.createElement(\"div\");\n list.className = \"model-chip-list\";\n if (!(profile.models || []).length) {\n const empty = document.createElement(\"p\");\n empty.className = \"hint\";\n empty.textContent = \"还没有模型,点「自动检测」或在下方手动添加。\";\n list.append(empty);\n } else {\n profile.models.forEach((model) => {\n const chip = document.createElement(\"span\");\n chip.className = \"model-chip\";\n const name = document.createElement(\"span\");\n name.textContent = model;\n const remove = document.createElement(\"button\");\n remove.type = \"button\";\n remove.className = \"model-chip-remove\";\n remove.setAttribute(\"aria-label\", `删除 ${model}`);\n remove.textContent = \"×\";\n remove.addEventListener(\"click\", () => {\n profile.models = profile.models.filter((item) => item !== model);\n renderProviderPage();\n refreshModelSlots();\n updateOnboardingUi();\n });\n chip.append(name, remove);\n list.append(chip);\n });\n }\n section.append(list);\n\n const addRow = document.createElement(\"div\");\n addRow.className = \"model-add-row\";\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.className = \"wide-input\";\n input.placeholder = \"手动添加模型 ID\";\n const addButton = document.createElement(\"button\");\n addButton.type = \"button\";\n addButton.className = \"secondary-button compact-button\";\n addButton.textContent = \"添加\";\n const commit = () => {\n const value = input.value.trim();\n if (!value) {\n return;\n }\n const added = addModelsToProfile(profile, [value]);\n input.value = \"\";\n setError(added ? \"\" : \"该模型已存在。\");\n };\n addButton.addEventListener(\"click\", commit);\n input.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commit();\n }\n });\n addRow.append(input, addButton);\n section.append(addRow);\n return section;\n}\n\nfunction addModelsToProfile(profile, models) {\n if (!Array.isArray(profile.models)) {\n profile.models = [];\n }\n const existing = new Set(profile.models);\n let added = 0;\n models.forEach((model) => {\n const name = String(model || \"\").trim();\n if (name && !existing.has(name)) {\n existing.add(name);\n profile.models.push(name);\n added += 1;\n }\n });\n if (added) {\n renderProviderPage();\n refreshModelSlots();\n updateOnboardingUi();\n }\n return added;\n}\n\nfunction providerDetailInput(key) {\n return fields.providerDetail.querySelector(`[data-provider-field=\"${key}\"]`);\n}\n\nasync function autoDetectModels(profile, button) {\n const baseUrl = (profile.base_url || \"\").trim();\n const apiKey = (profile.api_key || \"\").trim();\n if (!baseUrl) {\n markInvalid(providerDetailInput(\"base_url\"), true);\n setError(\"请先填写 Base URL。\");\n return;\n }\n if (!apiKey) {\n markInvalid(providerDetailInput(\"api_key\"), true);\n setError(\"请先填写 API Key。\");\n return;\n }\n setError(\"\");\n const original = button.textContent;\n button.disabled = true;\n button.textContent = \"检测中…\";\n try {\n const result = await hostCall(\"api.list_models\", {\n base_url: baseUrl,\n api_key: apiKey,\n timeout_seconds: request?.api?.settings?.timeout_seconds || 60,\n });\n const models = Array.isArray(result?.models) ? result.models : [];\n if (!models.length) {\n notify(\"未检测到任何模型。\", \"info\");\n return;\n }\n openModelPicker(profile, models);\n } catch (error) {\n setError(`自动检测失败:${error}`);\n } finally {\n button.disabled = false;\n button.textContent = original;\n }\n}\n\nasync function testProvider(profile, button) {\n const baseUrl = (profile.base_url || \"\").trim();\n const apiKey = (profile.api_key || \"\").trim();\n const model = (profile.models || [])[0];\n if (!baseUrl || !apiKey) {\n markInvalid(providerDetailInput(\"base_url\"), !baseUrl);\n markInvalid(providerDetailInput(\"api_key\"), !apiKey);\n setError(\"请先填写 Base URL 与 API Key。\");\n return;\n }\n if (!model) {\n setError(\"请先添加至少一个模型再测试。\");\n return;\n }\n setError(\"\");\n const original = button.textContent;\n button.disabled = true;\n button.textContent = \"测试中…\";\n try {\n const result = await hostCall(\"api.test_connection\", {\n base_url: baseUrl,\n api_key: apiKey,\n model,\n timeout_seconds: request?.api?.settings?.timeout_seconds || 60,\n });\n notify(`连接成功:${result?.message || \"OK\"}`, \"success\");\n } catch (error) {\n setError(`连接失败:${error}`);\n } finally {\n button.disabled = false;\n button.textContent = original;\n }\n}\n\nfunction removeProvider(profile) {\n providerState.profiles = providerState.profiles.filter((item) => item.id !== profile.id);\n if (providerState.selectedId === profile.id) {\n providerState.selectedId = providerState.profiles[0]?.id || \"\";\n }\n renderProviderPage();\n refreshModelSlots();\n updateOnboardingUi();\n}\n\nfunction addProvider(preset) {\n const profile = {\n id: makeProfileId(),\n alias: preset?.label || \"新供应商\",\n base_url: preset?.base_url || \"\",\n api_key: \"\",\n models: [],\n };\n providerState.profiles.push(profile);\n providerState.selectedId = profile.id;\n providerState.search = \"\";\n if (fields.providerSearch) {\n fields.providerSearch.value = \"\";\n }\n renderProviderPage();\n refreshModelSlots();\n updateOnboardingUi();\n}\n\nfunction makeModalButton(text, className, handler) {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = className;\n button.textContent = text;\n button.addEventListener(\"click\", handler);\n return button;\n}\n\nfunction openAddProviderChooser() {\n const overlay = document.createElement(\"div\");\n overlay.className = \"confirm-overlay\";\n const dialog = document.createElement(\"div\");\n dialog.className = \"confirm-dialog provider-add-dialog\";\n const heading = document.createElement(\"h2\");\n heading.textContent = \"添加供应商\";\n const grid = document.createElement(\"div\");\n grid.className = \"provider-preset-grid\";\n const close = () => overlay.remove();\n PROVIDER_PRESETS.forEach((preset) => {\n const option = makeModalButton(\"\", \"provider-preset-option\", () => {\n addProvider(preset);\n close();\n });\n const icon = document.createElement(\"span\");\n icon.className = \"provider-avatar\";\n applyAvatar(icon, { iconUrl: preset.iconUrl, icon: preset.icon, initial: preset.label });\n const label = document.createElement(\"span\");\n label.textContent = preset.label;\n option.append(icon, label);\n grid.append(option);\n });\n const custom = makeModalButton(\"\", \"provider-preset-option\", () => {\n addProvider(null);\n close();\n });\n const customIcon = document.createElement(\"span\");\n customIcon.className = \"provider-avatar is-initial\";\n customIcon.textContent = \"+\";\n const customLabel = document.createElement(\"span\");\n customLabel.textContent = \"自定义\";\n custom.append(customIcon, customLabel);\n grid.append(custom);\n const actions = document.createElement(\"div\");\n actions.className = \"confirm-actions\";\n actions.append(makeModalButton(\"取消\", \"secondary-button\", close));\n overlay.addEventListener(\"click\", (event) => {\n if (event.target === overlay) {\n close();\n }\n });\n dialog.append(heading, grid, actions);\n overlay.append(dialog);\n document.body.append(overlay);\n}\n\nfunction openModelPicker(profile, models) {\n const existing = new Set(profile.models || []);\n const overlay = document.createElement(\"div\");\n overlay.className = \"confirm-overlay\";\n const dialog = document.createElement(\"div\");\n dialog.className = \"confirm-dialog model-picker-dialog\";\n const heading = document.createElement(\"h2\");\n heading.textContent = `检测到 ${models.length} 个模型`;\n const toolbar = document.createElement(\"div\");\n toolbar.className = \"model-picker-toolbar\";\n const body = document.createElement(\"div\");\n body.className = \"model-picker-list\";\n const checks = models.map((model) => {\n const item = document.createElement(\"label\");\n item.className = \"check-control model-picker-item\";\n const checkbox = document.createElement(\"input\");\n checkbox.type = \"checkbox\";\n checkbox.value = model;\n checkbox.checked = !existing.has(model);\n const text = document.createElement(\"span\");\n text.textContent = existing.has(model) ? `${model}(已添加)` : model;\n item.append(checkbox, text);\n body.append(item);\n return checkbox;\n });\n const setAll = (predicate) => checks.forEach((checkbox) => {\n checkbox.checked = predicate(checkbox);\n });\n toolbar.append(\n makeModalButton(\"全选\", \"secondary-button compact-button\", () => setAll(() => true)),\n makeModalButton(\"只选新增\", \"secondary-button compact-button\", () =>\n setAll((checkbox) => !existing.has(checkbox.value)),\n ),\n makeModalButton(\"全不选\", \"secondary-button compact-button\", () => setAll(() => false)),\n );\n const actions = document.createElement(\"div\");\n actions.className = \"confirm-actions\";\n const close = () => overlay.remove();\n actions.append(\n makeModalButton(\"取消\", \"secondary-button\", close),\n makeModalButton(\"添加\", \"primary-button\", () => {\n const chosen = checks.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value);\n const added = addModelsToProfile(profile, chosen);\n close();\n notify(added ? `已添加 ${added} 个模型。` : \"没有新增模型。\", added ? \"success\" : \"info\");\n }),\n );\n overlay.addEventListener(\"click\", (event) => {\n if (event.target === overlay) {\n close();\n }\n });\n dialog.append(heading, toolbar, body, actions);\n overlay.append(dialog);\n document.body.append(overlay);\n}\n\nfunction modelSlotElements(slot) {\n return {\n inheritInput: fields.modelSlots.querySelector(`[data-slot-inherit=\"${slot}\"]`),\n profileSelect: fields.modelSlots.querySelector(`[data-slot-profile=\"${slot}\"]`),\n modelSelect: fields.modelSlots.querySelector(`[data-slot-model=\"${slot}\"]`),\n };\n}\n\nfunction readSlotSelection(slot) {\n const { profileSelect, modelSelect } = modelSlotElements(slot);\n return {\n profile_id: profileSelect?.value || \"\",\n model: modelSelect?.value || \"\",\n };\n}\n\nfunction setSlotSelection(slot, selection, { preserveMissing = true } = {}) {\n const { profileSelect, modelSelect } = modelSlotElements(slot);\n if (!profileSelect || !modelSelect) {\n return;\n }\n const profileId = selection?.profile_id || \"\";\n if (profileId && Array.from(profileSelect.options).some((option) => option.value === profileId)) {\n profileSelect.value = profileId;\n refreshSelect(profileSelect);\n }\n syncModelOptions(slot, selection?.model || \"\", { preserveMissing });\n}\n\nconst DEFAULT_SLOT_FALLBACKS = {\n chat_fast: [\"chat\"],\n vision_chat: [\"chat\"],\n memory_curation: [\"chat\"],\n inner_thought: [\"chat_fast\", \"chat\"],\n};\n\nfunction slotFallbackChain(slot) {\n const field = request.api.slot_fields?.find((item) => item.id === slot);\n if (Array.isArray(field?.fallbacks) && field.fallbacks.length) {\n return field.fallbacks;\n }\n return DEFAULT_SLOT_FALLBACKS[slot] || [\"chat\"];\n}\n\nfunction slotIsExplicitlyConfigured(slot) {\n if (slot === \"chat\") {\n const selection = readSlotSelection(slot);\n return Boolean(selection.profile_id && selection.model);\n }\n const inheritInput = fields.modelSlots.querySelector(`[data-slot-inherit=\"${slot}\"]`);\n if (inheritInput?.checked) {\n return false;\n }\n const selection = readSlotSelection(slot);\n return Boolean(selection.profile_id && selection.model);\n}\n\nfunction inheritedSlotSourceSelection(slot) {\n if (slot === \"chat\") {\n return null;\n }\n for (const candidate of slotFallbackChain(slot)) {\n if (!slotIsExplicitlyConfigured(candidate)) {\n continue;\n }\n return readSlotSelection(candidate);\n }\n return null;\n}\n\nfunction slotHasInheritDependents(slot) {\n return (request.api.slot_fields || []).some((field) =>\n (field.fallbacks || DEFAULT_SLOT_FALLBACKS[field.id] || []).includes(slot)\n );\n}\n\nfunction syncInheritedSlotDisplays() {\n request.api.slot_fields.forEach((slot) => {\n const inheritInput = fields.modelSlots.querySelector(`[data-slot-inherit=\"${slot.id}\"]`);\n if (inheritInput?.checked) {\n syncSlotInheritState(slot.id);\n }\n });\n}\n\nfunction handleSlotInheritChange(slot) {\n const { inheritInput } = modelSlotElements(slot);\n if (inheritInput?.checked) {\n const current = readSlotSelection(slot);\n if (current.profile_id && current.model) {\n inheritedSlotManualSelections[slot] = current;\n }\n } else if (inheritedSlotManualSelections[slot]) {\n setSlotSelection(slot, inheritedSlotManualSelections[slot], { preserveMissing: true });\n delete inheritedSlotManualSelections[slot];\n }\n syncSlotInheritState(slot);\n if (slotHasInheritDependents(slot)) {\n syncInheritedSlotDisplays();\n }\n}\n\nfunction renderModelSlots(selection) {\n fields.modelSlots.textContent = \"\";\n request.api.slot_fields.forEach((slot) => {\n const row = document.createElement(\"div\");\n row.className = \"form-row model-slot-row\";\n row.dataset.slot = slot.id;\n const label = document.createElement(\"label\");\n label.textContent = slot.label;\n const controls = document.createElement(\"div\");\n controls.className = \"slot-controls\";\n const profileSelect = document.createElement(\"select\");\n profileSelect.dataset.slotProfile = slot.id;\n const modelSelect = document.createElement(\"select\");\n modelSelect.dataset.slotModel = slot.id;\n if (!slot.required) {\n const inheritLabel = document.createElement(\"label\");\n inheritLabel.className = \"check-control slot-inherit\";\n const inheritInput = document.createElement(\"input\");\n inheritInput.type = \"checkbox\";\n inheritInput.dataset.slotInherit = slot.id;\n const inheritText = document.createElement(\"span\");\n inheritText.textContent = \"继承\";\n inheritLabel.append(inheritInput, inheritText);\n controls.append(inheritLabel);\n inheritInput.addEventListener(\"change\", () => handleSlotInheritChange(slot.id));\n }\n controls.append(profileSelect, modelSelect);\n row.append(label, controls);\n fields.modelSlots.append(row);\n enhanceSelect(profileSelect);\n enhanceSelect(modelSelect);\n profileSelect.addEventListener(\"change\", () => {\n syncModelOptions(slot.id, \"\", { preserveMissing: false });\n if (slotHasInheritDependents(slot.id)) {\n syncInheritedSlotDisplays();\n }\n });\n modelSelect.addEventListener(\"change\", () => {\n if (slotHasInheritDependents(slot.id)) {\n syncInheritedSlotDisplays();\n }\n });\n\n const selected = selection?.slots?.[slot.id] || { profile_id: \"\", model: \"\" };\n const inheritInput = fields.modelSlots.querySelector(`[data-slot-inherit=\"${slot.id}\"]`);\n if (inheritInput) {\n inheritInput.checked = !selected.profile_id || !selected.model;\n }\n fillProfileOptions(profileSelect, selected.profile_id, slot.required);\n syncModelOptions(slot.id, selected.model, { preserveMissing: true });\n syncSlotInheritState(slot.id);\n });\n}\n\nfunction fillProfileOptions(select, selectedId, required) {\n const profiles = providerState.profiles;\n select.textContent = \"\";\n profiles.forEach((profile) => {\n const option = document.createElement(\"option\");\n option.value = profile.id;\n option.textContent = profile.alias || profile.id;\n select.append(option);\n });\n // 选中的供应商可能已被删除:回退到首个供应商。\n const ids = profiles.map((profile) => profile.id);\n let value = ids.includes(selectedId) ? selectedId : \"\";\n if (!value && profiles[0]) {\n value = profiles[0].id;\n }\n select.value = value;\n refreshSelect(select);\n}\n\nfunction syncModelOptions(slot, selectedModel, { preserveMissing = selectedModel !== undefined } = {}) {\n const profileSelect = fields.modelSlots.querySelector(`[data-slot-profile=\"${slot}\"]`);\n const modelSelect = fields.modelSlots.querySelector(`[data-slot-model=\"${slot}\"]`);\n const profile = providerState.profiles.find((item) => item.id === profileSelect.value);\n const models = profile?.models || [];\n const current = selectedModel ?? \"\";\n modelSelect.textContent = \"\";\n if (!profileSelect.value) {\n refreshSelect(modelSelect);\n return;\n }\n models.forEach((model) => {\n const option = document.createElement(\"option\");\n option.value = model;\n option.textContent = model;\n modelSelect.append(option);\n });\n if (preserveMissing && current && !models.includes(current)) {\n const option = document.createElement(\"option\");\n option.value = current;\n option.textContent = current;\n modelSelect.append(option);\n }\n modelSelect.value = current || models[0] || \"\";\n refreshSelect(modelSelect);\n}\n\nfunction syncSlotInheritState(slot) {\n const inheritInput = fields.modelSlots.querySelector(`[data-slot-inherit=\"${slot}\"]`);\n const inherited = Boolean(inheritInput?.checked);\n const profileSelect = fields.modelSlots.querySelector(`[data-slot-profile=\"${slot}\"]`);\n const modelSelect = fields.modelSlots.querySelector(`[data-slot-model=\"${slot}\"]`);\n if (inherited) {\n const inheritedSelection = inheritedSlotSourceSelection(slot);\n if (inheritedSelection) {\n setSlotSelection(slot, inheritedSelection, { preserveMissing: true });\n }\n }\n if (profileSelect) {\n setControlDisabled(profileSelect, inherited, { row: false });\n }\n if (modelSelect) {\n setControlDisabled(modelSelect, inherited, { row: false });\n }\n fields.modelSlots\n .querySelector(`[data-slot=\"${slot}\"]`)\n ?.classList.toggle(\"is-inherited\", inherited);\n}\n\nfunction refreshModelSlots() {\n renderModelSlots(collectModelSelection());\n}\n\nfunction collectModelSelection() {\n const slots = {};\n request.api.slot_fields.forEach((slot) => {\n const inherited = fields.modelSlots.querySelector(`[data-slot-inherit=\"${slot.id}\"]`)?.checked;\n slots[slot.id] = {\n profile_id: inherited ? \"\" : fields.modelSlots.querySelector(`[data-slot-profile=\"${slot.id}\"]`)?.value || \"\",\n model: inherited ? \"\" : fields.modelSlots.querySelector(`[data-slot-model=\"${slot.id}\"]`)?.value || \"\",\n };\n });\n return { slots };\n}\n\nfunction renderTtsProviders() {\n fields.ttsProvider.textContent = \"\";\n request.tts.providers.filter((provider) => provider.id !== \"none\").forEach((provider) => {\n const option = document.createElement(\"option\");\n option.value = provider.id;\n option.textContent = provider.label;\n fields.ttsProvider.append(option);\n });\n}\n\nfunction setTtsProviderValue(provider) {\n fields.ttsProvider.value = provider === \"none\" ? \"\" : provider;\n if (!fields.ttsProvider.value) {\n fields.ttsProvider.value = request.tts.providers.find((item) => item.id !== \"none\")?.id || \"gpt-sovits\";\n }\n}\n\nasync function hostCall(method, params = {}) {\n return invoke(\"host_call\", { method, params });\n}\n\nfunction characterExportDefaultName(kind) {\n const id = selectedCharacter()?.id || \"character\";\n if (kind === \"voice\") {\n return `${id}.voice`;\n }\n if (kind === \"card\") {\n return `${id}.card.char`;\n }\n return `${id}.char`;\n}\n\nfunction archiveDialogFilter(kind) {\n if (kind === \"voice\") {\n return [{ name: \"Sakura TTS 模型包\", extensions: [\"voice\"] }];\n }\n return [{ name: \"Sakura 角色包\", extensions: [\"char\"] }];\n}\n\nasync function chooseArchivePath(kind) {\n const title = kind === \"voice\" ? \"导入 Sakura TTS 模型包\" : \"导入 Sakura 角色包\";\n const dialogApi = window.__TAURI__?.dialog;\n if (dialogApi?.open) {\n const selected = await dialogApi.open({\n title,\n multiple: false,\n filters: archiveDialogFilter(kind),\n });\n return Array.isArray(selected) ? selected[0] : selected;\n }\n return window.prompt(`${title}\\n请输入文件完整路径:`, \"\") || \"\";\n}\n\nasync function chooseExportPath(kind) {\n const title = kind === \"voice\"\n ? \"导出 Sakura TTS 模型包\"\n : (kind === \"card\" ? \"导出 Sakura 单角色包\" : \"导出 Sakura 完整角色包\");\n const dialogApi = window.__TAURI__?.dialog;\n const defaultPath = characterExportDefaultName(kind);\n if (dialogApi?.save) {\n return dialogApi.save({\n title,\n defaultPath,\n filters: archiveDialogFilter(kind),\n });\n }\n return window.prompt(`${title}\\n请输入保存路径:`, defaultPath) || \"\";\n}\n\nfunction chooseExportKind() {\n return new Promise((resolve) => {\n const hasVoice = selectedCharacterHasExportableVoice();\n const overlay = document.createElement(\"div\");\n overlay.className = \"confirm-overlay\";\n const dialog = document.createElement(\"section\");\n dialog.className = \"confirm-dialog export-kind-dialog\";\n dialog.setAttribute(\"role\", \"dialog\");\n dialog.setAttribute(\"aria-modal\", \"true\");\n const heading = document.createElement(\"h2\");\n heading.textContent = \"选择导出内容\";\n const body = document.createElement(\"div\");\n body.className = \"export-kind-list\";\n\n characterExportOptions.forEach((option) => {\n const disabled = option.requiresVoice && !hasVoice;\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = \"export-kind-option\";\n button.disabled = disabled;\n const title = document.createElement(\"span\");\n title.className = \"export-kind-title\";\n title.textContent = option.label;\n const desc = document.createElement(\"span\");\n desc.className = \"export-kind-desc\";\n desc.textContent = disabled\n ? `${option.description} 当前角色没有可导出的语音模型。`\n : option.description;\n button.append(title, desc);\n button.addEventListener(\"click\", () => close(option.kind));\n body.append(button);\n });\n\n const actions = document.createElement(\"div\");\n actions.className = \"confirm-actions\";\n const cancel = document.createElement(\"button\");\n cancel.type = \"button\";\n cancel.className = \"secondary-button\";\n cancel.textContent = \"取消\";\n actions.append(cancel);\n dialog.append(heading, body, actions);\n overlay.append(dialog);\n\n function close(kind) {\n document.removeEventListener(\"keydown\", onKey, true);\n overlay.remove();\n resolve(kind || \"\");\n }\n function onKey(event) {\n if (event.key === \"Escape\") {\n close(\"\");\n }\n }\n overlay.addEventListener(\"click\", (event) => {\n if (event.target === overlay) {\n close(\"\");\n }\n });\n cancel.addEventListener(\"click\", () => close(\"\"));\n document.addEventListener(\"keydown\", onKey, true);\n document.body.append(overlay);\n dialog.querySelector(\"button:not(:disabled)\")?.focus();\n });\n}\n\nfunction applyCharacterRpcResult(result, { dirty = true, applyTheme = false } = {}) {\n if (Array.isArray(result?.characters)) {\n request.character.characters = result.characters;\n }\n const hasCurrentCharacterId = typeof result?.current_character_id === \"string\";\n if (hasCurrentCharacterId) {\n request.character.current_character_id = result.current_character_id;\n }\n renderCharacters();\n refreshSelect(fields.characterSelect);\n if (hasCurrentCharacterId) {\n fields.characterSelect.value = result.current_character_id;\n refreshSelect(fields.characterSelect);\n }\n if (result?.disable_tts) {\n fields.ttsEnabled.checked = false;\n }\n if (applyTheme && selectedCharacter()) {\n applySelectedCharacterTheme();\n }\n syncTtsState();\n syncCharacterArchiveState();\n if (dirty) {\n scheduleDirty();\n }\n if (result?.message) {\n notify(result.message, \"success\");\n }\n if (isOnboarding() && selectedCharacter()) {\n showOnboardingStep(\"providers\");\n }\n}\n\nasync function runCharacterArchiveAction(action) {\n if (!request || characterArchiveBusy) {\n return;\n }\n setError(\"\");\n setCharacterArchiveBusy(true);\n try {\n await action();\n } catch (error) {\n setError(String(error));\n } finally {\n setCharacterArchiveBusy(false);\n }\n}\n\nasync function importCharacterArchive() {\n await runCharacterArchiveAction(async () => {\n const path = String(await chooseArchivePath(\"character\") || \"\").trim();\n if (!path) {\n return;\n }\n const result = await hostCall(\"character.import_archive\", { path });\n applyCharacterRpcResult(result, { dirty: true, applyTheme: true });\n });\n}\n\nasync function importCharacterVoiceArchive() {\n await runCharacterArchiveAction(async () => {\n const character = selectedCharacter();\n if (!character) {\n setError(\"请先选择一个角色。\");\n return;\n }\n const path = String(await chooseArchivePath(\"voice\") || \"\").trim();\n if (!path) {\n return;\n }\n const result = await hostCall(\"character.import_voice_archive\", {\n path,\n character_id: character.id,\n });\n applyCharacterRpcResult(result, { dirty: false });\n });\n}\n\nasync function exportCharacterArchive() {\n await runCharacterArchiveAction(async () => {\n const character = selectedCharacter();\n if (!character) {\n setError(\"当前没有可导出的角色。\");\n return;\n }\n const kind = await chooseExportKind();\n if (!kind) {\n return;\n }\n const path = String(await chooseExportPath(kind) || \"\").trim();\n if (!path) {\n return;\n }\n const result = await hostCall(\"character.export_archive\", {\n path,\n character_id: character.id,\n kind,\n });\n applyCharacterRpcResult(result, { dirty: false });\n });\n}\n\nasync function launchCharacterStudio() {\n await runCharacterArchiveAction(async () => {\n const character = selectedCharacter();\n const result = await hostCall(\"studio.launch\", { character_id: character?.id || \"\" });\n if (Array.isArray(result?.characters)) {\n applyCharacterRpcResult(result, { dirty: true, applyTheme: true });\n } else if (result?.message) {\n notify(result.message, \"success\");\n }\n });\n}\n\nfunction resourcesSnapshot() {\n return resourceState.snapshot || request?.resources || {};\n}\n\nfunction taskFor(kind, snapshot = resourcesSnapshot()) {\n if (kind === \"tts\") {\n return snapshot.tts?.task || snapshot.tasks?.tts || null;\n }\n if (kind === \"backchannel\") {\n return snapshot.backchannel?.task || snapshot.tasks?.backchannel || null;\n }\n if (kind === \"memory_model\") {\n return snapshot.memory_model?.task || snapshot.tasks?.memory_model || null;\n }\n if (kind === \"memory_rerank\") {\n return snapshot.memory_rerank?.task || snapshot.tasks?.memory_rerank || null;\n }\n return null;\n}\n\nfunction taskRunning(task) {\n return task?.status === \"running\" || task?.status === \"queued\";\n}\n\nfunction hasRunningResourceTask(snapshot = resourcesSnapshot()) {\n return [\"tts\", \"backchannel\", \"memory_model\", \"memory_rerank\"].some((kind) =>\n taskRunning(taskFor(kind, snapshot))\n );\n}\n\nfunction resourceStatusLabel(status, ready = false) {\n if (status === \"not_required\") {\n return \"无需\";\n }\n if (status === \"running\" || status === \"queued\") {\n return \"处理中\";\n }\n if (status === \"succeeded\") {\n return \"已完成\";\n }\n if (status === \"failed\") {\n return \"失败\";\n }\n if (status === \"cancelled\") {\n return \"可继续\";\n }\n return ready ? \"已就绪\" : \"缺失\";\n}\n\nfunction resourceStatusClass(status, ready = false) {\n if (status === \"not_required\") {\n return \"is-ready\";\n }\n if (status === \"running\" || status === \"queued\") {\n return \"is-running\";\n }\n if (status === \"succeeded\" || ready) {\n return \"is-ready\";\n }\n if (status === \"failed\") {\n return \"is-failed\";\n }\n if (status === \"cancelled\") {\n return \"is-paused\";\n }\n return \"is-missing\";\n}\n\nfunction renderResourceCards() {\n renderTtsResourceCard();\n renderBackchannelResourceCard();\n renderMemoryModelResourceCard();\n renderMemoryRerankResourceCard();\n}\n\nfunction renderResourceCard(container, model) {\n if (!container) {\n return;\n }\n container.textContent = \"\";\n container.classList.toggle(\"is-muted\", Boolean(model.muted));\n container.classList.toggle(\"is-running\", model.status === \"running\" || model.status === \"queued\");\n const head = document.createElement(\"div\");\n head.className = \"resource-card__head\";\n const titleWrap = document.createElement(\"div\");\n titleWrap.className = \"resource-card__title-wrap\";\n const title = document.createElement(\"strong\");\n title.textContent = model.title;\n const subtitle = document.createElement(\"span\");\n subtitle.textContent = model.subtitle || \"\";\n titleWrap.append(title, subtitle);\n const badge = document.createElement(\"span\");\n badge.className = `resource-badge ${resourceStatusClass(model.status, model.ready)}`;\n badge.textContent = resourceStatusLabel(model.status, model.ready);\n head.append(titleWrap, badge);\n\n const body = document.createElement(\"div\");\n body.className = \"resource-card__body\";\n if (model.message) {\n const message = document.createElement(\"p\");\n message.className = \"resource-message\";\n message.textContent = model.message;\n body.append(message);\n }\n if (model.detail) {\n const detail = document.createElement(\"p\");\n detail.className = \"resource-detail\";\n detail.textContent = model.detail;\n body.append(detail);\n }\n if (Array.isArray(model.selectOptions) && model.selectOptions.length) {\n const row = document.createElement(\"label\");\n row.className = \"resource-select-row\";\n const caption = document.createElement(\"span\");\n caption.textContent = model.selectLabel || \"型号\";\n const select = document.createElement(\"select\");\n select.disabled = Boolean(model.selectDisabled);\n model.selectOptions.forEach((option) => {\n const node = document.createElement(\"option\");\n node.value = option.value;\n node.textContent = option.label;\n if (option.value === model.selectValue) {\n node.selected = true;\n }\n select.append(node);\n });\n select.addEventListener(\"change\", () => {\n if (typeof model.onSelectChange === \"function\") {\n model.onSelectChange(select.value);\n }\n });\n row.append(caption, select);\n body.append(row);\n enhanceSelect(select);\n }\n if (model.progressVisible) {\n const progress = document.createElement(\"div\");\n progress.className = \"resource-progress\";\n const bar = document.createElement(\"span\");\n bar.style.width = `${Math.max(0, Math.min(100, Number(model.progress || 0)))}%`;\n progress.append(bar);\n body.append(progress);\n }\n if (Array.isArray(model.meta) && model.meta.length) {\n const meta = document.createElement(\"dl\");\n meta.className = \"resource-meta\";\n model.meta.forEach(([label, value]) => {\n if (!value) {\n return;\n }\n const dt = document.createElement(\"dt\");\n dt.textContent = label;\n const dd = document.createElement(\"dd\");\n dd.textContent = value;\n meta.append(dt, dd);\n });\n body.append(meta);\n }\n\n const actions = document.createElement(\"div\");\n actions.className = \"resource-actions\";\n (model.actions || []).forEach((action) => {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = action.primary ? \"\" : action.danger ? \"danger-button\" : \"secondary-button\";\n button.textContent = action.label;\n button.disabled = Boolean(action.disabled);\n button.addEventListener(\"click\", action.onClick);\n actions.append(button);\n });\n if (actions.childNodes.length) {\n body.append(actions);\n }\n container.append(head, body);\n}\n\nfunction selectedTtsBundle() {\n const resources = resourcesSnapshot().tts || {};\n const bundles = Array.isArray(resources.bundles) ? resources.bundles : [];\n const provider = fields.ttsProvider.value;\n const providerBundles = ttsBundlesForProvider(provider, bundles);\n if (!providerBundles.length) {\n return null;\n }\n\n const providerRecommendedKey = ttsProviderRecommendedKey(provider, resources);\n let selected = providerBundles.find((bundle) => bundle.key === resourceState.ttsBundleKey);\n if (!selected) {\n selected = providerBundles.find((bundle) => bundle.key === providerRecommendedKey) || providerBundles[0];\n }\n if (selected) {\n resourceState.ttsBundleKey = selected.key;\n }\n return selected || null;\n}\n\nfunction ttsBundlesForProvider(provider, bundles) {\n if (provider === \"genie-tts\") {\n return bundles.filter((bundle) => bundle.provider === \"genie-tts\");\n }\n if (provider === \"gpt-sovits\") {\n return bundles.filter((bundle) => bundle.provider === \"gpt-sovits\");\n }\n return bundles.filter((bundle) => bundle.provider === provider);\n}\n\nfunction ttsProviderRecommendedKey(provider, resources) {\n if (provider === \"genie-tts\") {\n return resources.genie_key || \"\";\n }\n if (provider === \"gpt-sovits\") {\n return resources.gpt_sovits_recommended_key || \"\";\n }\n return resources.recommended_key || \"\";\n}\n\nfunction ttsProviderLabel(provider) {\n if (provider === \"genie-tts\") {\n return \"Genie TTS\";\n }\n if (provider === \"gpt-sovits\") {\n return \"GPT-SoVITS\";\n }\n if (provider === \"custom-gpt-sovits\") {\n return \"外部 GPT-SoVITS\";\n }\n return \"TTS\";\n}\n\nfunction ttsInstallActionLabel(provider, selected, ready, running, task) {\n if (running) {\n return \"处理中\";\n }\n if (ready) {\n return \"重新安装\";\n }\n if (task?.status === \"cancelled\") {\n return \"继续安装\";\n }\n if (provider === \"genie-tts\") {\n return \"安装 Genie CPU 包\";\n }\n if (selected?.variant === \"gpt-sovits-50\") {\n return \"安装 SoVITS 50 系包\";\n }\n if (provider === \"gpt-sovits\") {\n return \"安装 SoVITS 通用包\";\n }\n return \"安装推荐包\";\n}\n\nfunction renderTtsBundleSelector(container, bundles, selectedKey, disabled) {\n const row = document.createElement(\"label\");\n row.className = \"resource-select-row\";\n const label = document.createElement(\"span\");\n label.textContent = \"整合包\";\n const select = document.createElement(\"select\");\n select.disabled = disabled;\n bundles.forEach((bundle) => {\n const option = document.createElement(\"option\");\n option.value = bundle.key;\n option.textContent = bundle.display_label || bundle.label || bundle.key;\n select.append(option);\n });\n select.value = selectedKey;\n select.addEventListener(\"change\", () => {\n resourceState.ttsBundleKey = select.value;\n renderResourceCards();\n });\n row.append(label, select);\n container.append(row);\n}\n\nasync function copyResourceDiagnostic(kind, task, context = {}) {\n const diagnostic = JSON.stringify(\n {\n kind,\n status: task?.status || \"\",\n stage: task?.stage || \"\",\n message: task?.message || \"\",\n detail: task?.detail || \"\",\n error: task?.error || \"\",\n result: task?.result || {},\n context,\n },\n null,\n 2,\n );\n try {\n await navigator.clipboard.writeText(diagnostic);\n notify(\"诊断信息已复制。\", \"success\");\n } catch (_error) {\n window.prompt(\"复制以下诊断信息:\", diagnostic);\n }\n}\n\nfunction renderTtsResourceCard() {\n const resources = resourcesSnapshot().tts || {};\n const bundles = Array.isArray(resources.bundles) ? resources.bundles : [];\n const provider = fields.ttsProvider.value;\n const providerBundles = ttsBundlesForProvider(provider, bundles);\n const task = taskFor(\"tts\");\n const running = taskRunning(task);\n const selected = selectedTtsBundle();\n const taskBundleKey = task?.context?.bundle_key || \"\";\n const taskMatchesSelected = !taskBundleKey || taskBundleKey === selected?.key;\n const muted = !fields.ttsEnabled.checked || provider === \"none\" || provider === \"custom-gpt-sovits\";\n const ready = Boolean(selected?.installed) || (taskMatchesSelected && task?.status === \"succeeded\");\n const providerLabel = ttsProviderLabel(provider);\n const providerHint = ttsProviderResourceHint(provider, resources, selected);\n const message = muted\n ? !fields.ttsEnabled.checked || provider === \"none\"\n ? \"TTS 已关闭。\"\n : provider === \"custom-gpt-sovits\"\n ? \"外部 GPT-SoVITS 使用你填写的本机路径。\"\n : \"无需内置资源。\"\n : running\n ? taskMatchesSelected\n ? task.message || `正在处理 ${providerLabel} 整合包。`\n : `后台正在处理 ${task.title || \"其他 TTS 整合包\"},当前展示 ${providerLabel} 的安装信息。`\n : ready\n ? `${providerLabel} 本地运行环境已就绪。`\n : `${providerLabel} 需要安装对应的本地整合包。`;\n const detail = running && taskMatchesSelected\n ? task.detail || \"\"\n : providerHint\n ? providerHint\n : selected\n ? `${selected.variant_label || selected.display_label || selected.label} · ${selected.work_dir || \"待安装\"}`\n : resources.gpu_summary || \"\";\n const actions = [];\n if (!muted && selected) {\n actions.push({\n label: ttsInstallActionLabel(provider, selected, ready, running, task),\n primary: !ready,\n disabled: running,\n onClick: () => startResourceAction(\"resources.tts.install\", { bundle_key: selected.key }),\n });\n if (running && task?.cancellable) {\n actions.push({\n label: \"暂停\",\n danger: true,\n onClick: () => startResourceAction(\"resources.tts.cancel\"),\n });\n }\n }\n if (task?.status === \"failed\") {\n actions.push({\n label: \"复制诊断\",\n onClick: () => copyResourceDiagnostic(\"tts\", task, { provider, bundle_key: selected?.key || \"\" }),\n });\n }\n actions.push({ label: \"刷新\", onClick: refreshResources });\n\n renderResourceCard(fields.ttsResourceCard, {\n title: `${providerLabel} 整合包`,\n subtitle: muted\n ? \"无需内置资源\"\n : selected?.variant_label || resources.platform || \"\",\n status: muted ? \"not_required\" : task?.status || \"\",\n ready,\n muted,\n message,\n detail,\n progressVisible: running && taskMatchesSelected,\n progress: task?.progress || 0,\n meta: [\n [\"平台\", resources.platform],\n [\"显卡\", provider === \"gpt-sovits\" ? resources.gpu_summary : \"\"],\n [\"下载源\", selected?.download_url],\n [\"安装目录\", selected?.work_dir],\n ],\n actions,\n });\n if (!muted && providerBundles.length > 1 && fields.ttsResourceCard) {\n const body = fields.ttsResourceCard.querySelector(\".resource-card__body\");\n if (body) {\n renderTtsBundleSelector(body, providerBundles, selected?.key || \"\", running);\n }\n }\n}\n\nfunction ttsProviderResourceHint(provider, resources, selected) {\n if (provider === \"genie-tts\") {\n return selected\n ? `${selected.display_label || selected.label} · 使用 CPU 整合包,不依赖 NVIDIA 显卡。`\n : \"Genie TTS 使用 CPU 整合包,不依赖 NVIDIA 显卡。\";\n }\n if (provider !== \"gpt-sovits\") {\n return \"\";\n }\n const status = resources.gpu_status?.gpt_sovits;\n if (!status) {\n return resources.gpu_summary || \"\";\n }\n const selectedText = selected?.variant_label ? `当前选择:${selected.variant_label}。` : \"\";\n if (status.severity === \"warning\") {\n return `${status.message} ${status.vram_note || \"\"} ${selectedText}`.trim();\n }\n return \"\";\n}\n\nfunction renderBackchannelResourceCard() {\n const resources = resourcesSnapshot().backchannel || {};\n const task = taskFor(\"backchannel\");\n const running = taskRunning(task);\n const mode = fields.backchannelMode.value;\n const enabled = fields.backchannelEnabled.checked;\n const ready = Boolean(resources.ready) || task?.status === \"succeeded\";\n const wantsModel = enabled && mode === \"hybrid\";\n const message = running\n ? task.message || \"正在处理接话模型。\"\n : ready\n ? mode === \"hybrid\"\n ? \"智能辅助已就绪。\"\n : \"本地接话模型已就绪,切换到智能辅助后启用。\"\n : wantsModel\n ? \"智能辅助还没有本地接话模型,当前会先用规则模式。\"\n : \"规则模式不需要本地模型,可先安装备用。\";\n const actions = [\n {\n label: running ? \"安装中\" : ready ? \"重新安装\" : \"在线安装\",\n primary: !ready,\n disabled: running,\n onClick: () => startResourceAction(\"resources.backchannel.download\"),\n },\n {\n label: \"导入 ZIP\",\n disabled: running,\n onClick: () => importResourceZip(\"backchannel\"),\n },\n ];\n if (task?.status === \"failed\") {\n actions.push({\n label: \"复制诊断\",\n onClick: () => copyResourceDiagnostic(\"backchannel\", task, { mode, enabled, model_name: resources.model_name || \"\" }),\n });\n }\n actions.push({ label: \"刷新\", onClick: refreshResources });\n\n renderResourceCard(fields.backchannelResourceCard, {\n title: \"接话模型\",\n subtitle: resources.model_name || \"\",\n status: task?.status || \"\",\n ready,\n message,\n detail: running ? task.detail || \"\" : resources.cache_folder || resources.endpoint || \"\",\n progressVisible: running,\n progress: task?.progress || (running ? 35 : 0),\n meta: [\n [\"端点\", resources.endpoint],\n [\"缓存\", resources.cache_folder],\n [\"错误\", task?.error],\n ],\n actions,\n });\n}\n\nfunction renderMemoryModelResourceCard() {\n const resources = resourcesSnapshot().memory_model || {};\n const task = taskFor(\"memory_model\");\n const running = taskRunning(task);\n const ready = Boolean(resources.ready) || task?.status === \"succeeded\";\n const available = resources.available !== false;\n const sizeLabel = resources.size_label || \"约 2.3GB\";\n const message = !available\n ? \"长期记忆系统暂不可用。\"\n : running\n ? task.message || \"正在处理记忆嵌入模型。\"\n : ready\n ? \"记忆嵌入模型已就绪(必需)。\"\n : `记忆检索需要本地嵌入模型(${sizeLabel}),请按需在线安装,不会在启动时自动下载。`;\n const actions = [\n {\n label: running ? \"安装中\" : ready ? \"重新安装\" : \"在线安装\",\n primary: !ready,\n disabled: running || !available,\n onClick: () => startResourceAction(\"resources.memory.download\"),\n },\n {\n label: \"导入 ZIP\",\n disabled: running || !available,\n onClick: () => importResourceZip(\"memory\"),\n },\n ];\n if (task?.status === \"failed\") {\n actions.push({\n label: \"复制诊断\",\n onClick: () => copyResourceDiagnostic(\"memory_model\", task, { model_name: resources.model_name || \"\" }),\n });\n }\n actions.push({ label: \"刷新\", disabled: !available, onClick: refreshResources });\n\n renderResourceCard(fields.memoryModelResourceCard, {\n title: \"记忆嵌入模型\",\n subtitle: resources.model_name || \"\",\n status: task?.status || \"\",\n ready,\n muted: !available,\n message,\n detail: running ? task.detail || \"\" : resources.error || \"\",\n progressVisible: running,\n progress: task?.progress || (running ? 35 : 0),\n meta: [\n [\"体积\", sizeLabel],\n [\"缓存\", task?.result?.cache_folder],\n [\"错误\", task?.error || resources.error],\n ],\n actions,\n });\n}\n\nfunction renderMemoryRerankResourceCard() {\n const resources = resourcesSnapshot().memory_rerank || {};\n const task = taskFor(\"memory_rerank\");\n const running = taskRunning(task);\n const ready = Boolean(resources.ready) || task?.status === \"succeeded\";\n const available = resources.available !== false;\n const catalog = Array.isArray(resources.catalog) ? resources.catalog : [];\n const selected =\n resources.model_name ||\n catalog[0]?.model_id ||\n \"BAAI/bge-reranker-v2-m3\";\n const selectedEntry = catalog.find((item) => item.model_id === selected) || null;\n const sizeLabel = selectedEntry?.size_label || resources.size_label || \"\";\n const note = selectedEntry?.note || resources.note || \"\";\n const message = !available\n ? \"长期记忆系统暂不可用。\"\n : running\n ? task.message || \"正在处理记忆精排模型。\"\n : ready\n ? `当前型号已就绪(可选,提升排序)${note ? `:${note}` : \"。\"}`\n : `可选:先选型号再安装。未安装时仍用向量检索,不影响基本记忆。${\n note ? ` ${note}。` : \"\"\n }`;\n const selectOptions = catalog.map((item) => ({\n value: item.model_id,\n label: `${item.label}(${item.size_label}${item.installed ? \" · 已下载\" : \"\"})`,\n }));\n const actions = [\n {\n label: running ? \"安装中\" : ready ? \"重新安装\" : \"在线安装\",\n primary: !ready,\n disabled: running || !available || !selected,\n onClick: () =>\n startResourceAction(\"resources.memory_rerank.download\", { model_id: selected }),\n },\n ];\n if (task?.status === \"failed\") {\n actions.push({\n label: \"复制诊断\",\n onClick: () =>\n copyResourceDiagnostic(\"memory_rerank\", task, { model_name: selected || \"\" }),\n });\n }\n actions.push({ label: \"刷新\", disabled: !available, onClick: refreshResources });\n\n renderResourceCard(fields.memoryRerankResourceCard, {\n title: \"记忆精排模型\",\n subtitle: selected || \"\",\n status: task?.status || \"\",\n ready,\n muted: !available,\n message,\n detail: running ? task.detail || \"\" : resources.error || \"\",\n selectLabel: \"型号\",\n selectValue: selected,\n selectDisabled: running || !available || selectOptions.length === 0,\n selectOptions,\n onSelectChange: (modelId) =>\n startResourceAction(\"resources.memory_rerank.select\", { model_id: modelId }),\n progressVisible: running,\n progress: task?.progress || (running ? 35 : 0),\n meta: [\n [\"体积\", sizeLabel],\n [\"说明\", note],\n [\"缓存\", task?.result?.cache_folder],\n [\"错误\", task?.error || resources.error],\n ],\n actions,\n });\n}\n\nasync function refreshResources() {\n if (!request) {\n return;\n }\n const previous = resourcesSnapshot();\n try {\n const snapshot = await hostCall(\"resources.status\");\n resourceState.snapshot = snapshot;\n handleResourceTaskTransitions(previous, snapshot);\n renderResourceCards();\n if (hasRunningResourceTask(snapshot)) {\n startResourcePolling();\n } else {\n stopResourcePolling();\n }\n } catch (error) {\n setError(String(error));\n }\n}\n\nasync function startResourceAction(method, params = {}) {\n setError(\"\");\n const previous = resourcesSnapshot();\n try {\n const snapshot = await hostCall(method, params);\n resourceState.snapshot = snapshot;\n handleResourceTaskTransitions(previous, snapshot);\n renderResourceCards();\n if (hasRunningResourceTask(snapshot)) {\n startResourcePolling();\n }\n } catch (error) {\n setError(String(error));\n }\n}\n\nfunction startResourcePolling() {\n if (resourceState.pollTimer) {\n return;\n }\n resourceState.pollTimer = window.setInterval(refreshResources, 1200);\n}\n\nfunction stopResourcePolling() {\n window.clearInterval(resourceState.pollTimer);\n resourceState.pollTimer = null;\n}\n\nfunction handleResourceTaskTransitions(previous, next) {\n const pairs = [\n [\"tts\", previous?.tts?.task || previous?.tasks?.tts, next?.tts?.task || next?.tasks?.tts],\n [\n \"backchannel\",\n previous?.backchannel?.task || previous?.tasks?.backchannel,\n next?.backchannel?.task || next?.tasks?.backchannel,\n ],\n [\n \"memory_model\",\n previous?.memory_model?.task || previous?.tasks?.memory_model,\n next?.memory_model?.task || next?.tasks?.memory_model,\n ],\n [\n \"memory_rerank\",\n previous?.memory_rerank?.task || previous?.tasks?.memory_rerank,\n next?.memory_rerank?.task || next?.tasks?.memory_rerank,\n ],\n ];\n pairs.forEach(([kind, before, after]) => {\n if (!after || after.status !== \"succeeded\") {\n return;\n }\n const finishKey = `${kind}:${after.finished_at || \"\"}`;\n if (!after.finished_at || resourceState.seenTaskFinishes[finishKey]) {\n return;\n }\n if (before?.status === \"succeeded\") {\n resourceState.seenTaskFinishes[finishKey] = true;\n return;\n }\n resourceState.seenTaskFinishes[finishKey] = true;\n if (kind === \"tts\") {\n applyTtsInstallResult(after.result || {});\n } else if (kind === \"backchannel\") {\n notify(\"接话模型已就绪。\", \"success\");\n } else if (kind === \"memory_model\") {\n notify(\"记忆嵌入模型已就绪。\", \"success\");\n if (fields.pages.memory.classList.contains(\"is-active\")) {\n loadMemories();\n }\n } else if (kind === \"memory_rerank\") {\n notify(\"记忆精排模型已就绪。\", \"success\");\n }\n });\n}\n\nfunction applyTtsInstallResult(result) {\n if (!result || !result.work_dir) {\n return;\n }\n fields.ttsEnabled.checked = true;\n fields.ttsProvider.value = result.provider || fields.ttsProvider.value;\n fields.ttsWorkDir.value = result.work_dir || \"\";\n fields.ttsPythonPath.value = result.python_path || \"\";\n fields.ttsConfigPath.value = result.tts_config_path || \"\";\n fields.ttsApiUrl.value = result.api_url || fields.ttsApiUrl.value;\n refreshSelect(fields.ttsProvider);\n syncTtsState();\n scheduleDirty();\n notify(\"TTS 整合包已安装,配置已回填。\", \"success\");\n}\n\nasync function chooseZipPath(title) {\n const dialogApi = window.__TAURI__?.dialog;\n if (dialogApi?.open) {\n const selected = await dialogApi.open({\n title,\n multiple: false,\n filters: [{ name: \"ZIP\", extensions: [\"zip\"] }],\n });\n return Array.isArray(selected) ? selected[0] : selected;\n }\n return window.prompt(`${title}\\n请输入 ZIP 文件完整路径:`, \"\") || \"\";\n}\n\nasync function importResourceZip(kind) {\n const title = kind === \"backchannel\" ? \"导入接话模型 ZIP\" : \"导入记忆模型 ZIP\";\n let path = \"\";\n try {\n path = String(await chooseZipPath(title) || \"\").trim();\n } catch (error) {\n setError(String(error));\n return;\n }\n if (!path) {\n return;\n }\n const method = kind === \"backchannel\" ? \"resources.backchannel.import\" : \"resources.memory.import\";\n await startResourceAction(method, { path });\n}\n\nfunction memoryLayers() {\n return request?.memory?.layers || [];\n}\n\nfunction memoryDefaults() {\n return request?.memory?.defaults || {\n layer: \"semantic\",\n source: \"manual\",\n importance: 0.5,\n confidence: 0.75,\n };\n}\n\nfunction memoryLayerLabel(layer) {\n return memoryLayers().find((item) => item.id === layer)?.label || layer || \"未分层\";\n}\n\nfunction memoryContent(record) {\n return String(record?.content || record?.memory || \"\");\n}\n\nfunction compactText(value, max = 110) {\n const text = String(value || \"\").replace(/\\s+/g, \" \").trim();\n if (text.length <= max) {\n return text;\n }\n return `${text.slice(0, max - 1)}…`;\n}\n\nfunction renderStrip(container, items) {\n container.textContent = \"\";\n items.forEach((item) => {\n const chip = document.createElement(\"span\");\n chip.className = \"status-chip\";\n chip.textContent = `${item.label} ${item.value}`;\n container.append(chip);\n });\n}\n\nfunction renderMemoryControls() {\n fields.memoryLayerFilter.textContent = \"\";\n const all = document.createElement(\"option\");\n all.value = \"\";\n all.textContent = \"全部层级\";\n fields.memoryLayerFilter.append(all);\n memoryLayers().forEach((layer) => {\n const option = document.createElement(\"option\");\n option.value = layer.id;\n option.textContent = layer.label;\n fields.memoryLayerFilter.append(option);\n });\n\n fields.memoryLayer.textContent = \"\";\n memoryLayers().forEach((layer) => {\n const option = document.createElement(\"option\");\n option.value = layer.id;\n option.textContent = layer.label;\n fields.memoryLayer.append(option);\n });\n}\n\nfunction selectedMemory() {\n if (memoryState.selectedId === \"__draft__\") {\n return memoryState.draft;\n }\n return memoryState.entries.find((entry) => entry.id === memoryState.selectedId) || null;\n}\n\nfunction memoryTimestamp(record, key) {\n return String(record?.[key] || record?.updated_at || record?.created_at || \"\");\n}\n\nfunction sortedMemories() {\n const entries = [...memoryState.entries];\n const sort = fields.memorySort.value || \"created_desc\";\n entries.sort((a, b) => {\n if (a.layer === \"core_profile\" && b.layer !== \"core_profile\") {\n return -1;\n }\n if (b.layer === \"core_profile\" && a.layer !== \"core_profile\") {\n return 1;\n }\n if (sort === \"importance_desc\") {\n const byImportance = Number(b.importance || 0) - Number(a.importance || 0);\n if (byImportance !== 0) {\n return byImportance;\n }\n // 重要度相同时用创建时间稳定次序,避免保存后无意义跳动\n return memoryTimestamp(b, \"created_at\").localeCompare(memoryTimestamp(a, \"created_at\"));\n }\n if (sort === \"confidence_desc\") {\n const byConfidence = Number(b.confidence || 0) - Number(a.confidence || 0);\n if (byConfidence !== 0) {\n return byConfidence;\n }\n return memoryTimestamp(b, \"created_at\").localeCompare(memoryTimestamp(a, \"created_at\"));\n }\n if (sort === \"updated_desc\") {\n return memoryTimestamp(b, \"updated_at\").localeCompare(memoryTimestamp(a, \"updated_at\"));\n }\n // created_desc:整顿记忆时的默认稳定时间序\n return memoryTimestamp(b, \"created_at\").localeCompare(memoryTimestamp(a, \"created_at\"));\n });\n return entries;\n}\n\nfunction cssEscapeAttr(value) {\n if (typeof CSS !== \"undefined\" && typeof CSS.escape === \"function\") {\n return CSS.escape(String(value));\n }\n return String(value).replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n}\n\nfunction memoryScrollContainer() {\n // 左侧记忆列表(#memoryList.admin-list)才是红框里那根滚动条;\n // 不要回退到 .page-scroll,否则保存后「恢复」的是整页滚动,列表条会自己回到顶。\n return fields.memoryList || null;\n}\n\nfunction freezeMemoryListScroll() {\n const scroller = memoryScrollContainer();\n return scroller ? scroller.scrollTop || 0 : 0;\n}\n\nfunction applyMemoryListScroll(scrollTop) {\n const top = Math.max(0, Number(scrollTop) || 0);\n const apply = () => {\n const scroller = memoryScrollContainer();\n if (scroller) {\n scroller.scrollTop = top;\n }\n };\n apply();\n window.requestAnimationFrame(() => {\n apply();\n window.requestAnimationFrame(apply);\n });\n}\n\nfunction captureMemoryListAnchor(preferredId = \"\") {\n const scroller = memoryScrollContainer();\n const list = fields.memoryList;\n if (!scroller || !list) {\n return { id: \"\", offset: 0, scrollTop: 0 };\n }\n const anchorId = String(preferredId || memoryState.selectedId || \"\");\n let card = null;\n if (anchorId && anchorId !== \"__draft__\") {\n card = list.querySelector(`[data-memory-id=\"${cssEscapeAttr(anchorId)}\"]`);\n }\n if (!card) {\n card = list.querySelector(\".memory-card.is-selected\");\n }\n const scrollerRect = scroller.getBoundingClientRect();\n const cardRect = card ? card.getBoundingClientRect() : null;\n return {\n id: anchorId,\n offset: cardRect ? cardRect.top - scrollerRect.top : 0,\n scrollTop: scroller.scrollTop || 0,\n };\n}\n\nfunction restoreMemoryListAnchor(anchor) {\n if (!anchor) {\n return;\n }\n const apply = () => {\n const scroller = memoryScrollContainer();\n const list = fields.memoryList;\n if (!scroller || !list) {\n return;\n }\n const targetId = String(anchor.id || memoryState.selectedId || \"\");\n let card = null;\n if (targetId && targetId !== \"__draft__\") {\n card = list.querySelector(`[data-memory-id=\"${cssEscapeAttr(targetId)}\"]`);\n }\n if (card) {\n const scrollerRect = scroller.getBoundingClientRect();\n const cardRect = card.getBoundingClientRect();\n const delta = cardRect.top - scrollerRect.top - Number(anchor.offset || 0);\n scroller.scrollTop = Math.max(0, (scroller.scrollTop || 0) + delta);\n return;\n }\n scroller.scrollTop = Math.max(0, Number(anchor.scrollTop) || 0);\n };\n apply();\n window.requestAnimationFrame(() => {\n apply();\n window.requestAnimationFrame(apply);\n });\n}\n\nfunction normalizeMemoryEntry(record, fallback = {}) {\n const base = { ...fallback, ...(record || {}) };\n const id = String(base.id || \"\").trim();\n const content = memoryContent(base);\n return {\n ...base,\n id,\n content,\n memory: content,\n layer: base.layer || memoryDefaults().layer,\n category: base.category || \"\",\n source: base.source || memoryDefaults().source,\n importance: Number(base.importance ?? memoryDefaults().importance),\n confidence: Number(base.confidence ?? memoryDefaults().confidence),\n created_at: base.created_at || fallback.created_at || \"\",\n updated_at: base.updated_at || fallback.updated_at || \"\",\n };\n}\n\nfunction upsertMemoryEntryLocally(record) {\n const id = String(record?.id || \"\").trim();\n if (!id) {\n return;\n }\n const index = memoryState.entries.findIndex((entry) => entry.id === id);\n const previous = index >= 0 ? memoryState.entries[index] : {};\n const merged = normalizeMemoryEntry(record, previous);\n if (index >= 0) {\n memoryState.entries[index] = merged;\n } else {\n memoryState.entries.unshift(merged);\n }\n}\n\nfunction removeMemoryEntryLocally(memoryId) {\n const id = String(memoryId || \"\").trim();\n if (!id) {\n return;\n }\n memoryState.entries = memoryState.entries.filter((entry) => entry.id !== id);\n}\n\nfunction neighborMemoryId(memoryId) {\n // 删除后默认落到「下面」那一条;没有下面才回退到上面。\n const sorted = sortedMemories();\n const index = sorted.findIndex((entry) => entry.id === memoryId);\n if (index < 0) {\n return \"\";\n }\n return sorted[index + 1]?.id || sorted[index - 1]?.id || \"\";\n}\n\nfunction setMemoryEditorDisabled(disabled) {\n [\n fields.memoryContent,\n fields.memoryLayer,\n fields.memoryCategory,\n fields.memorySource,\n fields.memoryImportance,\n fields.memoryConfidence,\n fields.memorySaveButton,\n fields.memoryRevertButton,\n fields.memoryDeleteButton,\n ].forEach((field) => {\n field.disabled = disabled;\n });\n refreshSelect(fields.memoryLayer);\n}\n\nfunction fillMemoryEditor(record) {\n const readOnly = memoryState.status === \"loading\" || memoryState.status === \"failed\";\n if (!record) {\n fields.memoryContent.value = \"\";\n fields.memoryCategory.value = \"\";\n fields.memorySource.value = \"\";\n fields.memoryImportance.value = \"\";\n fields.memoryConfidence.value = \"\";\n fields.memoryMeta.textContent = \"\";\n setMemoryEditorDisabled(true);\n return;\n }\n fields.memoryContent.value = memoryContent(record);\n fields.memoryLayer.value = record.layer || memoryDefaults().layer;\n fields.memoryCategory.value = record.category || \"\";\n fields.memorySource.value = record.source || memoryDefaults().source;\n fields.memoryImportance.value = Number(record.importance ?? memoryDefaults().importance);\n fields.memoryConfidence.value = Number(record.confidence ?? memoryDefaults().confidence);\n refreshSelect(fields.memoryLayer);\n fields.memoryMeta.textContent = \"\";\n [\n [\"ID\", record.id || \"新记忆\"],\n [\"创建\", record.created_at || \"未保存\"],\n [\"更新\", record.updated_at || \"未保存\"],\n ].forEach(([label, value]) => {\n const dt = document.createElement(\"dt\");\n dt.textContent = label;\n const dd = document.createElement(\"dd\");\n dd.textContent = value;\n fields.memoryMeta.append(dt, dd);\n });\n setMemoryEditorDisabled(readOnly);\n fields.memoryDeleteButton.disabled = readOnly || memoryState.selectedId === \"__draft__\";\n fields.memoryRevertButton.disabled = readOnly || memoryState.selectedId === \"__draft__\";\n}\n\nfunction renderMemoryStatus() {\n const counts = {\n all: memoryState.entries.length,\n core_profile: 0,\n semantic: 0,\n episodic: 0,\n procedural: 0,\n session: 0,\n };\n memoryState.entries.forEach((entry) => {\n if (counts[entry.layer] !== undefined) {\n counts[entry.layer] += 1;\n }\n });\n renderStrip(fields.memoryStatusStrip, [\n { label: \"总数\", value: counts.all },\n { label: \"常驻档案\", value: counts.core_profile },\n { label: \"长期事实\", value: counts.semantic },\n { label: \"事件总结\", value: counts.episodic },\n { label: \"协作规则\", value: counts.procedural },\n { label: \"当前任务\", value: counts.session },\n { label: \"整理频率\", value: `${fields.memoryTriggerTurns.value || request.memory.curation.trigger_turns} 轮` },\n ]);\n}\n\nfunction buildMemoryCard(entry) {\n const row = document.createElement(\"div\");\n row.className = \"memory-card\";\n row.dataset.memoryId = entry.id;\n row.setAttribute(\"role\", \"button\");\n row.tabIndex = 0;\n row.classList.toggle(\"is-selected\", entry.id === memoryState.selectedId);\n row.classList.toggle(\"is-core\", entry.layer === \"core_profile\");\n const selectRow = () => {\n selectMemoryEntry(entry.id);\n };\n row.addEventListener(\"click\", selectRow);\n row.addEventListener(\"keydown\", (event) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n selectRow();\n }\n });\n const title = document.createElement(\"strong\");\n title.className = \"memory-card-title\";\n title.textContent = compactText(memoryContent(entry) || \"(空记忆)\");\n const meta = document.createElement(\"span\");\n meta.className = \"card-meta\";\n meta.textContent = [\n memoryLayerLabel(entry.layer),\n entry.category || \"未分类\",\n entry.source || \"未知来源\",\n entry.updated_at || entry.created_at || \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n const chips = document.createElement(\"span\");\n chips.className = \"chip-row\";\n [\n `重要 ${Number(entry.importance ?? 0).toFixed(2)}`,\n `置信 ${Number(entry.confidence ?? 0).toFixed(2)}`,\n ].forEach((text) => {\n const chip = document.createElement(\"span\");\n chip.className = \"permission-chip\";\n chip.textContent = text;\n chips.append(chip);\n });\n row.append(title, meta, chips);\n return row;\n}\n\nfunction patchMemoryCard(entry) {\n if (!fields.memoryList || !entry?.id) {\n return false;\n }\n const row = fields.memoryList.querySelector(\n `[data-memory-id=\"${cssEscapeAttr(entry.id)}\"]`,\n );\n if (!row) {\n return false;\n }\n const title = row.querySelector(\".memory-card-title\");\n if (title) {\n title.textContent = compactText(memoryContent(entry) || \"(空记忆)\");\n }\n const meta = row.querySelector(\".card-meta\");\n if (meta) {\n meta.textContent = [\n memoryLayerLabel(entry.layer),\n entry.category || \"未分类\",\n entry.source || \"未知来源\",\n entry.updated_at || entry.created_at || \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n }\n const chips = row.querySelector(\".chip-row\");\n if (chips) {\n chips.textContent = \"\";\n [\n `重要 ${Number(entry.importance ?? 0).toFixed(2)}`,\n `置信 ${Number(entry.confidence ?? 0).toFixed(2)}`,\n ].forEach((text) => {\n const chip = document.createElement(\"span\");\n chip.className = \"permission-chip\";\n chip.textContent = text;\n chips.append(chip);\n });\n }\n row.classList.toggle(\"is-selected\", entry.id === memoryState.selectedId);\n row.classList.toggle(\"is-core\", entry.layer === \"core_profile\");\n return true;\n}\n\nfunction selectMemoryEntry(memoryId) {\n const id = String(memoryId || \"\").trim();\n if (!id || id === memoryState.selectedId) {\n fillMemoryEditor(selectedMemory());\n return;\n }\n const previousId = memoryState.selectedId;\n memoryState.selectedId = id;\n if (previousId && previousId !== \"__draft__\") {\n fields.memoryList\n ?.querySelector(`[data-memory-id=\"${cssEscapeAttr(previousId)}\"]`)\n ?.classList.remove(\"is-selected\");\n }\n fields.memoryList\n ?.querySelector(`[data-memory-id=\"${cssEscapeAttr(id)}\"]`)\n ?.classList.add(\"is-selected\");\n fillMemoryEditor(selectedMemory());\n fields.memoryDeleteButton.disabled =\n memoryState.status === \"loading\"\n || memoryState.status === \"failed\"\n || memoryState.selectedId === \"__draft__\";\n fields.memoryRevertButton.disabled =\n memoryState.status === \"loading\"\n || memoryState.status === \"failed\"\n || memoryState.selectedId === \"__draft__\";\n}\n\nfunction renderMemoryList() {\n fields.memoryList.textContent = \"\";\n if (memoryState.loading) {\n const item = document.createElement(\"p\");\n item.className = \"empty-state\";\n item.textContent = \"记忆系统正在加载。\";\n fields.memoryList.append(item);\n return;\n }\n if (memoryState.status === \"failed\") {\n const item = document.createElement(\"p\");\n item.className = \"empty-state\";\n item.textContent = memoryState.message || \"记忆系统加载失败。\";\n fields.memoryList.append(item);\n return;\n }\n const entries = sortedMemories();\n if (!entries.length) {\n const item = document.createElement(\"p\");\n item.className = \"empty-state\";\n item.textContent = memoryState.message || \"暂无记忆。\";\n fields.memoryList.append(item);\n return;\n }\n entries.forEach((entry) => {\n fields.memoryList.append(buildMemoryCard(entry));\n });\n}\n\nfunction renderMemoryPage() {\n renderMemoryStatus();\n renderMemoryList();\n fillMemoryEditor(selectedMemory());\n fields.memoryAddButton.disabled = memoryState.status === \"loading\" || memoryState.status === \"failed\";\n fields.memoryRefreshButton.disabled = memoryState.loading;\n renderMemoryModelResourceCard();\n}\n\nasync function loadMemories(options = {}) {\n if (!request) {\n return;\n }\n const silent = Boolean(options.silent);\n const preserveScroll = options.preserveScroll !== false;\n const preferredSelectedId = String(\n options.preferredSelectedId !== undefined\n ? options.preferredSelectedId\n : memoryState.selectedId || \"\",\n );\n const anchor =\n options.anchor\n || (preserveScroll ? captureMemoryListAnchor(preferredSelectedId) : null);\n clearMemoryRetry();\n let shouldRetry = false;\n if (!silent) {\n memoryState.loading = true;\n memoryState.status = \"loading\";\n memoryState.message = \"记忆系统正在加载。\";\n renderMemoryPage();\n }\n try {\n const params = {\n query: fields.memorySearch.value.trim(),\n limit: request.memory.page_size || 120,\n };\n if (fields.memoryLayerFilter.value) {\n params.layer = fields.memoryLayerFilter.value;\n }\n const result = await hostCall(\"memory.search\", params);\n memoryState.status = result.status || \"ready\";\n memoryState.message = result.message || result.error || \"\";\n shouldRetry = memoryState.status === \"loading\";\n memoryState.entries = Array.isArray(result.memories)\n ? result.memories\n .filter((entry) => entry && entry.id)\n .map((entry) => normalizeMemoryEntry(entry))\n : [];\n memoryState.loaded = true;\n if (preferredSelectedId === \"__draft__\" && memoryState.draft) {\n memoryState.selectedId = \"__draft__\";\n } else if (\n preferredSelectedId\n && memoryState.entries.some((entry) => entry.id === preferredSelectedId)\n ) {\n memoryState.selectedId = preferredSelectedId;\n } else if (memoryState.entries.some((entry) => entry.id === memoryState.selectedId)) {\n // 保持现有选中\n } else if (preferredSelectedId === \"\" && options.allowEmptySelection) {\n memoryState.selectedId = \"\";\n } else {\n memoryState.selectedId = memoryState.entries[0]?.id || \"\";\n }\n } catch (error) {\n memoryState.status = \"failed\";\n memoryState.message = String(error);\n if (!silent) {\n memoryState.entries = [];\n }\n } finally {\n memoryState.loading = false;\n renderMemoryPage();\n if (preserveScroll && anchor) {\n restoreMemoryListAnchor({\n ...anchor,\n id: memoryState.selectedId || anchor.id,\n });\n }\n if (shouldRetry) {\n scheduleMemoryRetry();\n }\n }\n}\n\nfunction newMemoryDraft() {\n const defaults = memoryDefaults();\n memoryState.draft = {\n id: \"\",\n content: \"\",\n layer: defaults.layer,\n category: \"\",\n source: defaults.source,\n importance: defaults.importance,\n confidence: defaults.confidence,\n };\n memoryState.selectedId = \"__draft__\";\n renderMemoryPage();\n fields.memoryContent.focus();\n}\n\nfunction collectMemoryEditor() {\n const payload = {\n content: fields.memoryContent.value.trim(),\n layer: fields.memoryLayer.value || memoryDefaults().layer,\n category: fields.memoryCategory.value.trim(),\n source: fields.memorySource.value.trim() || memoryDefaults().source,\n importance: clampFloat(fields.memoryImportance.value, [0, 1]),\n confidence: clampFloat(fields.memoryConfidence.value, [0, 1]),\n };\n if (memoryState.selectedId && memoryState.selectedId !== \"__draft__\") {\n payload.id = memoryState.selectedId;\n }\n return payload;\n}\n\nasync function saveMemoryEditor() {\n const payload = collectMemoryEditor();\n if (!payload.content) {\n setError(\"记忆内容不能为空。\");\n return;\n }\n setError(\"\");\n const previous = selectedMemory();\n const wasDraft = memoryState.selectedId === \"__draft__\";\n const frozenScrollTop = freezeMemoryListScroll();\n try {\n fields.memorySaveButton.disabled = true;\n const result = await hostCall(\"memory.upsert\", payload);\n if (result.status === \"loading\" || result.status === \"failed\") {\n setError(result.error || result.message || \"记忆系统暂不可用。\");\n return;\n }\n const savedId = String(result.memory?.id || payload.id || \"\").trim();\n const merged = normalizeMemoryEntry(\n {\n ...(result.memory || {}),\n ...payload,\n id: savedId,\n // 保留原创建时间,避免「创建时间」排序下无意义跳动\n created_at: result.memory?.created_at || previous?.created_at || result.memory?.updated_at || \"\",\n // 保存时优先保留本地旧 updated_at,避免「最近更新」排序下条目跳到列表顶\n updated_at: previous?.updated_at || result.memory?.updated_at || \"\",\n },\n previous || {},\n );\n memoryState.draft = null;\n memoryState.selectedId = savedId;\n upsertMemoryEntryLocally(merged);\n // 主路径:不整表重绘,只改右侧编辑器 + 左侧对应卡片\n if (!wasDraft && patchMemoryCard(merged)) {\n fillMemoryEditor(merged);\n renderMemoryStatus();\n applyMemoryListScroll(frozenScrollTop);\n } else {\n // 新建草稿首次落库:需要插入列表,再按冻结位置恢复\n renderMemoryPage();\n applyMemoryListScroll(frozenScrollTop);\n restoreMemoryListAnchor({\n id: savedId,\n offset: 0,\n scrollTop: frozenScrollTop,\n });\n }\n notify(\"已保存记忆。\", \"success\");\n } catch (error) {\n setError(String(error));\n } finally {\n const readOnly = memoryState.status === \"loading\" || memoryState.status === \"failed\";\n fields.memorySaveButton.disabled = readOnly || !selectedMemory();\n }\n}\n\nasync function deleteSelectedMemory() {\n const record = selectedMemory();\n if (!record || !record.id) {\n return;\n }\n const confirmed = await confirmAction(\"确认删除这条记忆?\", {\n title: \"删除记忆\",\n confirmText: \"删除\",\n danger: true,\n });\n if (!confirmed) {\n return;\n }\n setError(\"\");\n const nextSelectedId = neighborMemoryId(record.id);\n const frozenScrollTop = freezeMemoryListScroll();\n try {\n fields.memoryDeleteButton.disabled = true;\n const result = await hostCall(\"memory.delete\", { id: record.id });\n if (Array.isArray(result.failed) && result.failed.length) {\n setError(result.failed[0].error || \"记忆删除失败。\");\n return;\n }\n const row = fields.memoryList?.querySelector(\n `[data-memory-id=\"${cssEscapeAttr(record.id)}\"]`,\n );\n row?.remove();\n removeMemoryEntryLocally(record.id);\n memoryState.selectedId = nextSelectedId;\n renderMemoryStatus();\n if (nextSelectedId) {\n selectMemoryEntry(nextSelectedId);\n } else {\n fillMemoryEditor(null);\n }\n // 只恢复左侧列表滚动条位置,不整表重绘\n applyMemoryListScroll(frozenScrollTop);\n notify(\"已删除记忆。\", \"success\");\n } catch (error) {\n setError(String(error));\n } finally {\n const readOnly = memoryState.status === \"loading\" || memoryState.status === \"failed\";\n fields.memoryDeleteButton.disabled =\n readOnly || memoryState.selectedId === \"__draft__\" || !selectedMemory()?.id;\n }\n}\n\nfunction permissionInfo(permission) {\n return request?.plugins?.permission_labels?.[permission] || {\n group: \"其他\",\n label: permission,\n };\n}\n\nfunction clonePlain(value) {\n return JSON.parse(JSON.stringify(value || {}));\n}\n\nfunction plainEqual(left, right) {\n return JSON.stringify(left || {}) === JSON.stringify(right || {});\n}\n\nfunction pluginSettingsSections(plugin) {\n return Array.isArray(plugin?.settings) ? plugin.settings : [];\n}\n\nfunction pluginSectionValues(pluginId, sectionId) {\n pluginState.settingsValues[pluginId] = pluginState.settingsValues[pluginId] || {};\n pluginState.settingsValues[pluginId][sectionId] = pluginState.settingsValues[pluginId][sectionId] || {};\n return pluginState.settingsValues[pluginId][sectionId];\n}\n\nfunction pluginFieldValue(plugin, section, field) {\n const values = pluginSectionValues(plugin.id, section.section_id);\n if (!Object.prototype.hasOwnProperty.call(values, field.key)) {\n values[field.key] = field.value ?? field.default ?? \"\";\n }\n return values[field.key];\n}\n\nfunction setPluginFieldValue(plugin, section, field, value) {\n const values = pluginSectionValues(plugin.id, section.section_id);\n values[field.key] = value;\n}\n\nfunction initializePluginState() {\n pluginState.enabledById = {};\n pluginState.initialEnabledById = {};\n pluginState.settingsValues = {};\n pluginState.initialSettingsValues = {};\n (request.plugins?.items || []).forEach((plugin) => {\n pluginState.enabledById[plugin.id] = Boolean(plugin.enabled || plugin.required);\n pluginState.initialEnabledById[plugin.id] = Boolean(plugin.enabled || plugin.required);\n pluginState.settingsValues[plugin.id] = {};\n pluginSettingsSections(plugin).forEach((section) => {\n pluginState.settingsValues[plugin.id][section.section_id] = clonePlain(section.values);\n });\n pluginState.initialSettingsValues[plugin.id] = clonePlain(pluginState.settingsValues[plugin.id]);\n });\n pluginState.selectedId = request.plugins?.items?.[0]?.id || \"\";\n}\n\nfunction renderPluginPermissionFilter() {\n const current = fields.pluginPermissionFilter.value;\n fields.pluginPermissionFilter.textContent = \"\";\n const all = document.createElement(\"option\");\n all.value = \"\";\n all.textContent = \"全部权限\";\n fields.pluginPermissionFilter.append(all);\n const permissions = new Set();\n (request.plugins?.items || []).forEach((plugin) => {\n (plugin.permissions || []).forEach((permission) => permissions.add(permission));\n });\n [...permissions].sort().forEach((permission) => {\n const option = document.createElement(\"option\");\n option.value = permission;\n option.textContent = permissionInfo(permission).label;\n fields.pluginPermissionFilter.append(option);\n });\n fields.pluginPermissionFilter.value = current;\n}\n\nfunction pluginChanged(plugin) {\n return pluginState.enabledById[plugin.id] !== pluginState.initialEnabledById[plugin.id];\n}\n\nfunction filteredPlugins() {\n const query = fields.pluginSearch.value.trim().toLowerCase();\n const status = fields.pluginStatusFilter.value;\n const permission = fields.pluginPermissionFilter.value;\n return (request.plugins?.items || []).filter((plugin) => {\n const enabled = Boolean(pluginState.enabledById[plugin.id] || plugin.required);\n const text = [plugin.id, plugin.name, plugin.author, plugin.description]\n .join(\" \")\n .toLowerCase();\n if (query && !text.includes(query)) {\n return false;\n }\n if (permission && !(plugin.permissions || []).includes(permission)) {\n return false;\n }\n if (status === \"enabled\" && !enabled) {\n return false;\n }\n if (status === \"disabled\" && enabled) {\n return false;\n }\n if (status === \"required\" && !plugin.required) {\n return false;\n }\n if (status === \"changed\" && !pluginChanged(plugin)) {\n return false;\n }\n return true;\n });\n}\n\nfunction renderPluginStatus() {\n const items = request.plugins?.items || [];\n const enabled = items.filter((plugin) => pluginState.enabledById[plugin.id] || plugin.required).length;\n const changed = items.filter(pluginChanged).length;\n renderStrip(fields.pluginStatusStrip, [\n { label: \"全部\", value: items.length },\n { label: \"已启用\", value: enabled },\n { label: \"已禁用\", value: Math.max(0, items.length - enabled) },\n { label: \"必需\", value: items.filter((plugin) => plugin.required).length },\n { label: \"有改动\", value: changed },\n ]);\n}\n\nfunction setPluginEnabled(plugin, enabled) {\n pluginState.enabledById[plugin.id] = plugin.required ? true : Boolean(enabled);\n renderPluginPage();\n}\n\nfunction renderPluginList() {\n fields.pluginList.textContent = \"\";\n const plugins = filteredPlugins();\n if (!plugins.length) {\n const item = document.createElement(\"p\");\n item.className = \"empty-state\";\n item.textContent = \"没有匹配的插件。\";\n fields.pluginList.append(item);\n return;\n }\n plugins.forEach((plugin) => {\n const row = document.createElement(\"div\");\n row.className = \"plugin-card\";\n row.classList.toggle(\"is-selected\", plugin.id === pluginState.selectedId);\n row.classList.toggle(\"is-changed\", pluginChanged(plugin));\n row.addEventListener(\"click\", () => {\n pluginState.selectedId = plugin.id;\n renderPluginPage();\n });\n const top = document.createElement(\"div\");\n top.className = \"plugin-card-top\";\n const toggle = document.createElement(\"input\");\n toggle.type = \"checkbox\";\n toggle.checked = Boolean(pluginState.enabledById[plugin.id] || plugin.required);\n toggle.disabled = Boolean(plugin.required);\n toggle.addEventListener(\"click\", (event) => event.stopPropagation());\n toggle.addEventListener(\"change\", () => setPluginEnabled(plugin, toggle.checked));\n const title = document.createElement(\"strong\");\n title.textContent = plugin.name || plugin.id;\n const version = document.createElement(\"span\");\n version.className = \"card-meta\";\n version.textContent = `${plugin.author || \"未知作者\"} · ${plugin.version || \"0.0.0\"}`;\n top.append(toggle, title, version);\n const desc = document.createElement(\"p\");\n desc.className = \"card-desc\";\n desc.textContent = compactText(plugin.description || \"无描述\", 96);\n const chips = document.createElement(\"span\");\n chips.className = \"chip-row\";\n (plugin.permissions || []).slice(0, 4).forEach((permission) => {\n const chip = document.createElement(\"span\");\n chip.className = \"permission-chip\";\n chip.textContent = permissionInfo(permission).label;\n chips.append(chip);\n });\n if (plugin.required) {\n const chip = document.createElement(\"span\");\n chip.className = \"permission-chip is-locked\";\n chip.textContent = \"必需\";\n chips.append(chip);\n }\n if (pluginChanged(plugin)) {\n const chip = document.createElement(\"span\");\n chip.className = \"permission-chip is-pending\";\n chip.textContent = \"需重启生效\";\n chips.append(chip);\n }\n row.append(top, desc, chips);\n fields.pluginList.append(row);\n });\n}\n\nfunction pluginSettingControl(plugin, section, field) {\n const value = pluginFieldValue(plugin, section, field);\n if (field.readonly || field.type === \"readonly\") {\n const row = document.createElement(\"div\");\n row.className = \"plugin-readonly-control\";\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.readOnly = true;\n input.value = Array.isArray(value) ? value.join(\" ; \") : String(value ?? \"\");\n row.append(input);\n if (field.copyable) {\n const copy = document.createElement(\"button\");\n copy.type = \"button\";\n copy.className = \"secondary-button compact-button\";\n copy.textContent = \"复制\";\n copy.addEventListener(\"click\", async () => {\n await navigator.clipboard.writeText(input.value);\n copy.textContent = \"已复制\";\n window.setTimeout(() => {\n copy.textContent = \"复制\";\n }, 1200);\n });\n row.append(copy);\n }\n return row;\n }\n if (field.type === \"boolean\") {\n const label = document.createElement(\"label\");\n label.className = \"check-control\";\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.checked = Boolean(value);\n input.addEventListener(\"change\", () => setPluginFieldValue(plugin, section, field, input.checked));\n const text = document.createElement(\"span\");\n text.textContent = field.description || field.label;\n label.append(input, text);\n return label;\n }\n if (field.type === \"select\") {\n const select = document.createElement(\"select\");\n (field.options || []).forEach((option) => {\n const item = document.createElement(\"option\");\n item.value = String(option.value);\n item.textContent = option.label || String(option.value);\n select.append(item);\n });\n select.value = String(value ?? field.default ?? \"\");\n select.addEventListener(\"change\", () => setPluginFieldValue(plugin, section, field, select.value));\n window.setTimeout(() => enhanceSelect(select), 0);\n return select;\n }\n const input = document.createElement(\"input\");\n input.type = field.type === \"integer\" || field.type === \"number\" ? \"number\" : field.type === \"password\" ? \"password\" : \"text\";\n if (field.minimum !== undefined) {\n input.min = String(field.minimum);\n }\n if (field.maximum !== undefined) {\n input.max = String(field.maximum);\n }\n if (field.step !== undefined) {\n input.step = String(field.step);\n } else if (field.type === \"integer\") {\n input.step = \"1\";\n }\n input.value = String(value ?? \"\");\n input.addEventListener(\"input\", () => {\n if (field.type === \"integer\") {\n setPluginFieldValue(plugin, section, field, Number.parseInt(input.value, 10));\n } else if (field.type === \"number\") {\n setPluginFieldValue(plugin, section, field, Number.parseFloat(input.value));\n } else {\n setPluginFieldValue(plugin, section, field, input.value);\n }\n });\n return input;\n}\n\nfunction renderPluginSettings(plugin) {\n const sections = pluginSettingsSections(plugin);\n const container = document.createElement(\"div\");\n container.className = \"plugin-settings\";\n if (!sections.length) {\n const empty = document.createElement(\"p\");\n empty.className = \"page-note\";\n empty.textContent = plugin.enabled\n ? \"此插件没有内置详细设置。\"\n : \"此插件未启用;启用并保存重启 Sakura 后才会加载内置详细设置。\";\n container.append(empty);\n return container;\n }\n sections.forEach((section) => {\n const block = document.createElement(\"section\");\n block.className = \"plugin-settings-section\";\n const heading = document.createElement(\"h3\");\n heading.textContent = section.title || section.section_id;\n block.append(heading);\n if (section.error) {\n const error = document.createElement(\"p\");\n error.className = \"error\";\n error.textContent = section.error;\n block.append(error);\n }\n (section.fields || []).forEach((field) => {\n const row = document.createElement(\"div\");\n row.className = \"form-row\";\n const label = document.createElement(\"label\");\n label.textContent = field.label || field.key;\n const control = pluginSettingControl(plugin, section, field);\n if (field.type !== \"boolean\" && field.description) {\n control.title = field.description;\n }\n row.append(label, control);\n if (field.restart_required) {\n const hint = document.createElement(\"p\");\n hint.className = \"hint\";\n hint.textContent = \"保存后重启或下次启动生效。\";\n row.append(hint);\n }\n block.append(row);\n });\n if (Array.isArray(section.actions) && section.actions.length) {\n const actions = document.createElement(\"div\");\n actions.className = \"plugin-setting-actions\";\n section.actions.forEach((action) => {\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = action.danger ? \"danger-button\" : \"secondary-button\";\n button.textContent = action.label || action.action_id;\n const busyKey = `${plugin.id}:${section.section_id}:${action.action_id}`;\n button.disabled = pluginState.actionBusyKey === busyKey;\n button.addEventListener(\"click\", () => runPluginSettingsAction(plugin, section, action));\n actions.append(button);\n });\n block.append(actions);\n }\n container.append(block);\n });\n return container;\n}\n\nasync function runPluginSettingsAction(plugin, section, action) {\n const busyKey = `${plugin.id}:${section.section_id}:${action.action_id}`;\n pluginState.actionBusyKey = busyKey;\n renderPluginPage();\n setError(\"\");\n try {\n const result = await hostCall(\"plugin.settings_action\", {\n plugin_id: plugin.id,\n section_id: section.section_id,\n action_id: action.action_id,\n values: clonePlain(pluginSectionValues(plugin.id, section.section_id)),\n });\n if (result && typeof result.values === \"object\" && result.values !== null) {\n pluginState.settingsValues[plugin.id][section.section_id] = {\n ...pluginState.settingsValues[plugin.id][section.section_id],\n ...result.values,\n };\n }\n if (result && result.message) {\n notify(String(result.message), \"success\");\n }\n } catch (error) {\n setError(String(error));\n } finally {\n pluginState.actionBusyKey = \"\";\n renderPluginPage();\n }\n}\n\nfunction renderPluginDetail() {\n const plugin = (request.plugins?.items || []).find((item) => item.id === pluginState.selectedId);\n fields.pluginDetail.textContent = \"\";\n if (!plugin) {\n const empty = document.createElement(\"p\");\n empty.className = \"empty-state\";\n empty.textContent = \"选择左侧插件查看详情。\";\n fields.pluginDetail.append(empty);\n return;\n }\n const title = document.createElement(\"h2\");\n title.textContent = plugin.name || plugin.id;\n const desc = document.createElement(\"p\");\n desc.className = \"detail-desc\";\n desc.textContent = plugin.description || \"无描述。\";\n const meta = document.createElement(\"dl\");\n meta.className = \"detail-meta\";\n [\n [\"ID\", plugin.id],\n [\"入口\", plugin.entry || \"未声明\"],\n [\"来源\", plugin.source || \"未知\"],\n [\"优先级\", String(plugin.priority ?? \"\")],\n [\"版本\", plugin.version || \"0.0.0\"],\n [\"作者\", plugin.author || \"未知\"],\n [\n \"当前状态\",\n pluginState.initialEnabledById[plugin.id] ? \"已启用\" : \"已禁用\",\n ],\n [\n \"保存后状态\",\n pluginState.enabledById[plugin.id] || plugin.required ? \"已启用\" : \"已禁用\",\n ],\n ].forEach(([label, value]) => {\n const dt = document.createElement(\"dt\");\n dt.textContent = label;\n const dd = document.createElement(\"dd\");\n dd.textContent = value;\n meta.append(dt, dd);\n });\n\n const groups = new Map();\n (plugin.permissions || []).forEach((permission) => {\n const info = permissionInfo(permission);\n const list = groups.get(info.group) || [];\n list.push(info.label);\n groups.set(info.group, list);\n });\n const permissions = document.createElement(\"div\");\n permissions.className = \"permission-groups\";\n if (!groups.size) {\n const none = document.createElement(\"p\");\n none.className = \"hint\";\n none.textContent = \"未声明权限。\";\n permissions.append(none);\n }\n groups.forEach((labels, group) => {\n const block = document.createElement(\"section\");\n const heading = document.createElement(\"h3\");\n heading.textContent = group;\n const chips = document.createElement(\"div\");\n chips.className = \"chip-row\";\n labels.forEach((label) => {\n const chip = document.createElement(\"span\");\n chip.className = \"permission-chip\";\n chip.textContent = label;\n chips.append(chip);\n });\n block.append(heading, chips);\n permissions.append(block);\n });\n const note = document.createElement(\"p\");\n note.className = \"page-note\";\n note.textContent = plugin.required\n ? \"必需插件由宿主锁定,不能关闭。\"\n : \"启停变化保存后重启 Sakura 生效。\";\n fields.pluginDetail.append(title, desc, meta, permissions, note, renderPluginSettings(plugin));\n}\n\nfunction renderPluginPage() {\n renderPluginStatus();\n renderPluginList();\n renderPluginDetail();\n}\n\nfunction collectPluginSettings() {\n const enabledById = {};\n const settingsById = {};\n (request.plugins?.items || []).forEach((plugin) => {\n const enabled = plugin.required ? true : Boolean(pluginState.enabledById[plugin.id]);\n if (enabled !== pluginState.initialEnabledById[plugin.id]) {\n enabledById[plugin.id] = enabled;\n }\n const sections = pluginSettingsSections(plugin);\n if (sections.length) {\n sections.forEach((section) => {\n const values = clonePlain(pluginSectionValues(plugin.id, section.section_id));\n const initial = pluginState.initialSettingsValues[plugin.id]?.[section.section_id] || {};\n if (!plainEqual(values, initial)) {\n settingsById[plugin.id] = settingsById[plugin.id] || {};\n settingsById[plugin.id][section.section_id] = values;\n }\n });\n }\n });\n return { enabled_by_id: enabledById, settings_by_id: settingsById };\n}\n\nfunction collectCharacterSettings() {\n const limits = request.limits;\n return {\n current_character_id: fields.characterSelect.value,\n layout: {\n portrait_scale_percent: clampInt(fields.portraitScale.value, limits.portrait_scale_percent),\n control_panel_width: clampInt(fields.controlPanelWidth.value, limits.control_panel_width),\n bubble_height: clampInt(fields.bubbleHeight.value, limits.bubble_height),\n control_panel_vertical_offset: clampInt(\n fields.controlPanelOffset.value,\n limits.control_panel_vertical_offset,\n ),\n input_bar_offset: clampInt(fields.inputBarOffset.value, limits.input_bar_offset),\n },\n };\n}\n\n// 角色页的布局滑块:拖动时把数值实时回写到桌宠(preview_layout),保存时才落盘。\nconst layoutSliders = [\n \"portraitScale\",\n \"controlPanelWidth\",\n \"bubbleHeight\",\n \"controlPanelOffset\",\n \"inputBarOffset\",\n];\n\nfunction updateSliderOutput(fieldKey) {\n const input = fields[fieldKey];\n const output = input?.parentElement?.querySelector(\".slider-value\");\n if (output) {\n output.textContent = input.value;\n }\n if (input) {\n const min = Number(input.min || 0);\n const max = Number(input.max || 100);\n const value = Number(input.value);\n const progress = max > min ? ((value - min) / (max - min)) * 100 : 0;\n input.style.setProperty(\"--slider-progress\", `${Math.max(0, Math.min(100, progress))}%`);\n }\n}\n\nlet layoutPreviewPending = false;\nfunction requestLayoutPreview() {\n if (!request || layoutPreviewPending) {\n return;\n }\n layoutPreviewPending = true;\n requestAnimationFrame(async () => {\n layoutPreviewPending = false;\n try {\n await invoke(\"preview_layout\", { layout: collectCharacterSettings().layout });\n } catch (error) {\n // 实时预览失败不应打断编辑\n }\n });\n}\n\nlet fontPreviewPending = false;\nfunction requestFontPreview() {\n if (!request || fontPreviewPending) {\n return;\n }\n fontPreviewPending = true;\n requestAnimationFrame(async () => {\n fontPreviewPending = false;\n try {\n await invoke(\"preview_layout\", {\n layout: {\n speech_font_size: clampInt(\n fields.speechFontSize.value,\n request.limits.speech_font_size,\n ),\n name_font_size: clampInt(\n fields.nameFontSize.value,\n request.limits.name_font_size,\n ),\n input_font_size: clampInt(\n fields.inputFontSize.value,\n request.limits.input_font_size,\n ),\n button_font_size: clampInt(\n fields.buttonFontSize.value,\n request.limits.button_font_size,\n ),\n },\n });\n } catch (error) {\n // 实时预览失败不应打断编辑\n }\n });\n}\n\nfunction collectScreenAwarenessSettings() {\n const enabled = fields.enabled.checked;\n return { enabled, screen_context_enabled: enabled };\n}\n\nfunction collectProactiveSettings() {\n const limits = request.limits || {};\n const adaptiveMin = clampInt(fields.proactiveAdaptiveMin.value, limits.proactive_adaptive_interval_min || [15, 600]);\n const adaptiveMax = Math.max(\n adaptiveMin,\n clampInt(fields.proactiveAdaptiveMax.value, limits.proactive_adaptive_interval_max || [60, 7200]),\n );\n return {\n enabled: fields.enabled.checked,\n timer_seconds: clampInt(fields.proactiveTimerSeconds.value, limits.proactive_timer_seconds || [60, 3600]),\n cooldown_seconds: clampInt(fields.proactiveCooldownSeconds.value, limits.proactive_cooldown_seconds || [30, 3600]),\n min_silence_after_user: clampInt(fields.proactiveMinSilence.value, limits.proactive_min_silence_after_user || [0, 120]),\n window_switch_enabled: fields.proactiveWindowSwitch.checked,\n window_switch_cooldown: clampInt(fields.proactiveWindowSwitchCooldown.value, limits.proactive_window_switch_cooldown || [5, 300]),\n focus_settle_delay: clampInt(fields.proactiveFocusSettle.value, limits.proactive_focus_settle_delay || [1, 120]),\n idle_threshold_seconds: clampInt(fields.proactiveIdleThreshold.value, limits.proactive_idle_threshold_seconds || [60, 7200]),\n max_edge: clampInt(fields.proactiveMaxEdge.value, limits.proactive_max_edge || [640, 3840]),\n adaptive_interval_min: adaptiveMin,\n adaptive_interval_max: adaptiveMax,\n privacy: {\n blocked_processes: linesToList(fields.proactiveBlockedProcesses.value),\n blocked_title_keywords: linesToList(fields.proactiveBlockedTitles.value),\n },\n };\n}\n\nfunction collectRuntimeLoopSettings() {\n const limits = request.limits;\n const perStep = clampInt(fields.toolCallsPerStep.value, limits.max_tool_calls_per_step);\n const perTurn = clampInt(fields.toolCallsPerTurn.value, limits.max_tool_calls_per_turn);\n return {\n max_agent_steps_per_turn: clampInt(fields.agentSteps.value, limits.max_agent_steps_per_turn),\n max_tool_calls_per_step: perStep,\n max_tool_calls_per_turn: Math.max(perStep, perTurn),\n };\n}\n\nfunction normalizedProviderProfiles() {\n return providerState.profiles.map((profile) => ({\n id: profile.id,\n alias: (profile.alias || \"\").trim() || profile.id,\n base_url: (profile.base_url || \"\").trim(),\n api_key: (profile.api_key || \"\").trim(),\n models: (profile.models || []).map((model) => String(model).trim()).filter(Boolean),\n }));\n}\n\nfunction providerDisplayName(profile) {\n return profile.alias || profile.id || \"未命名供应商\";\n}\n\nfunction focusProviderValidation(profile, field) {\n providerState.selectedId = profile.id;\n providerState.search = \"\";\n if (fields.providerSearch) {\n fields.providerSearch.value = \"\";\n }\n showPage(\"providers\");\n renderProviderPage();\n markInvalid(providerDetailInput(field), true);\n}\n\nfunction validateOnboardingBeforeSubmit() {\n if (!isOnboarding()) {\n return true;\n }\n if (!selectedCharacter()) {\n showOnboardingStep(\"character\");\n setError(\"请先导入并选择一个角色包。\");\n return false;\n }\n const profile = onboardingChatProfile();\n if (profile && !profile.api_key) {\n focusProviderValidation(profile, \"api_key\");\n onboardingStep = \"providers\";\n updateOnboardingUi();\n setError(`供应商「${providerDisplayName(profile)}」缺少 API Key。`);\n return false;\n }\n return true;\n}\n\nfunction validateApiSettingsBeforeSubmit() {\n const profiles = normalizedProviderProfiles();\n if (!profiles.length) {\n showPage(\"providers\");\n setError(\"请至少添加一个 API 供应商。\");\n return false;\n }\n const missingBaseUrl = profiles.find((profile) => !profile.base_url);\n if (missingBaseUrl) {\n focusProviderValidation(missingBaseUrl, \"base_url\");\n setError(`供应商「${providerDisplayName(missingBaseUrl)}」缺少 Base URL。`);\n return false;\n }\n const missingModels = profiles.find((profile) => !profile.models.length);\n if (missingModels) {\n focusProviderValidation(missingModels, \"\");\n setError(`供应商「${providerDisplayName(missingModels)}」至少需要一个模型。`);\n return false;\n }\n const selection = collectModelSelection();\n const chat = selection.slots.chat || {};\n const chatProfile = profiles.find((profile) => profile.id === chat.profile_id);\n if (!chatProfile || !chat.model || !chatProfile.models.includes(chat.model)) {\n showPage(\"model\");\n refreshModelSlots();\n setError(\"请选择可用的聊天模型。\");\n return false;\n }\n return true;\n}\n\nfunction collectApiSettings() {\n const limits = request.limits;\n const temperature = clampFloat(fields.apiTemperature.value, limits.api_temperature);\n const initialTemperature = request.api.settings.temperature;\n return {\n settings: {\n timeout_seconds: clampInt(fields.apiTimeout.value, limits.api_timeout_seconds),\n temperature:\n initialTemperature === null && Math.abs(temperature - 0.8) < 0.005\n ? null\n : temperature,\n top_p: fields.apiTopPEnabled.checked\n ? clampFloat(fields.apiTopP.value, limits.api_top_p)\n : null,\n max_tokens: fields.apiMaxTokensEnabled.checked\n ? clampInt(fields.apiMaxTokens.value, limits.api_max_tokens)\n : null,\n },\n profiles: normalizedProviderProfiles(),\n model_selection: collectModelSelection(),\n };\n}\n\nfunction collectTtsSettings() {\n const enabled = fields.ttsEnabled.checked && fields.ttsProvider.value !== \"none\";\n return {\n enabled,\n provider: enabled ? fields.ttsProvider.value : \"none\",\n api_url: fields.ttsApiUrl.value.trim(),\n work_dir: fields.ttsWorkDir.value.trim(),\n python_path: fields.ttsPythonPath.value.trim(),\n tts_config_path: fields.ttsConfigPath.value.trim(),\n timeout_seconds: clampInt(fields.ttsTimeout.value, request.limits.tts_timeout_seconds),\n };\n}\n\nfunction collectSystemBasicSettings() {\n const limits = request.limits;\n const debugLogEnabled = fields.debugLogEnabled.checked;\n return {\n debug_log: {\n enabled: debugLogEnabled,\n body_enabled: debugLogEnabled && fields.debugBodyEnabled.checked,\n file_enabled: fields.debugFileEnabled.checked,\n profile: request.system_basic.debug_log.profile,\n stage_debug_overlay: fields.stageDebugOverlay.checked,\n stage_collision_mask: fields.stageCollisionMask.checked,\n },\n ui: {\n subtitle_typing_interval_ms: clampInt(\n fields.subtitleTypingInterval.value,\n limits.subtitle_typing_interval_ms,\n ),\n reply_segment_pause_ms: clampInt(\n fields.replySegmentPause.value,\n limits.reply_segment_pause_ms,\n ),\n speech_font_size: clampInt(\n fields.speechFontSize.value,\n limits.speech_font_size,\n ),\n name_font_size: clampInt(\n fields.nameFontSize.value,\n limits.name_font_size,\n ),\n input_font_size: clampInt(\n fields.inputFontSize.value,\n limits.input_font_size,\n ),\n button_font_size: clampInt(\n fields.buttonFontSize.value,\n limits.button_font_size,\n ),\n },\n bubble: {\n auto_hide_enabled: fields.bubbleAutoHide.checked,\n auto_hide_delay_seconds: clampInt(\n fields.bubbleAutoHideDelay.value,\n limits.bubble_auto_hide_delay_seconds,\n ),\n },\n };\n}\n\nfunction collectSystemExtraSettings() {\n return {\n startup: {\n launch_at_login: fields.launchAtLogin.checked,\n launch_at_login_supported: Boolean(request.system_extra.startup.launch_at_login_supported),\n },\n backchannel: {\n enabled: fields.backchannelEnabled.checked,\n mode: fields.backchannelMode.value,\n delay_ms: clampInt(fields.backchannelDelay.value, request.limits.backchannel_delay_ms),\n probability: clampFloat(\n fields.backchannelProbability.value,\n request.limits.backchannel_probability,\n ),\n tts_enabled: fields.backchannelTtsEnabled.checked,\n timeout_ms: request.system_extra.backchannel.timeout_ms,\n },\n };\n}\n\nfunction collectMemorySettings() {\n return {\n curation: {\n trigger_turns: clampInt(fields.memoryTriggerTurns.value, request.limits.memory_trigger_turns),\n backfill_limit: request.memory.curation.backfill_limit,\n },\n progressive_memory: fields.progressiveMemory.checked,\n };\n}\n\nfunction collectThemeSettings() {\n const theme = {};\n request.theme_fields.forEach(({ id }) => {\n const input = fields.themeColors.querySelector(`[data-theme-field=\"${id}\"]`);\n theme[id] = input.value;\n });\n theme.ai_enabled = Boolean(request.theme.ai_enabled && !themeChanged);\n theme.visual_effect_mode = fields.visualEffectMode.value || request.theme.visual_effect_mode;\n return theme;\n}\n\nfunction collectSettings() {\n return {\n screen_awareness: collectScreenAwarenessSettings(),\n proactive: collectProactiveSettings(),\n mcp: {\n windows_enabled: fields.windowsMcp.checked,\n },\n runtime_loop: collectRuntimeLoopSettings(),\n system_basic: collectSystemBasicSettings(),\n theme: collectThemeSettings(),\n theme_changed: themeChanged,\n character: collectCharacterSettings(),\n api: collectApiSettings(),\n tts: collectTtsSettings(),\n system_extra: collectSystemExtraSettings(),\n memory: collectMemorySettings(),\n plugins: collectPluginSettings(),\n };\n}\n\nfunction upgradeSliderControls() {\n // 点击 .slider-value 可进入编辑模式,回车/失焦后切回显示并同步滑块。\n document.querySelectorAll(\".slider-control\").forEach((control) => {\n const output = control.querySelector(\".slider-value\");\n const slider = control.querySelector(\"input[type='range']\");\n if (!output || !slider || output.dataset.upgraded) return;\n output.dataset.upgraded = \"true\";\n\n output.addEventListener(\"click\", () => {\n const min = Number(slider.min || 0);\n const max = Number(slider.max || 100);\n const editor = document.createElement(\"input\");\n editor.type = \"number\";\n editor.className = \"slider-value-editor\";\n editor.min = String(min);\n editor.max = String(max);\n editor.step = slider.step || \"1\";\n editor.value = slider.value;\n editor.style.width = `${Math.max(40, output.offsetWidth)}px`;\n output.replaceWith(editor);\n editor.focus();\n editor.select();\n\n function commit() {\n const clamped = clampInt(editor.value, [Number(editor.min), Number(editor.max)]);\n const changed = String(clamped) !== slider.value;\n slider.value = String(clamped);\n if (changed) {\n slider.dispatchEvent(new Event(\"input\", { bubbles: true }));\n }\n output.textContent = slider.value;\n editor.replaceWith(output);\n }\n\n editor.addEventListener(\"blur\", commit);\n editor.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") { e.preventDefault(); commit(); }\n if (e.key === \"Escape\") { e.preventDefault(); output.textContent = slider.value; editor.replaceWith(output); }\n });\n });\n });\n}\n\nasync function load() {\n request = await invoke(\"load_request\");\n resourceState.snapshot = request.resources || {};\n renderCharacters();\n renderThemeControls();\n initializeProviderState();\n renderProviderPage();\n renderModelSlots(request.api.model_selection);\n renderTtsProviders();\n renderMemoryControls();\n initializePluginState();\n renderPluginPermissionFilter();\n enhanceSelect(fields.characterSelect);\n enhanceSelect(fields.visualEffectMode);\n enhanceSelect(fields.ttsProvider);\n enhanceSelect(fields.backchannelMode);\n enhanceSelect(fields.memoryLayerFilter);\n enhanceSelect(fields.memorySort);\n enhanceSelect(fields.memoryLayer);\n enhanceSelect(fields.pluginStatusFilter);\n enhanceSelect(fields.pluginPermissionFilter);\n\n setNumericBounds(fields.agentSteps, request.limits.max_agent_steps_per_turn);\n setNumericBounds(fields.toolCallsPerStep, request.limits.max_tool_calls_per_step);\n setNumericBounds(fields.toolCallsPerTurn, request.limits.max_tool_calls_per_turn);\n setNumericBounds(fields.subtitleTypingInterval, request.limits.subtitle_typing_interval_ms);\n setNumericBounds(fields.replySegmentPause, request.limits.reply_segment_pause_ms);\n setNumericBounds(fields.bubbleAutoHideDelay, request.limits.bubble_auto_hide_delay_seconds);\n setNumericBounds(fields.portraitScale, request.limits.portrait_scale_percent);\n setNumericBounds(fields.controlPanelWidth, request.limits.control_panel_width);\n setNumericBounds(fields.bubbleHeight, request.limits.bubble_height);\n setNumericBounds(fields.controlPanelOffset, request.limits.control_panel_vertical_offset);\n setNumericBounds(fields.inputBarOffset, request.limits.input_bar_offset);\n setNumericBounds(fields.apiTimeout, request.limits.api_timeout_seconds);\n setNumericBounds(fields.apiMaxTokens, request.limits.api_max_tokens);\n setNumericBounds(fields.ttsTimeout, request.limits.tts_timeout_seconds);\n setNumericBounds(fields.backchannelDelay, request.limits.backchannel_delay_ms);\n setNumericBounds(fields.memoryTriggerTurns, request.limits.memory_trigger_turns);\n setNumericBounds(fields.speechFontSize, request.limits.speech_font_size);\n setNumericBounds(fields.nameFontSize, request.limits.name_font_size);\n setNumericBounds(fields.inputFontSize, request.limits.input_font_size);\n setNumericBounds(fields.buttonFontSize, request.limits.button_font_size);\n\n const layout = request.character.layout;\n fields.portraitScale.value = layout.portrait_scale_percent;\n fields.controlPanelWidth.value = layout.control_panel_width;\n fields.bubbleHeight.value = layout.bubble_height;\n fields.controlPanelOffset.value = layout.control_panel_vertical_offset;\n fields.inputBarOffset.value = layout.input_bar_offset;\n layoutSliders.forEach(updateSliderOutput);\n\n const settings = request.screen_awareness;\n fields.enabled.checked = settings.enabled && settings.screen_context_enabled;\n const proactive = request.proactive || {};\n const privacy = proactive.privacy || {};\n setNumericBounds(fields.proactiveCooldownSeconds, request.limits.proactive_cooldown_seconds);\n setNumericBounds(fields.proactiveTimerSeconds, request.limits.proactive_timer_seconds);\n setNumericBounds(fields.proactiveAdaptiveMin, request.limits.proactive_adaptive_interval_min);\n setNumericBounds(fields.proactiveAdaptiveMax, request.limits.proactive_adaptive_interval_max);\n setNumericBounds(fields.proactiveIdleThreshold, request.limits.proactive_idle_threshold_seconds);\n setNumericBounds(fields.proactiveMinSilence, request.limits.proactive_min_silence_after_user);\n setNumericBounds(fields.proactiveWindowSwitchCooldown, request.limits.proactive_window_switch_cooldown);\n setNumericBounds(fields.proactiveFocusSettle, request.limits.proactive_focus_settle_delay);\n setNumericBounds(fields.proactiveMaxEdge, request.limits.proactive_max_edge);\n fields.proactiveCooldownSeconds.value = proactive.cooldown_seconds ?? 600;\n fields.proactiveTimerSeconds.value = proactive.timer_seconds ?? 480;\n fields.proactiveAdaptiveMin.value = proactive.adaptive_interval_min ?? 60;\n fields.proactiveAdaptiveMax.value = proactive.adaptive_interval_max ?? 1800;\n fields.proactiveIdleThreshold.value = proactive.idle_threshold_seconds ?? 600;\n fields.proactiveMinSilence.value = proactive.min_silence_after_user ?? 10;\n fields.proactiveWindowSwitch.checked = proactive.window_switch_enabled !== false;\n fields.proactiveWindowSwitchCooldown.value = proactive.window_switch_cooldown ?? 60;\n fields.proactiveFocusSettle.value = proactive.focus_settle_delay ?? 15;\n fields.proactiveMaxEdge.value = proactive.max_edge ?? 1920;\n fields.proactiveBlockedProcesses.value = listToLines(privacy.blocked_processes);\n fields.proactiveBlockedTitles.value = listToLines(privacy.blocked_title_keywords);\n syncEnabledState();\n syncDesktopMcpControl(request.mcp);\n fields.windowsMcp.checked = request.mcp.windows_enabled;\n fields.agentSteps.value = request.runtime_loop.max_agent_steps_per_turn;\n fields.toolCallsPerStep.value = request.runtime_loop.max_tool_calls_per_step;\n fields.toolCallsPerTurn.value = request.runtime_loop.max_tool_calls_per_turn;\n\n fields.apiTimeout.value = request.api.settings.timeout_seconds;\n fields.apiTemperature.value = request.api.settings.temperature ?? 0.8;\n fields.apiTopPEnabled.checked = request.api.settings.top_p !== null;\n fields.apiTopP.value = request.api.settings.top_p ?? 1;\n fields.apiMaxTokensEnabled.checked = request.api.settings.max_tokens !== null;\n fields.apiMaxTokens.value = request.api.settings.max_tokens ?? 2048;\n\n fields.ttsEnabled.checked = request.tts.enabled;\n setTtsProviderValue(request.tts.provider);\n fields.ttsApiUrl.value = request.tts.api_url;\n fields.ttsWorkDir.value = request.tts.work_dir;\n fields.ttsPythonPath.value = request.tts.python_path;\n fields.ttsConfigPath.value = request.tts.tts_config_path;\n fields.ttsTimeout.value = request.tts.timeout_seconds;\n lastTtsProvider = fields.ttsProvider.value;\n applyTtsProviderDefaults(lastTtsProvider);\n\n fields.launchAtLogin.checked = request.system_extra.startup.launch_at_login;\n setControlDisabled(fields.launchAtLogin, !request.system_extra.startup.launch_at_login_supported);\n fields.debugLogEnabled.checked = request.system_basic.debug_log.enabled;\n fields.debugBodyEnabled.checked = request.system_basic.debug_log.body_enabled;\n fields.debugFileEnabled.checked = request.system_basic.debug_log.file_enabled;\n fields.stageDebugOverlay.checked = request.system_basic.debug_log.stage_debug_overlay;\n fields.stageCollisionMask.checked = request.system_basic.debug_log.stage_collision_mask;\n fields.subtitleTypingInterval.value = request.system_basic.ui.subtitle_typing_interval_ms;\n fields.replySegmentPause.value = request.system_basic.ui.reply_segment_pause_ms;\n fields.speechFontSize.value = request.system_basic.ui.speech_font_size;\n fields.nameFontSize.value = request.system_basic.ui.name_font_size;\n fields.inputFontSize.value = request.system_basic.ui.input_font_size;\n fields.buttonFontSize.value = request.system_basic.ui.button_font_size;\n updateSliderOutput(\"speechFontSize\");\n updateSliderOutput(\"nameFontSize\");\n updateSliderOutput(\"inputFontSize\");\n updateSliderOutput(\"buttonFontSize\");\n fields.bubbleAutoHide.checked = request.system_basic.bubble.auto_hide_enabled;\n fields.bubbleAutoHideDelay.value = request.system_basic.bubble.auto_hide_delay_seconds;\n fields.backchannelEnabled.checked = request.system_extra.backchannel.enabled;\n fields.backchannelMode.value =\n request.system_extra.backchannel.mode === \"hybrid\" ? \"hybrid\" : \"rules\";\n fields.backchannelDelay.value = request.system_extra.backchannel.delay_ms;\n fields.backchannelProbability.value = request.system_extra.backchannel.probability;\n fields.backchannelTtsEnabled.checked = request.system_extra.backchannel.tts_enabled;\n fields.memoryTriggerTurns.value = request.memory.curation.trigger_turns;\n fields.progressiveMemory.checked = request.memory.progressive_memory === true;\n\n setThemeValues(request.theme);\n themeChanged = false;\n updateScreenResolutionEstimate();\n syncEnabledState();\n syncRuntimeLoopState();\n syncDebugLogState();\n syncBubbleState();\n syncApiAdvancedState();\n syncTtsState();\n syncBackchannelState();\n syncCharacterArchiveState();\n refreshSelect(fields.characterSelect);\n refreshSelect(fields.ttsProvider);\n refreshSelect(fields.backchannelMode);\n renderMemoryPage();\n renderPluginPage();\n renderResourceCards();\n initializeOnboarding();\n if (hasRunningResourceTask()) {\n startResourcePolling();\n }\n\n // 给所有滑块追加数字输入框,滑块粗调 + 数字精确输入。\n upgradeSliderControls();\n\n // 配置全部填充完毕后拍基线,作为「未保存改动」的比对基准。\n settingsBaseline = settingsSnapshot();\n refreshDirty();\n}\n\nfields.navItems.forEach((item) => {\n item.addEventListener(\"click\", () => showPage(item.dataset.page));\n});\nlayoutSliders.forEach((fieldKey) => {\n const preview = () => {\n updateSliderOutput(fieldKey);\n requestLayoutPreview();\n };\n fields[fieldKey].addEventListener(\"input\", preview);\n fields[fieldKey].addEventListener(\"change\", preview);\n});\n[\"speechFontSize\", \"nameFontSize\", \"inputFontSize\", \"buttonFontSize\"].forEach((fieldKey) => {\n const preview = () => {\n updateSliderOutput(fieldKey);\n requestFontPreview();\n };\n fields[fieldKey].addEventListener(\"input\", preview);\n fields[fieldKey].addEventListener(\"change\", preview);\n});\nfields.characterSelect.addEventListener(\"change\", syncTtsState);\nfields.characterSelect.addEventListener(\"change\", applySelectedCharacterTheme);\nfields.characterSelect.addEventListener(\"change\", syncCharacterArchiveState);\nfields.characterSelect.addEventListener(\"change\", updateOnboardingUi);\nfields.characterImportButton.addEventListener(\"click\", importCharacterArchive);\nfields.ttsVoiceImportButton.addEventListener(\"click\", importCharacterVoiceArchive);\nfields.characterExportButton.addEventListener(\"click\", exportCharacterArchive);\nfields.characterEditorButton.addEventListener(\"click\", launchCharacterStudio);\nfields.enabled.addEventListener(\"change\", syncEnabledState);\nfields.proactiveWindowSwitch.addEventListener(\"change\", syncEnabledState);\nfields.proactiveMaxEdge.addEventListener(\"change\", updateScreenResolutionEstimate);\nfields.proactiveMaxEdge.addEventListener(\"input\", updateScreenResolutionEstimate);\nfields.toolCallsPerStep.addEventListener(\"input\", syncRuntimeLoopState);\nfields.addProviderButton.addEventListener(\"click\", openAddProviderChooser);\nfields.onboardingCharacterStep.addEventListener(\"click\", () => showOnboardingStep(\"character\"));\nfields.onboardingProviderStep.addEventListener(\"click\", () => showOnboardingStep(\"providers\"));\nfields.onboardingBackButton.addEventListener(\"click\", () => showOnboardingStep(\"character\"));\nfields.providerSearch.addEventListener(\"input\", () => {\n providerState.search = fields.providerSearch.value;\n renderProviderList();\n});\nfields.apiTopPEnabled.addEventListener(\"change\", syncApiAdvancedState);\nfields.apiMaxTokensEnabled.addEventListener(\"change\", syncApiAdvancedState);\nfields.ttsEnabled.addEventListener(\"change\", syncTtsState);\nfields.ttsProvider.addEventListener(\"change\", handleTtsProviderChange);\nfields.ttsTestButton.addEventListener(\"click\", testTtsSettings);\nfields.backchannelEnabled.addEventListener(\"change\", syncBackchannelState);\nfields.backchannelMode.addEventListener(\"change\", renderBackchannelResourceCard);\nfields.visualEffectMode.addEventListener(\"change\", markThemeChanged);\nfields.themeAiButton.addEventListener(\"click\", generateAiTheme);\nfields.resetThemeButton.addEventListener(\"click\", () => {\n setThemeValues(selectedCharacterThemeDefaults(), { updateVisualEffect: false, animateTheme: true });\n themeChanged = true;\n});\nfields.debugLogEnabled.addEventListener(\"change\", syncDebugLogState);\nfields.bubbleAutoHide.addEventListener(\"change\", syncBubbleState);\nlet memorySearchTimer = null;\nfields.memorySearch.addEventListener(\"input\", () => {\n clearMemoryRetry();\n window.clearTimeout(memorySearchTimer);\n memorySearchTimer = window.setTimeout(loadMemories, 180);\n});\nfields.memoryLayerFilter.addEventListener(\"change\", loadMemories);\nfields.memorySort.addEventListener(\"change\", () => {\n const anchor = captureMemoryListAnchor();\n renderMemoryPage();\n restoreMemoryListAnchor(anchor);\n});\nfields.memoryAddButton.addEventListener(\"click\", newMemoryDraft);\nfields.memoryRefreshButton.addEventListener(\"click\", loadMemories);\nfields.memorySaveButton.addEventListener(\"click\", saveMemoryEditor);\nfields.memoryRevertButton.addEventListener(\"click\", () => fillMemoryEditor(selectedMemory()));\nfields.memoryDeleteButton.addEventListener(\"click\", deleteSelectedMemory);\nfields.memoryTriggerTurns.addEventListener(\"input\", renderMemoryStatus);\nfields.pluginSearch.addEventListener(\"input\", renderPluginPage);\nfields.pluginStatusFilter.addEventListener(\"change\", renderPluginPage);\nfields.pluginPermissionFilter.addEventListener(\"change\", renderPluginPage);\nfields.saveButton.addEventListener(\"click\", async () => {\n if (!request) {\n return;\n }\n setError(\"\");\n if (!validateOnboardingBeforeSubmit() || !validateApiSettingsBeforeSubmit()) {\n return;\n }\n const original = fields.saveButton.textContent;\n let settings;\n try {\n settings = collectSettings();\n } catch (error) {\n setError(String(error));\n return;\n }\n // 保存成功后 Rust/Python 会关窗,提前放行关窗拦截。\n bypassCloseGuard = true;\n setSubmissionBusy(true);\n fields.saveButton.textContent = \"保存中…\";\n try {\n await invoke(\"save_settings\", { settings });\n settingsBaseline = JSON.stringify(settings);\n refreshDirty();\n notify(\"已保存。\", \"success\");\n } catch (error) {\n bypassCloseGuard = false;\n setSubmissionBusy(false);\n fields.saveButton.textContent = original;\n setError(String(error));\n return;\n }\n window.setTimeout(() => {\n bypassCloseGuard = false;\n setSubmissionBusy(false);\n fields.saveButton.textContent = original;\n }, 800);\n});\n\nfields.applyButton.addEventListener(\"click\", async () => {\n if (!request) {\n return;\n }\n setError(\"\");\n if (!validateOnboardingBeforeSubmit() || !validateApiSettingsBeforeSubmit()) {\n return;\n }\n let settings;\n try {\n settings = collectSettings();\n } catch (error) {\n setError(String(error));\n return;\n }\n setSubmissionBusy(true);\n try {\n await invoke(\"apply_settings\", { settings });\n // 应用同样会持久化(仅不关窗),故重置基线,清掉「未保存」状态。\n settingsBaseline = JSON.stringify(settings);\n refreshDirty();\n notify(\"已应用。\", \"success\");\n } catch (error) {\n setError(String(error));\n } finally {\n setSubmissionBusy(false);\n }\n});\n\nfields.cancelButton.addEventListener(\"click\", async () => {\n await requestCancelClose();\n});\n\n// 任意输入/勾选/点击后重算「未保存」状态(动态重建 DOM 的供应商/插件/模型区也能覆盖)。\n[\"input\", \"change\", \"click\"].forEach((evt) => {\n document.addEventListener(evt, scheduleDirty, true);\n});\n\n// 数字输入失焦时越界标红,改回合法即清除。\nconst detailCard = document.querySelector(\".detail-card\");\nfunction numberOutOfBounds(el) {\n if (el.value === \"\") {\n return false;\n }\n const value = Number.parseFloat(el.value);\n const min = el.min !== \"\" ? Number.parseFloat(el.min) : -Infinity;\n const max = el.max !== \"\" ? Number.parseFloat(el.max) : Infinity;\n return Number.isNaN(value) || value < min || value > max;\n}\ndetailCard?.addEventListener(\"focusout\", (event) => {\n const el = event.target;\n if (el instanceof HTMLInputElement && el.type === \"number\") {\n markInvalid(el, numberOutOfBounds(el));\n }\n});\ndetailCard?.addEventListener(\"input\", (event) => {\n const el = event.target;\n if (el instanceof HTMLInputElement && el.type === \"number\" && el.classList.contains(\"is-invalid\")) {\n markInvalid(el, numberOutOfBounds(el));\n }\n});\n\n// 关窗(X / OS)拦截:统一走「取消」路径;有未保存改动时二次确认。\n(function guardWindowClose() {\n try {\n window.__TAURI__?.event?.listen?.(\"sakura://settings-close-requested\", requestCancelClose);\n const current = window.__TAURI__?.window?.getCurrentWindow?.();\n if (!current?.onCloseRequested) {\n return;\n }\n current.onCloseRequested(async (event) => {\n if (bypassCloseGuard) {\n return;\n }\n event.preventDefault();\n await requestCancelClose();\n });\n } catch {\n // 监听不可用时不阻断窗口正常关闭。\n }\n})();\n\nload().catch((error) => setError(String(error)));\n"}}}}