Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
182 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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 上的原生 <select> 弹层无法被 CSS 主题化,\n// 这里保留原生 <select>(隐藏)承载取值与 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 内:打开时才挂到 <body>(见 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 // 弹层挂在 <body> 上,按视口坐标定位;下方空间不足且上方更宽裕时向上弹出。\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"}}}}