Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/6mmliu_video-caption-ai-summarizer.json

1 line
291 KiB
JSON

{"repo": "6mmliu/video-caption-ai-summarizer", "files": {"options/options.js": {"d5ac437217dffe78b166d9257db039738a971798": {"t": 1785746002.1919193, "n": 41337, "h": "68b087d953be71f6", "c": "const SETTINGS_KEY = \"vcsSettings\";\nconst HISTORY_KEY = \"vcsHistory\";\nconst HISTORY_LIMIT = 30;\nconst I18N = globalThis.VCS_I18N;\n\nconst OLD_DEFAULT_PROMPT_TEMPLATE = [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst OLD_DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n].join(\"\\n\");\n\nconst DEFAULT_PROMPT_TEMPLATE = [\n \"你是一位视频内容提炼专家。请基于字幕文本,生成一份精炼、高信息密度的结构化总结。\",\n \"\",\n \"## 视频信息\",\n \"📌 标题:{{title}}|🌐 平台:{{platform}}|🔗 链接:{{url}}\",\n \"\",\n \"## 核心原则\",\n \"- 总结 ≠ 复述。你的任务是**压缩与重构**,不是逐段改写\",\n \"- 保留硬信息(数据、人名、术语、方法论),砍掉软信息(过渡语、重复论证、情绪渲染)\",\n \"- 每句话必须承载独立信息量,删掉后会造成信息缺失才有资格留下\",\n \"- 仅基于字幕实际内容,不推测、不补充、不评价\",\n \"\",\n \"## 输出语言:{{language}}\",\n \"\",\n \"## 输出格式\",\n \"{{outputTemplate}}\",\n \"\",\n \"## 字幕正文\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"\",\n \"## 💡 一句话结论\",\n \"≤ 30字。视频最核心的一个判断或发现。\",\n \"\",\n \"## 📋 核心摘要\",\n \"3~4句话覆盖完整逻辑链:什么问题 → 什么观点/方案 → 什么结论。不用条目,写成连贯段落。\",\n \"\",\n \"## 🎯 关键观点\",\n \"3~7条,每条格式:\",\n \"- ⏱️ [时间戳] **关键词**:一句话说明(无时间戳则省略⏱️)\",\n \"\",\n \"只保留\\\"删掉就损失信息\\\"的观点,不凑数。\",\n \"\",\n \"## 🗂️ 章节时间线\",\n \"按叙事顺序分3~6段,每段格式:\",\n \"- ⏱️ [起止时间] **章节名**:一句话概括(无时间戳则用 1️⃣2️⃣3️⃣ 编号)\"\n].join(\"\\n\");\n\nconst DEFAULT_SETTINGS = {\n settingsVersion: 7,\n theme: \"auto\",\n uiLanguage: \"zh-CN\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"custom\",\n profiles: [\n {\n ...profilePreset(\"deepseek\"),\n id: \"custom\",\n name: \"我的 API 配置\"\n }\n ],\n promptTemplate: DEFAULT_PROMPT_TEMPLATE,\n outputTemplate: DEFAULT_OUTPUT_TEMPLATE,\n chunkSize: 12000,\n chunkOverlap: 600,\n requestTimeoutSeconds: 60,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: false\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n uiLanguage: document.querySelector(\"#uiLanguage\"),\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n accessToken: document.querySelector(\"#accessToken\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n requestTimeoutSeconds: document.querySelector(\"#requestTimeoutSeconds\"),\n importText: document.querySelector(\"#importText\"),\n importFile: document.querySelector(\"#importFile\"),\n historyList: document.querySelector(\"#historyList\"),\n historyCount: document.querySelector(\"#historyCount\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nfunction i18n() {\n return I18N.create(settings);\n}\n\nfunction t(key, variables) {\n return i18n().t(key, variables);\n}\n\nasync function init() {\n const result = await chrome.storage.local.get(SETTINGS_KEY);\n settings = normalizeSettings(result[SETTINGS_KEY]);\n if (JSON.stringify(result[SETTINGS_KEY] || {}) !== JSON.stringify(settings)) {\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n }\n hydrateForm();\n bindEvents();\n await renderHistory();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", () => saveAll());\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", () => saveCurrentProfile());\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#exportCompleteSettings\").addEventListener(\"click\", exportCompleteSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n document.querySelector(\"#clearHistory\").addEventListener(\"click\", clearHistory);\n document.querySelector(\"#exportHistory\").addEventListener(\"click\", exportHistory);\n fields.importFile.addEventListener(\"change\", importSettingsFile);\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", async () => {\n settings.theme = input.value;\n applyTheme();\n await saveAll(t(\"options.status.themeSaved\"));\n });\n });\n\n fields.uiLanguage.addEventListener(\"change\", async () => {\n settings.uiLanguage = fields.uiLanguage.value;\n applyTranslations();\n await renderHistory();\n await saveAll(t(\"options.status.uiLanguageSaved\"));\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n fields.provider.addEventListener(\"change\", handleProviderChange);\n fields.historyList.addEventListener(\"click\", copyHistoryFromList);\n initSectionNav();\n\n chrome.storage.onChanged.addListener((changes, area) => {\n if (area !== \"local\") {\n return;\n }\n if (changes[HISTORY_KEY]) {\n renderHistory();\n }\n if (changes[SETTINGS_KEY] && changes[SETTINGS_KEY].newValue) {\n settings = normalizeSettings(changes[SETTINGS_KEY].newValue);\n hydrateForm();\n renderHistory();\n }\n });\n\n window.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (settings.theme === \"auto\") {\n applyTheme();\n }\n });\n}\n\nfunction hydrateForm() {\n const themeInput = document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`)\n || document.querySelector(\"input[name='theme'][value='auto']\");\n if (themeInput) {\n themeInput.checked = true;\n }\n fields.uiLanguage.value = settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage;\n fields.language.value = settings.language || DEFAULT_SETTINGS.language;\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = settings.saveHistory !== false;\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n fields.requestTimeoutSeconds.value = settings.requestTimeoutSeconds || DEFAULT_SETTINGS.requestTimeoutSeconds;\n applyTheme();\n applyTranslations();\n renderProfileFields();\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"proxy\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.accessToken.value = getProfileSecret(profile);\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 1;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n updateProviderHints();\n}\n\nasync function saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n await saveAll(t(\"options.status.profileSaved\"));\n}\n\nasync function saveAll(message = t(\"options.status.settingsSaved\")) {\n if (typeof message !== \"string\") {\n message = t(\"options.status.settingsSaved\");\n }\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.uiLanguage = I18N.normalizeUiLanguage(fields.uiLanguage.value);\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n settings.requestTimeoutSeconds = Number(fields.requestTimeoutSeconds.value || DEFAULT_SETTINGS.requestTimeoutSeconds);\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions(settings.profiles)) {\n setStatus(fields.saveStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: settings.uiLanguage === \"en\" ? \"New Profile\" : \"新配置\",\n provider: \"openai-compatible\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, t(\"options.status.keepOneProfile\"), \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n profile.provider = preset.provider;\n profile.endpoint = preset.endpoint;\n profile.apiKey = \"\";\n profile.accessToken = \"\";\n fields.provider.value = profile.provider;\n fields.endpoint.value = profile.endpoint;\n fields.accessToken.value = \"\";\n updateProviderHints();\n setStatus(fields.apiStatus, t(\"options.status.presetApplied\"), \"ok\");\n}\n\nfunction handleProviderChange() {\n const preset = profilePreset(providerDefaultPresetName(fields.provider.value));\n fields.endpoint.value = preset.endpoint || \"\";\n fields.accessToken.value = \"\";\n updateProviderHints();\n}\n\nfunction providerDefaultPresetName(provider) {\n const names = {\n proxy: \"proxy\",\n \"openai-compatible\": \"deepseek\",\n anthropic: \"claude\",\n gemini: \"gemini\",\n ollama: \"ollama\"\n };\n return names[provider] || \"deepseek\";\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.ai/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n proxy: {\n id: \"proxy\",\n name: \"专用服务\",\n provider: \"proxy\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions([getActiveProfile()])) {\n setStatus(fields.apiStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n setStatus(fields.apiStatus, t(\"options.status.testing\"), \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile(),\n stream: true\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, t(\"options.status.connectionSuccess\", { text: response.payload.text }), \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || t(\"options.status.connectionFailed\"), \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(t(\"options.status.savedForExport\"));\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 1,\n exportedAt: new Date().toISOString(),\n settings: sanitizeSettingsForExport(settings)\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-${formatDateForFilename(new Date())}.json`);\n const copied = await writeClipboard(json);\n setStatus(fields.saveStatus, copied ? t(\"options.status.settingsExportedCopied\") : t(\"options.status.settingsExported\"), \"ok\");\n}\n\nasync function exportCompleteSettings() {\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n const confirmed = window.confirm(t(\"options.confirm.exportSecrets\"));\n if (!confirmed) {\n return;\n }\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 2,\n exportedAt: new Date().toISOString(),\n includesSecrets: true,\n settings\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-full-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, t(\"options.status.fullSettingsExported\"), \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = parseImportedSettings(fields.importText.value);\n settings = normalizeSettings(imported);\n hydrateForm();\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, t(\"options.status.settingsImported\"), \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, t(\"options.status.importFailed\", { message: error.message }), \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n setStatus(fields.saveStatus, t(\"options.status.resetRequiresSave\"), \"ok\");\n}\n\nasync function importSettingsFile() {\n const file = fields.importFile.files?.[0];\n if (!file) {\n return;\n }\n\n try {\n fields.importText.value = await file.text();\n await importSettings();\n } finally {\n fields.importFile.value = \"\";\n }\n}\n\nasync function renderHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n setDigits(fields.historyCount, `${history.length} / ${HISTORY_LIMIT}`);\n\n if (!history.length) {\n fields.historyList.innerHTML = `<div class=\"history-empty\">${escapeHtml(t(\"options.history.empty\"))}</div>`;\n return;\n }\n\n fields.historyList.innerHTML = history.map((item, index) => {\n const summary = item.summary || item.text || \"\";\n const title = item.title || t(\"options.history.untitled\");\n const createdAt = formatDate(item.createdAt);\n const meta = [item.platform, item.model, createdAt].filter(Boolean).join(\" · \");\n return `\n <article class=\"history-item\">\n <div class=\"history-item__head\">\n <div>\n <div class=\"history-item__title\" title=\"${escapeHtml(title)}\">${escapeHtml(title)}</div>\n <div class=\"history-item__meta\">${escapeHtml(meta || t(\"options.history.local\"))}</div>\n </div>\n <button class=\"button button--ghost history-copy\" type=\"button\" data-history-index=\"${index}\">${escapeHtml(t(\"options.history.copy\"))}</button>\n </div>\n <div class=\"history-item__summary\">${escapeHtml(summary || t(\"options.history.noSummary\"))}</div>\n </article>\n `;\n }).join(\"\");\n\n if (result[HISTORY_KEY]?.length > HISTORY_LIMIT) {\n await chrome.storage.local.set({ [HISTORY_KEY]: history });\n }\n}\n\nasync function copyHistoryFromList(event) {\n const button = event.target.closest(\"[data-history-index]\");\n if (!button) {\n return;\n }\n\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const item = history[Number(button.dataset.historyIndex)];\n const text = item?.summary || item?.text || \"\";\n if (!text.trim()) {\n setStatus(fields.saveStatus, t(\"options.status.noCopyableHistory\"), \"error\");\n return;\n }\n\n const copied = await writeClipboard(text);\n setStatus(fields.saveStatus, copied ? t(\"options.status.historyCopied\") : t(\"options.status.clipboardDenied\"), copied ? \"ok\" : \"error\");\n}\n\nasync function clearHistory() {\n await chrome.storage.local.set({ [HISTORY_KEY]: [] });\n await renderHistory();\n setStatus(fields.saveStatus, t(\"options.status.historyCleared\"), \"ok\");\n}\n\nasync function exportHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const payload = {\n type: \"video-caption-ai-history\",\n version: 1,\n exportedAt: new Date().toISOString(),\n limit: HISTORY_LIMIT,\n history\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-history-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, history.length ? t(\"options.status.historyExported\") : t(\"options.status.emptyHistoryExported\"), \"ok\");\n}\n\nfunction parseImportedSettings(text) {\n if (!text.trim()) {\n throw new Error(t(\"options.error.emptyImport\"));\n }\n\n const parsed = JSON.parse(text);\n const imported = parsed?.settings || parsed;\n if (!imported || typeof imported !== \"object\" || Array.isArray(imported)) {\n throw new Error(t(\"options.error.invalidSettings\"));\n }\n return imported;\n}\n\nfunction sanitizeSettingsForExport(value) {\n const output = structuredClone(value || {});\n output.profiles = (Array.isArray(output.profiles) ? output.profiles : []).map((profile) => {\n const cleanProfile = { ...profile };\n delete cleanProfile.apiKey;\n cleanProfile.accessToken = \"\";\n return cleanProfile;\n });\n return output;\n}\n\nfunction getProfileSecret(profile) {\n if (!profile || profile.provider === \"ollama\") {\n return \"\";\n }\n return profile.provider === \"proxy\"\n ? profile.accessToken || \"\"\n : profile.apiKey || \"\";\n}\n\nfunction setProfileSecret(profile, value) {\n if (!profile) {\n return;\n }\n if (profile.provider === \"proxy\") {\n profile.accessToken = value;\n profile.apiKey = \"\";\n return;\n }\n if (profile.provider === \"ollama\") {\n profile.accessToken = \"\";\n profile.apiKey = \"\";\n return;\n }\n profile.apiKey = value;\n profile.accessToken = \"\";\n}\n\nasync function ensureEndpointPermissions(profiles) {\n const origins = uniqueValues((profiles || [])\n .map(endpointPermissionPattern)\n .filter(Boolean));\n\n for (const origin of origins) {\n const hasPermission = await chrome.permissions.contains({ origins: [origin] });\n if (hasPermission) {\n continue;\n }\n const granted = await chrome.permissions.request({ origins: [origin] });\n if (!granted) {\n return false;\n }\n }\n\n return true;\n}\n\nfunction endpointPermissionPattern(profile) {\n const url = parseUrl(getProfileEndpoint(profile));\n if (!url) {\n return \"\";\n }\n if (url.protocol === \"https:\") {\n return `https://${url.hostname}/*`;\n }\n if (url.protocol === \"http:\" && isLoopbackHostname(url.hostname)) {\n return `http://${url.hostname}/*`;\n }\n return \"\";\n}\n\nfunction updateProviderHints() {\n const locale = i18n();\n const provider = fields.provider.value;\n const isOllama = provider === \"ollama\";\n const isProxy = provider === \"proxy\";\n const isGemini = provider === \"gemini\";\n setFieldLabel(fields.endpoint, getEndpointLabel(provider, locale));\n fields.endpoint.placeholder = getEndpointPlaceholder(provider);\n setFieldLabel(fields.accessToken, isProxy ? locale.t(\"options.api.accessToken\") : locale.t(\"options.api.apiKey\"));\n fields.accessToken.placeholder = getSecretPlaceholder(provider, locale);\n fields.accessToken.disabled = isOllama;\n fields.endpoint.disabled = isGemini;\n fields.model.placeholder = isProxy ? locale.t(\"options.api.modelPlaceholder\") : locale.t(\"options.api.directModelPlaceholder\");\n}\n\nfunction getEndpointLabel(provider, locale) {\n if (provider === \"ollama\") {\n return locale.t(\"options.api.ollamaEndpoint\");\n }\n if (provider === \"proxy\") {\n return locale.t(\"options.api.endpoint\");\n }\n return locale.t(\"options.api.directEndpoint\");\n}\n\nfunction getEndpointPlaceholder(provider) {\n const placeholders = {\n proxy: \"https://your-domain.example.com/v1/chat/completions\",\n \"openai-compatible\": \"https://api.deepseek.com/v1/chat/completions\",\n anthropic: \"https://api.anthropic.com/v1/messages\",\n gemini: \"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent\",\n ollama: \"http://localhost:11434/v1/chat/completions\"\n };\n return placeholders[provider] || placeholders[\"openai-compatible\"];\n}\n\nfunction getSecretPlaceholder(provider, locale) {\n if (provider === \"ollama\") {\n return \"\";\n }\n if (provider === \"proxy\") {\n return locale.language === \"en\" ? \"Optional access token\" : \"可选访问口令\";\n }\n return \"sk-...\";\n}\n\nfunction getProfileEndpoint(profile) {\n if (!profile) {\n return \"\";\n }\n if (profile.provider === \"gemini\" && !profile.endpoint) {\n const model = profile.model || \"gemini-pro\";\n return `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;\n }\n return profile.endpoint || \"\";\n}\n\nfunction parseUrl(value) {\n try {\n return value ? new URL(value) : null;\n } catch (_error) {\n return null;\n }\n}\n\nfunction normalizeHostname(hostname) {\n return String(hostname || \"\")\n .replace(/^\\[/, \"\")\n .replace(/\\]$/, \"\")\n .toLowerCase();\n}\n\nfunction isLoopbackHostname(hostname) {\n const host = normalizeHostname(hostname);\n return host === \"localhost\"\n || host.endsWith(\".localhost\")\n || host === \"::1\"\n || host === \"0:0:0:0:0:0:0:1\"\n || host.startsWith(\"127.\");\n}\n\nfunction isLoopbackEndpoint(endpoint) {\n const url = parseUrl(endpoint);\n return Boolean(url && url.protocol === \"http:\" && isLoopbackHostname(url.hostname));\n}\n\nfunction uniqueValues(values) {\n return [...new Set(values)];\n}\n\nfunction normalizeSettings(input) {\n const normalized = deepMerge(DEFAULT_SETTINGS, input || {});\n const importedVersion = Number(input?.settingsVersion || 0);\n normalized.settingsVersion = DEFAULT_SETTINGS.settingsVersion;\n normalized.theme = [\"auto\", \"light\", \"dark\"].includes(normalized.theme) ? normalized.theme : DEFAULT_SETTINGS.theme;\n normalized.uiLanguage = I18N.normalizeUiLanguage(normalized.uiLanguage);\n normalized.language = String(normalized.language || \"\").trim() || DEFAULT_SETTINGS.language;\n normalized.panelEnabled = normalized.panelEnabled !== false;\n normalized.includeTimestamps = normalized.includeTimestamps !== false;\n normalized.includeTitleAndUrl = normalized.includeTitleAndUrl !== false;\n normalized.saveHistory = normalized.saveHistory === true;\n normalized.promptTemplate = String(normalized.promptTemplate || DEFAULT_SETTINGS.promptTemplate);\n normalized.outputTemplate = String(normalized.outputTemplate || DEFAULT_SETTINGS.outputTemplate);\n if (String(input?.promptTemplate || \"\") === OLD_DEFAULT_PROMPT_TEMPLATE) {\n normalized.promptTemplate = DEFAULT_SETTINGS.promptTemplate;\n }\n if (String(input?.outputTemplate || \"\") === OLD_DEFAULT_OUTPUT_TEMPLATE) {\n normalized.outputTemplate = DEFAULT_SETTINGS.outputTemplate;\n }\n normalized.redactTerms = String(normalized.redactTerms || \"\");\n normalized.chunkSize = clampNumber(normalized.chunkSize, 12000, 2000, 50000);\n normalized.chunkOverlap = clampNumber(normalized.chunkOverlap, 600, 0, 5000);\n normalized.requestTimeoutSeconds = clampNumber(\n normalized.requestTimeoutSeconds,\n DEFAULT_SETTINGS.requestTimeoutSeconds,\n 30,\n 600\n );\n\n if (!Array.isArray(normalized.profiles) || !normalized.profiles.length) {\n normalized.profiles = structuredClone(DEFAULT_SETTINGS.profiles);\n }\n normalized.profiles = normalized.profiles.map((profile, index) => normalizeProfile(profile, index, importedVersion));\n if (!normalized.profiles.some((profile) => profile.id === normalized.activeProfileId)) {\n normalized.activeProfileId = normalized.profiles[0].id;\n }\n return normalized;\n}\n\nfunction normalizeProfile(profile, index, importedVersion) {\n const source = profile || {};\n const rawProvider = String(source.provider || \"openai-compatible\");\n const isLocalOpenAI = rawProvider === \"ollama\"\n || (rawProvider === \"openai-compatible\" && isLoopbackEndpoint(source.endpoint));\n const supportedProviders = [\"proxy\", \"openai-compatible\", \"anthropic\", \"gemini\", \"ollama\"];\n const provider = isLocalOpenAI\n ? \"ollama\"\n : (supportedProviders.includes(rawProvider) ? rawProvider : \"openai-compatible\");\n const fallback = profilePreset(provider === \"openai-compatible\" ? \"deepseek\" : provider);\n const temperature = importedVersion < 3 && Number(source.temperature) === 0.2\n ? 1\n : source.temperature;\n return {\n ...fallback,\n id: String(source.id || fallback.id || `profile-${index + 1}`),\n name: String(source.name || fallback.name || `Profile ${index + 1}`),\n provider,\n endpoint: String(source.endpoint || fallback.endpoint || \"\"),\n apiKey: String(source.apiKey || (![\"proxy\", \"ollama\"].includes(provider) ? source.accessToken || \"\" : \"\")),\n accessToken: String(provider === \"proxy\" ? source.accessToken || \"\" : \"\"),\n model: String(source.model || fallback.model || \"\"),\n temperature: clampNumber(temperature, fallback.temperature ?? 1, 0, 2),\n maxTokens: clampNumber(source.maxTokens, fallback.maxTokens, 256, 32000)\n };\n}\n\nfunction normalizeHistory(value) {\n return (Array.isArray(value) ? value : [])\n .filter((item) => item && typeof item === \"object\")\n .slice(0, HISTORY_LIMIT);\n}\n\nfunction applyTranslations() {\n const locale = i18n();\n document.documentElement.lang = locale.language === \"en\" ? \"en\" : \"zh-CN\";\n document.title = locale.t(\"options.documentTitle\");\n\n setText(\".topbar h1\", locale.t(\"common.appName\"));\n setText(\".topbar p\", locale.t(\"options.brandTagline\"));\n setText(\"#exportSettings\", locale.t(\"options.exportSettings\"));\n setText(\"#saveAll\", locale.t(\"options.saveAll\"));\n\n setText(\".sidebar a[href='#appearance']\", locale.t(\"options.nav.appearance\"));\n setText(\".sidebar a[href='#api']\", locale.t(\"options.nav.api\"));\n setText(\".sidebar a[href='#prompt']\", locale.t(\"options.nav.prompt\"));\n setText(\".sidebar a[href='#platforms']\", locale.t(\"options.nav.platforms\"));\n setText(\".sidebar a[href='#history']\", locale.t(\"options.nav.history\"));\n setText(\".sidebar a[href='#advanced']\", locale.t(\"options.nav.advanced\"));\n\n setText(\"#appearance .section__head h2\", locale.t(\"options.nav.appearance\"));\n setText(\"#appearance .section__head p\", locale.t(\"options.appearance.description\"));\n setText(\"#appearance > .field > label\", locale.t(\"options.appearance.theme\"));\n document.querySelector(\"#appearance .segmented\")?.setAttribute(\"aria-label\", locale.t(\"options.appearance.theme\"));\n setRadioLabel(\"auto\", locale.t(\"options.appearance.theme.auto\"));\n setRadioLabel(\"light\", locale.t(\"options.appearance.theme.light\"));\n setRadioLabel(\"dark\", locale.t(\"options.appearance.theme.dark\"));\n setFieldLabel(fields.uiLanguage, locale.t(\"options.appearance.uiLanguage\"));\n setOptionText(fields.uiLanguage, \"zh-CN\", locale.t(\"ui.zh-CN\"));\n setOptionText(fields.uiLanguage, \"en\", locale.t(\"ui.en\"));\n setFieldLabel(fields.language, locale.t(\"options.appearance.summaryLanguage\"));\n fields.language.placeholder = locale.t(\"options.appearance.summaryLanguagePlaceholder\");\n setFieldLabel(fields.panelEnabled, locale.t(\"options.appearance.panelEnabled\"));\n\n setText(\"#api .section__head h2\", locale.t(\"options.nav.api\"));\n setText(\"#api .section__head p\", locale.t(\"options.api.description\"));\n setText(\"#addProfile\", locale.t(\"options.api.add\"));\n setText(\"#deleteProfile\", locale.t(\"options.api.delete\"));\n setFieldLabel(fields.profileName, locale.t(\"options.api.profileName\"));\n fields.profileName.placeholder = locale.t(\"options.api.profileNamePlaceholder\");\n setFieldLabel(fields.provider, locale.t(\"options.api.provider\"));\n setOptionText(fields.provider, \"proxy\", locale.t(\"options.api.provider.proxy\"));\n setOptionText(fields.provider, \"openai-compatible\", locale.t(\"options.api.provider.openai\"));\n setOptionText(fields.provider, \"anthropic\", locale.t(\"options.api.provider.anthropic\"));\n setOptionText(fields.provider, \"gemini\", locale.t(\"options.api.provider.gemini\"));\n setOptionText(fields.provider, \"ollama\", locale.t(\"options.api.provider.ollama\"));\n setFieldLabel(fields.endpoint, locale.t(\"options.api.endpoint\"));\n setFieldLabel(fields.accessToken, locale.t(\"options.api.accessToken\"));\n setFieldLabel(fields.model, locale.t(\"options.api.model\"));\n fields.model.placeholder = locale.t(\"options.api.modelPlaceholder\");\n setFieldLabel(fields.temperature, locale.t(\"options.api.temperature\"));\n setFieldLabel(fields.maxTokens, locale.t(\"options.api.maxTokens\"));\n setText(\"#saveProfile\", locale.t(\"options.api.saveProfile\"));\n setText(\"#testProfile\", locale.t(\"options.api.testProfile\"));\n updateProviderHints();\n\n setText(\"#prompt .section__head h2\", locale.t(\"options.nav.prompt\"));\n setText(\"#prompt .section__head p\", locale.t(\"options.prompt.description\"));\n setFieldLabel(fields.promptTemplate, locale.t(\"options.prompt.promptTemplate\"));\n setFieldLabel(fields.outputTemplate, locale.t(\"options.prompt.outputTemplate\"));\n\n setText(\"#platforms .section__head h2\", locale.t(\"options.nav.platforms\"));\n setText(\"#platforms .section__head p\", locale.t(\"options.platforms.description\"));\n setText(\"#platforms .support-list > div:nth-child(1) span\", locale.t(\"options.platforms.youtube\"));\n setText(\"#platforms .support-list > div:nth-child(2) span\", locale.t(\"options.platforms.bilibili\"));\n setText(\"#platforms .support-list > div:nth-child(3) span\", locale.t(\"options.platforms.html5\"));\n setText(\"#platforms .support-list > div:nth-child(4) span\", locale.t(\"options.platforms.transcript\"));\n setFieldLabel(fields.includeTimestamps, locale.t(\"options.platforms.includeTimestamps\"));\n setFieldLabel(fields.redactTerms, locale.t(\"options.platforms.redactTerms\"));\n fields.redactTerms.placeholder = locale.t(\"options.platforms.redactTermsPlaceholder\");\n\n setText(\"#history .section__head h2\", locale.t(\"options.nav.history\"));\n setText(\"#history .section__head p\", locale.t(\"options.history.description\"));\n setText(\"#exportHistory\", locale.t(\"options.history.export\"));\n setText(\"#clearHistory\", locale.t(\"options.history.clear\"));\n setFieldLabel(fields.saveHistory, locale.t(\"options.history.saveHistory\"));\n\n setText(\"#advanced .section__head h2\", locale.t(\"options.nav.advanced\"));\n setText(\"#advanced .section__head p\", locale.t(\"options.advanced.description\"));\n setFieldLabel(fields.chunkSize, locale.t(\"options.advanced.chunkSize\"));\n setFieldLabel(fields.chunkOverlap, locale.t(\"options.advanced.chunkOverlap\"));\n setFieldLabel(fields.requestTimeoutSeconds, locale.t(\"options.advanced.requestTimeoutSeconds\"));\n setFieldLabel(fields.importText, locale.t(\"options.advanced.importJson\"));\n fields.importText.placeholder = locale.t(\"options.advanced.importJsonPlaceholder\");\n setFieldLabel(fields.importFile, locale.t(\"options.advanced.importFile\"));\n setText(\"#importSettings\", locale.t(\"options.advanced.importSettings\"));\n setText(\"#exportCompleteSettings\", locale.t(\"options.advanced.exportCompleteSettings\"));\n setText(\"#resetSettings\", locale.t(\"options.advanced.resetSettings\"));\n}\n\nfunction applyTheme() {\n const mode = settings.theme || \"auto\";\n const resolved = mode === \"auto\"\n ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\")\n : mode;\n document.documentElement.dataset.themeMode = mode;\n document.documentElement.dataset.theme = resolved;\n}\n\nfunction downloadJson(json, filename) {\n const blob = new Blob([json], { type: \"application/json;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\nasync function writeClipboard(text) {\n if (navigator.clipboard?.writeText) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (_error) {\n // Fall back to the selection-based copy path below.\n }\n }\n\n const textarea = document.createElement(\"textarea\");\n textarea.value = text;\n textarea.setAttribute(\"readonly\", \"\");\n textarea.style.position = \"fixed\";\n textarea.style.top = \"0\";\n textarea.style.left = \"0\";\n textarea.style.width = \"1px\";\n textarea.style.height = \"1px\";\n textarea.style.opacity = \"0\";\n textarea.style.pointerEvents = \"none\";\n document.body.appendChild(textarea);\n textarea.focus();\n textarea.select();\n textarea.setSelectionRange(0, text.length);\n const copied = document.execCommand(\"copy\");\n textarea.remove();\n return copied;\n}\n\nfunction formatDateForFilename(date) {\n return date.toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n}\n\nfunction formatDate(value) {\n if (!value) {\n return \"\";\n }\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return \"\";\n }\n return date.toLocaleString(i18n().locale, {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\"\n });\n}\n\nfunction clampNumber(value, fallback, min, max) {\n const number = Number(value);\n if (!Number.isFinite(number)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, number));\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setText(selector, value) {\n const element = document.querySelector(selector);\n if (element) {\n element.textContent = value;\n }\n}\n\nfunction setFieldLabel(field, value) {\n const label = field?.closest(\".field\")?.querySelector(\"span\");\n if (label) {\n label.textContent = value;\n }\n}\n\nfunction setRadioLabel(value, text) {\n const input = document.querySelector(`input[name='theme'][value='${value}']`);\n const label = input?.closest(\"label\");\n if (!label) {\n return;\n }\n let textNode = [...label.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());\n if (!textNode) {\n textNode = document.createTextNode(\"\");\n label.appendChild(textNode);\n }\n textNode.textContent = ` ${text}`;\n}\n\nfunction setOptionText(select, value, text) {\n const option = select?.querySelector(`option[value='${value}']`);\n if (option) {\n option.textContent = text;\n }\n}\n\nfunction setStatus(element, message, tone) {\n const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n element.dataset.statusToken = token;\n element.dataset.tone = tone || \"\";\n swapText(element, message);\n if (message) {\n window.setTimeout(() => {\n if (element.dataset.statusToken === token) {\n element.dataset.statusToken = \"\";\n element.dataset.tone = \"\";\n swapText(element, \"\");\n }\n }, 5000);\n }\n}\n\nfunction swapText(element, nextText) {\n if (!element) {\n return;\n }\n\n element.classList.remove(\"is-exit\", \"is-enter-start\");\n if (element.textContent === nextText) {\n return;\n }\n\n element.textContent = nextText;\n if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n return;\n }\n\n element.classList.add(\"is-enter-start\");\n void element.offsetHeight;\n element.classList.remove(\"is-enter-start\");\n}\n\nfunction setDigits(group, value) {\n const text = String(value);\n if (!group || (group.textContent === text && group.children.length)) {\n return;\n }\n\n group.setAttribute(\"aria-label\", text);\n group.classList.remove(\"is-animating\");\n group.replaceChildren();\n\n [...text].forEach((character, index, characters) => {\n const digit = document.createElement(\"span\");\n digit.className = \"t-digit\";\n digit.textContent = character;\n if (index === characters.length - 2) {\n digit.dataset.stagger = \"1\";\n } else if (index === characters.length - 1) {\n digit.dataset.stagger = \"2\";\n }\n group.appendChild(digit);\n });\n\n void group.offsetHeight;\n group.classList.add(\"is-animating\");\n}\n\nfunction initSectionNav() {\n const links = [...document.querySelectorAll(\".sidebar a[href^='#']\")];\n const sections = links\n .map((link) => document.querySelector(link.getAttribute(\"href\")))\n .filter(Boolean);\n\n if (!links.length || !sections.length) {\n return;\n }\n\n const setCurrent = (id) => {\n links.forEach((link) => {\n const isCurrent = link.getAttribute(\"href\") === `#${id}`;\n if (isCurrent) {\n link.setAttribute(\"aria-current\", \"true\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n });\n };\n\n setCurrent(location.hash.slice(1) || sections[0].id);\n\n if (!(\"IntersectionObserver\" in window)) {\n window.addEventListener(\"scroll\", () => {\n let current = sections[0];\n for (const section of sections) {\n if (section.getBoundingClientRect().top <= 140) {\n current = section;\n }\n }\n setCurrent(current.id);\n }, { passive: true });\n return;\n }\n\n const observer = new IntersectionObserver((entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];\n if (visible?.target?.id) {\n setCurrent(visible.target.id);\n }\n }, {\n rootMargin: \"-18% 0px -62% 0px\",\n threshold: [0, 0.1, 0.35, 0.7]\n });\n\n sections.forEach((section) => observer.observe(section));\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "f28c71f09ef7dcc905dbd0b523af846dfc8acc7a": {"t": 1785758694.0424378, "n": 41337, "h": "68b087d953be71f6", "c": "const SETTINGS_KEY = \"vcsSettings\";\nconst HISTORY_KEY = \"vcsHistory\";\nconst HISTORY_LIMIT = 30;\nconst I18N = globalThis.VCS_I18N;\n\nconst OLD_DEFAULT_PROMPT_TEMPLATE = [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst OLD_DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n].join(\"\\n\");\n\nconst DEFAULT_PROMPT_TEMPLATE = [\n \"你是一位视频内容提炼专家。请基于字幕文本,生成一份精炼、高信息密度的结构化总结。\",\n \"\",\n \"## 视频信息\",\n \"📌 标题:{{title}}|🌐 平台:{{platform}}|🔗 链接:{{url}}\",\n \"\",\n \"## 核心原则\",\n \"- 总结 ≠ 复述。你的任务是**压缩与重构**,不是逐段改写\",\n \"- 保留硬信息(数据、人名、术语、方法论),砍掉软信息(过渡语、重复论证、情绪渲染)\",\n \"- 每句话必须承载独立信息量,删掉后会造成信息缺失才有资格留下\",\n \"- 仅基于字幕实际内容,不推测、不补充、不评价\",\n \"\",\n \"## 输出语言:{{language}}\",\n \"\",\n \"## 输出格式\",\n \"{{outputTemplate}}\",\n \"\",\n \"## 字幕正文\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"\",\n \"## 💡 一句话结论\",\n \"≤ 30字。视频最核心的一个判断或发现。\",\n \"\",\n \"## 📋 核心摘要\",\n \"3~4句话覆盖完整逻辑链:什么问题 → 什么观点/方案 → 什么结论。不用条目,写成连贯段落。\",\n \"\",\n \"## 🎯 关键观点\",\n \"3~7条,每条格式:\",\n \"- ⏱️ [时间戳] **关键词**:一句话说明(无时间戳则省略⏱️)\",\n \"\",\n \"只保留\\\"删掉就损失信息\\\"的观点,不凑数。\",\n \"\",\n \"## 🗂️ 章节时间线\",\n \"按叙事顺序分3~6段,每段格式:\",\n \"- ⏱️ [起止时间] **章节名**:一句话概括(无时间戳则用 1️⃣2️⃣3️⃣ 编号)\"\n].join(\"\\n\");\n\nconst DEFAULT_SETTINGS = {\n settingsVersion: 7,\n theme: \"auto\",\n uiLanguage: \"zh-CN\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"custom\",\n profiles: [\n {\n ...profilePreset(\"deepseek\"),\n id: \"custom\",\n name: \"我的 API 配置\"\n }\n ],\n promptTemplate: DEFAULT_PROMPT_TEMPLATE,\n outputTemplate: DEFAULT_OUTPUT_TEMPLATE,\n chunkSize: 12000,\n chunkOverlap: 600,\n requestTimeoutSeconds: 60,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: false\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n uiLanguage: document.querySelector(\"#uiLanguage\"),\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n accessToken: document.querySelector(\"#accessToken\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n requestTimeoutSeconds: document.querySelector(\"#requestTimeoutSeconds\"),\n importText: document.querySelector(\"#importText\"),\n importFile: document.querySelector(\"#importFile\"),\n historyList: document.querySelector(\"#historyList\"),\n historyCount: document.querySelector(\"#historyCount\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nfunction i18n() {\n return I18N.create(settings);\n}\n\nfunction t(key, variables) {\n return i18n().t(key, variables);\n}\n\nasync function init() {\n const result = await chrome.storage.local.get(SETTINGS_KEY);\n settings = normalizeSettings(result[SETTINGS_KEY]);\n if (JSON.stringify(result[SETTINGS_KEY] || {}) !== JSON.stringify(settings)) {\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n }\n hydrateForm();\n bindEvents();\n await renderHistory();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", () => saveAll());\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", () => saveCurrentProfile());\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#exportCompleteSettings\").addEventListener(\"click\", exportCompleteSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n document.querySelector(\"#clearHistory\").addEventListener(\"click\", clearHistory);\n document.querySelector(\"#exportHistory\").addEventListener(\"click\", exportHistory);\n fields.importFile.addEventListener(\"change\", importSettingsFile);\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", async () => {\n settings.theme = input.value;\n applyTheme();\n await saveAll(t(\"options.status.themeSaved\"));\n });\n });\n\n fields.uiLanguage.addEventListener(\"change\", async () => {\n settings.uiLanguage = fields.uiLanguage.value;\n applyTranslations();\n await renderHistory();\n await saveAll(t(\"options.status.uiLanguageSaved\"));\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n fields.provider.addEventListener(\"change\", handleProviderChange);\n fields.historyList.addEventListener(\"click\", copyHistoryFromList);\n initSectionNav();\n\n chrome.storage.onChanged.addListener((changes, area) => {\n if (area !== \"local\") {\n return;\n }\n if (changes[HISTORY_KEY]) {\n renderHistory();\n }\n if (changes[SETTINGS_KEY] && changes[SETTINGS_KEY].newValue) {\n settings = normalizeSettings(changes[SETTINGS_KEY].newValue);\n hydrateForm();\n renderHistory();\n }\n });\n\n window.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (settings.theme === \"auto\") {\n applyTheme();\n }\n });\n}\n\nfunction hydrateForm() {\n const themeInput = document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`)\n || document.querySelector(\"input[name='theme'][value='auto']\");\n if (themeInput) {\n themeInput.checked = true;\n }\n fields.uiLanguage.value = settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage;\n fields.language.value = settings.language || DEFAULT_SETTINGS.language;\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = settings.saveHistory !== false;\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n fields.requestTimeoutSeconds.value = settings.requestTimeoutSeconds || DEFAULT_SETTINGS.requestTimeoutSeconds;\n applyTheme();\n applyTranslations();\n renderProfileFields();\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"proxy\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.accessToken.value = getProfileSecret(profile);\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 1;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n updateProviderHints();\n}\n\nasync function saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n await saveAll(t(\"options.status.profileSaved\"));\n}\n\nasync function saveAll(message = t(\"options.status.settingsSaved\")) {\n if (typeof message !== \"string\") {\n message = t(\"options.status.settingsSaved\");\n }\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.uiLanguage = I18N.normalizeUiLanguage(fields.uiLanguage.value);\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n settings.requestTimeoutSeconds = Number(fields.requestTimeoutSeconds.value || DEFAULT_SETTINGS.requestTimeoutSeconds);\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions(settings.profiles)) {\n setStatus(fields.saveStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: settings.uiLanguage === \"en\" ? \"New Profile\" : \"新配置\",\n provider: \"openai-compatible\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, t(\"options.status.keepOneProfile\"), \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n profile.provider = preset.provider;\n profile.endpoint = preset.endpoint;\n profile.apiKey = \"\";\n profile.accessToken = \"\";\n fields.provider.value = profile.provider;\n fields.endpoint.value = profile.endpoint;\n fields.accessToken.value = \"\";\n updateProviderHints();\n setStatus(fields.apiStatus, t(\"options.status.presetApplied\"), \"ok\");\n}\n\nfunction handleProviderChange() {\n const preset = profilePreset(providerDefaultPresetName(fields.provider.value));\n fields.endpoint.value = preset.endpoint || \"\";\n fields.accessToken.value = \"\";\n updateProviderHints();\n}\n\nfunction providerDefaultPresetName(provider) {\n const names = {\n proxy: \"proxy\",\n \"openai-compatible\": \"deepseek\",\n anthropic: \"claude\",\n gemini: \"gemini\",\n ollama: \"ollama\"\n };\n return names[provider] || \"deepseek\";\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.ai/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n proxy: {\n id: \"proxy\",\n name: \"专用服务\",\n provider: \"proxy\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions([getActiveProfile()])) {\n setStatus(fields.apiStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n setStatus(fields.apiStatus, t(\"options.status.testing\"), \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile(),\n stream: true\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, t(\"options.status.connectionSuccess\", { text: response.payload.text }), \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || t(\"options.status.connectionFailed\"), \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(t(\"options.status.savedForExport\"));\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 1,\n exportedAt: new Date().toISOString(),\n settings: sanitizeSettingsForExport(settings)\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-${formatDateForFilename(new Date())}.json`);\n const copied = await writeClipboard(json);\n setStatus(fields.saveStatus, copied ? t(\"options.status.settingsExportedCopied\") : t(\"options.status.settingsExported\"), \"ok\");\n}\n\nasync function exportCompleteSettings() {\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n const confirmed = window.confirm(t(\"options.confirm.exportSecrets\"));\n if (!confirmed) {\n return;\n }\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 2,\n exportedAt: new Date().toISOString(),\n includesSecrets: true,\n settings\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-full-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, t(\"options.status.fullSettingsExported\"), \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = parseImportedSettings(fields.importText.value);\n settings = normalizeSettings(imported);\n hydrateForm();\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, t(\"options.status.settingsImported\"), \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, t(\"options.status.importFailed\", { message: error.message }), \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n setStatus(fields.saveStatus, t(\"options.status.resetRequiresSave\"), \"ok\");\n}\n\nasync function importSettingsFile() {\n const file = fields.importFile.files?.[0];\n if (!file) {\n return;\n }\n\n try {\n fields.importText.value = await file.text();\n await importSettings();\n } finally {\n fields.importFile.value = \"\";\n }\n}\n\nasync function renderHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n setDigits(fields.historyCount, `${history.length} / ${HISTORY_LIMIT}`);\n\n if (!history.length) {\n fields.historyList.innerHTML = `<div class=\"history-empty\">${escapeHtml(t(\"options.history.empty\"))}</div>`;\n return;\n }\n\n fields.historyList.innerHTML = history.map((item, index) => {\n const summary = item.summary || item.text || \"\";\n const title = item.title || t(\"options.history.untitled\");\n const createdAt = formatDate(item.createdAt);\n const meta = [item.platform, item.model, createdAt].filter(Boolean).join(\" · \");\n return `\n <article class=\"history-item\">\n <div class=\"history-item__head\">\n <div>\n <div class=\"history-item__title\" title=\"${escapeHtml(title)}\">${escapeHtml(title)}</div>\n <div class=\"history-item__meta\">${escapeHtml(meta || t(\"options.history.local\"))}</div>\n </div>\n <button class=\"button button--ghost history-copy\" type=\"button\" data-history-index=\"${index}\">${escapeHtml(t(\"options.history.copy\"))}</button>\n </div>\n <div class=\"history-item__summary\">${escapeHtml(summary || t(\"options.history.noSummary\"))}</div>\n </article>\n `;\n }).join(\"\");\n\n if (result[HISTORY_KEY]?.length > HISTORY_LIMIT) {\n await chrome.storage.local.set({ [HISTORY_KEY]: history });\n }\n}\n\nasync function copyHistoryFromList(event) {\n const button = event.target.closest(\"[data-history-index]\");\n if (!button) {\n return;\n }\n\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const item = history[Number(button.dataset.historyIndex)];\n const text = item?.summary || item?.text || \"\";\n if (!text.trim()) {\n setStatus(fields.saveStatus, t(\"options.status.noCopyableHistory\"), \"error\");\n return;\n }\n\n const copied = await writeClipboard(text);\n setStatus(fields.saveStatus, copied ? t(\"options.status.historyCopied\") : t(\"options.status.clipboardDenied\"), copied ? \"ok\" : \"error\");\n}\n\nasync function clearHistory() {\n await chrome.storage.local.set({ [HISTORY_KEY]: [] });\n await renderHistory();\n setStatus(fields.saveStatus, t(\"options.status.historyCleared\"), \"ok\");\n}\n\nasync function exportHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const payload = {\n type: \"video-caption-ai-history\",\n version: 1,\n exportedAt: new Date().toISOString(),\n limit: HISTORY_LIMIT,\n history\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-history-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, history.length ? t(\"options.status.historyExported\") : t(\"options.status.emptyHistoryExported\"), \"ok\");\n}\n\nfunction parseImportedSettings(text) {\n if (!text.trim()) {\n throw new Error(t(\"options.error.emptyImport\"));\n }\n\n const parsed = JSON.parse(text);\n const imported = parsed?.settings || parsed;\n if (!imported || typeof imported !== \"object\" || Array.isArray(imported)) {\n throw new Error(t(\"options.error.invalidSettings\"));\n }\n return imported;\n}\n\nfunction sanitizeSettingsForExport(value) {\n const output = structuredClone(value || {});\n output.profiles = (Array.isArray(output.profiles) ? output.profiles : []).map((profile) => {\n const cleanProfile = { ...profile };\n delete cleanProfile.apiKey;\n cleanProfile.accessToken = \"\";\n return cleanProfile;\n });\n return output;\n}\n\nfunction getProfileSecret(profile) {\n if (!profile || profile.provider === \"ollama\") {\n return \"\";\n }\n return profile.provider === \"proxy\"\n ? profile.accessToken || \"\"\n : profile.apiKey || \"\";\n}\n\nfunction setProfileSecret(profile, value) {\n if (!profile) {\n return;\n }\n if (profile.provider === \"proxy\") {\n profile.accessToken = value;\n profile.apiKey = \"\";\n return;\n }\n if (profile.provider === \"ollama\") {\n profile.accessToken = \"\";\n profile.apiKey = \"\";\n return;\n }\n profile.apiKey = value;\n profile.accessToken = \"\";\n}\n\nasync function ensureEndpointPermissions(profiles) {\n const origins = uniqueValues((profiles || [])\n .map(endpointPermissionPattern)\n .filter(Boolean));\n\n for (const origin of origins) {\n const hasPermission = await chrome.permissions.contains({ origins: [origin] });\n if (hasPermission) {\n continue;\n }\n const granted = await chrome.permissions.request({ origins: [origin] });\n if (!granted) {\n return false;\n }\n }\n\n return true;\n}\n\nfunction endpointPermissionPattern(profile) {\n const url = parseUrl(getProfileEndpoint(profile));\n if (!url) {\n return \"\";\n }\n if (url.protocol === \"https:\") {\n return `https://${url.hostname}/*`;\n }\n if (url.protocol === \"http:\" && isLoopbackHostname(url.hostname)) {\n return `http://${url.hostname}/*`;\n }\n return \"\";\n}\n\nfunction updateProviderHints() {\n const locale = i18n();\n const provider = fields.provider.value;\n const isOllama = provider === \"ollama\";\n const isProxy = provider === \"proxy\";\n const isGemini = provider === \"gemini\";\n setFieldLabel(fields.endpoint, getEndpointLabel(provider, locale));\n fields.endpoint.placeholder = getEndpointPlaceholder(provider);\n setFieldLabel(fields.accessToken, isProxy ? locale.t(\"options.api.accessToken\") : locale.t(\"options.api.apiKey\"));\n fields.accessToken.placeholder = getSecretPlaceholder(provider, locale);\n fields.accessToken.disabled = isOllama;\n fields.endpoint.disabled = isGemini;\n fields.model.placeholder = isProxy ? locale.t(\"options.api.modelPlaceholder\") : locale.t(\"options.api.directModelPlaceholder\");\n}\n\nfunction getEndpointLabel(provider, locale) {\n if (provider === \"ollama\") {\n return locale.t(\"options.api.ollamaEndpoint\");\n }\n if (provider === \"proxy\") {\n return locale.t(\"options.api.endpoint\");\n }\n return locale.t(\"options.api.directEndpoint\");\n}\n\nfunction getEndpointPlaceholder(provider) {\n const placeholders = {\n proxy: \"https://your-domain.example.com/v1/chat/completions\",\n \"openai-compatible\": \"https://api.deepseek.com/v1/chat/completions\",\n anthropic: \"https://api.anthropic.com/v1/messages\",\n gemini: \"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent\",\n ollama: \"http://localhost:11434/v1/chat/completions\"\n };\n return placeholders[provider] || placeholders[\"openai-compatible\"];\n}\n\nfunction getSecretPlaceholder(provider, locale) {\n if (provider === \"ollama\") {\n return \"\";\n }\n if (provider === \"proxy\") {\n return locale.language === \"en\" ? \"Optional access token\" : \"可选访问口令\";\n }\n return \"sk-...\";\n}\n\nfunction getProfileEndpoint(profile) {\n if (!profile) {\n return \"\";\n }\n if (profile.provider === \"gemini\" && !profile.endpoint) {\n const model = profile.model || \"gemini-pro\";\n return `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;\n }\n return profile.endpoint || \"\";\n}\n\nfunction parseUrl(value) {\n try {\n return value ? new URL(value) : null;\n } catch (_error) {\n return null;\n }\n}\n\nfunction normalizeHostname(hostname) {\n return String(hostname || \"\")\n .replace(/^\\[/, \"\")\n .replace(/\\]$/, \"\")\n .toLowerCase();\n}\n\nfunction isLoopbackHostname(hostname) {\n const host = normalizeHostname(hostname);\n return host === \"localhost\"\n || host.endsWith(\".localhost\")\n || host === \"::1\"\n || host === \"0:0:0:0:0:0:0:1\"\n || host.startsWith(\"127.\");\n}\n\nfunction isLoopbackEndpoint(endpoint) {\n const url = parseUrl(endpoint);\n return Boolean(url && url.protocol === \"http:\" && isLoopbackHostname(url.hostname));\n}\n\nfunction uniqueValues(values) {\n return [...new Set(values)];\n}\n\nfunction normalizeSettings(input) {\n const normalized = deepMerge(DEFAULT_SETTINGS, input || {});\n const importedVersion = Number(input?.settingsVersion || 0);\n normalized.settingsVersion = DEFAULT_SETTINGS.settingsVersion;\n normalized.theme = [\"auto\", \"light\", \"dark\"].includes(normalized.theme) ? normalized.theme : DEFAULT_SETTINGS.theme;\n normalized.uiLanguage = I18N.normalizeUiLanguage(normalized.uiLanguage);\n normalized.language = String(normalized.language || \"\").trim() || DEFAULT_SETTINGS.language;\n normalized.panelEnabled = normalized.panelEnabled !== false;\n normalized.includeTimestamps = normalized.includeTimestamps !== false;\n normalized.includeTitleAndUrl = normalized.includeTitleAndUrl !== false;\n normalized.saveHistory = normalized.saveHistory === true;\n normalized.promptTemplate = String(normalized.promptTemplate || DEFAULT_SETTINGS.promptTemplate);\n normalized.outputTemplate = String(normalized.outputTemplate || DEFAULT_SETTINGS.outputTemplate);\n if (String(input?.promptTemplate || \"\") === OLD_DEFAULT_PROMPT_TEMPLATE) {\n normalized.promptTemplate = DEFAULT_SETTINGS.promptTemplate;\n }\n if (String(input?.outputTemplate || \"\") === OLD_DEFAULT_OUTPUT_TEMPLATE) {\n normalized.outputTemplate = DEFAULT_SETTINGS.outputTemplate;\n }\n normalized.redactTerms = String(normalized.redactTerms || \"\");\n normalized.chunkSize = clampNumber(normalized.chunkSize, 12000, 2000, 50000);\n normalized.chunkOverlap = clampNumber(normalized.chunkOverlap, 600, 0, 5000);\n normalized.requestTimeoutSeconds = clampNumber(\n normalized.requestTimeoutSeconds,\n DEFAULT_SETTINGS.requestTimeoutSeconds,\n 30,\n 600\n );\n\n if (!Array.isArray(normalized.profiles) || !normalized.profiles.length) {\n normalized.profiles = structuredClone(DEFAULT_SETTINGS.profiles);\n }\n normalized.profiles = normalized.profiles.map((profile, index) => normalizeProfile(profile, index, importedVersion));\n if (!normalized.profiles.some((profile) => profile.id === normalized.activeProfileId)) {\n normalized.activeProfileId = normalized.profiles[0].id;\n }\n return normalized;\n}\n\nfunction normalizeProfile(profile, index, importedVersion) {\n const source = profile || {};\n const rawProvider = String(source.provider || \"openai-compatible\");\n const isLocalOpenAI = rawProvider === \"ollama\"\n || (rawProvider === \"openai-compatible\" && isLoopbackEndpoint(source.endpoint));\n const supportedProviders = [\"proxy\", \"openai-compatible\", \"anthropic\", \"gemini\", \"ollama\"];\n const provider = isLocalOpenAI\n ? \"ollama\"\n : (supportedProviders.includes(rawProvider) ? rawProvider : \"openai-compatible\");\n const fallback = profilePreset(provider === \"openai-compatible\" ? \"deepseek\" : provider);\n const temperature = importedVersion < 3 && Number(source.temperature) === 0.2\n ? 1\n : source.temperature;\n return {\n ...fallback,\n id: String(source.id || fallback.id || `profile-${index + 1}`),\n name: String(source.name || fallback.name || `Profile ${index + 1}`),\n provider,\n endpoint: String(source.endpoint || fallback.endpoint || \"\"),\n apiKey: String(source.apiKey || (![\"proxy\", \"ollama\"].includes(provider) ? source.accessToken || \"\" : \"\")),\n accessToken: String(provider === \"proxy\" ? source.accessToken || \"\" : \"\"),\n model: String(source.model || fallback.model || \"\"),\n temperature: clampNumber(temperature, fallback.temperature ?? 1, 0, 2),\n maxTokens: clampNumber(source.maxTokens, fallback.maxTokens, 256, 32000)\n };\n}\n\nfunction normalizeHistory(value) {\n return (Array.isArray(value) ? value : [])\n .filter((item) => item && typeof item === \"object\")\n .slice(0, HISTORY_LIMIT);\n}\n\nfunction applyTranslations() {\n const locale = i18n();\n document.documentElement.lang = locale.language === \"en\" ? \"en\" : \"zh-CN\";\n document.title = locale.t(\"options.documentTitle\");\n\n setText(\".topbar h1\", locale.t(\"common.appName\"));\n setText(\".topbar p\", locale.t(\"options.brandTagline\"));\n setText(\"#exportSettings\", locale.t(\"options.exportSettings\"));\n setText(\"#saveAll\", locale.t(\"options.saveAll\"));\n\n setText(\".sidebar a[href='#appearance']\", locale.t(\"options.nav.appearance\"));\n setText(\".sidebar a[href='#api']\", locale.t(\"options.nav.api\"));\n setText(\".sidebar a[href='#prompt']\", locale.t(\"options.nav.prompt\"));\n setText(\".sidebar a[href='#platforms']\", locale.t(\"options.nav.platforms\"));\n setText(\".sidebar a[href='#history']\", locale.t(\"options.nav.history\"));\n setText(\".sidebar a[href='#advanced']\", locale.t(\"options.nav.advanced\"));\n\n setText(\"#appearance .section__head h2\", locale.t(\"options.nav.appearance\"));\n setText(\"#appearance .section__head p\", locale.t(\"options.appearance.description\"));\n setText(\"#appearance > .field > label\", locale.t(\"options.appearance.theme\"));\n document.querySelector(\"#appearance .segmented\")?.setAttribute(\"aria-label\", locale.t(\"options.appearance.theme\"));\n setRadioLabel(\"auto\", locale.t(\"options.appearance.theme.auto\"));\n setRadioLabel(\"light\", locale.t(\"options.appearance.theme.light\"));\n setRadioLabel(\"dark\", locale.t(\"options.appearance.theme.dark\"));\n setFieldLabel(fields.uiLanguage, locale.t(\"options.appearance.uiLanguage\"));\n setOptionText(fields.uiLanguage, \"zh-CN\", locale.t(\"ui.zh-CN\"));\n setOptionText(fields.uiLanguage, \"en\", locale.t(\"ui.en\"));\n setFieldLabel(fields.language, locale.t(\"options.appearance.summaryLanguage\"));\n fields.language.placeholder = locale.t(\"options.appearance.summaryLanguagePlaceholder\");\n setFieldLabel(fields.panelEnabled, locale.t(\"options.appearance.panelEnabled\"));\n\n setText(\"#api .section__head h2\", locale.t(\"options.nav.api\"));\n setText(\"#api .section__head p\", locale.t(\"options.api.description\"));\n setText(\"#addProfile\", locale.t(\"options.api.add\"));\n setText(\"#deleteProfile\", locale.t(\"options.api.delete\"));\n setFieldLabel(fields.profileName, locale.t(\"options.api.profileName\"));\n fields.profileName.placeholder = locale.t(\"options.api.profileNamePlaceholder\");\n setFieldLabel(fields.provider, locale.t(\"options.api.provider\"));\n setOptionText(fields.provider, \"proxy\", locale.t(\"options.api.provider.proxy\"));\n setOptionText(fields.provider, \"openai-compatible\", locale.t(\"options.api.provider.openai\"));\n setOptionText(fields.provider, \"anthropic\", locale.t(\"options.api.provider.anthropic\"));\n setOptionText(fields.provider, \"gemini\", locale.t(\"options.api.provider.gemini\"));\n setOptionText(fields.provider, \"ollama\", locale.t(\"options.api.provider.ollama\"));\n setFieldLabel(fields.endpoint, locale.t(\"options.api.endpoint\"));\n setFieldLabel(fields.accessToken, locale.t(\"options.api.accessToken\"));\n setFieldLabel(fields.model, locale.t(\"options.api.model\"));\n fields.model.placeholder = locale.t(\"options.api.modelPlaceholder\");\n setFieldLabel(fields.temperature, locale.t(\"options.api.temperature\"));\n setFieldLabel(fields.maxTokens, locale.t(\"options.api.maxTokens\"));\n setText(\"#saveProfile\", locale.t(\"options.api.saveProfile\"));\n setText(\"#testProfile\", locale.t(\"options.api.testProfile\"));\n updateProviderHints();\n\n setText(\"#prompt .section__head h2\", locale.t(\"options.nav.prompt\"));\n setText(\"#prompt .section__head p\", locale.t(\"options.prompt.description\"));\n setFieldLabel(fields.promptTemplate, locale.t(\"options.prompt.promptTemplate\"));\n setFieldLabel(fields.outputTemplate, locale.t(\"options.prompt.outputTemplate\"));\n\n setText(\"#platforms .section__head h2\", locale.t(\"options.nav.platforms\"));\n setText(\"#platforms .section__head p\", locale.t(\"options.platforms.description\"));\n setText(\"#platforms .support-list > div:nth-child(1) span\", locale.t(\"options.platforms.youtube\"));\n setText(\"#platforms .support-list > div:nth-child(2) span\", locale.t(\"options.platforms.bilibili\"));\n setText(\"#platforms .support-list > div:nth-child(3) span\", locale.t(\"options.platforms.html5\"));\n setText(\"#platforms .support-list > div:nth-child(4) span\", locale.t(\"options.platforms.transcript\"));\n setFieldLabel(fields.includeTimestamps, locale.t(\"options.platforms.includeTimestamps\"));\n setFieldLabel(fields.redactTerms, locale.t(\"options.platforms.redactTerms\"));\n fields.redactTerms.placeholder = locale.t(\"options.platforms.redactTermsPlaceholder\");\n\n setText(\"#history .section__head h2\", locale.t(\"options.nav.history\"));\n setText(\"#history .section__head p\", locale.t(\"options.history.description\"));\n setText(\"#exportHistory\", locale.t(\"options.history.export\"));\n setText(\"#clearHistory\", locale.t(\"options.history.clear\"));\n setFieldLabel(fields.saveHistory, locale.t(\"options.history.saveHistory\"));\n\n setText(\"#advanced .section__head h2\", locale.t(\"options.nav.advanced\"));\n setText(\"#advanced .section__head p\", locale.t(\"options.advanced.description\"));\n setFieldLabel(fields.chunkSize, locale.t(\"options.advanced.chunkSize\"));\n setFieldLabel(fields.chunkOverlap, locale.t(\"options.advanced.chunkOverlap\"));\n setFieldLabel(fields.requestTimeoutSeconds, locale.t(\"options.advanced.requestTimeoutSeconds\"));\n setFieldLabel(fields.importText, locale.t(\"options.advanced.importJson\"));\n fields.importText.placeholder = locale.t(\"options.advanced.importJsonPlaceholder\");\n setFieldLabel(fields.importFile, locale.t(\"options.advanced.importFile\"));\n setText(\"#importSettings\", locale.t(\"options.advanced.importSettings\"));\n setText(\"#exportCompleteSettings\", locale.t(\"options.advanced.exportCompleteSettings\"));\n setText(\"#resetSettings\", locale.t(\"options.advanced.resetSettings\"));\n}\n\nfunction applyTheme() {\n const mode = settings.theme || \"auto\";\n const resolved = mode === \"auto\"\n ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\")\n : mode;\n document.documentElement.dataset.themeMode = mode;\n document.documentElement.dataset.theme = resolved;\n}\n\nfunction downloadJson(json, filename) {\n const blob = new Blob([json], { type: \"application/json;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\nasync function writeClipboard(text) {\n if (navigator.clipboard?.writeText) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (_error) {\n // Fall back to the selection-based copy path below.\n }\n }\n\n const textarea = document.createElement(\"textarea\");\n textarea.value = text;\n textarea.setAttribute(\"readonly\", \"\");\n textarea.style.position = \"fixed\";\n textarea.style.top = \"0\";\n textarea.style.left = \"0\";\n textarea.style.width = \"1px\";\n textarea.style.height = \"1px\";\n textarea.style.opacity = \"0\";\n textarea.style.pointerEvents = \"none\";\n document.body.appendChild(textarea);\n textarea.focus();\n textarea.select();\n textarea.setSelectionRange(0, text.length);\n const copied = document.execCommand(\"copy\");\n textarea.remove();\n return copied;\n}\n\nfunction formatDateForFilename(date) {\n return date.toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n}\n\nfunction formatDate(value) {\n if (!value) {\n return \"\";\n }\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return \"\";\n }\n return date.toLocaleString(i18n().locale, {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\"\n });\n}\n\nfunction clampNumber(value, fallback, min, max) {\n const number = Number(value);\n if (!Number.isFinite(number)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, number));\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setText(selector, value) {\n const element = document.querySelector(selector);\n if (element) {\n element.textContent = value;\n }\n}\n\nfunction setFieldLabel(field, value) {\n const label = field?.closest(\".field\")?.querySelector(\"span\");\n if (label) {\n label.textContent = value;\n }\n}\n\nfunction setRadioLabel(value, text) {\n const input = document.querySelector(`input[name='theme'][value='${value}']`);\n const label = input?.closest(\"label\");\n if (!label) {\n return;\n }\n let textNode = [...label.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());\n if (!textNode) {\n textNode = document.createTextNode(\"\");\n label.appendChild(textNode);\n }\n textNode.textContent = ` ${text}`;\n}\n\nfunction setOptionText(select, value, text) {\n const option = select?.querySelector(`option[value='${value}']`);\n if (option) {\n option.textContent = text;\n }\n}\n\nfunction setStatus(element, message, tone) {\n const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n element.dataset.statusToken = token;\n element.dataset.tone = tone || \"\";\n swapText(element, message);\n if (message) {\n window.setTimeout(() => {\n if (element.dataset.statusToken === token) {\n element.dataset.statusToken = \"\";\n element.dataset.tone = \"\";\n swapText(element, \"\");\n }\n }, 5000);\n }\n}\n\nfunction swapText(element, nextText) {\n if (!element) {\n return;\n }\n\n element.classList.remove(\"is-exit\", \"is-enter-start\");\n if (element.textContent === nextText) {\n return;\n }\n\n element.textContent = nextText;\n if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n return;\n }\n\n element.classList.add(\"is-enter-start\");\n void element.offsetHeight;\n element.classList.remove(\"is-enter-start\");\n}\n\nfunction setDigits(group, value) {\n const text = String(value);\n if (!group || (group.textContent === text && group.children.length)) {\n return;\n }\n\n group.setAttribute(\"aria-label\", text);\n group.classList.remove(\"is-animating\");\n group.replaceChildren();\n\n [...text].forEach((character, index, characters) => {\n const digit = document.createElement(\"span\");\n digit.className = \"t-digit\";\n digit.textContent = character;\n if (index === characters.length - 2) {\n digit.dataset.stagger = \"1\";\n } else if (index === characters.length - 1) {\n digit.dataset.stagger = \"2\";\n }\n group.appendChild(digit);\n });\n\n void group.offsetHeight;\n group.classList.add(\"is-animating\");\n}\n\nfunction initSectionNav() {\n const links = [...document.querySelectorAll(\".sidebar a[href^='#']\")];\n const sections = links\n .map((link) => document.querySelector(link.getAttribute(\"href\")))\n .filter(Boolean);\n\n if (!links.length || !sections.length) {\n return;\n }\n\n const setCurrent = (id) => {\n links.forEach((link) => {\n const isCurrent = link.getAttribute(\"href\") === `#${id}`;\n if (isCurrent) {\n link.setAttribute(\"aria-current\", \"true\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n });\n };\n\n setCurrent(location.hash.slice(1) || sections[0].id);\n\n if (!(\"IntersectionObserver\" in window)) {\n window.addEventListener(\"scroll\", () => {\n let current = sections[0];\n for (const section of sections) {\n if (section.getBoundingClientRect().top <= 140) {\n current = section;\n }\n }\n setCurrent(current.id);\n }, { passive: true });\n return;\n }\n\n const observer = new IntersectionObserver((entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];\n if (visible?.target?.id) {\n setCurrent(visible.target.id);\n }\n }, {\n rootMargin: \"-18% 0px -62% 0px\",\n threshold: [0, 0.1, 0.35, 0.7]\n });\n\n sections.forEach((section) => observer.observe(section));\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "4e570230f72afa5be9e1333d7e293aaa0b080692": {"t": 1785758696.6416516, "n": 40677, "h": "7332e620d94c573d", "c": "const SETTINGS_KEY = \"vcsSettings\";\nconst HISTORY_KEY = \"vcsHistory\";\nconst HISTORY_LIMIT = 30;\nconst I18N = globalThis.VCS_I18N;\n\nconst OLD_DEFAULT_PROMPT_TEMPLATE = [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst OLD_DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n].join(\"\\n\");\n\nconst DEFAULT_PROMPT_TEMPLATE = [\n \"你是一位视频内容提炼专家。请基于字幕文本,生成一份精炼、高信息密度的结构化总结。\",\n \"\",\n \"## 视频信息\",\n \"📌 标题:{{title}}|🌐 平台:{{platform}}|🔗 链接:{{url}}\",\n \"\",\n \"## 核心原则\",\n \"- 总结 ≠ 复述。你的任务是**压缩与重构**,不是逐段改写\",\n \"- 保留硬信息(数据、人名、术语、方法论),砍掉软信息(过渡语、重复论证、情绪渲染)\",\n \"- 每句话必须承载独立信息量,删掉后会造成信息缺失才有资格留下\",\n \"- 仅基于字幕实际内容,不推测、不补充、不评价\",\n \"\",\n \"## 输出语言:{{language}}\",\n \"\",\n \"## 输出格式\",\n \"{{outputTemplate}}\",\n \"\",\n \"## 字幕正文\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"\",\n \"## 💡 一句话结论\",\n \"≤ 30字。视频最核心的一个判断或发现。\",\n \"\",\n \"## 📋 核心摘要\",\n \"3~4句话覆盖完整逻辑链:什么问题 → 什么观点/方案 → 什么结论。不用条目,写成连贯段落。\",\n \"\",\n \"## 🎯 关键观点\",\n \"3~7条,每条格式:\",\n \"- ⏱️ [时间戳] **关键词**:一句话说明(无时间戳则省略⏱️)\",\n \"\",\n \"只保留\\\"删掉就损失信息\\\"的观点,不凑数。\",\n \"\",\n \"## 🗂️ 章节时间线\",\n \"按叙事顺序分3~6段,每段格式:\",\n \"- ⏱️ [起止时间] **章节名**:一句话概括(无时间戳则用 1️⃣2️⃣3️⃣ 编号)\"\n].join(\"\\n\");\n\nconst DEFAULT_SETTINGS = {\n settingsVersion: 7,\n theme: \"auto\",\n uiLanguage: \"zh-CN\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"custom\",\n profiles: [\n {\n ...profilePreset(\"deepseek\"),\n id: \"custom\",\n name: \"我的 API 配置\"\n }\n ],\n promptTemplate: DEFAULT_PROMPT_TEMPLATE,\n outputTemplate: DEFAULT_OUTPUT_TEMPLATE,\n chunkSize: 12000,\n chunkOverlap: 600,\n requestTimeoutSeconds: 60,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: false\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n uiLanguage: document.querySelector(\"#uiLanguage\"),\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n accessToken: document.querySelector(\"#accessToken\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n requestTimeoutSeconds: document.querySelector(\"#requestTimeoutSeconds\"),\n importText: document.querySelector(\"#importText\"),\n importFile: document.querySelector(\"#importFile\"),\n historyList: document.querySelector(\"#historyList\"),\n historyCount: document.querySelector(\"#historyCount\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nfunction i18n() {\n return I18N.create(settings);\n}\n\nfunction t(key, variables) {\n return i18n().t(key, variables);\n}\n\nasync function init() {\n const result = await chrome.storage.local.get(SETTINGS_KEY);\n settings = normalizeSettings(result[SETTINGS_KEY]);\n if (JSON.stringify(result[SETTINGS_KEY] || {}) !== JSON.stringify(settings)) {\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n }\n hydrateForm();\n bindEvents();\n await renderHistory();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", () => saveAll());\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", () => saveCurrentProfile());\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#exportCompleteSettings\").addEventListener(\"click\", exportCompleteSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n document.querySelector(\"#clearHistory\").addEventListener(\"click\", clearHistory);\n document.querySelector(\"#exportHistory\").addEventListener(\"click\", exportHistory);\n fields.importFile.addEventListener(\"change\", importSettingsFile);\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", async () => {\n settings.theme = input.value;\n applyTheme();\n await saveAll(t(\"options.status.themeSaved\"));\n });\n });\n\n fields.uiLanguage.addEventListener(\"change\", async () => {\n settings.uiLanguage = fields.uiLanguage.value;\n applyTranslations();\n await renderHistory();\n await saveAll(t(\"options.status.uiLanguageSaved\"));\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n fields.provider.addEventListener(\"change\", handleProviderChange);\n fields.historyList.addEventListener(\"click\", copyHistoryFromList);\n initSectionNav();\n\n chrome.storage.onChanged.addListener((changes, area) => {\n if (area !== \"local\") {\n return;\n }\n if (changes[HISTORY_KEY]) {\n renderHistory();\n }\n if (changes[SETTINGS_KEY] && changes[SETTINGS_KEY].newValue) {\n settings = normalizeSettings(changes[SETTINGS_KEY].newValue);\n hydrateForm();\n renderHistory();\n }\n });\n\n window.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (settings.theme === \"auto\") {\n applyTheme();\n }\n });\n}\n\nfunction hydrateForm() {\n const themeInput = document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`)\n || document.querySelector(\"input[name='theme'][value='auto']\");\n if (themeInput) {\n themeInput.checked = true;\n }\n fields.uiLanguage.value = settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage;\n fields.language.value = settings.language || DEFAULT_SETTINGS.language;\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = settings.saveHistory !== false;\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n fields.requestTimeoutSeconds.value = settings.requestTimeoutSeconds || DEFAULT_SETTINGS.requestTimeoutSeconds;\n applyTheme();\n applyTranslations();\n renderProfileFields();\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"proxy\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.accessToken.value = getProfileSecret(profile);\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 1;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n updateProviderHints();\n}\n\nasync function saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n await saveAll(t(\"options.status.profileSaved\"));\n}\n\nasync function saveAll(message = t(\"options.status.settingsSaved\")) {\n if (typeof message !== \"string\") {\n message = t(\"options.status.settingsSaved\");\n }\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.uiLanguage = I18N.normalizeUiLanguage(fields.uiLanguage.value);\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n settings.requestTimeoutSeconds = Number(fields.requestTimeoutSeconds.value || DEFAULT_SETTINGS.requestTimeoutSeconds);\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions(settings.profiles)) {\n setStatus(fields.saveStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: settings.uiLanguage === \"en\" ? \"New Profile\" : \"新配置\",\n provider: \"openai-compatible\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, t(\"options.status.keepOneProfile\"), \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n profile.provider = preset.provider;\n profile.endpoint = preset.endpoint;\n profile.apiKey = \"\";\n profile.accessToken = \"\";\n fields.provider.value = profile.provider;\n fields.endpoint.value = profile.endpoint;\n fields.accessToken.value = \"\";\n updateProviderHints();\n setStatus(fields.apiStatus, t(\"options.status.presetApplied\"), \"ok\");\n}\n\nfunction handleProviderChange() {\n const preset = profilePreset(providerDefaultPresetName(fields.provider.value));\n fields.endpoint.value = preset.endpoint || \"\";\n fields.accessToken.value = \"\";\n updateProviderHints();\n}\n\nfunction providerDefaultPresetName(provider) {\n const names = {\n proxy: \"proxy\",\n \"openai-compatible\": \"deepseek\",\n anthropic: \"claude\",\n gemini: \"gemini\",\n ollama: \"ollama\"\n };\n return names[provider] || \"deepseek\";\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.ai/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n proxy: {\n id: \"proxy\",\n name: \"专用服务\",\n provider: \"proxy\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions([getActiveProfile()])) {\n setStatus(fields.apiStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n setStatus(fields.apiStatus, t(\"options.status.testing\"), \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile(),\n stream: true\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, t(\"options.status.connectionSuccess\", { text: response.payload.text }), \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || t(\"options.status.connectionFailed\"), \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(t(\"options.status.savedForExport\"));\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 1,\n exportedAt: new Date().toISOString(),\n settings: sanitizeSettingsForExport(settings)\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-${formatDateForFilename(new Date())}.json`);\n const copied = await writeClipboard(json);\n setStatus(fields.saveStatus, copied ? t(\"options.status.settingsExportedCopied\") : t(\"options.status.settingsExported\"), \"ok\");\n}\n\nasync function exportCompleteSettings() {\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n const confirmed = window.confirm(t(\"options.confirm.exportSecrets\"));\n if (!confirmed) {\n return;\n }\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 2,\n exportedAt: new Date().toISOString(),\n includesSecrets: true,\n settings\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-full-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, t(\"options.status.fullSettingsExported\"), \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = parseImportedSettings(fields.importText.value);\n settings = normalizeSettings(imported);\n hydrateForm();\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, t(\"options.status.settingsImported\"), \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, t(\"options.status.importFailed\", { message: error.message }), \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n setStatus(fields.saveStatus, t(\"options.status.resetRequiresSave\"), \"ok\");\n}\n\nasync function importSettingsFile() {\n const file = fields.importFile.files?.[0];\n if (!file) {\n return;\n }\n\n try {\n fields.importText.value = await file.text();\n await importSettings();\n } finally {\n fields.importFile.value = \"\";\n }\n}\n\nasync function renderHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n setDigits(fields.historyCount, `${history.length} / ${HISTORY_LIMIT}`);\n\n if (!history.length) {\n fields.historyList.innerHTML = `<div class=\"history-empty\">${escapeHtml(t(\"options.history.empty\"))}</div>`;\n return;\n }\n\n fields.historyList.innerHTML = history.map((item, index) => {\n const summary = item.summary || item.text || \"\";\n const title = item.title || t(\"options.history.untitled\");\n const createdAt = formatDate(item.createdAt);\n const meta = [item.platform, item.model, createdAt].filter(Boolean).join(\" · \");\n return `\n <article class=\"history-item\">\n <div class=\"history-item__head\">\n <div>\n <div class=\"history-item__title\" title=\"${escapeHtml(title)}\">${escapeHtml(title)}</div>\n <div class=\"history-item__meta\">${escapeHtml(meta || t(\"options.history.local\"))}</div>\n </div>\n <button class=\"button button--ghost history-copy\" type=\"button\" data-history-index=\"${index}\">${escapeHtml(t(\"options.history.copy\"))}</button>\n </div>\n <div class=\"history-item__summary\">${escapeHtml(summary || t(\"options.history.noSummary\"))}</div>\n </article>\n `;\n }).join(\"\");\n\n if (result[HISTORY_KEY]?.length > HISTORY_LIMIT) {\n await chrome.storage.local.set({ [HISTORY_KEY]: history });\n }\n}\n\nasync function copyHistoryFromList(event) {\n const button = event.target.closest(\"[data-history-index]\");\n if (!button) {\n return;\n }\n\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const item = history[Number(button.dataset.historyIndex)];\n const text = item?.summary || item?.text || \"\";\n if (!text.trim()) {\n setStatus(fields.saveStatus, t(\"options.status.noCopyableHistory\"), \"error\");\n return;\n }\n\n const copied = await writeClipboard(text);\n setStatus(fields.saveStatus, copied ? t(\"options.status.historyCopied\") : t(\"options.status.clipboardDenied\"), copied ? \"ok\" : \"error\");\n}\n\nasync function clearHistory() {\n await chrome.storage.local.set({ [HISTORY_KEY]: [] });\n await renderHistory();\n setStatus(fields.saveStatus, t(\"options.status.historyCleared\"), \"ok\");\n}\n\nasync function exportHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const payload = {\n type: \"video-caption-ai-history\",\n version: 1,\n exportedAt: new Date().toISOString(),\n limit: HISTORY_LIMIT,\n history\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-history-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, history.length ? t(\"options.status.historyExported\") : t(\"options.status.emptyHistoryExported\"), \"ok\");\n}\n\nfunction parseImportedSettings(text) {\n if (!text.trim()) {\n throw new Error(t(\"options.error.emptyImport\"));\n }\n\n const parsed = JSON.parse(text);\n const imported = parsed?.settings || parsed;\n if (!imported || typeof imported !== \"object\" || Array.isArray(imported)) {\n throw new Error(t(\"options.error.invalidSettings\"));\n }\n return imported;\n}\n\nfunction sanitizeSettingsForExport(value) {\n const output = structuredClone(value || {});\n output.profiles = (Array.isArray(output.profiles) ? output.profiles : []).map((profile) => {\n const cleanProfile = { ...profile };\n delete cleanProfile.apiKey;\n cleanProfile.accessToken = \"\";\n return cleanProfile;\n });\n return output;\n}\n\nfunction getProfileSecret(profile) {\n if (!profile || profile.provider === \"ollama\") {\n return \"\";\n }\n return profile.provider === \"proxy\"\n ? profile.accessToken || \"\"\n : profile.apiKey || \"\";\n}\n\nfunction setProfileSecret(profile, value) {\n if (!profile) {\n return;\n }\n if (profile.provider === \"proxy\") {\n profile.accessToken = value;\n profile.apiKey = \"\";\n return;\n }\n if (profile.provider === \"ollama\") {\n profile.accessToken = \"\";\n profile.apiKey = \"\";\n return;\n }\n profile.apiKey = value;\n profile.accessToken = \"\";\n}\n\nasync function ensureEndpointPermissions(profiles) {\n const origins = uniqueValues((profiles || [])\n .map(endpointPermissionPattern)\n .filter(Boolean));\n\n for (const origin of origins) {\n const hasPermission = await chrome.permissions.contains({ origins: [origin] });\n if (hasPermission) {\n continue;\n }\n const granted = await chrome.permissions.request({ origins: [origin] });\n if (!granted) {\n return false;\n }\n }\n\n return true;\n}\n\nfunction endpointPermissionPattern(profile) {\n const url = parseUrl(getProfileEndpoint(profile));\n if (!url) {\n return \"\";\n }\n if (url.protocol === \"https:\") {\n return `https://${url.hostname}/*`;\n }\n if (url.protocol === \"http:\" && isLoopbackHostname(url.hostname)) {\n return `http://${url.hostname}/*`;\n }\n return \"\";\n}\n\nfunction updateProviderHints() {\n const locale = i18n();\n const provider = fields.provider.value;\n const isOllama = provider === \"ollama\";\n const isProxy = provider === \"proxy\";\n const isGemini = provider === \"gemini\";\n setFieldLabel(fields.endpoint, getEndpointLabel(provider, locale));\n fields.endpoint.placeholder = getEndpointPlaceholder(provider);\n setFieldLabel(fields.accessToken, isProxy ? locale.t(\"options.api.accessToken\") : locale.t(\"options.api.apiKey\"));\n fields.accessToken.placeholder = getSecretPlaceholder(provider, locale);\n fields.accessToken.disabled = isOllama;\n fields.endpoint.disabled = isGemini;\n fields.model.placeholder = isProxy ? locale.t(\"options.api.modelPlaceholder\") : locale.t(\"options.api.directModelPlaceholder\");\n}\n\nfunction getEndpointLabel(provider, locale) {\n if (provider === \"ollama\") {\n return locale.t(\"options.api.ollamaEndpoint\");\n }\n if (provider === \"proxy\") {\n return locale.t(\"options.api.endpoint\");\n }\n return locale.t(\"options.api.directEndpoint\");\n}\n\nfunction getEndpointPlaceholder(provider) {\n const placeholders = {\n proxy: \"https://your-domain.example.com/v1/chat/completions\",\n \"openai-compatible\": \"https://api.deepseek.com/v1/chat/completions\",\n anthropic: \"https://api.anthropic.com/v1/messages\",\n gemini: \"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent\",\n ollama: \"http://localhost:11434/v1/chat/completions\"\n };\n return placeholders[provider] || placeholders[\"openai-compatible\"];\n}\n\nfunction getSecretPlaceholder(provider, locale) {\n if (provider === \"ollama\") {\n return \"\";\n }\n if (provider === \"proxy\") {\n return locale.language === \"en\" ? \"Optional access token\" : \"可选访问口令\";\n }\n return \"sk-...\";\n}\n\nfunction getProfileEndpoint(profile) {\n if (!profile) {\n return \"\";\n }\n if (profile.provider === \"gemini\" && !profile.endpoint) {\n const model = profile.model || \"gemini-pro\";\n return `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;\n }\n return profile.endpoint || \"\";\n}\n\nfunction parseUrl(value) {\n try {\n return value ? new URL(value) : null;\n } catch (_error) {\n return null;\n }\n}\n\nfunction normalizeHostname(hostname) {\n return String(hostname || \"\")\n .replace(/^\\[/, \"\")\n .replace(/\\]$/, \"\")\n .toLowerCase();\n}\n\nfunction isLoopbackHostname(hostname) {\n const host = normalizeHostname(hostname);\n return host === \"localhost\"\n || host.endsWith(\".localhost\")\n || host === \"::1\"\n || host === \"0:0:0:0:0:0:0:1\"\n || host.startsWith(\"127.\");\n}\n\nfunction isLoopbackEndpoint(endpoint) {\n const url = parseUrl(endpoint);\n return Boolean(url && url.protocol === \"http:\" && isLoopbackHostname(url.hostname));\n}\n\nfunction uniqueValues(values) {\n return [...new Set(values)];\n}\n\nfunction normalizeSettings(input) {\n const normalized = deepMerge(DEFAULT_SETTINGS, input || {});\n const importedVersion = Number(input?.settingsVersion || 0);\n normalized.settingsVersion = DEFAULT_SETTINGS.settingsVersion;\n normalized.theme = [\"auto\", \"light\", \"dark\"].includes(normalized.theme) ? normalized.theme : DEFAULT_SETTINGS.theme;\n normalized.uiLanguage = I18N.normalizeUiLanguage(normalized.uiLanguage);\n normalized.language = String(normalized.language || \"\").trim() || DEFAULT_SETTINGS.language;\n normalized.panelEnabled = normalized.panelEnabled !== false;\n normalized.includeTimestamps = normalized.includeTimestamps !== false;\n normalized.includeTitleAndUrl = normalized.includeTitleAndUrl !== false;\n normalized.saveHistory = normalized.saveHistory === true;\n normalized.promptTemplate = String(normalized.promptTemplate || DEFAULT_SETTINGS.promptTemplate);\n normalized.outputTemplate = String(normalized.outputTemplate || DEFAULT_SETTINGS.outputTemplate);\n if (String(input?.promptTemplate || \"\") === OLD_DEFAULT_PROMPT_TEMPLATE) {\n normalized.promptTemplate = DEFAULT_SETTINGS.promptTemplate;\n }\n if (String(input?.outputTemplate || \"\") === OLD_DEFAULT_OUTPUT_TEMPLATE) {\n normalized.outputTemplate = DEFAULT_SETTINGS.outputTemplate;\n }\n normalized.redactTerms = String(normalized.redactTerms || \"\");\n normalized.chunkSize = clampNumber(normalized.chunkSize, 12000, 2000, 50000);\n normalized.chunkOverlap = clampNumber(normalized.chunkOverlap, 600, 0, 5000);\n normalized.requestTimeoutSeconds = clampNumber(\n normalized.requestTimeoutSeconds,\n DEFAULT_SETTINGS.requestTimeoutSeconds,\n 30,\n 600\n );\n\n if (!Array.isArray(normalized.profiles) || !normalized.profiles.length) {\n normalized.profiles = structuredClone(DEFAULT_SETTINGS.profiles);\n }\n normalized.profiles = normalized.profiles.map((profile, index) => normalizeProfile(profile, index, importedVersion));\n if (!normalized.profiles.some((profile) => profile.id === normalized.activeProfileId)) {\n normalized.activeProfileId = normalized.profiles[0].id;\n }\n return normalized;\n}\n\nfunction normalizeProfile(profile, index, importedVersion) {\n const source = profile || {};\n const rawProvider = String(source.provider || \"openai-compatible\");\n const isLocalOpenAI = rawProvider === \"ollama\"\n || (rawProvider === \"openai-compatible\" && isLoopbackEndpoint(source.endpoint));\n const supportedProviders = [\"proxy\", \"openai-compatible\", \"anthropic\", \"gemini\", \"ollama\"];\n const provider = isLocalOpenAI\n ? \"ollama\"\n : (supportedProviders.includes(rawProvider) ? rawProvider : \"openai-compatible\");\n const fallback = profilePreset(provider === \"openai-compatible\" ? \"deepseek\" : provider);\n const temperature = importedVersion < 3 && Number(source.temperature) === 0.2\n ? 1\n : source.temperature;\n return {\n ...fallback,\n id: String(source.id || fallback.id || `profile-${index + 1}`),\n name: String(source.name || fallback.name || `Profile ${index + 1}`),\n provider,\n endpoint: String(source.endpoint || fallback.endpoint || \"\"),\n apiKey: String(source.apiKey || (![\"proxy\", \"ollama\"].includes(provider) ? source.accessToken || \"\" : \"\")),\n accessToken: String(provider === \"proxy\" ? source.accessToken || \"\" : \"\"),\n model: String(source.model || fallback.model || \"\"),\n temperature: clampNumber(temperature, fallback.temperature ?? 1, 0, 2),\n maxTokens: clampNumber(source.maxTokens, fallback.maxTokens, 256, 32000)\n };\n}\n\nfunction normalizeHistory(value) {\n return (Array.isArray(value) ? value : [])\n .filter((item) => item && typeof item === \"object\")\n .slice(0, HISTORY_LIMIT);\n}\n\nfunction applyTranslations() {\n const locale = i18n();\n document.documentElement.lang = locale.language === \"en\" ? \"en\" : \"zh-CN\";\n document.title = locale.t(\"options.documentTitle\");\n\n setText(\".topbar h1\", locale.t(\"common.appName\"));\n setText(\".topbar p\", locale.t(\"options.brandTagline\"));\n setText(\"#exportSettings\", locale.t(\"options.exportSettings\"));\n setText(\"#saveAll\", locale.t(\"options.saveAll\"));\n\n setText(\".sidebar a[href='#appearance']\", locale.t(\"options.nav.appearance\"));\n setText(\".sidebar a[href='#api']\", locale.t(\"options.nav.api\"));\n setText(\".sidebar a[href='#prompt']\", locale.t(\"options.nav.prompt\"));\n setText(\".sidebar a[href='#platforms']\", locale.t(\"options.nav.platforms\"));\n setText(\".sidebar a[href='#history']\", locale.t(\"options.nav.history\"));\n setText(\".sidebar a[href='#advanced']\", locale.t(\"options.nav.advanced\"));\n\n setText(\"#appearance .section__head h2\", locale.t(\"options.nav.appearance\"));\n setText(\"#appearance .section__head p\", locale.t(\"options.appearance.description\"));\n setText(\"#appearance > .field > label\", locale.t(\"options.appearance.theme\"));\n document.querySelector(\"#appearance .segmented\")?.setAttribute(\"aria-label\", locale.t(\"options.appearance.theme\"));\n setRadioLabel(\"auto\", locale.t(\"options.appearance.theme.auto\"));\n setRadioLabel(\"light\", locale.t(\"options.appearance.theme.light\"));\n setRadioLabel(\"dark\", locale.t(\"options.appearance.theme.dark\"));\n setFieldLabel(fields.uiLanguage, locale.t(\"options.appearance.uiLanguage\"));\n setOptionText(fields.uiLanguage, \"zh-CN\", locale.t(\"ui.zh-CN\"));\n setOptionText(fields.uiLanguage, \"en\", locale.t(\"ui.en\"));\n setFieldLabel(fields.language, locale.t(\"options.appearance.summaryLanguage\"));\n fields.language.placeholder = locale.t(\"options.appearance.summaryLanguagePlaceholder\");\n setFieldLabel(fields.panelEnabled, locale.t(\"options.appearance.panelEnabled\"));\n\n setText(\"#api .section__head h2\", locale.t(\"options.nav.api\"));\n setText(\"#api .section__head p\", locale.t(\"options.api.description\"));\n setText(\"#addProfile\", locale.t(\"options.api.add\"));\n setText(\"#deleteProfile\", locale.t(\"options.api.delete\"));\n setFieldLabel(fields.profileName, locale.t(\"options.api.profileName\"));\n fields.profileName.placeholder = locale.t(\"options.api.profileNamePlaceholder\");\n setFieldLabel(fields.provider, locale.t(\"options.api.provider\"));\n setOptionText(fields.provider, \"proxy\", locale.t(\"options.api.provider.proxy\"));\n setOptionText(fields.provider, \"openai-compatible\", locale.t(\"options.api.provider.openai\"));\n setOptionText(fields.provider, \"anthropic\", locale.t(\"options.api.provider.anthropic\"));\n setOptionText(fields.provider, \"gemini\", locale.t(\"options.api.provider.gemini\"));\n setOptionText(fields.provider, \"ollama\", locale.t(\"options.api.provider.ollama\"));\n setFieldLabel(fields.endpoint, locale.t(\"options.api.endpoint\"));\n setFieldLabel(fields.accessToken, locale.t(\"options.api.accessToken\"));\n setFieldLabel(fields.model, locale.t(\"options.api.model\"));\n fields.model.placeholder = locale.t(\"options.api.modelPlaceholder\");\n setFieldLabel(fields.temperature, locale.t(\"options.api.temperature\"));\n setFieldLabel(fields.maxTokens, locale.t(\"options.api.maxTokens\"));\n setText(\"#saveProfile\", locale.t(\"options.api.saveProfile\"));\n setText(\"#testProfile\", locale.t(\"options.api.testProfile\"));\n updateProviderHints();\n\n setText(\"#prompt .section__head h2\", locale.t(\"options.nav.prompt\"));\n setText(\"#prompt .section__head p\", locale.t(\"options.prompt.description\"));\n setFieldLabel(fields.promptTemplate, locale.t(\"options.prompt.promptTemplate\"));\n setFieldLabel(fields.outputTemplate, locale.t(\"options.prompt.outputTemplate\"));\n\n setText(\"#platforms .section__head h2\", locale.t(\"options.nav.platforms\"));\n setText(\"#platforms .section__head p\", locale.t(\"options.platforms.description\"));\n setText(\"#platforms .support-list > div:nth-child(1) span\", locale.t(\"options.platforms.youtube\"));\n setText(\"#platforms .support-list > div:nth-child(2) span\", locale.t(\"options.platforms.bilibili\"));\n setText(\"#platforms .support-list > div:nth-child(3) span\", locale.t(\"options.platforms.html5\"));\n setText(\"#platforms .support-list > div:nth-child(4) span\", locale.t(\"options.platforms.transcript\"));\n setFieldLabel(fields.includeTimestamps, locale.t(\"options.platforms.includeTimestamps\"));\n setFieldLabel(fields.redactTerms, locale.t(\"options.platforms.redactTerms\"));\n fields.redactTerms.placeholder = locale.t(\"options.platforms.redactTermsPlaceholder\");\n\n setText(\"#history .section__head h2\", locale.t(\"options.nav.history\"));\n setText(\"#history .section__head p\", locale.t(\"options.history.description\"));\n setText(\"#exportHistory\", locale.t(\"options.history.export\"));\n setText(\"#clearHistory\", locale.t(\"options.history.clear\"));\n setFieldLabel(fields.saveHistory, locale.t(\"options.history.saveHistory\"));\n\n setText(\"#advanced .section__head h2\", locale.t(\"options.nav.advanced\"));\n setText(\"#advanced .section__head p\", locale.t(\"options.advanced.description\"));\n setFieldLabel(fields.chunkSize, locale.t(\"options.advanced.chunkSize\"));\n setFieldLabel(fields.chunkOverlap, locale.t(\"options.advanced.chunkOverlap\"));\n setFieldLabel(fields.requestTimeoutSeconds, locale.t(\"options.advanced.requestTimeoutSeconds\"));\n setFieldLabel(fields.importText, locale.t(\"options.advanced.importJson\"));\n fields.importText.placeholder = locale.t(\"options.advanced.importJsonPlaceholder\");\n setFieldLabel(fields.importFile, locale.t(\"options.advanced.importFile\"));\n setText(\"#importSettings\", locale.t(\"options.advanced.importSettings\"));\n setText(\"#exportCompleteSettings\", locale.t(\"options.advanced.exportCompleteSettings\"));\n setText(\"#resetSettings\", locale.t(\"options.advanced.resetSettings\"));\n}\n\nfunction applyTheme() {\n const mode = settings.theme || \"auto\";\n const resolved = mode === \"auto\"\n ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\")\n : mode;\n document.documentElement.dataset.themeMode = mode;\n document.documentElement.dataset.theme = resolved;\n}\n\nfunction downloadJson(json, filename) {\n const blob = new Blob([json], { type: \"application/json;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\nasync function writeClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (_error) {\n return false;\n }\n}\n\nfunction formatDateForFilename(date) {\n return date.toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n}\n\nfunction formatDate(value) {\n if (!value) {\n return \"\";\n }\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return \"\";\n }\n return date.toLocaleString(i18n().locale, {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\"\n });\n}\n\nfunction clampNumber(value, fallback, min, max) {\n const number = Number(value);\n if (!Number.isFinite(number)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, number));\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setText(selector, value) {\n const element = document.querySelector(selector);\n if (element) {\n element.textContent = value;\n }\n}\n\nfunction setFieldLabel(field, value) {\n const label = field?.closest(\".field\")?.querySelector(\"span\");\n if (label) {\n label.textContent = value;\n }\n}\n\nfunction setRadioLabel(value, text) {\n const input = document.querySelector(`input[name='theme'][value='${value}']`);\n const label = input?.closest(\"label\");\n if (!label) {\n return;\n }\n let textNode = [...label.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());\n if (!textNode) {\n textNode = document.createTextNode(\"\");\n label.appendChild(textNode);\n }\n textNode.textContent = ` ${text}`;\n}\n\nfunction setOptionText(select, value, text) {\n const option = select?.querySelector(`option[value='${value}']`);\n if (option) {\n option.textContent = text;\n }\n}\n\nfunction setStatus(element, message, tone) {\n const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n element.dataset.statusToken = token;\n element.dataset.tone = tone || \"\";\n swapText(element, message);\n if (message) {\n window.setTimeout(() => {\n if (element.dataset.statusToken === token) {\n element.dataset.statusToken = \"\";\n element.dataset.tone = \"\";\n swapText(element, \"\");\n }\n }, 5000);\n }\n}\n\nfunction swapText(element, nextText) {\n if (!element) {\n return;\n }\n\n element.classList.remove(\"is-exit\", \"is-enter-start\");\n if (element.textContent === nextText) {\n return;\n }\n\n element.textContent = nextText;\n if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n return;\n }\n\n element.classList.add(\"is-enter-start\");\n void element.offsetHeight;\n element.classList.remove(\"is-enter-start\");\n}\n\nfunction setDigits(group, value) {\n const text = String(value);\n if (!group || (group.textContent === text && group.children.length)) {\n return;\n }\n\n group.setAttribute(\"aria-label\", text);\n group.classList.remove(\"is-animating\");\n group.replaceChildren();\n\n [...text].forEach((character, index, characters) => {\n const digit = document.createElement(\"span\");\n digit.className = \"t-digit\";\n digit.textContent = character;\n if (index === characters.length - 2) {\n digit.dataset.stagger = \"1\";\n } else if (index === characters.length - 1) {\n digit.dataset.stagger = \"2\";\n }\n group.appendChild(digit);\n });\n\n void group.offsetHeight;\n group.classList.add(\"is-animating\");\n}\n\nfunction initSectionNav() {\n const links = [...document.querySelectorAll(\".sidebar a[href^='#']\")];\n const sections = links\n .map((link) => document.querySelector(link.getAttribute(\"href\")))\n .filter(Boolean);\n\n if (!links.length || !sections.length) {\n return;\n }\n\n const setCurrent = (id) => {\n links.forEach((link) => {\n const isCurrent = link.getAttribute(\"href\") === `#${id}`;\n if (isCurrent) {\n link.setAttribute(\"aria-current\", \"true\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n });\n };\n\n setCurrent(location.hash.slice(1) || sections[0].id);\n\n if (!(\"IntersectionObserver\" in window)) {\n window.addEventListener(\"scroll\", () => {\n let current = sections[0];\n for (const section of sections) {\n if (section.getBoundingClientRect().top <= 140) {\n current = section;\n }\n }\n setCurrent(current.id);\n }, { passive: true });\n return;\n }\n\n const observer = new IntersectionObserver((entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];\n if (visible?.target?.id) {\n setCurrent(visible.target.id);\n }\n }, {\n rootMargin: \"-18% 0px -62% 0px\",\n threshold: [0, 0.1, 0.35, 0.7]\n });\n\n sections.forEach((section) => observer.observe(section));\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "9106c2f4aeb7336ea129edd5b8e4bcfaf94e475f": {"t": 1785758698.2583022, "n": 39800, "h": "73dfd797f73f99d5", "c": "const SETTINGS_KEY = \"vcsSettings\";\nconst HISTORY_KEY = \"vcsHistory\";\nconst HISTORY_LIMIT = 30;\nconst I18N = globalThis.VCS_I18N;\n\nconst OLD_DEFAULT_PROMPT_TEMPLATE = [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst OLD_DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n].join(\"\\n\");\n\nconst DEFAULT_PROMPT_TEMPLATE = [\n \"你是一位视频内容提炼专家。请基于字幕文本,生成一份精炼、高信息密度的结构化总结。\",\n \"\",\n \"## 视频信息\",\n \"📌 标题:{{title}}|🌐 平台:{{platform}}|🔗 链接:{{url}}\",\n \"\",\n \"## 核心原则\",\n \"- 总结 ≠ 复述。你的任务是**压缩与重构**,不是逐段改写\",\n \"- 保留硬信息(数据、人名、术语、方法论),砍掉软信息(过渡语、重复论证、情绪渲染)\",\n \"- 每句话必须承载独立信息量,删掉后会造成信息缺失才有资格留下\",\n \"- 仅基于字幕实际内容,不推测、不补充、不评价\",\n \"\",\n \"## 输出语言:{{language}}\",\n \"\",\n \"## 输出格式\",\n \"{{outputTemplate}}\",\n \"\",\n \"## 字幕正文\",\n \"{{transcript}}\"\n].join(\"\\n\");\n\nconst DEFAULT_OUTPUT_TEMPLATE = [\n \"请使用 Markdown 输出:\",\n \"\",\n \"## 💡 一句话结论\",\n \"≤ 30字。视频最核心的一个判断或发现。\",\n \"\",\n \"## 📋 核心摘要\",\n \"3~4句话覆盖完整逻辑链:什么问题 → 什么观点/方案 → 什么结论。不用条目,写成连贯段落。\",\n \"\",\n \"## 🎯 关键观点\",\n \"3~7条,每条格式:\",\n \"- ⏱️ [时间戳] **关键词**:一句话说明(无时间戳则省略⏱️)\",\n \"\",\n \"只保留\\\"删掉就损失信息\\\"的观点,不凑数。\",\n \"\",\n \"## 🗂️ 章节时间线\",\n \"按叙事顺序分3~6段,每段格式:\",\n \"- ⏱️ [起止时间] **章节名**:一句话概括(无时间戳则用 1️⃣2️⃣3️⃣ 编号)\"\n].join(\"\\n\");\n\nconst DEFAULT_SETTINGS = {\n settingsVersion: 7,\n theme: \"auto\",\n uiLanguage: \"zh-CN\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"custom\",\n profiles: [\n {\n ...profilePreset(\"deepseek\"),\n id: \"custom\",\n name: \"我的 API 配置\"\n }\n ],\n promptTemplate: DEFAULT_PROMPT_TEMPLATE,\n outputTemplate: DEFAULT_OUTPUT_TEMPLATE,\n chunkSize: 12000,\n chunkOverlap: 600,\n requestTimeoutSeconds: 60,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: false\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n uiLanguage: document.querySelector(\"#uiLanguage\"),\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n accessToken: document.querySelector(\"#accessToken\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n requestTimeoutSeconds: document.querySelector(\"#requestTimeoutSeconds\"),\n importText: document.querySelector(\"#importText\"),\n importFile: document.querySelector(\"#importFile\"),\n historyList: document.querySelector(\"#historyList\"),\n historyCount: document.querySelector(\"#historyCount\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nfunction i18n() {\n return I18N.create(settings);\n}\n\nfunction t(key, variables) {\n return i18n().t(key, variables);\n}\n\nasync function init() {\n const result = await chrome.storage.local.get(SETTINGS_KEY);\n settings = normalizeSettings(result[SETTINGS_KEY]);\n if (JSON.stringify(result[SETTINGS_KEY] || {}) !== JSON.stringify(settings)) {\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n }\n hydrateForm();\n bindEvents();\n await renderHistory();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", () => saveAll());\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", () => saveCurrentProfile());\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n document.querySelector(\"#clearHistory\").addEventListener(\"click\", clearHistory);\n document.querySelector(\"#exportHistory\").addEventListener(\"click\", exportHistory);\n fields.importFile.addEventListener(\"change\", importSettingsFile);\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", async () => {\n settings.theme = input.value;\n applyTheme();\n await saveAll(t(\"options.status.themeSaved\"));\n });\n });\n\n fields.uiLanguage.addEventListener(\"change\", async () => {\n settings.uiLanguage = fields.uiLanguage.value;\n applyTranslations();\n await renderHistory();\n await saveAll(t(\"options.status.uiLanguageSaved\"));\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n fields.provider.addEventListener(\"change\", handleProviderChange);\n fields.historyList.addEventListener(\"click\", copyHistoryFromList);\n initSectionNav();\n\n chrome.storage.onChanged.addListener((changes, area) => {\n if (area !== \"local\") {\n return;\n }\n if (changes[HISTORY_KEY]) {\n renderHistory();\n }\n if (changes[SETTINGS_KEY] && changes[SETTINGS_KEY].newValue) {\n settings = normalizeSettings(changes[SETTINGS_KEY].newValue);\n hydrateForm();\n renderHistory();\n }\n });\n\n window.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (settings.theme === \"auto\") {\n applyTheme();\n }\n });\n}\n\nfunction hydrateForm() {\n const themeInput = document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`)\n || document.querySelector(\"input[name='theme'][value='auto']\");\n if (themeInput) {\n themeInput.checked = true;\n }\n fields.uiLanguage.value = settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage;\n fields.language.value = settings.language || DEFAULT_SETTINGS.language;\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = settings.saveHistory !== false;\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n fields.requestTimeoutSeconds.value = settings.requestTimeoutSeconds || DEFAULT_SETTINGS.requestTimeoutSeconds;\n applyTheme();\n applyTranslations();\n renderProfileFields();\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"proxy\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.accessToken.value = getProfileSecret(profile);\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 1;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n updateProviderHints();\n}\n\nasync function saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n await saveAll(t(\"options.status.profileSaved\"));\n}\n\nasync function saveAll(message = t(\"options.status.settingsSaved\")) {\n if (typeof message !== \"string\") {\n message = t(\"options.status.settingsSaved\");\n }\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.uiLanguage = I18N.normalizeUiLanguage(fields.uiLanguage.value);\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n settings.requestTimeoutSeconds = Number(fields.requestTimeoutSeconds.value || DEFAULT_SETTINGS.requestTimeoutSeconds);\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions(settings.profiles)) {\n setStatus(fields.saveStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n setProfileSecret(profile, fields.accessToken.value.trim());\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: settings.uiLanguage === \"en\" ? \"New Profile\" : \"新配置\",\n provider: \"openai-compatible\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, t(\"options.status.keepOneProfile\"), \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n profile.provider = preset.provider;\n profile.endpoint = preset.endpoint;\n profile.apiKey = \"\";\n profile.accessToken = \"\";\n fields.provider.value = profile.provider;\n fields.endpoint.value = profile.endpoint;\n fields.accessToken.value = \"\";\n updateProviderHints();\n setStatus(fields.apiStatus, t(\"options.status.presetApplied\"), \"ok\");\n}\n\nfunction handleProviderChange() {\n const preset = profilePreset(providerDefaultPresetName(fields.provider.value));\n fields.endpoint.value = preset.endpoint || \"\";\n fields.accessToken.value = \"\";\n updateProviderHints();\n}\n\nfunction providerDefaultPresetName(provider) {\n const names = {\n proxy: \"proxy\",\n \"openai-compatible\": \"deepseek\",\n anthropic: \"claude\",\n gemini: \"gemini\",\n ollama: \"ollama\"\n };\n return names[provider] || \"deepseek\";\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.ai/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n proxy: {\n id: \"proxy\",\n name: \"专用服务\",\n provider: \"proxy\",\n endpoint: \"\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n accessToken: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n if (!await ensureEndpointPermissions([getActiveProfile()])) {\n setStatus(fields.apiStatus, t(\"options.status.endpointPermissionDenied\"), \"error\");\n return;\n }\n setStatus(fields.apiStatus, t(\"options.status.testing\"), \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile(),\n stream: true\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, t(\"options.status.connectionSuccess\", { text: response.payload.text }), \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || t(\"options.status.connectionFailed\"), \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(t(\"options.status.savedForExport\"));\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 1,\n exportedAt: new Date().toISOString(),\n settings: sanitizeSettingsForExport(settings)\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-${formatDateForFilename(new Date())}.json`);\n const copied = await writeClipboard(json);\n setStatus(fields.saveStatus, copied ? t(\"options.status.settingsExportedCopied\") : t(\"options.status.settingsExported\"), \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = parseImportedSettings(fields.importText.value);\n settings = normalizeSettings(imported);\n hydrateForm();\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, t(\"options.status.settingsImported\"), \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, t(\"options.status.importFailed\", { message: error.message }), \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n setStatus(fields.saveStatus, t(\"options.status.resetRequiresSave\"), \"ok\");\n}\n\nasync function importSettingsFile() {\n const file = fields.importFile.files?.[0];\n if (!file) {\n return;\n }\n\n try {\n fields.importText.value = await file.text();\n await importSettings();\n } finally {\n fields.importFile.value = \"\";\n }\n}\n\nasync function renderHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n setDigits(fields.historyCount, `${history.length} / ${HISTORY_LIMIT}`);\n\n if (!history.length) {\n fields.historyList.innerHTML = `<div class=\"history-empty\">${escapeHtml(t(\"options.history.empty\"))}</div>`;\n return;\n }\n\n fields.historyList.innerHTML = history.map((item, index) => {\n const summary = item.summary || item.text || \"\";\n const title = item.title || t(\"options.history.untitled\");\n const createdAt = formatDate(item.createdAt);\n const meta = [item.platform, item.model, createdAt].filter(Boolean).join(\" · \");\n return `\n <article class=\"history-item\">\n <div class=\"history-item__head\">\n <div>\n <div class=\"history-item__title\" title=\"${escapeHtml(title)}\">${escapeHtml(title)}</div>\n <div class=\"history-item__meta\">${escapeHtml(meta || t(\"options.history.local\"))}</div>\n </div>\n <button class=\"button button--ghost history-copy\" type=\"button\" data-history-index=\"${index}\">${escapeHtml(t(\"options.history.copy\"))}</button>\n </div>\n <div class=\"history-item__summary\">${escapeHtml(summary || t(\"options.history.noSummary\"))}</div>\n </article>\n `;\n }).join(\"\");\n\n if (result[HISTORY_KEY]?.length > HISTORY_LIMIT) {\n await chrome.storage.local.set({ [HISTORY_KEY]: history });\n }\n}\n\nasync function copyHistoryFromList(event) {\n const button = event.target.closest(\"[data-history-index]\");\n if (!button) {\n return;\n }\n\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const item = history[Number(button.dataset.historyIndex)];\n const text = item?.summary || item?.text || \"\";\n if (!text.trim()) {\n setStatus(fields.saveStatus, t(\"options.status.noCopyableHistory\"), \"error\");\n return;\n }\n\n const copied = await writeClipboard(text);\n setStatus(fields.saveStatus, copied ? t(\"options.status.historyCopied\") : t(\"options.status.clipboardDenied\"), copied ? \"ok\" : \"error\");\n}\n\nasync function clearHistory() {\n await chrome.storage.local.set({ [HISTORY_KEY]: [] });\n await renderHistory();\n setStatus(fields.saveStatus, t(\"options.status.historyCleared\"), \"ok\");\n}\n\nasync function exportHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const payload = {\n type: \"video-caption-ai-history\",\n version: 1,\n exportedAt: new Date().toISOString(),\n limit: HISTORY_LIMIT,\n history\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-history-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, history.length ? t(\"options.status.historyExported\") : t(\"options.status.emptyHistoryExported\"), \"ok\");\n}\n\nfunction parseImportedSettings(text) {\n if (!text.trim()) {\n throw new Error(t(\"options.error.emptyImport\"));\n }\n\n const parsed = JSON.parse(text);\n const imported = parsed?.settings || parsed;\n if (!imported || typeof imported !== \"object\" || Array.isArray(imported)) {\n throw new Error(t(\"options.error.invalidSettings\"));\n }\n return imported;\n}\n\nfunction sanitizeSettingsForExport(value) {\n const output = structuredClone(value || {});\n output.profiles = (Array.isArray(output.profiles) ? output.profiles : []).map((profile) => {\n const cleanProfile = { ...profile };\n delete cleanProfile.apiKey;\n cleanProfile.accessToken = \"\";\n return cleanProfile;\n });\n return output;\n}\n\nfunction getProfileSecret(profile) {\n if (!profile || profile.provider === \"ollama\") {\n return \"\";\n }\n return profile.provider === \"proxy\"\n ? profile.accessToken || \"\"\n : profile.apiKey || \"\";\n}\n\nfunction setProfileSecret(profile, value) {\n if (!profile) {\n return;\n }\n if (profile.provider === \"proxy\") {\n profile.accessToken = value;\n profile.apiKey = \"\";\n return;\n }\n if (profile.provider === \"ollama\") {\n profile.accessToken = \"\";\n profile.apiKey = \"\";\n return;\n }\n profile.apiKey = value;\n profile.accessToken = \"\";\n}\n\nasync function ensureEndpointPermissions(profiles) {\n const origins = uniqueValues((profiles || [])\n .map(endpointPermissionPattern)\n .filter(Boolean));\n\n for (const origin of origins) {\n const hasPermission = await chrome.permissions.contains({ origins: [origin] });\n if (hasPermission) {\n continue;\n }\n const granted = await chrome.permissions.request({ origins: [origin] });\n if (!granted) {\n return false;\n }\n }\n\n return true;\n}\n\nfunction endpointPermissionPattern(profile) {\n const url = parseUrl(getProfileEndpoint(profile));\n if (!url) {\n return \"\";\n }\n if (url.protocol === \"https:\") {\n return `https://${url.hostname}/*`;\n }\n if (url.protocol === \"http:\" && isLoopbackHostname(url.hostname)) {\n return `http://${url.hostname}/*`;\n }\n return \"\";\n}\n\nfunction updateProviderHints() {\n const locale = i18n();\n const provider = fields.provider.value;\n const isOllama = provider === \"ollama\";\n const isProxy = provider === \"proxy\";\n const isGemini = provider === \"gemini\";\n setFieldLabel(fields.endpoint, getEndpointLabel(provider, locale));\n fields.endpoint.placeholder = getEndpointPlaceholder(provider);\n setFieldLabel(fields.accessToken, isProxy ? locale.t(\"options.api.accessToken\") : locale.t(\"options.api.apiKey\"));\n fields.accessToken.placeholder = getSecretPlaceholder(provider, locale);\n fields.accessToken.disabled = isOllama;\n fields.endpoint.disabled = isGemini;\n fields.model.placeholder = isProxy ? locale.t(\"options.api.modelPlaceholder\") : locale.t(\"options.api.directModelPlaceholder\");\n}\n\nfunction getEndpointLabel(provider, locale) {\n if (provider === \"ollama\") {\n return locale.t(\"options.api.ollamaEndpoint\");\n }\n if (provider === \"proxy\") {\n return locale.t(\"options.api.endpoint\");\n }\n return locale.t(\"options.api.directEndpoint\");\n}\n\nfunction getEndpointPlaceholder(provider) {\n const placeholders = {\n proxy: \"https://your-domain.example.com/v1/chat/completions\",\n \"openai-compatible\": \"https://api.deepseek.com/v1/chat/completions\",\n anthropic: \"https://api.anthropic.com/v1/messages\",\n gemini: \"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent\",\n ollama: \"http://localhost:11434/v1/chat/completions\"\n };\n return placeholders[provider] || placeholders[\"openai-compatible\"];\n}\n\nfunction getSecretPlaceholder(provider, locale) {\n if (provider === \"ollama\") {\n return \"\";\n }\n if (provider === \"proxy\") {\n return locale.language === \"en\" ? \"Optional access token\" : \"可选访问口令\";\n }\n return \"sk-...\";\n}\n\nfunction getProfileEndpoint(profile) {\n if (!profile) {\n return \"\";\n }\n if (profile.provider === \"gemini\" && !profile.endpoint) {\n const model = profile.model || \"gemini-pro\";\n return `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`;\n }\n return profile.endpoint || \"\";\n}\n\nfunction parseUrl(value) {\n try {\n return value ? new URL(value) : null;\n } catch (_error) {\n return null;\n }\n}\n\nfunction normalizeHostname(hostname) {\n return String(hostname || \"\")\n .replace(/^\\[/, \"\")\n .replace(/\\]$/, \"\")\n .toLowerCase();\n}\n\nfunction isLoopbackHostname(hostname) {\n const host = normalizeHostname(hostname);\n return host === \"localhost\"\n || host.endsWith(\".localhost\")\n || host === \"::1\"\n || host === \"0:0:0:0:0:0:0:1\"\n || host.startsWith(\"127.\");\n}\n\nfunction isLoopbackEndpoint(endpoint) {\n const url = parseUrl(endpoint);\n return Boolean(url && url.protocol === \"http:\" && isLoopbackHostname(url.hostname));\n}\n\nfunction uniqueValues(values) {\n return [...new Set(values)];\n}\n\nfunction normalizeSettings(input) {\n const normalized = deepMerge(DEFAULT_SETTINGS, input || {});\n const importedVersion = Number(input?.settingsVersion || 0);\n normalized.settingsVersion = DEFAULT_SETTINGS.settingsVersion;\n normalized.theme = [\"auto\", \"light\", \"dark\"].includes(normalized.theme) ? normalized.theme : DEFAULT_SETTINGS.theme;\n normalized.uiLanguage = I18N.normalizeUiLanguage(normalized.uiLanguage);\n normalized.language = String(normalized.language || \"\").trim() || DEFAULT_SETTINGS.language;\n normalized.panelEnabled = normalized.panelEnabled !== false;\n normalized.includeTimestamps = normalized.includeTimestamps !== false;\n normalized.includeTitleAndUrl = normalized.includeTitleAndUrl !== false;\n normalized.saveHistory = normalized.saveHistory === true;\n normalized.promptTemplate = String(normalized.promptTemplate || DEFAULT_SETTINGS.promptTemplate);\n normalized.outputTemplate = String(normalized.outputTemplate || DEFAULT_SETTINGS.outputTemplate);\n if (String(input?.promptTemplate || \"\") === OLD_DEFAULT_PROMPT_TEMPLATE) {\n normalized.promptTemplate = DEFAULT_SETTINGS.promptTemplate;\n }\n if (String(input?.outputTemplate || \"\") === OLD_DEFAULT_OUTPUT_TEMPLATE) {\n normalized.outputTemplate = DEFAULT_SETTINGS.outputTemplate;\n }\n normalized.redactTerms = String(normalized.redactTerms || \"\");\n normalized.chunkSize = clampNumber(normalized.chunkSize, 12000, 2000, 50000);\n normalized.chunkOverlap = clampNumber(normalized.chunkOverlap, 600, 0, 5000);\n normalized.requestTimeoutSeconds = clampNumber(\n normalized.requestTimeoutSeconds,\n DEFAULT_SETTINGS.requestTimeoutSeconds,\n 30,\n 600\n );\n\n if (!Array.isArray(normalized.profiles) || !normalized.profiles.length) {\n normalized.profiles = structuredClone(DEFAULT_SETTINGS.profiles);\n }\n normalized.profiles = normalized.profiles.map((profile, index) => normalizeProfile(profile, index, importedVersion));\n if (!normalized.profiles.some((profile) => profile.id === normalized.activeProfileId)) {\n normalized.activeProfileId = normalized.profiles[0].id;\n }\n return normalized;\n}\n\nfunction normalizeProfile(profile, index, importedVersion) {\n const source = profile || {};\n const rawProvider = String(source.provider || \"openai-compatible\");\n const isLocalOpenAI = rawProvider === \"ollama\"\n || (rawProvider === \"openai-compatible\" && isLoopbackEndpoint(source.endpoint));\n const supportedProviders = [\"proxy\", \"openai-compatible\", \"anthropic\", \"gemini\", \"ollama\"];\n const provider = isLocalOpenAI\n ? \"ollama\"\n : (supportedProviders.includes(rawProvider) ? rawProvider : \"openai-compatible\");\n const fallback = profilePreset(provider === \"openai-compatible\" ? \"deepseek\" : provider);\n const temperature = importedVersion < 3 && Number(source.temperature) === 0.2\n ? 1\n : source.temperature;\n return {\n ...fallback,\n id: String(source.id || fallback.id || `profile-${index + 1}`),\n name: String(source.name || fallback.name || `Profile ${index + 1}`),\n provider,\n endpoint: String(source.endpoint || fallback.endpoint || \"\"),\n apiKey: String(source.apiKey || (![\"proxy\", \"ollama\"].includes(provider) ? source.accessToken || \"\" : \"\")),\n accessToken: String(provider === \"proxy\" ? source.accessToken || \"\" : \"\"),\n model: String(source.model || fallback.model || \"\"),\n temperature: clampNumber(temperature, fallback.temperature ?? 1, 0, 2),\n maxTokens: clampNumber(source.maxTokens, fallback.maxTokens, 256, 32000)\n };\n}\n\nfunction normalizeHistory(value) {\n return (Array.isArray(value) ? value : [])\n .filter((item) => item && typeof item === \"object\")\n .slice(0, HISTORY_LIMIT);\n}\n\nfunction applyTranslations() {\n const locale = i18n();\n document.documentElement.lang = locale.language === \"en\" ? \"en\" : \"zh-CN\";\n document.title = locale.t(\"options.documentTitle\");\n\n setText(\".topbar h1\", locale.t(\"common.appName\"));\n setText(\".topbar p\", locale.t(\"options.brandTagline\"));\n setText(\"#exportSettings\", locale.t(\"options.exportSettings\"));\n setText(\"#saveAll\", locale.t(\"options.saveAll\"));\n\n setText(\".sidebar a[href='#appearance']\", locale.t(\"options.nav.appearance\"));\n setText(\".sidebar a[href='#api']\", locale.t(\"options.nav.api\"));\n setText(\".sidebar a[href='#prompt']\", locale.t(\"options.nav.prompt\"));\n setText(\".sidebar a[href='#platforms']\", locale.t(\"options.nav.platforms\"));\n setText(\".sidebar a[href='#history']\", locale.t(\"options.nav.history\"));\n setText(\".sidebar a[href='#advanced']\", locale.t(\"options.nav.advanced\"));\n\n setText(\"#appearance .section__head h2\", locale.t(\"options.nav.appearance\"));\n setText(\"#appearance .section__head p\", locale.t(\"options.appearance.description\"));\n setText(\"#appearance > .field > label\", locale.t(\"options.appearance.theme\"));\n document.querySelector(\"#appearance .segmented\")?.setAttribute(\"aria-label\", locale.t(\"options.appearance.theme\"));\n setRadioLabel(\"auto\", locale.t(\"options.appearance.theme.auto\"));\n setRadioLabel(\"light\", locale.t(\"options.appearance.theme.light\"));\n setRadioLabel(\"dark\", locale.t(\"options.appearance.theme.dark\"));\n setFieldLabel(fields.uiLanguage, locale.t(\"options.appearance.uiLanguage\"));\n setOptionText(fields.uiLanguage, \"zh-CN\", locale.t(\"ui.zh-CN\"));\n setOptionText(fields.uiLanguage, \"en\", locale.t(\"ui.en\"));\n setFieldLabel(fields.language, locale.t(\"options.appearance.summaryLanguage\"));\n fields.language.placeholder = locale.t(\"options.appearance.summaryLanguagePlaceholder\");\n setFieldLabel(fields.panelEnabled, locale.t(\"options.appearance.panelEnabled\"));\n\n setText(\"#api .section__head h2\", locale.t(\"options.nav.api\"));\n setText(\"#api .section__head p\", locale.t(\"options.api.description\"));\n setText(\"#addProfile\", locale.t(\"options.api.add\"));\n setText(\"#deleteProfile\", locale.t(\"options.api.delete\"));\n setFieldLabel(fields.profileName, locale.t(\"options.api.profileName\"));\n fields.profileName.placeholder = locale.t(\"options.api.profileNamePlaceholder\");\n setFieldLabel(fields.provider, locale.t(\"options.api.provider\"));\n setOptionText(fields.provider, \"proxy\", locale.t(\"options.api.provider.proxy\"));\n setOptionText(fields.provider, \"openai-compatible\", locale.t(\"options.api.provider.openai\"));\n setOptionText(fields.provider, \"anthropic\", locale.t(\"options.api.provider.anthropic\"));\n setOptionText(fields.provider, \"gemini\", locale.t(\"options.api.provider.gemini\"));\n setOptionText(fields.provider, \"ollama\", locale.t(\"options.api.provider.ollama\"));\n setFieldLabel(fields.endpoint, locale.t(\"options.api.endpoint\"));\n setFieldLabel(fields.accessToken, locale.t(\"options.api.accessToken\"));\n setFieldLabel(fields.model, locale.t(\"options.api.model\"));\n fields.model.placeholder = locale.t(\"options.api.modelPlaceholder\");\n setFieldLabel(fields.temperature, locale.t(\"options.api.temperature\"));\n setFieldLabel(fields.maxTokens, locale.t(\"options.api.maxTokens\"));\n setText(\"#saveProfile\", locale.t(\"options.api.saveProfile\"));\n setText(\"#testProfile\", locale.t(\"options.api.testProfile\"));\n updateProviderHints();\n\n setText(\"#prompt .section__head h2\", locale.t(\"options.nav.prompt\"));\n setText(\"#prompt .section__head p\", locale.t(\"options.prompt.description\"));\n setFieldLabel(fields.promptTemplate, locale.t(\"options.prompt.promptTemplate\"));\n setFieldLabel(fields.outputTemplate, locale.t(\"options.prompt.outputTemplate\"));\n\n setText(\"#platforms .section__head h2\", locale.t(\"options.nav.platforms\"));\n setText(\"#platforms .section__head p\", locale.t(\"options.platforms.description\"));\n setText(\"#platforms .support-list > div:nth-child(1) span\", locale.t(\"options.platforms.youtube\"));\n setText(\"#platforms .support-list > div:nth-child(2) span\", locale.t(\"options.platforms.bilibili\"));\n setText(\"#platforms .support-list > div:nth-child(3) span\", locale.t(\"options.platforms.html5\"));\n setText(\"#platforms .support-list > div:nth-child(4) span\", locale.t(\"options.platforms.transcript\"));\n setFieldLabel(fields.includeTimestamps, locale.t(\"options.platforms.includeTimestamps\"));\n setFieldLabel(fields.redactTerms, locale.t(\"options.platforms.redactTerms\"));\n fields.redactTerms.placeholder = locale.t(\"options.platforms.redactTermsPlaceholder\");\n\n setText(\"#history .section__head h2\", locale.t(\"options.nav.history\"));\n setText(\"#history .section__head p\", locale.t(\"options.history.description\"));\n setText(\"#exportHistory\", locale.t(\"options.history.export\"));\n setText(\"#clearHistory\", locale.t(\"options.history.clear\"));\n setFieldLabel(fields.saveHistory, locale.t(\"options.history.saveHistory\"));\n\n setText(\"#advanced .section__head h2\", locale.t(\"options.nav.advanced\"));\n setText(\"#advanced .section__head p\", locale.t(\"options.advanced.description\"));\n setFieldLabel(fields.chunkSize, locale.t(\"options.advanced.chunkSize\"));\n setFieldLabel(fields.chunkOverlap, locale.t(\"options.advanced.chunkOverlap\"));\n setFieldLabel(fields.requestTimeoutSeconds, locale.t(\"options.advanced.requestTimeoutSeconds\"));\n setFieldLabel(fields.importText, locale.t(\"options.advanced.importJson\"));\n fields.importText.placeholder = locale.t(\"options.advanced.importJsonPlaceholder\");\n setFieldLabel(fields.importFile, locale.t(\"options.advanced.importFile\"));\n setText(\"#importSettings\", locale.t(\"options.advanced.importSettings\"));\n setText(\"#resetSettings\", locale.t(\"options.advanced.resetSettings\"));\n}\n\nfunction applyTheme() {\n const mode = settings.theme || \"auto\";\n const resolved = mode === \"auto\"\n ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\")\n : mode;\n document.documentElement.dataset.themeMode = mode;\n document.documentElement.dataset.theme = resolved;\n}\n\nfunction downloadJson(json, filename) {\n const blob = new Blob([json], { type: \"application/json;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\nasync function writeClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (_error) {\n return false;\n }\n}\n\nfunction formatDateForFilename(date) {\n return date.toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n}\n\nfunction formatDate(value) {\n if (!value) {\n return \"\";\n }\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return \"\";\n }\n return date.toLocaleString(i18n().locale, {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\"\n });\n}\n\nfunction clampNumber(value, fallback, min, max) {\n const number = Number(value);\n if (!Number.isFinite(number)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, number));\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setText(selector, value) {\n const element = document.querySelector(selector);\n if (element) {\n element.textContent = value;\n }\n}\n\nfunction setFieldLabel(field, value) {\n const label = field?.closest(\".field\")?.querySelector(\"span\");\n if (label) {\n label.textContent = value;\n }\n}\n\nfunction setRadioLabel(value, text) {\n const input = document.querySelector(`input[name='theme'][value='${value}']`);\n const label = input?.closest(\"label\");\n if (!label) {\n return;\n }\n let textNode = [...label.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());\n if (!textNode) {\n textNode = document.createTextNode(\"\");\n label.appendChild(textNode);\n }\n textNode.textContent = ` ${text}`;\n}\n\nfunction setOptionText(select, value, text) {\n const option = select?.querySelector(`option[value='${value}']`);\n if (option) {\n option.textContent = text;\n }\n}\n\nfunction setStatus(element, message, tone) {\n const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n element.dataset.statusToken = token;\n element.dataset.tone = tone || \"\";\n swapText(element, message);\n if (message) {\n window.setTimeout(() => {\n if (element.dataset.statusToken === token) {\n element.dataset.statusToken = \"\";\n element.dataset.tone = \"\";\n swapText(element, \"\");\n }\n }, 5000);\n }\n}\n\nfunction swapText(element, nextText) {\n if (!element) {\n return;\n }\n\n element.classList.remove(\"is-exit\", \"is-enter-start\");\n if (element.textContent === nextText) {\n return;\n }\n\n element.textContent = nextText;\n if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n return;\n }\n\n element.classList.add(\"is-enter-start\");\n void element.offsetHeight;\n element.classList.remove(\"is-enter-start\");\n}\n\nfunction setDigits(group, value) {\n const text = String(value);\n if (!group || (group.textContent === text && group.children.length)) {\n return;\n }\n\n group.setAttribute(\"aria-label\", text);\n group.classList.remove(\"is-animating\");\n group.replaceChildren();\n\n [...text].forEach((character, index, characters) => {\n const digit = document.createElement(\"span\");\n digit.className = \"t-digit\";\n digit.textContent = character;\n if (index === characters.length - 2) {\n digit.dataset.stagger = \"1\";\n } else if (index === characters.length - 1) {\n digit.dataset.stagger = \"2\";\n }\n group.appendChild(digit);\n });\n\n void group.offsetHeight;\n group.classList.add(\"is-animating\");\n}\n\nfunction initSectionNav() {\n const links = [...document.querySelectorAll(\".sidebar a[href^='#']\")];\n const sections = links\n .map((link) => document.querySelector(link.getAttribute(\"href\")))\n .filter(Boolean);\n\n if (!links.length || !sections.length) {\n return;\n }\n\n const setCurrent = (id) => {\n links.forEach((link) => {\n const isCurrent = link.getAttribute(\"href\") === `#${id}`;\n if (isCurrent) {\n link.setAttribute(\"aria-current\", \"true\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n });\n };\n\n setCurrent(location.hash.slice(1) || sections[0].id);\n\n if (!(\"IntersectionObserver\" in window)) {\n window.addEventListener(\"scroll\", () => {\n let current = sections[0];\n for (const section of sections) {\n if (section.getBoundingClientRect().top <= 140) {\n current = section;\n }\n }\n setCurrent(current.id);\n }, { passive: true });\n return;\n }\n\n const observer = new IntersectionObserver((entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];\n if (visible?.target?.id) {\n setCurrent(visible.target.id);\n }\n }, {\n rootMargin: \"-18% 0px -62% 0px\",\n threshold: [0, 0.1, 0.35, 0.7]\n });\n\n sections.forEach((section) => observer.observe(section));\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "687afcf010fa515d50dbd5775fc5cb7faf10d695": {"t": 1785758699.9355445, "n": 33510, "h": "1b4dee584cb0f6e3", "c": "const SETTINGS_KEY = \"vcsSettings\";\nconst HISTORY_KEY = \"vcsHistory\";\nconst HISTORY_LIMIT = 30;\nconst I18N = globalThis.VCS_I18N;\n\nconst DEFAULT_SETTINGS = {\n settingsVersion: 5,\n theme: \"auto\",\n uiLanguage: \"zh-CN\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"custom\",\n profiles: [\n {\n ...profilePreset(\"deepseek\"),\n id: \"custom\",\n name: \"我的 API 配置\"\n }\n ],\n promptTemplate: [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n ].join(\"\\n\"),\n outputTemplate: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n ].join(\"\\n\"),\n chunkSize: 12000,\n chunkOverlap: 600,\n requestTimeoutSeconds: 60,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: true\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n uiLanguage: document.querySelector(\"#uiLanguage\"),\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n apiKey: document.querySelector(\"#apiKey\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n requestTimeoutSeconds: document.querySelector(\"#requestTimeoutSeconds\"),\n importText: document.querySelector(\"#importText\"),\n importFile: document.querySelector(\"#importFile\"),\n historyList: document.querySelector(\"#historyList\"),\n historyCount: document.querySelector(\"#historyCount\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nfunction i18n() {\n return I18N.create(settings);\n}\n\nfunction t(key, variables) {\n return i18n().t(key, variables);\n}\n\nasync function init() {\n const result = await chrome.storage.local.get(SETTINGS_KEY);\n settings = normalizeSettings(result[SETTINGS_KEY]);\n if (JSON.stringify(result[SETTINGS_KEY] || {}) !== JSON.stringify(settings)) {\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n }\n hydrateForm();\n bindEvents();\n await renderHistory();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", () => saveAll());\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", saveCurrentProfile);\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n document.querySelector(\"#clearHistory\").addEventListener(\"click\", clearHistory);\n document.querySelector(\"#exportHistory\").addEventListener(\"click\", exportHistory);\n fields.importFile.addEventListener(\"change\", importSettingsFile);\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", async () => {\n settings.theme = input.value;\n applyTheme();\n await saveAll(t(\"options.status.themeSaved\"));\n });\n });\n\n fields.uiLanguage.addEventListener(\"change\", async () => {\n settings.uiLanguage = fields.uiLanguage.value;\n applyTranslations();\n await renderHistory();\n await saveAll(t(\"options.status.uiLanguageSaved\"));\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n document.querySelectorAll(\".template\").forEach((button) => {\n button.addEventListener(\"click\", () => applyTemplate(button.dataset.template));\n });\n\n fields.historyList.addEventListener(\"click\", copyHistoryFromList);\n initSectionNav();\n\n chrome.storage.onChanged.addListener((changes, area) => {\n if (area !== \"local\") {\n return;\n }\n if (changes[HISTORY_KEY]) {\n renderHistory();\n }\n if (changes[SETTINGS_KEY] && changes[SETTINGS_KEY].newValue) {\n settings = normalizeSettings(changes[SETTINGS_KEY].newValue);\n hydrateForm();\n renderHistory();\n }\n });\n\n window.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (settings.theme === \"auto\") {\n applyTheme();\n }\n });\n}\n\nfunction hydrateForm() {\n const themeInput = document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`)\n || document.querySelector(\"input[name='theme'][value='auto']\");\n if (themeInput) {\n themeInput.checked = true;\n }\n fields.uiLanguage.value = settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage;\n fields.language.value = settings.language || DEFAULT_SETTINGS.language;\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = settings.saveHistory !== false;\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n fields.requestTimeoutSeconds.value = settings.requestTimeoutSeconds || DEFAULT_SETTINGS.requestTimeoutSeconds;\n applyTheme();\n applyTranslations();\n renderProfileFields();\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"openai-compatible\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.apiKey.value = profile.apiKey || \"\";\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 1;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n}\n\nfunction saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n saveAll(t(\"options.status.profileSaved\"));\n}\n\nasync function saveAll(message = t(\"options.status.settingsSaved\")) {\n if (typeof message !== \"string\") {\n message = t(\"options.status.settingsSaved\");\n }\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.uiLanguage = I18N.normalizeUiLanguage(fields.uiLanguage.value);\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n settings.requestTimeoutSeconds = Number(fields.requestTimeoutSeconds.value || DEFAULT_SETTINGS.requestTimeoutSeconds);\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: settings.uiLanguage === \"en\" ? \"New Profile\" : \"新配置\",\n provider: \"openai-compatible\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, t(\"options.status.keepOneProfile\"), \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n profile.provider = preset.provider;\n profile.endpoint = preset.endpoint;\n fields.provider.value = profile.provider;\n fields.endpoint.value = profile.endpoint;\n setStatus(fields.apiStatus, t(\"options.status.presetApplied\"), \"ok\");\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.ai/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n mimo: {\n id: \"mimo\",\n name: \"小米 MiMo (本地)\",\n provider: \"openai-compatible\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nfunction applyTemplate(name) {\n const templates = {\n study: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 概念解释\",\n \"## 章节时间线\",\n \"## 关键术语\",\n \"## 课后复盘问题\"\n ].join(\"\\n\"),\n meeting: [\n \"请使用 Markdown 输出:\",\n \"## 会议结论\",\n \"## 已确认决策\",\n \"## 行动项\",\n \"- 负责人 / 截止时间 / 背景\",\n \"## 风险与阻塞\",\n \"## 需要追问\"\n ].join(\"\\n\"),\n research: [\n \"请使用 Markdown 输出:\",\n \"## 研究摘要\",\n \"## 论点与证据\",\n \"## 重要数据 / 名词\",\n \"## 反方观点或不确定性\",\n \"## 可引用时间点\"\n ].join(\"\\n\")\n };\n\n fields.outputTemplate.value = templates[name] || templates.study;\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n setStatus(fields.apiStatus, t(\"options.status.testing\"), \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile(),\n stream: true\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, t(\"options.status.connectionSuccess\", { text: response.payload.text }), \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || t(\"options.status.connectionFailed\"), \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(t(\"options.status.savedForExport\"));\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 1,\n exportedAt: new Date().toISOString(),\n settings\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-${formatDateForFilename(new Date())}.json`);\n const copied = await writeClipboard(json);\n setStatus(fields.saveStatus, copied ? t(\"options.status.settingsExportedCopied\") : t(\"options.status.settingsExported\"), \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = parseImportedSettings(fields.importText.value);\n settings = normalizeSettings(imported);\n hydrateForm();\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, t(\"options.status.settingsImported\"), \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, t(\"options.status.importFailed\", { message: error.message }), \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n setStatus(fields.saveStatus, t(\"options.status.resetRequiresSave\"), \"ok\");\n}\n\nasync function importSettingsFile() {\n const file = fields.importFile.files?.[0];\n if (!file) {\n return;\n }\n\n try {\n fields.importText.value = await file.text();\n await importSettings();\n } finally {\n fields.importFile.value = \"\";\n }\n}\n\nasync function renderHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n setDigits(fields.historyCount, `${history.length} / ${HISTORY_LIMIT}`);\n\n if (!history.length) {\n fields.historyList.innerHTML = `<div class=\"history-empty\">${escapeHtml(t(\"options.history.empty\"))}</div>`;\n return;\n }\n\n fields.historyList.innerHTML = history.map((item, index) => {\n const summary = item.summary || item.text || \"\";\n const title = item.title || t(\"options.history.untitled\");\n const createdAt = formatDate(item.createdAt);\n const meta = [item.platform, item.model, createdAt].filter(Boolean).join(\" · \");\n return `\n <article class=\"history-item\">\n <div class=\"history-item__head\">\n <div>\n <div class=\"history-item__title\" title=\"${escapeHtml(title)}\">${escapeHtml(title)}</div>\n <div class=\"history-item__meta\">${escapeHtml(meta || t(\"options.history.local\"))}</div>\n </div>\n <button class=\"button button--ghost history-copy\" type=\"button\" data-history-index=\"${index}\">${escapeHtml(t(\"options.history.copy\"))}</button>\n </div>\n <div class=\"history-item__summary\">${escapeHtml(summary || t(\"options.history.noSummary\"))}</div>\n </article>\n `;\n }).join(\"\");\n\n if (result[HISTORY_KEY]?.length > HISTORY_LIMIT) {\n await chrome.storage.local.set({ [HISTORY_KEY]: history });\n }\n}\n\nasync function copyHistoryFromList(event) {\n const button = event.target.closest(\"[data-history-index]\");\n if (!button) {\n return;\n }\n\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const item = history[Number(button.dataset.historyIndex)];\n const text = item?.summary || item?.text || \"\";\n if (!text.trim()) {\n setStatus(fields.saveStatus, t(\"options.status.noCopyableHistory\"), \"error\");\n return;\n }\n\n const copied = await writeClipboard(text);\n setStatus(fields.saveStatus, copied ? t(\"options.status.historyCopied\") : t(\"options.status.clipboardDenied\"), copied ? \"ok\" : \"error\");\n}\n\nasync function clearHistory() {\n await chrome.storage.local.set({ [HISTORY_KEY]: [] });\n await renderHistory();\n setStatus(fields.saveStatus, t(\"options.status.historyCleared\"), \"ok\");\n}\n\nasync function exportHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const payload = {\n type: \"video-caption-ai-history\",\n version: 1,\n exportedAt: new Date().toISOString(),\n limit: HISTORY_LIMIT,\n history\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-history-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, history.length ? t(\"options.status.historyExported\") : t(\"options.status.emptyHistoryExported\"), \"ok\");\n}\n\nfunction parseImportedSettings(text) {\n if (!text.trim()) {\n throw new Error(t(\"options.error.emptyImport\"));\n }\n\n const parsed = JSON.parse(text);\n const imported = parsed?.settings || parsed;\n if (!imported || typeof imported !== \"object\" || Array.isArray(imported)) {\n throw new Error(t(\"options.error.invalidSettings\"));\n }\n return imported;\n}\n\nfunction normalizeSettings(input) {\n const normalized = deepMerge(DEFAULT_SETTINGS, input || {});\n const importedVersion = Number(input?.settingsVersion || 0);\n normalized.settingsVersion = DEFAULT_SETTINGS.settingsVersion;\n normalized.theme = [\"auto\", \"light\", \"dark\"].includes(normalized.theme) ? normalized.theme : DEFAULT_SETTINGS.theme;\n normalized.uiLanguage = I18N.normalizeUiLanguage(normalized.uiLanguage);\n normalized.language = String(normalized.language || \"\").trim() || DEFAULT_SETTINGS.language;\n normalized.panelEnabled = normalized.panelEnabled !== false;\n normalized.includeTimestamps = normalized.includeTimestamps !== false;\n normalized.includeTitleAndUrl = normalized.includeTitleAndUrl !== false;\n normalized.saveHistory = importedVersion < 2 ? true : normalized.saveHistory !== false;\n normalized.promptTemplate = String(normalized.promptTemplate || DEFAULT_SETTINGS.promptTemplate);\n normalized.outputTemplate = String(normalized.outputTemplate || DEFAULT_SETTINGS.outputTemplate);\n normalized.redactTerms = String(normalized.redactTerms || \"\");\n normalized.chunkSize = clampNumber(normalized.chunkSize, 12000, 2000, 50000);\n normalized.chunkOverlap = clampNumber(normalized.chunkOverlap, 600, 0, 5000);\n normalized.requestTimeoutSeconds = clampNumber(\n normalized.requestTimeoutSeconds,\n DEFAULT_SETTINGS.requestTimeoutSeconds,\n 30,\n 600\n );\n\n if (!Array.isArray(normalized.profiles) || !normalized.profiles.length) {\n normalized.profiles = structuredClone(DEFAULT_SETTINGS.profiles);\n }\n normalized.profiles = normalized.profiles.map((profile, index) => normalizeProfile(profile, index, importedVersion));\n if (!normalized.profiles.some((profile) => profile.id === normalized.activeProfileId)) {\n normalized.activeProfileId = normalized.profiles[0].id;\n }\n return normalized;\n}\n\nfunction normalizeProfile(profile, index, importedVersion) {\n const fallback = profilePreset(index === 1 ? \"openai\" : index === 2 ? \"claude\" : \"deepseek\");\n const temperature = importedVersion < 3 && Number(profile?.temperature) === 0.2\n ? 1\n : profile?.temperature;\n return {\n ...fallback,\n ...(profile || {}),\n id: String(profile?.id || fallback.id || `profile-${index + 1}`),\n name: String(profile?.name || fallback.name || `API ${index + 1}`),\n provider: String(profile?.provider || fallback.provider || \"openai-compatible\"),\n endpoint: String(profile?.endpoint || fallback.endpoint || \"\"),\n apiKey: String(profile?.apiKey || \"\"),\n model: String(profile?.model || fallback.model || \"\"),\n temperature: clampNumber(temperature, fallback.temperature ?? 1, 0, 2),\n maxTokens: clampNumber(profile?.maxTokens, fallback.maxTokens, 256, 32000)\n };\n}\n\nfunction normalizeHistory(value) {\n return (Array.isArray(value) ? value : [])\n .filter((item) => item && typeof item === \"object\")\n .slice(0, HISTORY_LIMIT);\n}\n\nfunction applyTranslations() {\n const locale = i18n();\n document.documentElement.lang = locale.language === \"en\" ? \"en\" : \"zh-CN\";\n document.title = locale.t(\"options.documentTitle\");\n\n setText(\".topbar h1\", locale.t(\"common.appName\"));\n setText(\".topbar p\", locale.t(\"options.brandTagline\"));\n setText(\"#exportSettings\", locale.t(\"options.exportSettings\"));\n setText(\"#saveAll\", locale.t(\"options.saveAll\"));\n\n setText(\".sidebar a[href='#appearance']\", locale.t(\"options.nav.appearance\"));\n setText(\".sidebar a[href='#api']\", locale.t(\"options.nav.api\"));\n setText(\".sidebar a[href='#prompt']\", locale.t(\"options.nav.prompt\"));\n setText(\".sidebar a[href='#platforms']\", locale.t(\"options.nav.platforms\"));\n setText(\".sidebar a[href='#history']\", locale.t(\"options.nav.history\"));\n setText(\".sidebar a[href='#advanced']\", locale.t(\"options.nav.advanced\"));\n\n setText(\"#appearance .section__head h2\", locale.t(\"options.nav.appearance\"));\n setText(\"#appearance .section__head p\", locale.t(\"options.appearance.description\"));\n setText(\"#appearance > .field > label\", locale.t(\"options.appearance.theme\"));\n document.querySelector(\"#appearance .segmented\")?.setAttribute(\"aria-label\", locale.t(\"options.appearance.theme\"));\n setRadioLabel(\"auto\", locale.t(\"options.appearance.theme.auto\"));\n setRadioLabel(\"light\", locale.t(\"options.appearance.theme.light\"));\n setRadioLabel(\"dark\", locale.t(\"options.appearance.theme.dark\"));\n setFieldLabel(fields.uiLanguage, locale.t(\"options.appearance.uiLanguage\"));\n setOptionText(fields.uiLanguage, \"zh-CN\", locale.t(\"ui.zh-CN\"));\n setOptionText(fields.uiLanguage, \"en\", locale.t(\"ui.en\"));\n setFieldLabel(fields.language, locale.t(\"options.appearance.summaryLanguage\"));\n fields.language.placeholder = locale.t(\"options.appearance.summaryLanguagePlaceholder\");\n setFieldLabel(fields.panelEnabled, locale.t(\"options.appearance.panelEnabled\"));\n\n setText(\"#api .section__head h2\", locale.t(\"options.nav.api\"));\n setText(\"#api .section__head p\", locale.t(\"options.api.description\"));\n setText(\"#addProfile\", locale.t(\"options.api.add\"));\n setText(\"#deleteProfile\", locale.t(\"options.api.delete\"));\n setFieldLabel(fields.profileName, locale.t(\"options.api.profileName\"));\n fields.profileName.placeholder = locale.t(\"options.api.profileNamePlaceholder\");\n setFieldLabel(fields.provider, locale.t(\"options.api.provider\"));\n setOptionText(fields.provider, \"openai-compatible\", locale.t(\"options.api.provider.openai\"));\n setOptionText(fields.provider, \"anthropic\", locale.t(\"options.api.provider.anthropic\"));\n setOptionText(fields.provider, \"gemini\", locale.t(\"options.api.provider.gemini\"));\n setOptionText(fields.provider, \"ollama\", locale.t(\"options.api.provider.ollama\"));\n setFieldLabel(fields.endpoint, locale.t(\"options.api.endpoint\"));\n setFieldLabel(fields.apiKey, locale.t(\"options.api.apiKey\"));\n setFieldLabel(fields.model, locale.t(\"options.api.model\"));\n fields.model.placeholder = locale.t(\"options.api.modelPlaceholder\");\n setFieldLabel(fields.temperature, locale.t(\"options.api.temperature\"));\n setFieldLabel(fields.maxTokens, locale.t(\"options.api.maxTokens\"));\n setText(\"#saveProfile\", locale.t(\"options.api.saveProfile\"));\n setText(\"#testProfile\", locale.t(\"options.api.testProfile\"));\n\n setText(\"#prompt .section__head h2\", locale.t(\"options.nav.prompt\"));\n setText(\"#prompt .section__head p\", locale.t(\"options.prompt.description\"));\n setFieldLabel(fields.promptTemplate, locale.t(\"options.prompt.promptTemplate\"));\n setFieldLabel(fields.outputTemplate, locale.t(\"options.prompt.outputTemplate\"));\n setTemplateText(\"study\", locale.t(\"options.prompt.study.title\"), locale.t(\"options.prompt.study.body\"));\n setTemplateText(\"meeting\", locale.t(\"options.prompt.meeting.title\"), locale.t(\"options.prompt.meeting.body\"));\n setTemplateText(\"research\", locale.t(\"options.prompt.research.title\"), locale.t(\"options.prompt.research.body\"));\n\n setText(\"#platforms .section__head h2\", locale.t(\"options.nav.platforms\"));\n setText(\"#platforms .section__head p\", locale.t(\"options.platforms.description\"));\n setText(\"#platforms .support-list > div:nth-child(1) span\", locale.t(\"options.platforms.youtube\"));\n setText(\"#platforms .support-list > div:nth-child(2) span\", locale.t(\"options.platforms.bilibili\"));\n setText(\"#platforms .support-list > div:nth-child(3) span\", locale.t(\"options.platforms.html5\"));\n setText(\"#platforms .support-list > div:nth-child(4) span\", locale.t(\"options.platforms.transcript\"));\n setFieldLabel(fields.includeTimestamps, locale.t(\"options.platforms.includeTimestamps\"));\n setFieldLabel(fields.redactTerms, locale.t(\"options.platforms.redactTerms\"));\n fields.redactTerms.placeholder = locale.t(\"options.platforms.redactTermsPlaceholder\");\n\n setText(\"#history .section__head h2\", locale.t(\"options.nav.history\"));\n setText(\"#history .section__head p\", locale.t(\"options.history.description\"));\n setText(\"#exportHistory\", locale.t(\"options.history.export\"));\n setText(\"#clearHistory\", locale.t(\"options.history.clear\"));\n setFieldLabel(fields.saveHistory, locale.t(\"options.history.saveHistory\"));\n\n setText(\"#advanced .section__head h2\", locale.t(\"options.nav.advanced\"));\n setText(\"#advanced .section__head p\", locale.t(\"options.advanced.description\"));\n setFieldLabel(fields.chunkSize, locale.t(\"options.advanced.chunkSize\"));\n setFieldLabel(fields.chunkOverlap, locale.t(\"options.advanced.chunkOverlap\"));\n setFieldLabel(fields.requestTimeoutSeconds, locale.t(\"options.advanced.requestTimeoutSeconds\"));\n setFieldLabel(fields.importText, locale.t(\"options.advanced.importJson\"));\n fields.importText.placeholder = locale.t(\"options.advanced.importJsonPlaceholder\");\n setFieldLabel(fields.importFile, locale.t(\"options.advanced.importFile\"));\n setText(\"#importSettings\", locale.t(\"options.advanced.importSettings\"));\n setText(\"#resetSettings\", locale.t(\"options.advanced.resetSettings\"));\n}\n\nfunction applyTheme() {\n const mode = settings.theme || \"auto\";\n const resolved = mode === \"auto\"\n ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\")\n : mode;\n document.documentElement.dataset.themeMode = mode;\n document.documentElement.dataset.theme = resolved;\n}\n\nfunction downloadJson(json, filename) {\n const blob = new Blob([json], { type: \"application/json;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\nasync function writeClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (_error) {\n return false;\n }\n}\n\nfunction formatDateForFilename(date) {\n return date.toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n}\n\nfunction formatDate(value) {\n if (!value) {\n return \"\";\n }\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return \"\";\n }\n return date.toLocaleString(i18n().locale, {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\"\n });\n}\n\nfunction clampNumber(value, fallback, min, max) {\n const number = Number(value);\n if (!Number.isFinite(number)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, number));\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setText(selector, value) {\n const element = document.querySelector(selector);\n if (element) {\n element.textContent = value;\n }\n}\n\nfunction setFieldLabel(field, value) {\n const label = field?.closest(\".field\")?.querySelector(\"span\");\n if (label) {\n label.textContent = value;\n }\n}\n\nfunction setRadioLabel(value, text) {\n const input = document.querySelector(`input[name='theme'][value='${value}']`);\n const label = input?.closest(\"label\");\n if (!label) {\n return;\n }\n let textNode = [...label.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());\n if (!textNode) {\n textNode = document.createTextNode(\"\");\n label.appendChild(textNode);\n }\n textNode.textContent = ` ${text}`;\n}\n\nfunction setOptionText(select, value, text) {\n const option = select?.querySelector(`option[value='${value}']`);\n if (option) {\n option.textContent = text;\n }\n}\n\nfunction setTemplateText(name, title, body) {\n const template = document.querySelector(`.template[data-template='${name}']`);\n if (!template) {\n return;\n }\n template.querySelector(\"strong\").textContent = title;\n template.querySelector(\"span\").textContent = body;\n}\n\nfunction setStatus(element, message, tone) {\n const token = `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n element.dataset.statusToken = token;\n element.dataset.tone = tone || \"\";\n swapText(element, message);\n if (message) {\n window.setTimeout(() => {\n if (element.dataset.statusToken === token) {\n element.dataset.statusToken = \"\";\n element.dataset.tone = \"\";\n swapText(element, \"\");\n }\n }, 5000);\n }\n}\n\nfunction swapText(element, nextText) {\n if (!element) {\n return;\n }\n\n element.classList.remove(\"is-exit\", \"is-enter-start\");\n if (element.textContent === nextText) {\n return;\n }\n\n element.textContent = nextText;\n if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n return;\n }\n\n element.classList.add(\"is-enter-start\");\n void element.offsetHeight;\n element.classList.remove(\"is-enter-start\");\n}\n\nfunction setDigits(group, value) {\n const text = String(value);\n if (!group || (group.textContent === text && group.children.length)) {\n return;\n }\n\n group.setAttribute(\"aria-label\", text);\n group.classList.remove(\"is-animating\");\n group.replaceChildren();\n\n [...text].forEach((character, index, characters) => {\n const digit = document.createElement(\"span\");\n digit.className = \"t-digit\";\n digit.textContent = character;\n if (index === characters.length - 2) {\n digit.dataset.stagger = \"1\";\n } else if (index === characters.length - 1) {\n digit.dataset.stagger = \"2\";\n }\n group.appendChild(digit);\n });\n\n void group.offsetHeight;\n group.classList.add(\"is-animating\");\n}\n\nfunction initSectionNav() {\n const links = [...document.querySelectorAll(\".sidebar a[href^='#']\")];\n const sections = links\n .map((link) => document.querySelector(link.getAttribute(\"href\")))\n .filter(Boolean);\n\n if (!links.length || !sections.length) {\n return;\n }\n\n const setCurrent = (id) => {\n links.forEach((link) => {\n const isCurrent = link.getAttribute(\"href\") === `#${id}`;\n if (isCurrent) {\n link.setAttribute(\"aria-current\", \"true\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n });\n };\n\n setCurrent(location.hash.slice(1) || sections[0].id);\n\n if (!(\"IntersectionObserver\" in window)) {\n window.addEventListener(\"scroll\", () => {\n let current = sections[0];\n for (const section of sections) {\n if (section.getBoundingClientRect().top <= 140) {\n current = section;\n }\n }\n setCurrent(current.id);\n }, { passive: true });\n return;\n }\n\n const observer = new IntersectionObserver((entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];\n if (visible?.target?.id) {\n setCurrent(visible.target.id);\n }\n }, {\n rootMargin: \"-18% 0px -62% 0px\",\n threshold: [0, 0.1, 0.35, 0.7]\n });\n\n sections.forEach((section) => observer.observe(section));\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "02db7645d302fe51d6c06e9fdb59ae1179a47595": {"t": 1785758701.6358933, "n": 30202, "h": "ab2cd0bd2298965c", "c": "const SETTINGS_KEY = \"vcsSettings\";\nconst HISTORY_KEY = \"vcsHistory\";\nconst HISTORY_LIMIT = 30;\nconst I18N = globalThis.VCS_I18N;\n\nconst DEFAULT_SETTINGS = {\n settingsVersion: 4,\n theme: \"auto\",\n uiLanguage: \"zh-CN\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"custom\",\n profiles: [\n {\n ...profilePreset(\"deepseek\"),\n id: \"custom\",\n name: \"我的 API 配置\"\n }\n ],\n promptTemplate: [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n ].join(\"\\n\"),\n outputTemplate: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n ].join(\"\\n\"),\n chunkSize: 12000,\n chunkOverlap: 600,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: true\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n uiLanguage: document.querySelector(\"#uiLanguage\"),\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n apiKey: document.querySelector(\"#apiKey\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n importText: document.querySelector(\"#importText\"),\n importFile: document.querySelector(\"#importFile\"),\n historyList: document.querySelector(\"#historyList\"),\n historyCount: document.querySelector(\"#historyCount\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nfunction i18n() {\n return I18N.create(settings);\n}\n\nfunction t(key, variables) {\n return i18n().t(key, variables);\n}\n\nasync function init() {\n const result = await chrome.storage.local.get(SETTINGS_KEY);\n settings = normalizeSettings(result[SETTINGS_KEY]);\n if (JSON.stringify(result[SETTINGS_KEY] || {}) !== JSON.stringify(settings)) {\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n }\n hydrateForm();\n bindEvents();\n await renderHistory();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", () => saveAll());\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", saveCurrentProfile);\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n document.querySelector(\"#clearHistory\").addEventListener(\"click\", clearHistory);\n document.querySelector(\"#exportHistory\").addEventListener(\"click\", exportHistory);\n fields.importFile.addEventListener(\"change\", importSettingsFile);\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", async () => {\n settings.theme = input.value;\n applyTheme();\n await saveAll(t(\"options.status.themeSaved\"));\n });\n });\n\n fields.uiLanguage.addEventListener(\"change\", async () => {\n settings.uiLanguage = fields.uiLanguage.value;\n applyTranslations();\n await renderHistory();\n await saveAll(t(\"options.status.uiLanguageSaved\"));\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n document.querySelectorAll(\".template\").forEach((button) => {\n button.addEventListener(\"click\", () => applyTemplate(button.dataset.template));\n });\n\n fields.historyList.addEventListener(\"click\", copyHistoryFromList);\n\n chrome.storage.onChanged.addListener((changes, area) => {\n if (area !== \"local\") {\n return;\n }\n if (changes[HISTORY_KEY]) {\n renderHistory();\n }\n if (changes[SETTINGS_KEY] && changes[SETTINGS_KEY].newValue) {\n settings = normalizeSettings(changes[SETTINGS_KEY].newValue);\n hydrateForm();\n renderHistory();\n }\n });\n\n window.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (settings.theme === \"auto\") {\n applyTheme();\n }\n });\n}\n\nfunction hydrateForm() {\n const themeInput = document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`)\n || document.querySelector(\"input[name='theme'][value='auto']\");\n if (themeInput) {\n themeInput.checked = true;\n }\n fields.uiLanguage.value = settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage;\n fields.language.value = settings.language || DEFAULT_SETTINGS.language;\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = settings.saveHistory !== false;\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n applyTheme();\n applyTranslations();\n renderProfileFields();\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"openai-compatible\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.apiKey.value = profile.apiKey || \"\";\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 1;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n}\n\nfunction saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n saveAll(t(\"options.status.profileSaved\"));\n}\n\nasync function saveAll(message = t(\"options.status.settingsSaved\")) {\n if (typeof message !== \"string\") {\n message = t(\"options.status.settingsSaved\");\n }\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.uiLanguage = I18N.normalizeUiLanguage(fields.uiLanguage.value);\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: settings.uiLanguage === \"en\" ? \"New Profile\" : \"新配置\",\n provider: \"openai-compatible\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, t(\"options.status.keepOneProfile\"), \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n profile.provider = preset.provider;\n profile.endpoint = preset.endpoint;\n fields.provider.value = profile.provider;\n fields.endpoint.value = profile.endpoint;\n setStatus(fields.apiStatus, t(\"options.status.presetApplied\"), \"ok\");\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.ai/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n mimo: {\n id: \"mimo\",\n name: \"小米 MiMo (本地)\",\n provider: \"openai-compatible\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nfunction applyTemplate(name) {\n const templates = {\n study: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 概念解释\",\n \"## 章节时间线\",\n \"## 关键术语\",\n \"## 课后复盘问题\"\n ].join(\"\\n\"),\n meeting: [\n \"请使用 Markdown 输出:\",\n \"## 会议结论\",\n \"## 已确认决策\",\n \"## 行动项\",\n \"- 负责人 / 截止时间 / 背景\",\n \"## 风险与阻塞\",\n \"## 需要追问\"\n ].join(\"\\n\"),\n research: [\n \"请使用 Markdown 输出:\",\n \"## 研究摘要\",\n \"## 论点与证据\",\n \"## 重要数据 / 名词\",\n \"## 反方观点或不确定性\",\n \"## 可引用时间点\"\n ].join(\"\\n\")\n };\n\n fields.outputTemplate.value = templates[name] || templates.study;\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n setStatus(fields.apiStatus, t(\"options.status.testing\"), \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile()\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, t(\"options.status.connectionSuccess\", { text: response.payload.text }), \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || t(\"options.status.connectionFailed\"), \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(t(\"options.status.savedForExport\"));\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 1,\n exportedAt: new Date().toISOString(),\n settings\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-${formatDateForFilename(new Date())}.json`);\n const copied = await writeClipboard(json);\n setStatus(fields.saveStatus, copied ? t(\"options.status.settingsExportedCopied\") : t(\"options.status.settingsExported\"), \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = parseImportedSettings(fields.importText.value);\n settings = normalizeSettings(imported);\n hydrateForm();\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, t(\"options.status.settingsImported\"), \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, t(\"options.status.importFailed\", { message: error.message }), \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n setStatus(fields.saveStatus, t(\"options.status.resetRequiresSave\"), \"ok\");\n}\n\nasync function importSettingsFile() {\n const file = fields.importFile.files?.[0];\n if (!file) {\n return;\n }\n\n try {\n fields.importText.value = await file.text();\n await importSettings();\n } finally {\n fields.importFile.value = \"\";\n }\n}\n\nasync function renderHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n fields.historyCount.textContent = `${history.length} / ${HISTORY_LIMIT}`;\n\n if (!history.length) {\n fields.historyList.innerHTML = `<div class=\"history-empty\">${escapeHtml(t(\"options.history.empty\"))}</div>`;\n return;\n }\n\n fields.historyList.innerHTML = history.map((item, index) => {\n const summary = item.summary || item.text || \"\";\n const title = item.title || t(\"options.history.untitled\");\n const createdAt = formatDate(item.createdAt);\n const meta = [item.platform, item.model, createdAt].filter(Boolean).join(\" · \");\n return `\n <article class=\"history-item\">\n <div class=\"history-item__head\">\n <div>\n <div class=\"history-item__title\" title=\"${escapeHtml(title)}\">${escapeHtml(title)}</div>\n <div class=\"history-item__meta\">${escapeHtml(meta || t(\"options.history.local\"))}</div>\n </div>\n <button class=\"button button--ghost history-copy\" type=\"button\" data-history-index=\"${index}\">${escapeHtml(t(\"options.history.copy\"))}</button>\n </div>\n <div class=\"history-item__summary\">${escapeHtml(summary || t(\"options.history.noSummary\"))}</div>\n </article>\n `;\n }).join(\"\");\n\n if (result[HISTORY_KEY]?.length > HISTORY_LIMIT) {\n await chrome.storage.local.set({ [HISTORY_KEY]: history });\n }\n}\n\nasync function copyHistoryFromList(event) {\n const button = event.target.closest(\"[data-history-index]\");\n if (!button) {\n return;\n }\n\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const item = history[Number(button.dataset.historyIndex)];\n const text = item?.summary || item?.text || \"\";\n if (!text.trim()) {\n setStatus(fields.saveStatus, t(\"options.status.noCopyableHistory\"), \"error\");\n return;\n }\n\n const copied = await writeClipboard(text);\n setStatus(fields.saveStatus, copied ? t(\"options.status.historyCopied\") : t(\"options.status.clipboardDenied\"), copied ? \"ok\" : \"error\");\n}\n\nasync function clearHistory() {\n await chrome.storage.local.set({ [HISTORY_KEY]: [] });\n await renderHistory();\n setStatus(fields.saveStatus, t(\"options.status.historyCleared\"), \"ok\");\n}\n\nasync function exportHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const payload = {\n type: \"video-caption-ai-history\",\n version: 1,\n exportedAt: new Date().toISOString(),\n limit: HISTORY_LIMIT,\n history\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-history-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, history.length ? t(\"options.status.historyExported\") : t(\"options.status.emptyHistoryExported\"), \"ok\");\n}\n\nfunction parseImportedSettings(text) {\n if (!text.trim()) {\n throw new Error(t(\"options.error.emptyImport\"));\n }\n\n const parsed = JSON.parse(text);\n const imported = parsed?.settings || parsed;\n if (!imported || typeof imported !== \"object\" || Array.isArray(imported)) {\n throw new Error(t(\"options.error.invalidSettings\"));\n }\n return imported;\n}\n\nfunction normalizeSettings(input) {\n const normalized = deepMerge(DEFAULT_SETTINGS, input || {});\n const importedVersion = Number(input?.settingsVersion || 0);\n normalized.settingsVersion = DEFAULT_SETTINGS.settingsVersion;\n normalized.theme = [\"auto\", \"light\", \"dark\"].includes(normalized.theme) ? normalized.theme : DEFAULT_SETTINGS.theme;\n normalized.uiLanguage = I18N.normalizeUiLanguage(normalized.uiLanguage);\n normalized.language = String(normalized.language || \"\").trim() || DEFAULT_SETTINGS.language;\n normalized.panelEnabled = normalized.panelEnabled !== false;\n normalized.includeTimestamps = normalized.includeTimestamps !== false;\n normalized.includeTitleAndUrl = normalized.includeTitleAndUrl !== false;\n normalized.saveHistory = importedVersion < 2 ? true : normalized.saveHistory !== false;\n normalized.promptTemplate = String(normalized.promptTemplate || DEFAULT_SETTINGS.promptTemplate);\n normalized.outputTemplate = String(normalized.outputTemplate || DEFAULT_SETTINGS.outputTemplate);\n normalized.redactTerms = String(normalized.redactTerms || \"\");\n normalized.chunkSize = clampNumber(normalized.chunkSize, 12000, 2000, 50000);\n normalized.chunkOverlap = clampNumber(normalized.chunkOverlap, 600, 0, 5000);\n\n if (!Array.isArray(normalized.profiles) || !normalized.profiles.length) {\n normalized.profiles = structuredClone(DEFAULT_SETTINGS.profiles);\n }\n normalized.profiles = normalized.profiles.map((profile, index) => normalizeProfile(profile, index, importedVersion));\n if (!normalized.profiles.some((profile) => profile.id === normalized.activeProfileId)) {\n normalized.activeProfileId = normalized.profiles[0].id;\n }\n return normalized;\n}\n\nfunction normalizeProfile(profile, index, importedVersion) {\n const fallback = profilePreset(index === 1 ? \"openai\" : index === 2 ? \"claude\" : \"deepseek\");\n const temperature = importedVersion < 3 && Number(profile?.temperature) === 0.2\n ? 1\n : profile?.temperature;\n return {\n ...fallback,\n ...(profile || {}),\n id: String(profile?.id || fallback.id || `profile-${index + 1}`),\n name: String(profile?.name || fallback.name || `API ${index + 1}`),\n provider: String(profile?.provider || fallback.provider || \"openai-compatible\"),\n endpoint: String(profile?.endpoint || fallback.endpoint || \"\"),\n apiKey: String(profile?.apiKey || \"\"),\n model: String(profile?.model || fallback.model || \"\"),\n temperature: clampNumber(temperature, fallback.temperature ?? 1, 0, 2),\n maxTokens: clampNumber(profile?.maxTokens, fallback.maxTokens, 256, 32000)\n };\n}\n\nfunction normalizeHistory(value) {\n return (Array.isArray(value) ? value : [])\n .filter((item) => item && typeof item === \"object\")\n .slice(0, HISTORY_LIMIT);\n}\n\nfunction applyTranslations() {\n const locale = i18n();\n document.documentElement.lang = locale.language === \"en\" ? \"en\" : \"zh-CN\";\n document.title = locale.t(\"options.documentTitle\");\n\n setText(\".topbar h1\", locale.t(\"common.appName\"));\n setText(\".topbar p\", locale.t(\"options.brandTagline\"));\n setText(\"#exportSettings\", locale.t(\"options.exportSettings\"));\n setText(\"#saveAll\", locale.t(\"options.saveAll\"));\n\n setText(\".sidebar a[href='#appearance']\", locale.t(\"options.nav.appearance\"));\n setText(\".sidebar a[href='#api']\", locale.t(\"options.nav.api\"));\n setText(\".sidebar a[href='#prompt']\", locale.t(\"options.nav.prompt\"));\n setText(\".sidebar a[href='#platforms']\", locale.t(\"options.nav.platforms\"));\n setText(\".sidebar a[href='#history']\", locale.t(\"options.nav.history\"));\n setText(\".sidebar a[href='#advanced']\", locale.t(\"options.nav.advanced\"));\n\n setText(\"#appearance .section__head h2\", locale.t(\"options.nav.appearance\"));\n setText(\"#appearance .section__head p\", locale.t(\"options.appearance.description\"));\n setText(\"#appearance > .field > label\", locale.t(\"options.appearance.theme\"));\n document.querySelector(\"#appearance .segmented\")?.setAttribute(\"aria-label\", locale.t(\"options.appearance.theme\"));\n setRadioLabel(\"auto\", locale.t(\"options.appearance.theme.auto\"));\n setRadioLabel(\"light\", locale.t(\"options.appearance.theme.light\"));\n setRadioLabel(\"dark\", locale.t(\"options.appearance.theme.dark\"));\n setFieldLabel(fields.uiLanguage, locale.t(\"options.appearance.uiLanguage\"));\n setOptionText(fields.uiLanguage, \"zh-CN\", locale.t(\"ui.zh-CN\"));\n setOptionText(fields.uiLanguage, \"en\", locale.t(\"ui.en\"));\n setFieldLabel(fields.language, locale.t(\"options.appearance.summaryLanguage\"));\n fields.language.placeholder = locale.t(\"options.appearance.summaryLanguagePlaceholder\");\n setFieldLabel(fields.panelEnabled, locale.t(\"options.appearance.panelEnabled\"));\n\n setText(\"#api .section__head h2\", locale.t(\"options.nav.api\"));\n setText(\"#api .section__head p\", locale.t(\"options.api.description\"));\n setText(\"#addProfile\", locale.t(\"options.api.add\"));\n setText(\"#deleteProfile\", locale.t(\"options.api.delete\"));\n setFieldLabel(fields.profileName, locale.t(\"options.api.profileName\"));\n fields.profileName.placeholder = locale.t(\"options.api.profileNamePlaceholder\");\n setFieldLabel(fields.provider, locale.t(\"options.api.provider\"));\n setOptionText(fields.provider, \"openai-compatible\", locale.t(\"options.api.provider.openai\"));\n setOptionText(fields.provider, \"anthropic\", locale.t(\"options.api.provider.anthropic\"));\n setOptionText(fields.provider, \"gemini\", locale.t(\"options.api.provider.gemini\"));\n setOptionText(fields.provider, \"ollama\", locale.t(\"options.api.provider.ollama\"));\n setFieldLabel(fields.endpoint, locale.t(\"options.api.endpoint\"));\n setFieldLabel(fields.apiKey, locale.t(\"options.api.apiKey\"));\n setFieldLabel(fields.model, locale.t(\"options.api.model\"));\n fields.model.placeholder = locale.t(\"options.api.modelPlaceholder\");\n setFieldLabel(fields.temperature, locale.t(\"options.api.temperature\"));\n setFieldLabel(fields.maxTokens, locale.t(\"options.api.maxTokens\"));\n setText(\"#saveProfile\", locale.t(\"options.api.saveProfile\"));\n setText(\"#testProfile\", locale.t(\"options.api.testProfile\"));\n\n setText(\"#prompt .section__head h2\", locale.t(\"options.nav.prompt\"));\n setText(\"#prompt .section__head p\", locale.t(\"options.prompt.description\"));\n setFieldLabel(fields.promptTemplate, locale.t(\"options.prompt.promptTemplate\"));\n setFieldLabel(fields.outputTemplate, locale.t(\"options.prompt.outputTemplate\"));\n setTemplateText(\"study\", locale.t(\"options.prompt.study.title\"), locale.t(\"options.prompt.study.body\"));\n setTemplateText(\"meeting\", locale.t(\"options.prompt.meeting.title\"), locale.t(\"options.prompt.meeting.body\"));\n setTemplateText(\"research\", locale.t(\"options.prompt.research.title\"), locale.t(\"options.prompt.research.body\"));\n\n setText(\"#platforms .section__head h2\", locale.t(\"options.nav.platforms\"));\n setText(\"#platforms .section__head p\", locale.t(\"options.platforms.description\"));\n setText(\"#platforms .support-list > div:nth-child(1) span\", locale.t(\"options.platforms.youtube\"));\n setText(\"#platforms .support-list > div:nth-child(2) span\", locale.t(\"options.platforms.bilibili\"));\n setText(\"#platforms .support-list > div:nth-child(3) span\", locale.t(\"options.platforms.html5\"));\n setText(\"#platforms .support-list > div:nth-child(4) span\", locale.t(\"options.platforms.transcript\"));\n setFieldLabel(fields.includeTimestamps, locale.t(\"options.platforms.includeTimestamps\"));\n setFieldLabel(fields.redactTerms, locale.t(\"options.platforms.redactTerms\"));\n fields.redactTerms.placeholder = locale.t(\"options.platforms.redactTermsPlaceholder\");\n\n setText(\"#history .section__head h2\", locale.t(\"options.nav.history\"));\n setText(\"#history .section__head p\", locale.t(\"options.history.description\"));\n setText(\"#exportHistory\", locale.t(\"options.history.export\"));\n setText(\"#clearHistory\", locale.t(\"options.history.clear\"));\n setFieldLabel(fields.saveHistory, locale.t(\"options.history.saveHistory\"));\n\n setText(\"#advanced .section__head h2\", locale.t(\"options.nav.advanced\"));\n setText(\"#advanced .section__head p\", locale.t(\"options.advanced.description\"));\n setFieldLabel(fields.chunkSize, locale.t(\"options.advanced.chunkSize\"));\n setFieldLabel(fields.chunkOverlap, locale.t(\"options.advanced.chunkOverlap\"));\n setFieldLabel(fields.importText, locale.t(\"options.advanced.importJson\"));\n fields.importText.placeholder = locale.t(\"options.advanced.importJsonPlaceholder\");\n setFieldLabel(fields.importFile, locale.t(\"options.advanced.importFile\"));\n setText(\"#importSettings\", locale.t(\"options.advanced.importSettings\"));\n setText(\"#resetSettings\", locale.t(\"options.advanced.resetSettings\"));\n}\n\nfunction applyTheme() {\n const mode = settings.theme || \"auto\";\n const resolved = mode === \"auto\"\n ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\")\n : mode;\n document.documentElement.dataset.themeMode = mode;\n document.documentElement.dataset.theme = resolved;\n}\n\nfunction downloadJson(json, filename) {\n const blob = new Blob([json], { type: \"application/json;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\nasync function writeClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (_error) {\n return false;\n }\n}\n\nfunction formatDateForFilename(date) {\n return date.toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n}\n\nfunction formatDate(value) {\n if (!value) {\n return \"\";\n }\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return \"\";\n }\n return date.toLocaleString(i18n().locale, {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\"\n });\n}\n\nfunction clampNumber(value, fallback, min, max) {\n const number = Number(value);\n if (!Number.isFinite(number)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, number));\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setText(selector, value) {\n const element = document.querySelector(selector);\n if (element) {\n element.textContent = value;\n }\n}\n\nfunction setFieldLabel(field, value) {\n const label = field?.closest(\".field\")?.querySelector(\"span\");\n if (label) {\n label.textContent = value;\n }\n}\n\nfunction setRadioLabel(value, text) {\n const input = document.querySelector(`input[name='theme'][value='${value}']`);\n const label = input?.closest(\"label\");\n if (!label) {\n return;\n }\n let textNode = [...label.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());\n if (!textNode) {\n textNode = document.createTextNode(\"\");\n label.appendChild(textNode);\n }\n textNode.textContent = ` ${text}`;\n}\n\nfunction setOptionText(select, value, text) {\n const option = select?.querySelector(`option[value='${value}']`);\n if (option) {\n option.textContent = text;\n }\n}\n\nfunction setTemplateText(name, title, body) {\n const template = document.querySelector(`.template[data-template='${name}']`);\n if (!template) {\n return;\n }\n template.querySelector(\"strong\").textContent = title;\n template.querySelector(\"span\").textContent = body;\n}\n\nfunction setStatus(element, message, tone) {\n element.textContent = message;\n element.dataset.tone = tone || \"\";\n if (message) {\n window.setTimeout(() => {\n if (element.textContent === message) {\n element.textContent = \"\";\n element.dataset.tone = \"\";\n }\n }, 5000);\n }\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "46fdd96de3db320be986c70b1f3ec377fa9cc5aa": {"t": 1785758703.395062, "n": 21753, "h": "85d67b52c5307f7f", "c": "const SETTINGS_KEY = \"vcsSettings\";\nconst HISTORY_KEY = \"vcsHistory\";\nconst HISTORY_LIMIT = 30;\n\nconst DEFAULT_SETTINGS = {\n settingsVersion: 3,\n theme: \"auto\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"custom\",\n profiles: [\n {\n ...profilePreset(\"deepseek\"),\n id: \"custom\",\n name: \"我的 API 配置\"\n }\n ],\n promptTemplate: [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n ].join(\"\\n\"),\n outputTemplate: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n ].join(\"\\n\"),\n chunkSize: 12000,\n chunkOverlap: 600,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: true\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n apiKey: document.querySelector(\"#apiKey\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n importText: document.querySelector(\"#importText\"),\n importFile: document.querySelector(\"#importFile\"),\n historyList: document.querySelector(\"#historyList\"),\n historyCount: document.querySelector(\"#historyCount\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nasync function init() {\n const result = await chrome.storage.local.get(SETTINGS_KEY);\n settings = normalizeSettings(result[SETTINGS_KEY]);\n if (JSON.stringify(result[SETTINGS_KEY] || {}) !== JSON.stringify(settings)) {\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n }\n hydrateForm();\n bindEvents();\n await renderHistory();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", () => saveAll());\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", saveCurrentProfile);\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n document.querySelector(\"#clearHistory\").addEventListener(\"click\", clearHistory);\n document.querySelector(\"#exportHistory\").addEventListener(\"click\", exportHistory);\n fields.importFile.addEventListener(\"change\", importSettingsFile);\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", async () => {\n settings.theme = input.value;\n applyTheme();\n await saveAll(\"主题已保存\");\n });\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n document.querySelectorAll(\".template\").forEach((button) => {\n button.addEventListener(\"click\", () => applyTemplate(button.dataset.template));\n });\n\n fields.historyList.addEventListener(\"click\", copyHistoryFromList);\n\n chrome.storage.onChanged.addListener((changes, area) => {\n if (area !== \"local\") {\n return;\n }\n if (changes[HISTORY_KEY]) {\n renderHistory();\n }\n if (changes[SETTINGS_KEY] && changes[SETTINGS_KEY].newValue) {\n settings = normalizeSettings(changes[SETTINGS_KEY].newValue);\n hydrateForm();\n }\n });\n\n window.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", () => {\n if (settings.theme === \"auto\") {\n applyTheme();\n }\n });\n}\n\nfunction hydrateForm() {\n const themeInput = document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`)\n || document.querySelector(\"input[name='theme'][value='auto']\");\n if (themeInput) {\n themeInput.checked = true;\n }\n fields.language.value = settings.language || DEFAULT_SETTINGS.language;\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = settings.saveHistory !== false;\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n applyTheme();\n renderProfileFields();\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"openai-compatible\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.apiKey.value = profile.apiKey || \"\";\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 1;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n}\n\nfunction saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n saveAll(\"API 配置已保存\");\n}\n\nasync function saveAll(message = \"设置已保存\") {\n if (typeof message !== \"string\") {\n message = \"设置已保存\";\n }\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n saveCurrentProfileValuesOnly();\n settings = normalizeSettings(settings);\n\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 1);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: \"新配置\",\n provider: \"openai-compatible\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, \"至少保留一个 API 配置。\", \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n profile.provider = preset.provider;\n profile.endpoint = preset.endpoint;\n fields.provider.value = profile.provider;\n fields.endpoint.value = profile.endpoint;\n setStatus(fields.apiStatus, \"已切换服务商和接口地址,配置名称与模型名称保持不变。\", \"ok\");\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.ai/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n mimo: {\n id: \"mimo\",\n name: \"小米 MiMo (本地)\",\n provider: \"openai-compatible\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"\",\n temperature: 1,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nfunction applyTemplate(name) {\n const templates = {\n study: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 概念解释\",\n \"## 章节时间线\",\n \"## 关键术语\",\n \"## 课后复盘问题\"\n ].join(\"\\n\"),\n meeting: [\n \"请使用 Markdown 输出:\",\n \"## 会议结论\",\n \"## 已确认决策\",\n \"## 行动项\",\n \"- 负责人 / 截止时间 / 背景\",\n \"## 风险与阻塞\",\n \"## 需要追问\"\n ].join(\"\\n\"),\n research: [\n \"请使用 Markdown 输出:\",\n \"## 研究摘要\",\n \"## 论点与证据\",\n \"## 重要数据 / 名词\",\n \"## 反方观点或不确定性\",\n \"## 可引用时间点\"\n ].join(\"\\n\")\n };\n\n fields.outputTemplate.value = templates[name] || templates.study;\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n setStatus(fields.apiStatus, \"正在测试连接...\", \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile()\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, `连接成功:${response.payload.text}`, \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || \"连接失败。\", \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(\"设置已保存,可导出\");\n const payload = {\n type: \"video-caption-ai-settings\",\n version: 1,\n exportedAt: new Date().toISOString(),\n settings\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-settings-${formatDateForFilename(new Date())}.json`);\n const copied = await writeClipboard(json);\n setStatus(fields.saveStatus, copied ? \"配置 JSON 已下载,并已复制到剪贴板。\" : \"配置 JSON 已下载。\", \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = parseImportedSettings(fields.importText.value);\n settings = normalizeSettings(imported);\n hydrateForm();\n await chrome.storage.local.set({ [SETTINGS_KEY]: settings });\n setStatus(fields.saveStatus, \"配置已导入。\", \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, `导入失败:${error.message}`, \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n setStatus(fields.saveStatus, \"已恢复默认设置,点击保存设置后才会生效。\", \"ok\");\n}\n\nasync function importSettingsFile() {\n const file = fields.importFile.files?.[0];\n if (!file) {\n return;\n }\n\n try {\n fields.importText.value = await file.text();\n await importSettings();\n } finally {\n fields.importFile.value = \"\";\n }\n}\n\nasync function renderHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n fields.historyCount.textContent = `${history.length} / ${HISTORY_LIMIT}`;\n\n if (!history.length) {\n fields.historyList.innerHTML = `<div class=\"history-empty\">还没有保存的总结历史。</div>`;\n return;\n }\n\n fields.historyList.innerHTML = history.map((item, index) => {\n const summary = item.summary || item.text || \"\";\n const title = item.title || \"未命名视频\";\n const createdAt = formatDate(item.createdAt);\n const meta = [item.platform, item.model, createdAt].filter(Boolean).join(\" · \");\n return `\n <article class=\"history-item\">\n <div class=\"history-item__head\">\n <div>\n <div class=\"history-item__title\" title=\"${escapeHtml(title)}\">${escapeHtml(title)}</div>\n <div class=\"history-item__meta\">${escapeHtml(meta || \"本机历史\")}</div>\n </div>\n <button class=\"button button--ghost history-copy\" type=\"button\" data-history-index=\"${index}\">复制</button>\n </div>\n <div class=\"history-item__summary\">${escapeHtml(summary || \"无摘要内容\")}</div>\n </article>\n `;\n }).join(\"\");\n\n if (result[HISTORY_KEY]?.length > HISTORY_LIMIT) {\n await chrome.storage.local.set({ [HISTORY_KEY]: history });\n }\n}\n\nasync function copyHistoryFromList(event) {\n const button = event.target.closest(\"[data-history-index]\");\n if (!button) {\n return;\n }\n\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const item = history[Number(button.dataset.historyIndex)];\n const text = item?.summary || item?.text || \"\";\n if (!text.trim()) {\n setStatus(fields.saveStatus, \"这条历史没有可复制的摘要。\", \"error\");\n return;\n }\n\n const copied = await writeClipboard(text);\n setStatus(fields.saveStatus, copied ? \"历史摘要已复制。\" : \"浏览器拒绝写入剪贴板。\", copied ? \"ok\" : \"error\");\n}\n\nasync function clearHistory() {\n await chrome.storage.local.set({ [HISTORY_KEY]: [] });\n await renderHistory();\n setStatus(fields.saveStatus, \"总结历史已清空。\", \"ok\");\n}\n\nasync function exportHistory() {\n const result = await chrome.storage.local.get(HISTORY_KEY);\n const history = normalizeHistory(result[HISTORY_KEY]);\n const payload = {\n type: \"video-caption-ai-history\",\n version: 1,\n exportedAt: new Date().toISOString(),\n limit: HISTORY_LIMIT,\n history\n };\n const json = JSON.stringify(payload, null, 2);\n downloadJson(json, `video-caption-ai-history-${formatDateForFilename(new Date())}.json`);\n setStatus(fields.saveStatus, history.length ? \"总结历史 JSON 已下载。\" : \"历史为空,已下载空历史 JSON。\", \"ok\");\n}\n\nfunction parseImportedSettings(text) {\n if (!text.trim()) {\n throw new Error(\"请先粘贴或选择配置 JSON。\");\n }\n\n const parsed = JSON.parse(text);\n const imported = parsed?.settings || parsed;\n if (!imported || typeof imported !== \"object\" || Array.isArray(imported)) {\n throw new Error(\"JSON 中没有有效的 settings 对象。\");\n }\n return imported;\n}\n\nfunction normalizeSettings(input) {\n const normalized = deepMerge(DEFAULT_SETTINGS, input || {});\n const importedVersion = Number(input?.settingsVersion || 0);\n normalized.settingsVersion = DEFAULT_SETTINGS.settingsVersion;\n normalized.theme = [\"auto\", \"light\", \"dark\"].includes(normalized.theme) ? normalized.theme : DEFAULT_SETTINGS.theme;\n normalized.language = String(normalized.language || \"\").trim() || DEFAULT_SETTINGS.language;\n normalized.panelEnabled = normalized.panelEnabled !== false;\n normalized.includeTimestamps = normalized.includeTimestamps !== false;\n normalized.includeTitleAndUrl = normalized.includeTitleAndUrl !== false;\n normalized.saveHistory = importedVersion < 2 ? true : normalized.saveHistory !== false;\n normalized.promptTemplate = String(normalized.promptTemplate || DEFAULT_SETTINGS.promptTemplate);\n normalized.outputTemplate = String(normalized.outputTemplate || DEFAULT_SETTINGS.outputTemplate);\n normalized.redactTerms = String(normalized.redactTerms || \"\");\n normalized.chunkSize = clampNumber(normalized.chunkSize, 12000, 2000, 50000);\n normalized.chunkOverlap = clampNumber(normalized.chunkOverlap, 600, 0, 5000);\n\n if (!Array.isArray(normalized.profiles) || !normalized.profiles.length) {\n normalized.profiles = structuredClone(DEFAULT_SETTINGS.profiles);\n }\n normalized.profiles = normalized.profiles.map((profile, index) => normalizeProfile(profile, index, importedVersion));\n if (!normalized.profiles.some((profile) => profile.id === normalized.activeProfileId)) {\n normalized.activeProfileId = normalized.profiles[0].id;\n }\n return normalized;\n}\n\nfunction normalizeProfile(profile, index, importedVersion) {\n const fallback = profilePreset(index === 1 ? \"openai\" : index === 2 ? \"claude\" : \"deepseek\");\n const temperature = importedVersion < 3 && Number(profile?.temperature) === 0.2\n ? 1\n : profile?.temperature;\n return {\n ...fallback,\n ...(profile || {}),\n id: String(profile?.id || fallback.id || `profile-${index + 1}`),\n name: String(profile?.name || fallback.name || `API ${index + 1}`),\n provider: String(profile?.provider || fallback.provider || \"openai-compatible\"),\n endpoint: String(profile?.endpoint || fallback.endpoint || \"\"),\n apiKey: String(profile?.apiKey || \"\"),\n model: String(profile?.model || fallback.model || \"\"),\n temperature: clampNumber(temperature, fallback.temperature ?? 1, 0, 2),\n maxTokens: clampNumber(profile?.maxTokens, fallback.maxTokens, 256, 32000)\n };\n}\n\nfunction normalizeHistory(value) {\n return (Array.isArray(value) ? value : [])\n .filter((item) => item && typeof item === \"object\")\n .slice(0, HISTORY_LIMIT);\n}\n\nfunction applyTheme() {\n const mode = settings.theme || \"auto\";\n const resolved = mode === \"auto\"\n ? (window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\")\n : mode;\n document.documentElement.dataset.themeMode = mode;\n document.documentElement.dataset.theme = resolved;\n}\n\nfunction downloadJson(json, filename) {\n const blob = new Blob([json], { type: \"application/json;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n document.body.appendChild(anchor);\n anchor.click();\n anchor.remove();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\nasync function writeClipboard(text) {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch (_error) {\n return false;\n }\n}\n\nfunction formatDateForFilename(date) {\n return date.toISOString().replace(/[:.]/g, \"-\").slice(0, 19);\n}\n\nfunction formatDate(value) {\n if (!value) {\n return \"\";\n }\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return \"\";\n }\n return date.toLocaleString(\"zh-CN\", {\n year: \"numeric\",\n month: \"2-digit\",\n day: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\"\n });\n}\n\nfunction clampNumber(value, fallback, min, max) {\n const number = Number(value);\n if (!Number.isFinite(number)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, number));\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setStatus(element, message, tone) {\n element.textContent = message;\n element.dataset.tone = tone || \"\";\n if (message) {\n window.setTimeout(() => {\n if (element.textContent === message) {\n element.textContent = \"\";\n element.dataset.tone = \"\";\n }\n }, 5000);\n }\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "30bec3090d8ab28d0ce4ebe6a4b91d9fd4d11798": {"t": 1785758704.8248997, "n": 13291, "h": "91a9eb3542aab855", "c": "const DEFAULT_SETTINGS = {\n theme: \"auto\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"deepseek\",\n profiles: [\n profilePreset(\"deepseek\"),\n profilePreset(\"openai\"),\n profilePreset(\"claude\")\n ],\n promptTemplate: [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n ].join(\"\\n\"),\n outputTemplate: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n ].join(\"\\n\"),\n chunkSize: 12000,\n chunkOverlap: 600,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: false\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n activeProfile: document.querySelector(\"#activeProfile\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n apiKey: document.querySelector(\"#apiKey\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n importText: document.querySelector(\"#importText\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nasync function init() {\n const result = await chrome.storage.local.get(\"vcsSettings\");\n settings = deepMerge(DEFAULT_SETTINGS, result.vcsSettings || {});\n hydrateForm();\n bindEvents();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", saveAll);\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", saveCurrentProfile);\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n\n fields.activeProfile.addEventListener(\"change\", () => {\n settings.activeProfileId = fields.activeProfile.value;\n renderProfileFields();\n });\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", () => {\n settings.theme = input.value;\n });\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n document.querySelectorAll(\".template\").forEach((button) => {\n button.addEventListener(\"click\", () => applyTemplate(button.dataset.template));\n });\n}\n\nfunction hydrateForm() {\n document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`).checked = true;\n fields.language.value = settings.language || \"\";\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = Boolean(settings.saveHistory);\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n renderProfileSelect();\n renderProfileFields();\n}\n\nfunction renderProfileSelect() {\n fields.activeProfile.innerHTML = settings.profiles.map((profile) => {\n const selected = profile.id === settings.activeProfileId ? \"selected\" : \"\";\n return `<option value=\"${escapeHtml(profile.id)}\" ${selected}>${escapeHtml(profile.name || profile.id)}</option>`;\n }).join(\"\");\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"openai-compatible\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.apiKey.value = profile.apiKey || \"\";\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 0.2;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n}\n\nfunction saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 0.2);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n renderProfileSelect();\n saveAll(\"API 配置已保存\");\n}\n\nasync function saveAll(message = \"设置已保存\") {\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n saveCurrentProfileValuesOnly();\n\n await chrome.storage.local.set({ vcsSettings: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 0.2);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: \"Custom API\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.example.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"custom-model\",\n temperature: 0.2,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileSelect();\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, \"至少保留一个 API 配置。\", \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileSelect();\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n Object.assign(profile, {\n ...preset,\n id: profile.id,\n apiKey: profile.apiKey || preset.apiKey\n });\n renderProfileFields();\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"deepseek-chat\",\n temperature: 0.2,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"gpt-4o-mini\",\n temperature: 0.2,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n model: \"claude-sonnet-4-5\",\n temperature: 0.2,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"gemini-1.5-pro\",\n temperature: 0.2,\n maxTokens: 4096\n },\n kimi: {\n id: \"kimi\",\n name: \"Kimi (Moonshot)\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.moonshot.cn/v1/chat/completions\",\n apiKey: \"\",\n model: \"moonshot-v1-32k\",\n temperature: 0.2,\n maxTokens: 4096\n },\n qwen: {\n id: \"qwen\",\n name: \"通义千问\",\n provider: \"openai-compatible\",\n endpoint: \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\",\n apiKey: \"\",\n model: \"qwen-plus\",\n temperature: 0.2,\n maxTokens: 4096\n },\n glm: {\n id: \"glm\",\n name: \"智谱 GLM\",\n provider: \"openai-compatible\",\n endpoint: \"https://open.bigmodel.cn/api/paas/v4/chat/completions\",\n apiKey: \"\",\n model: \"glm-4-plus\",\n temperature: 0.2,\n maxTokens: 4096\n },\n mimo: {\n id: \"mimo\",\n name: \"小米 MiMo (本地)\",\n provider: \"openai-compatible\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"mimo\",\n temperature: 0.2,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"llama3.1\",\n temperature: 0.2,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nfunction applyTemplate(name) {\n const templates = {\n study: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 概念解释\",\n \"## 章节时间线\",\n \"## 关键术语\",\n \"## 课后复盘问题\"\n ].join(\"\\n\"),\n meeting: [\n \"请使用 Markdown 输出:\",\n \"## 会议结论\",\n \"## 已确认决策\",\n \"## 行动项\",\n \"- 负责人 / 截止时间 / 背景\",\n \"## 风险与阻塞\",\n \"## 需要追问\"\n ].join(\"\\n\"),\n research: [\n \"请使用 Markdown 输出:\",\n \"## 研究摘要\",\n \"## 论点与证据\",\n \"## 重要数据 / 名词\",\n \"## 反方观点或不确定性\",\n \"## 可引用时间点\"\n ].join(\"\\n\")\n };\n\n fields.outputTemplate.value = templates[name] || templates.study;\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n setStatus(fields.apiStatus, \"正在测试连接...\", \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile()\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, `连接成功:${response.payload.text}`, \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || \"连接失败。\", \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(\"设置已保存,可导出\");\n const json = JSON.stringify(settings, null, 2);\n await navigator.clipboard.writeText(json);\n setStatus(fields.saveStatus, \"配置 JSON 已复制。\", \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = JSON.parse(fields.importText.value);\n settings = deepMerge(DEFAULT_SETTINGS, imported);\n hydrateForm();\n await chrome.storage.local.set({ vcsSettings: settings });\n setStatus(fields.saveStatus, \"配置已导入。\", \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, `导入失败:${error.message}`, \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n await chrome.storage.local.set({ vcsSettings: settings });\n setStatus(fields.saveStatus, \"已恢复默认设置。\", \"ok\");\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setStatus(element, message, tone) {\n element.textContent = message;\n element.dataset.tone = tone || \"\";\n if (message) {\n window.setTimeout(() => {\n if (element.textContent === message) {\n element.textContent = \"\";\n element.dataset.tone = \"\";\n }\n }, 5000);\n }\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}, "0b4402090eb201cfd494ec9aa87c9e6e2e0cd185": {"t": 1785758706.227374, "n": 12250, "h": "c2a88916c8285eed", "c": "const DEFAULT_SETTINGS = {\n theme: \"auto\",\n language: \"中文(简体)\",\n panelEnabled: true,\n activeProfileId: \"deepseek\",\n profiles: [\n profilePreset(\"deepseek\"),\n profilePreset(\"openai\"),\n profilePreset(\"claude\")\n ],\n promptTemplate: [\n \"你是一个擅长处理视频字幕的中文研究助理。\",\n \"请根据下面的视频字幕生成高质量总结。\",\n \"\",\n \"标题:{{title}}\",\n \"平台:{{platform}}\",\n \"链接:{{url}}\",\n \"输出语言:{{language}}\",\n \"\",\n \"字幕:\",\n \"{{transcript}}\"\n ].join(\"\\n\"),\n outputTemplate: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 关键观点\",\n \"- 每条观点尽量带上相关时间点\",\n \"## 章节时间线\",\n \"## 可执行事项 / 值得追问的问题\"\n ].join(\"\\n\"),\n chunkSize: 12000,\n chunkOverlap: 600,\n includeTimestamps: true,\n includeTitleAndUrl: true,\n redactTerms: \"\",\n saveHistory: false\n};\n\nlet settings = structuredClone(DEFAULT_SETTINGS);\n\nconst fields = {\n language: document.querySelector(\"#language\"),\n panelEnabled: document.querySelector(\"#panelEnabled\"),\n activeProfile: document.querySelector(\"#activeProfile\"),\n profileName: document.querySelector(\"#profileName\"),\n provider: document.querySelector(\"#provider\"),\n endpoint: document.querySelector(\"#endpoint\"),\n apiKey: document.querySelector(\"#apiKey\"),\n model: document.querySelector(\"#model\"),\n temperature: document.querySelector(\"#temperature\"),\n maxTokens: document.querySelector(\"#maxTokens\"),\n promptTemplate: document.querySelector(\"#promptTemplate\"),\n outputTemplate: document.querySelector(\"#outputTemplate\"),\n includeTimestamps: document.querySelector(\"#includeTimestamps\"),\n saveHistory: document.querySelector(\"#saveHistory\"),\n redactTerms: document.querySelector(\"#redactTerms\"),\n chunkSize: document.querySelector(\"#chunkSize\"),\n chunkOverlap: document.querySelector(\"#chunkOverlap\"),\n importText: document.querySelector(\"#importText\"),\n apiStatus: document.querySelector(\"#apiStatus\"),\n saveStatus: document.querySelector(\"#saveStatus\")\n};\n\ninit();\n\nasync function init() {\n const result = await chrome.storage.local.get(\"vcsSettings\");\n settings = deepMerge(DEFAULT_SETTINGS, result.vcsSettings || {});\n hydrateForm();\n bindEvents();\n}\n\nfunction bindEvents() {\n document.querySelector(\"#saveAll\").addEventListener(\"click\", saveAll);\n document.querySelector(\"#saveProfile\").addEventListener(\"click\", saveCurrentProfile);\n document.querySelector(\"#addProfile\").addEventListener(\"click\", addProfile);\n document.querySelector(\"#deleteProfile\").addEventListener(\"click\", deleteProfile);\n document.querySelector(\"#testProfile\").addEventListener(\"click\", testProfile);\n document.querySelector(\"#exportSettings\").addEventListener(\"click\", exportSettings);\n document.querySelector(\"#importSettings\").addEventListener(\"click\", importSettings);\n document.querySelector(\"#resetSettings\").addEventListener(\"click\", resetSettings);\n\n fields.activeProfile.addEventListener(\"change\", () => {\n settings.activeProfileId = fields.activeProfile.value;\n renderProfileFields();\n });\n\n document.querySelectorAll(\"input[name='theme']\").forEach((input) => {\n input.addEventListener(\"change\", () => {\n settings.theme = input.value;\n });\n });\n\n document.querySelectorAll(\".preset\").forEach((button) => {\n button.addEventListener(\"click\", () => applyPreset(button.dataset.preset));\n });\n\n document.querySelectorAll(\".template\").forEach((button) => {\n button.addEventListener(\"click\", () => applyTemplate(button.dataset.template));\n });\n}\n\nfunction hydrateForm() {\n document.querySelector(`input[name='theme'][value='${settings.theme || \"auto\"}']`).checked = true;\n fields.language.value = settings.language || \"\";\n fields.panelEnabled.checked = settings.panelEnabled !== false;\n fields.promptTemplate.value = settings.promptTemplate || \"\";\n fields.outputTemplate.value = settings.outputTemplate || \"\";\n fields.includeTimestamps.checked = settings.includeTimestamps !== false;\n fields.saveHistory.checked = Boolean(settings.saveHistory);\n fields.redactTerms.value = settings.redactTerms || \"\";\n fields.chunkSize.value = settings.chunkSize || 12000;\n fields.chunkOverlap.value = settings.chunkOverlap || 600;\n renderProfileSelect();\n renderProfileFields();\n}\n\nfunction renderProfileSelect() {\n fields.activeProfile.innerHTML = settings.profiles.map((profile) => {\n const selected = profile.id === settings.activeProfileId ? \"selected\" : \"\";\n return `<option value=\"${escapeHtml(profile.id)}\" ${selected}>${escapeHtml(profile.name || profile.id)}</option>`;\n }).join(\"\");\n}\n\nfunction renderProfileFields() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n fields.profileName.value = profile.name || \"\";\n fields.provider.value = profile.provider || \"openai-compatible\";\n fields.endpoint.value = profile.endpoint || \"\";\n fields.apiKey.value = profile.apiKey || \"\";\n fields.model.value = profile.model || \"\";\n fields.temperature.value = profile.temperature ?? 0.2;\n fields.maxTokens.value = profile.maxTokens ?? 4096;\n}\n\nfunction saveCurrentProfile() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 0.2);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n renderProfileSelect();\n saveAll(\"API 配置已保存\");\n}\n\nasync function saveAll(message = \"设置已保存\") {\n settings.theme = document.querySelector(\"input[name='theme']:checked\")?.value || \"auto\";\n settings.language = fields.language.value.trim() || \"中文(简体)\";\n settings.panelEnabled = fields.panelEnabled.checked;\n settings.promptTemplate = fields.promptTemplate.value;\n settings.outputTemplate = fields.outputTemplate.value;\n settings.includeTimestamps = fields.includeTimestamps.checked;\n settings.saveHistory = fields.saveHistory.checked;\n settings.redactTerms = fields.redactTerms.value;\n settings.chunkSize = Number(fields.chunkSize.value || 12000);\n settings.chunkOverlap = Number(fields.chunkOverlap.value || 600);\n saveCurrentProfileValuesOnly();\n\n await chrome.storage.local.set({ vcsSettings: settings });\n setStatus(fields.saveStatus, message, \"ok\");\n}\n\nfunction saveCurrentProfileValuesOnly() {\n const profile = getActiveProfile();\n if (!profile) {\n return;\n }\n\n profile.name = fields.profileName.value.trim() || profile.id;\n profile.provider = fields.provider.value;\n profile.endpoint = fields.endpoint.value.trim();\n profile.apiKey = fields.apiKey.value.trim();\n profile.model = fields.model.value.trim();\n profile.temperature = Number(fields.temperature.value || 0.2);\n profile.maxTokens = Number(fields.maxTokens.value || 4096);\n}\n\nfunction addProfile() {\n const profile = {\n id: `custom-${Date.now()}`,\n name: \"Custom API\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.example.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"custom-model\",\n temperature: 0.2,\n maxTokens: 4096\n };\n settings.profiles.push(profile);\n settings.activeProfileId = profile.id;\n renderProfileSelect();\n renderProfileFields();\n}\n\nfunction deleteProfile() {\n if (settings.profiles.length <= 1) {\n setStatus(fields.apiStatus, \"至少保留一个 API 配置。\", \"error\");\n return;\n }\n\n const index = settings.profiles.findIndex((profile) => profile.id === settings.activeProfileId);\n if (index >= 0) {\n settings.profiles.splice(index, 1);\n }\n settings.activeProfileId = settings.profiles[0].id;\n renderProfileSelect();\n renderProfileFields();\n}\n\nfunction applyPreset(name) {\n const preset = profilePreset(name);\n const profile = getActiveProfile();\n Object.assign(profile, {\n ...preset,\n id: profile.id,\n apiKey: profile.apiKey || preset.apiKey\n });\n renderProfileFields();\n}\n\nfunction profilePreset(name) {\n const presets = {\n deepseek: {\n id: \"deepseek\",\n name: \"DeepSeek\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.deepseek.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"deepseek-chat\",\n temperature: 0.2,\n maxTokens: 4096\n },\n openai: {\n id: \"openai\",\n name: \"OpenAI / ChatGPT\",\n provider: \"openai-compatible\",\n endpoint: \"https://api.openai.com/v1/chat/completions\",\n apiKey: \"\",\n model: \"gpt-4o-mini\",\n temperature: 0.2,\n maxTokens: 4096\n },\n claude: {\n id: \"claude\",\n name: \"Claude\",\n provider: \"anthropic\",\n endpoint: \"https://api.anthropic.com/v1/messages\",\n apiKey: \"\",\n model: \"claude-sonnet-4-5\",\n temperature: 0.2,\n maxTokens: 4096\n },\n gemini: {\n id: \"gemini\",\n name: \"Gemini\",\n provider: \"gemini\",\n endpoint: \"\",\n apiKey: \"\",\n model: \"gemini-1.5-pro\",\n temperature: 0.2,\n maxTokens: 4096\n },\n ollama: {\n id: \"ollama\",\n name: \"Ollama Local\",\n provider: \"ollama\",\n endpoint: \"http://localhost:11434/v1/chat/completions\",\n apiKey: \"\",\n model: \"llama3.1\",\n temperature: 0.2,\n maxTokens: 4096\n }\n };\n\n return structuredClone(presets[name] || presets.deepseek);\n}\n\nfunction applyTemplate(name) {\n const templates = {\n study: [\n \"请使用 Markdown 输出:\",\n \"## 一句话结论\",\n \"## 核心摘要\",\n \"## 概念解释\",\n \"## 章节时间线\",\n \"## 关键术语\",\n \"## 课后复盘问题\"\n ].join(\"\\n\"),\n meeting: [\n \"请使用 Markdown 输出:\",\n \"## 会议结论\",\n \"## 已确认决策\",\n \"## 行动项\",\n \"- 负责人 / 截止时间 / 背景\",\n \"## 风险与阻塞\",\n \"## 需要追问\"\n ].join(\"\\n\"),\n research: [\n \"请使用 Markdown 输出:\",\n \"## 研究摘要\",\n \"## 论点与证据\",\n \"## 重要数据 / 名词\",\n \"## 反方观点或不确定性\",\n \"## 可引用时间点\"\n ].join(\"\\n\")\n };\n\n fields.outputTemplate.value = templates[name] || templates.study;\n}\n\nasync function testProfile() {\n saveCurrentProfileValuesOnly();\n setStatus(fields.apiStatus, \"正在测试连接...\", \"\");\n\n const response = await chrome.runtime.sendMessage({\n type: \"VCS_TEST_API\",\n profile: getActiveProfile()\n });\n\n if (response?.ok) {\n setStatus(fields.apiStatus, `连接成功:${response.payload.text}`, \"ok\");\n } else {\n setStatus(fields.apiStatus, response?.error || \"连接失败。\", \"error\");\n }\n}\n\nasync function exportSettings() {\n await saveAll(\"设置已保存,可导出\");\n const json = JSON.stringify(settings, null, 2);\n await navigator.clipboard.writeText(json);\n setStatus(fields.saveStatus, \"配置 JSON 已复制。\", \"ok\");\n}\n\nasync function importSettings() {\n try {\n const imported = JSON.parse(fields.importText.value);\n settings = deepMerge(DEFAULT_SETTINGS, imported);\n hydrateForm();\n await chrome.storage.local.set({ vcsSettings: settings });\n setStatus(fields.saveStatus, \"配置已导入。\", \"ok\");\n } catch (error) {\n setStatus(fields.saveStatus, `导入失败:${error.message}`, \"error\");\n }\n}\n\nasync function resetSettings() {\n settings = structuredClone(DEFAULT_SETTINGS);\n hydrateForm();\n await chrome.storage.local.set({ vcsSettings: settings });\n setStatus(fields.saveStatus, \"已恢复默认设置。\", \"ok\");\n}\n\nfunction getActiveProfile() {\n return settings.profiles.find((profile) => profile.id === settings.activeProfileId) || settings.profiles[0];\n}\n\nfunction setStatus(element, message, tone) {\n element.textContent = message;\n element.dataset.tone = tone || \"\";\n if (message) {\n window.setTimeout(() => {\n if (element.textContent === message) {\n element.textContent = \"\";\n element.dataset.tone = \"\";\n }\n }, 5000);\n }\n}\n\nfunction deepMerge(base, extra) {\n if (!extra || typeof extra !== \"object\") {\n return structuredClone(base);\n }\n\n const output = Array.isArray(base) ? [...base] : { ...base };\n for (const [key, value] of Object.entries(extra)) {\n if (\n value &&\n typeof value === \"object\" &&\n !Array.isArray(value) &&\n base[key] &&\n typeof base[key] === \"object\" &&\n !Array.isArray(base[key])\n ) {\n output[key] = deepMerge(base[key], value);\n } else {\n output[key] = value;\n }\n }\n return output;\n}\n\nfunction escapeHtml(value) {\n return String(value || \"\")\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#039;\");\n}\n"}}}}