Files

1 line
222 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "chen-1912/browser-copilot", "files": {"options.js": {"24474f9d1effe3384ec18e4a496493654331c6b1": {"t": 1785674070.0475914, "n": 22485, "h": "c47953c210f18a88", "c": "// ===== options.js =====\n\n// 同步立即应用英文(CSP 禁止 HTML 内联脚本,故在此处执行)\nI18n.apply();\n\nconst PROVIDERS = {\n ollama: {\n name: \"Ollama\",\n hints: [\"qwen3:8b\", \"qwen2.5:7b\", \"llama3.2:3b\", \"llama3.1:8b\", \"deepseek-r1:7b\", \"gemma3:4b\", \"mistral:7b\"],\n keyPlaceholder: \"ollama(本地服务无需 Key,留空即可)\",\n keyDesc: \"Ollama 本地服务不校验 API Key,留空或填任意字符均可。\",\n defaultKey: \"ollama\",\n },\n cliproxy: {\n name: \"CLI Proxy API\",\n hints: [\"gpt-4o\", \"gpt-4o-mini\", \"claude-sonnet-4-5\", \"o3-mini\", \"gemini-2.0-flash\"],\n keyPlaceholder: \"sk-...\",\n keyDesc: \"填写 CLI Proxy API 所要求的访问密钥。\",\n endpointPlaceholder: \"https://your-proxy.example.com/v1/chat/completions\",\n endpointDesc: \"CLI Proxy API 的聊天补全接口地址。通常填写完整的 OpenAI 兼容 chat/completions 端点。\",\n defaultKey: \"\",\n needsEndpoint: true,\n },\n openrouter: {\n name: \"OpenRouter\",\n hints: [\n \"meta-llama/llama-3.3-70b-instruct:free\",\n \"deepseek/deepseek-r1:free\",\n \"google/gemini-2.0-flash-exp:free\",\n \"google/gemma-4-31b-it:free\",\n \"openrouter/auto\",\n \"meta-llama/llama-3-8b-instruct\",\n ],\n keyPlaceholder: \"sk-or-v1-...\",\n keyDesc: \"在 openrouter.ai → Keys 页面创建 API Key。\",\n defaultKey: \"\",\n },\n deepseek: {\n name: \"DeepSeek\",\n hints: [\"deepseek-v4-flash\", \"deepseek-v4-pro\", \"deepseek-chat\", \"deepseek-reasoner\"],\n keyPlaceholder: \"sk-...\",\n keyDesc: \"在 platform.deepseek.com → API Keys 页面获取。\",\n defaultKey: \"\",\n },\n siliconflow: {\n name: \"SiliconFlow\",\n hints: [\"Qwen/Qwen3-8B\", \"Qwen/Qwen2.5-7B-Instruct\", \"deepseek-ai/DeepSeek-V3\", \"deepseek-ai/DeepSeek-R1\", \"THUDM/GLM-4-9B-Chat\"],\n keyPlaceholder: \"sk-...\",\n keyDesc: \"在 cloud.siliconflow.cn → API Keys 页面创建。\",\n defaultKey: \"\",\n },\n aistudio: {\n name: \"Google AI Studio\",\n hints: [\"gemini-2.0-flash\", \"gemini-2.5-pro-exp-03-25\", \"gemini-1.5-pro\", \"gemini-1.5-flash\", \"gemma-4-31b-it\"],\n keyPlaceholder: \"AIzaSy...\",\n keyDesc: \"在 aistudio.google.com → Get API Key 页面创建。\",\n defaultKey: \"\",\n },\n copilot: {\n name: \"GitHub Copilot\",\n hints: [\"gpt-4o\", \"gpt-4o-mini\", \"claude-sonnet-4-5\", \"o3-mini\", \"gemini-2.0-flash\"],\n keyPlaceholder: \"\",\n keyDesc: \"\",\n defaultKey: \"\",\n noApiKey: true, // 授权由 GitHub 设备流管理\n },\n};\n\nconst DEFAULT_API_KEYS = { ollama: \"ollama\", cliproxy: \"\", openrouter: \"\", deepseek: \"\", siliconflow: \"\", aistudio: \"\", copilot: \"\" };\nconst DEFAULT_ENDPOINTS = { cliproxy: \"\" };\n\n// 内置系统提示词(发布前将该常量更新为目标内容)\nconst DEFAULT_SYSTEM_PROMPT =\n \"You are an intelligent assistant embedded in the browser sidebar, and also a page interaction tool.\\n\" +\n \"The user will provide the current page's \\\"DOM HTML\\\". Use the DOM structure to identify and locate elements to act on.\\n\\n\" +\n \"When you need to interact with the page, briefly describe what you are about to do, then append the action command at the end of your response:\\n\" +\n \" Click: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"button.submit-btn\\\"}</ACTION>\\n\" +\n \" Ctrl+Click: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"a.result\\\",\\\"modifiers\\\":[\\\"ctrl\\\"]}</ACTION>\\n\" +\n \" Double-click: <ACTION>{\\\"type\\\":\\\"double_click\\\",\\\"selector\\\":\\\".editable-cell\\\"}</ACTION>\\n\" +\n \" Right-click: <ACTION>{\\\"type\\\":\\\"right_click\\\",\\\"selector\\\":\\\".item\\\"}</ACTION>\\n\" +\n \" Type: <ACTION>{\\\"type\\\":\\\"type\\\",\\\"selector\\\":\\\"input[name='q']\\\",\\\"text\\\":\\\"search content\\\"}</ACTION>\\n\" +\n \" Select: <ACTION>{\\\"type\\\":\\\"select\\\",\\\"selector\\\":\\\"select#category\\\",\\\"value\\\":\\\"option\\\"}</ACTION>\\n\" +\n \" Press key: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"key\\\":\\\"Enter\\\"}</ACTION>\\n\" +\n \" Key on el: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"selector\\\":\\\".dropdown\\\",\\\"key\\\":\\\"ArrowDown\\\"}</ACTION>\\n\" +\n \" Navigate: <ACTION>{\\\"type\\\":\\\"navigate\\\",\\\"url\\\":\\\"https://example.com\\\"}</ACTION>\\n\" +\n \" Scroll: <ACTION>{\\\"type\\\":\\\"scroll\\\",\\\"y\\\":300}</ACTION>\\n\" +\n \" Hover: <ACTION>{\\\"type\\\":\\\"hover\\\",\\\"selector\\\":\\\".menu-item\\\"}</ACTION>\\n\" +\n \" Clear: <ACTION>{\\\"type\\\":\\\"clear\\\",\\\"selector\\\":\\\"input#search\\\"}</ACTION>\\n\\n\" +\n \"Rules:\\n\" +\n \"- selector must be a CSS selector string derived from the DOM HTML \\u2014 do not guess\\n\" +\n \"- Prefer stable attributes: id > name > aria-label > data-testid > class\\n\" +\n \"- Output one or more <ACTION> tags per turn; if a next action depends on the previous result (e.g. waiting for a page to load before locating an element), output them one at a time; if several actions are deterministic (e.g. filling multiple fields in sequence), you may output multiple <ACTION> tags in a single reply — all will be executed in order before the updated page state is fed back to you\\n\" +\n \"- If no page interaction is needed, reply in Markdown only \\u2014 do not output <ACTION>\\n\" +\n \"- If a suitable element is not found in the DOM, tell the user instead of guessing\\n\" +\n \"- Some elements (e.g. hover menus) are absent from the initial DOM; hover over the parent first to trigger rendering, then act on the target\\n\";\n\n// 内置循环反馈提示词默认值\nconst DEFAULT_LOOP_FEEDBACK_PROMPT =\n \"{{actions}}. Please continue completing the user's task. \" +\n \"If the task is fully completed, inform the user of the result and do not output any more <ACTION>.\";\n\n// ── DOM 引用 ──\nconst providerSelect = document.getElementById(\"provider-select\");\nconst modelInput = document.getElementById(\"model-input\");\nconst modelHints = document.getElementById(\"model-hints\");\nconst apikeyInput = document.getElementById(\"apikey-input\");\nconst endpointInput = document.getElementById(\"endpoint-input\");\nconst toggleKeyBtn = document.getElementById(\"toggle-key\");\nconst toggleKeyIcon = document.getElementById(\"toggle-key-icon\");\nconst keyDesc = document.getElementById(\"key-desc\");\nconst endpointDesc = document.getElementById(\"endpoint-desc\");\nconst saveBtn = document.getElementById(\"save-btn\");\nconst saveMsg = document.getElementById(\"save-msg\");\nconst systemPromptInput = document.getElementById(\"system-prompt\");\nconst skillsList = document.getElementById(\"skills-list\");\nconst addSkillBtn = document.getElementById(\"add-skill-btn\");\nconst languageSelect = document.getElementById(\"language-select\");\nconst themeSelect = document.getElementById(\"theme-select\");\nconst historyRoundsInput = document.getElementById(\"history-rounds\");\nconst agentMaxStepsInput = document.getElementById(\"agent-max-steps\");\nconst actionDelayInput = document.getElementById(\"action-delay\");\nconst loopFeedbackPromptInput = document.getElementById(\"loop-feedback-prompt\");\n\n// ── GitHub Copilot DOM 引用 ──\nconst apikeySection = document.getElementById(\"apikey-section\");\nconst endpointSection = document.getElementById(\"endpoint-section\");\nconst ghCopilotSection = document.getElementById(\"gh-copilot-section\");\nconst ghStatusText = document.getElementById(\"gh-status-text\");\nconst ghAuthFlow = document.getElementById(\"gh-auth-flow\");\nconst ghVerifyLink = document.getElementById(\"gh-verify-link\");\nconst ghUserCode = document.getElementById(\"gh-user-code\");\nconst ghCopyBtn = document.getElementById(\"gh-copy-btn\");\nconst ghOpenBtn = document.getElementById(\"gh-open-btn\");\nconst ghWaitingText = document.getElementById(\"gh-waiting-text\");\nconst ghConnectBtn = document.getElementById(\"gh-connect-btn\");\nconst ghDisconnectBtn = document.getElementById(\"gh-disconnect-btn\");\n\n// 当前所有平台已保存的 Key(切换平台时需要保留其他平台的值)\nlet currentApiKeys = { ...DEFAULT_API_KEYS };\nlet currentEndpoints = { ...DEFAULT_ENDPOINTS };\n// 当前各平台的自定义常用模型列表\nlet currentCustomHints = {};\n\n// 设备授权轮询中止信号\nlet _pollAbort = false;\nconst systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');\n\n// ─────────────────────────────────────────────\n// 存储操作\n// ─────────────────────────────────────────────\nfunction loadConfig() {\n return new Promise(resolve => chrome.storage.sync.get(null, resolve));\n}\n\nfunction saveConfig(data) {\n return new Promise(resolve => chrome.storage.sync.set(data, resolve));\n}\n\nfunction normalizeTheme(theme) {\n return theme === 'dark' || theme === 'light' || theme === 'device' ? theme : 'light';\n}\n\nfunction resolveTheme(theme) {\n const normalizedTheme = normalizeTheme(theme);\n if (normalizedTheme === 'device') {\n return systemThemeMedia.matches ? 'dark' : 'light';\n }\n return normalizedTheme;\n}\n\nfunction applyTheme(theme) {\n document.body.dataset.theme = resolveTheme(theme);\n}\n\nfunction getThemeColor(name) {\n return getComputedStyle(document.body).getPropertyValue(name).trim();\n}\n\n// ─────────────────────────────────────────────\n// 切换平台时更新提示/Key 字段\n// ─────────────────────────────────────────────\nfunction onProviderChange() {\n const pid = providerSelect.value;\n const conf = PROVIDERS[pid];\n\n // GitHub Copilot:隐藏 API Key 区,显示 GitHub 授权区\n if (conf.noApiKey) {\n apikeySection.style.display = 'none';\n endpointSection.style.display = 'none';\n ghCopilotSection.style.display = '';\n } else {\n apikeySection.style.display = '';\n ghCopilotSection.style.display = 'none';\n\n // 更新 Key 字段(显示该平台已保存的 Key)\n apikeyInput.value = currentApiKeys[pid] || \"\";\n apikeyInput.placeholder = I18n.t('key_ph_' + pid);\n keyDesc.textContent = I18n.t('key_desc_' + pid);\n\n if (conf.needsEndpoint) {\n endpointSection.style.display = '';\n endpointInput.value = currentEndpoints[pid] || \"\";\n endpointInput.placeholder = I18n.t('endpoint_ph_' + pid);\n endpointDesc.textContent = I18n.t('endpoint_desc_' + pid);\n } else {\n endpointSection.style.display = 'none';\n endpointInput.value = '';\n }\n }\n\n // 渲染模型提示 chips\n renderHints();\n}\n\n// 渲染常用模型列表(支持删除 + 添加)\nfunction renderHints() {\n const pid = providerSelect.value;\n // 首次访问该平台时,用默认列表初始化\n if (!currentCustomHints[pid]) {\n currentCustomHints[pid] = [...(PROVIDERS[pid].hints || [])];\n }\n const hints = currentCustomHints[pid];\n\n modelHints.innerHTML = \"\";\n hints.forEach((hint, idx) => {\n const chip = document.createElement(\"div\");\n chip.className = \"hint-chip\";\n\n const label = document.createElement(\"span\");\n label.textContent = hint;\n label.addEventListener(\"click\", () => {\n modelInput.value = hint;\n modelInput.focus();\n });\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.className = \"hint-chip-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = \"移除\";\n removeBtn.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n hints.splice(idx, 1);\n renderHints();\n });\n\n chip.appendChild(label);\n chip.appendChild(removeBtn);\n chip.addEventListener(\"click\", (e) => {\n if (e.target !== removeBtn) {\n modelInput.value = hint;\n modelInput.focus();\n }\n });\n modelHints.appendChild(chip);\n });\n\n // 加号按钮\n const addBtn = document.createElement(\"button\");\n addBtn.className = \"hint-add-btn\";\n addBtn.textContent = \"+\";\n addBtn.title = I18n.t('btn_add_model_hint');\n addBtn.addEventListener(\"click\", () => {\n addBtn.remove();\n const inp = document.createElement(\"input\");\n inp.type = \"text\";\n inp.placeholder = I18n.t('model_hint_placeholder');\n inp.style.cssText = `font-size:11.5px;font-family:Consolas,monospace;border:1px solid ${getThemeColor('--blue')};border-radius:12px;padding:3px 10px;outline:none;width:150px;background:${getThemeColor('--surface')};color:${getThemeColor('--text')};`;\n const confirm = () => {\n const val = inp.value.trim();\n if (val) hints.push(val);\n renderHints();\n };\n inp.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") confirm();\n if (e.key === \"Escape\") renderHints();\n });\n inp.addEventListener(\"blur\", confirm);\n modelHints.appendChild(inp);\n inp.focus();\n });\n modelHints.appendChild(addBtn);\n}\n\n// ─────────────────────────────────────────────\n// Skill 卡片\n// ─────────────────────────────────────────────\nfunction addSkillCard(name = \"\", content = \"\") {\n const card = document.createElement(\"div\");\n card.className = \"skill-card\";\n\n const header = document.createElement(\"div\");\n header.className = \"skill-header\";\n\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"skill-name\";\n nameInput.placeholder = I18n.t('skill_name_ph');\n nameInput.value = name;\n nameInput.spellcheck = false;\n nameInput.autocomplete = \"off\";\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.type = \"button\";\n removeBtn.className = \"skill-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = I18n.t('skill_remove_title');\n removeBtn.addEventListener(\"click\", () => card.remove());\n\n header.appendChild(nameInput);\n header.appendChild(removeBtn);\n\n const contentArea = document.createElement(\"textarea\");\n contentArea.className = \"skill-content\";\n contentArea.placeholder = I18n.t('skill_content_ph');\n contentArea.rows = 3;\n contentArea.spellcheck = false;\n contentArea.value = content;\n\n card.appendChild(header);\n card.appendChild(contentArea);\n skillsList.appendChild(card);\n}\n\nfunction collectSkills() {\n return Array.from(skillsList.querySelectorAll(\".skill-card\")).map(card => ({\n name: card.querySelector(\".skill-name\").value.trim(),\n content: card.querySelector(\".skill-content\").value.trim(),\n })).filter(s => s.name || s.content);\n}\n\naddSkillBtn.addEventListener(\"click\", () => addSkillCard());\n\n// ─────────────────────────────────────────────\n// GitHub Copilot 设备授权\n// ─────────────────────────────────────────────\n\n// 刷新 GitHub Copilot 授权状态 UI\nasync function refreshGhStatus() {\n const user = await GitHubAuth.getConnectedUser();\n if (user) {\n ghStatusText.textContent = `${I18n.t('gh_connected_as')}: @${user}`;\n ghStatusText.style.color = getThemeColor('--success-strong');\n ghConnectBtn.style.display = 'none';\n ghDisconnectBtn.style.display = '';\n ghAuthFlow.style.display = 'none';\n } else {\n ghStatusText.textContent = I18n.t('gh_not_connected');\n ghStatusText.style.color = getThemeColor('--muted-2');\n ghConnectBtn.style.display = '';\n ghDisconnectBtn.style.display = 'none';\n ghAuthFlow.style.display = 'none';\n }\n}\n\n// 复制验证码\nghCopyBtn.addEventListener('click', () => {\n const code = ghUserCode.textContent;\n if (code) {\n navigator.clipboard.writeText(code).then(() => {\n ghCopyBtn.textContent = '✓';\n setTimeout(() => { ghCopyBtn.textContent = I18n.t('gh_copy_code'); }, 1500);\n });\n }\n});\n\n// 连接 GitHub 账号(开始设备授权流程)\nghConnectBtn.addEventListener('click', async () => {\n _pollAbort = false;\n ghConnectBtn.disabled = true;\n ghConnectBtn.textContent = '...';\n\n try {\n const { device_code, user_code, verification_uri, expires_in, interval } =\n await GitHubAuth.startDeviceFlow();\n\n // 显示验证码和授权链接\n ghUserCode.textContent = user_code;\n ghVerifyLink.textContent = verification_uri;\n ghVerifyLink.href = verification_uri;\n ghOpenBtn.href = verification_uri;\n ghAuthFlow.style.display = '';\n ghWaitingText.textContent = I18n.t('gh_waiting');\n\n // 轮询直到授权完成或超时\n const expiresAt = Date.now() + expires_in * 1000;\n const oauthToken = await GitHubAuth.pollForToken(device_code, interval, () => {\n if (_pollAbort) throw new Error('aborted');\n if (Date.now() > expiresAt) throw new Error('expired');\n });\n\n // 授权成功\n await GitHubAuth.saveOAuthToken(oauthToken);\n ghWaitingText.textContent = I18n.t('gh_auth_success');\n ghWaitingText.style.color = getThemeColor('--success-strong');\n setTimeout(async () => {\n ghWaitingText.style.color = '';\n await refreshGhStatus();\n }, 1500);\n\n } catch (e) {\n ghAuthFlow.style.display = 'none';\n const msg = e.message === 'expired' ? I18n.t('gh_auth_expired')\n : e.message === 'denied' ? I18n.t('gh_auth_denied')\n : e.message === 'aborted' ? ''\n : I18n.t('gh_auth_error');\n if (msg) {\n ghStatusText.textContent = msg;\n ghStatusText.style.color = getThemeColor('--danger-strong');\n }\n ghConnectBtn.disabled = false;\n ghConnectBtn.textContent = I18n.t('gh_connect_btn');\n }\n});\n\n// 断开连接\nghDisconnectBtn.addEventListener('click', async () => {\n _pollAbort = true;\n await GitHubAuth.logout();\n await refreshGhStatus();\n});\n\n// 语言切换即时生效\nlanguageSelect.addEventListener(\"change\", () => {\n I18n.setLang(languageSelect.value);\n I18n.apply();\n onProviderChange(); // 重新渲染 keyDesc 和 hints\n});\n\nthemeSelect.addEventListener(\"change\", () => {\n applyTheme(themeSelect.value);\n});\n\nsystemThemeMedia.addEventListener('change', () => {\n if (themeSelect.value === 'device') {\n applyTheme('device');\n }\n});\n\n// ─────────────────────────────────────────────\n// 侧边栏内嵌模式:检测是否在 iframe 中运行\n// ─────────────────────────────────────────────\nconst inPanel = window.self !== window.top;\nif (inPanel) {\n document.body.classList.add('in-panel');\n const closePanel = () => window.parent.postMessage({ type: 'settings-close' }, '*');\n document.getElementById('btn-back').addEventListener('click', closePanel);\n document.getElementById('btn-back-bottom').addEventListener('click', closePanel);\n}\n\n// ─────────────────────────────────────────────\n// 保存\n// ─────────────────────────────────────────────\nasync function onSave() {\n const pid = providerSelect.value;\n\n // copilot 的认证由 GitHubAuth 独立管理,不写入 apiKeys\n if (pid !== 'copilot') {\n currentApiKeys[pid] = apikeyInput.value.trim();\n }\n\n if (PROVIDERS[pid]?.needsEndpoint) {\n currentEndpoints[pid] = endpointInput.value.trim();\n }\n\n await saveConfig({\n provider: pid,\n model: modelInput.value.trim(),\n apiKeys: { ...currentApiKeys },\n endpoints: { ...currentEndpoints },\n customHints: { ...currentCustomHints },\n systemPrompt: systemPromptInput.value.trim(),\n loopFeedbackPrompt: loopFeedbackPromptInput.value,\n skills: collectSkills(),\n language: languageSelect.value,\n userLang: languageSelect.value,\n theme: themeSelect.value,\n historyRounds: historyRoundsInput.value.trim(),\n agentMaxSteps: agentMaxStepsInput.value.trim(),\n actionDelay: actionDelayInput.value !== '' ? (parseInt(actionDelayInput.value) || 0) : 0,\n });\n\n // 显示保存成功提示\n saveMsg.classList.add(\"visible\");\n setTimeout(() => saveMsg.classList.remove(\"visible\"), 2000);\n\n // 如果在侧边栏 iframe 中,通知父页面已保存\n if (inPanel) {\n window.parent.postMessage({ type: 'settings-saved' }, '*');\n }\n}\n\n// ─────────────────────────────────────────────\n// 显示/隐藏 Key\n// ─────────────────────────────────────────────\nfunction syncToggleKeyIcon() {\n const isHidden = apikeyInput.type === \"password\";\n toggleKeyIcon.src = isHidden ? \"assets/icons/show.svg\" : \"assets/icons/hide.svg\";\n toggleKeyIcon.alt = isHidden ? \"Show password\" : \"Hide password\";\n}\n\ntoggleKeyBtn.addEventListener(\"click\", () => {\n if (apikeyInput.type === \"password\") {\n apikeyInput.type = \"text\";\n } else {\n apikeyInput.type = \"password\";\n }\n syncToggleKeyIcon();\n});\n\nsyncToggleKeyIcon();\n\n// 实时同步输入的 Key 到内存(切换平台前自动保留)\napikeyInput.addEventListener(\"input\", () => {\n currentApiKeys[providerSelect.value] = apikeyInput.value;\n});\n\nendpointInput.addEventListener(\"input\", () => {\n currentEndpoints[providerSelect.value] = endpointInput.value;\n});\n\nproviderSelect.addEventListener(\"change\", () => {\n modelInput.value = ''; // 切换平台时清空模型名称\n onProviderChange();\n if (providerSelect.value === 'copilot') refreshGhStatus();\n});\n\n// 恢复系统提示词默认值\ndocument.getElementById('reset-prompt-btn').addEventListener('click', () => {\n systemPromptInput.value = DEFAULT_SYSTEM_PROMPT;\n systemPromptInput.focus();\n});\n\n// 恢复循环反馈提示词默认值\ndocument.getElementById('reset-loop-feedback-btn').addEventListener('click', () => {\n loopFeedbackPromptInput.value = DEFAULT_LOOP_FEEDBACK_PROMPT;\n loopFeedbackPromptInput.focus();\n});\nsaveBtn.addEventListener(\"click\", onSave);\n\n// ─────────────────────────────────────────────\n// 初始化:读取已保存配置并填入表单\n// ─────────────────────────────────────────────\n(async () => {\n const stored = await loadConfig();\n\n // 先应用语言;用 userLang 键(新键,旧存储没有此值时默认英文)\n const effectiveLang = stored.userLang || 'en';\n // 仅当存储语言与已渲染语言不同时才重新 apply,避免 DOM 变更关闭已打开的下拉框\n if (I18n.lang !== effectiveLang) {\n I18n.setLang(effectiveLang);\n I18n.apply();\n }\n\n const effectiveTheme = normalizeTheme(stored.theme);\n applyTheme(effectiveTheme);\n\n if (stored.provider && PROVIDERS[stored.provider]) {\n providerSelect.value = stored.provider;\n }\n\n if (stored.model) {\n modelInput.value = stored.model;\n } else {\n // 首次未保存时,根据当前平台给出默认模型名\n const _defaultModels = { deepseek: 'deepseek-v4-flash', siliconflow: 'Qwen/Qwen3-8B', cliproxy: 'gpt-4o', ollama: 'qwen3:8b', aistudio: 'gemini-2.0-flash' };\n modelInput.value = _defaultModels[providerSelect.value] || '';\n }\n\n if (stored.apiKeys) {\n Object.assign(currentApiKeys, stored.apiKeys);\n }\n\n if (stored.endpoints && typeof stored.endpoints === 'object') {\n Object.assign(currentEndpoints, stored.endpoints);\n }\n\n if (stored.customHints && typeof stored.customHints === 'object') {\n Object.assign(currentCustomHints, stored.customHints);\n }\n\n // 系统提示词:显示已保存的值,首次未保存则显示默认值\n systemPromptInput.value = stored.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n // 循环反馈提示词\n loopFeedbackPromptInput.value = stored.loopFeedbackPrompt !== undefined ? stored.loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT;\n\n if (Array.isArray(stored.skills) && stored.skills.length > 0) {\n stored.skills.forEach(s => addSkillCard(s.name || \"\", s.content || \"\"));\n }\n\n languageSelect.value = effectiveLang;\n themeSelect.value = effectiveTheme;\n\n historyRoundsInput.value = stored.historyRounds != null ? stored.historyRounds : 3;\n\n if (stored.agentMaxSteps != null && stored.agentMaxSteps !== '') {\n agentMaxStepsInput.value = stored.agentMaxSteps;\n }\n\n actionDelayInput.value = stored.actionDelay != null ? stored.actionDelay : 500;\n\n onProviderChange(); // 渲染当前平台的 Key / 提示\n\n // 如果当前平台是 copilot,刷新授权状态\n if ((stored.provider || 'ollama') === 'copilot') {\n refreshGhStatus();\n }\n})();\n"}, "b916dc06631fbd2bef8311f03ff2bd90e985fca3": {"t": 1785758638.6279132, "n": 19048, "h": "58b53aeb60a1b571", "c": "// ===== options.js =====\n\n// 同步立即应用英文(CSP 禁止 HTML 内联脚本,故在此处执行)\nI18n.apply();\n\nconst PROVIDERS = {\n ollama: {\n name: \"Ollama\",\n hints: [\"qwen3:8b\", \"qwen2.5:7b\", \"llama3.2:3b\", \"llama3.1:8b\", \"deepseek-r1:7b\", \"gemma3:4b\", \"mistral:7b\"],\n keyPlaceholder: \"ollama(本地服务无需 Key,留空即可)\",\n keyDesc: \"Ollama 本地服务不校验 API Key,留空或填任意字符均可。\",\n defaultKey: \"ollama\",\n },\n openrouter: {\n name: \"OpenRouter\",\n hints: [\n \"meta-llama/llama-3.3-70b-instruct:free\",\n \"deepseek/deepseek-r1:free\",\n \"google/gemini-2.0-flash-exp:free\",\n \"google/gemma-4-31b-it:free\",\n \"openrouter/auto\",\n \"meta-llama/llama-3-8b-instruct\",\n ],\n keyPlaceholder: \"sk-or-v1-...\",\n keyDesc: \"在 openrouter.ai → Keys 页面创建 API Key。\",\n defaultKey: \"\",\n },\n deepseek: {\n name: \"DeepSeek\",\n hints: [\"deepseek-v4-flash\", \"deepseek-v4-pro\", \"deepseek-chat\", \"deepseek-reasoner\"],\n keyPlaceholder: \"sk-...\",\n keyDesc: \"在 platform.deepseek.com → API Keys 页面获取。\",\n defaultKey: \"\",\n },\n aistudio: {\n name: \"Google AI Studio\",\n hints: [\"gemini-2.0-flash\", \"gemini-2.5-pro-exp-03-25\", \"gemini-1.5-pro\", \"gemini-1.5-flash\", \"gemma-4-31b-it\"],\n keyPlaceholder: \"AIzaSy...\",\n keyDesc: \"在 aistudio.google.com → Get API Key 页面创建。\",\n defaultKey: \"\",\n },\n copilot: {\n name: \"GitHub Copilot\",\n hints: [\"gpt-4o\", \"gpt-4o-mini\", \"claude-sonnet-4-5\", \"o3-mini\", \"gemini-2.0-flash\"],\n keyPlaceholder: \"\",\n keyDesc: \"\",\n defaultKey: \"\",\n noApiKey: true, // 授权由 GitHub 设备流管理\n },\n};\n\nconst DEFAULT_API_KEYS = { ollama: \"ollama\", openrouter: \"\", deepseek: \"\", aistudio: \"\", copilot: \"\" };\n\n// 内置系统提示词(发布前将该常量更新为目标内容)\nconst DEFAULT_SYSTEM_PROMPT =\n \"You are an intelligent assistant embedded in the browser sidebar, and also a page interaction tool.\\n\" +\n \"The user will provide the current page's \\\"DOM HTML\\\". Use the DOM structure to identify and locate elements to act on.\\n\\n\" +\n \"When you need to interact with the page, briefly describe what you are about to do, then append the action command at the end of your response:\\n\" +\n \" Click: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"button.submit-btn\\\"}</ACTION>\\n\" +\n \" Ctrl+Click: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"a.result\\\",\\\"modifiers\\\":[\\\"ctrl\\\"]}</ACTION>\\n\" +\n \" Double-click: <ACTION>{\\\"type\\\":\\\"double_click\\\",\\\"selector\\\":\\\".editable-cell\\\"}</ACTION>\\n\" +\n \" Right-click: <ACTION>{\\\"type\\\":\\\"right_click\\\",\\\"selector\\\":\\\".item\\\"}</ACTION>\\n\" +\n \" Type: <ACTION>{\\\"type\\\":\\\"type\\\",\\\"selector\\\":\\\"input[name='q']\\\",\\\"text\\\":\\\"search content\\\"}</ACTION>\\n\" +\n \" Select: <ACTION>{\\\"type\\\":\\\"select\\\",\\\"selector\\\":\\\"select#category\\\",\\\"value\\\":\\\"option\\\"}</ACTION>\\n\" +\n \" Press key: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"key\\\":\\\"Enter\\\"}</ACTION>\\n\" +\n \" Key on el: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"selector\\\":\\\".dropdown\\\",\\\"key\\\":\\\"ArrowDown\\\"}</ACTION>\\n\" +\n \" Navigate: <ACTION>{\\\"type\\\":\\\"navigate\\\",\\\"url\\\":\\\"https://example.com\\\"}</ACTION>\\n\" +\n \" Scroll: <ACTION>{\\\"type\\\":\\\"scroll\\\",\\\"y\\\":300}</ACTION>\\n\" +\n \" Hover: <ACTION>{\\\"type\\\":\\\"hover\\\",\\\"selector\\\":\\\".menu-item\\\"}</ACTION>\\n\" +\n \" Clear: <ACTION>{\\\"type\\\":\\\"clear\\\",\\\"selector\\\":\\\"input#search\\\"}</ACTION>\\n\\n\" +\n \"Rules:\\n\" +\n \"- selector must be a CSS selector string derived from the DOM HTML \\u2014 do not guess\\n\" +\n \"- Prefer stable attributes: id > name > aria-label > data-testid > class\\n\" +\n \"- Output one or more <ACTION> tags per turn; if a next action depends on the previous result (e.g. waiting for a page to load before locating an element), output them one at a time; if several actions are deterministic (e.g. filling multiple fields in sequence), you may output multiple <ACTION> tags in a single reply — all will be executed in order before the updated page state is fed back to you\\n\" +\n \"- If no page interaction is needed, reply in Markdown only \\u2014 do not output <ACTION>\\n\" +\n \"- If a suitable element is not found in the DOM, tell the user instead of guessing\\n\" +\n \"- Some elements (e.g. hover menus) are absent from the initial DOM; hover over the parent first to trigger rendering, then act on the target\\n\";\n\n// 内置循环反馈提示词默认值\nconst DEFAULT_LOOP_FEEDBACK_PROMPT =\n \"{{actions}}. Please continue completing the user's task. \" +\n \"If the task is fully completed, inform the user of the result and do not output any more <ACTION>.\";\n\n// ── DOM 引用 ──\nconst providerSelect = document.getElementById(\"provider-select\");\nconst modelInput = document.getElementById(\"model-input\");\nconst modelHints = document.getElementById(\"model-hints\");\nconst apikeyInput = document.getElementById(\"apikey-input\");\nconst toggleKeyBtn = document.getElementById(\"toggle-key\");\nconst keyDesc = document.getElementById(\"key-desc\");\nconst saveBtn = document.getElementById(\"save-btn\");\nconst saveMsg = document.getElementById(\"save-msg\");\nconst systemPromptInput = document.getElementById(\"system-prompt\");\nconst skillsList = document.getElementById(\"skills-list\");\nconst addSkillBtn = document.getElementById(\"add-skill-btn\");\nconst languageSelect = document.getElementById(\"language-select\");\nconst historyRoundsInput = document.getElementById(\"history-rounds\");\nconst agentMaxStepsInput = document.getElementById(\"agent-max-steps\");\nconst actionDelayInput = document.getElementById(\"action-delay\");\nconst loopFeedbackPromptInput = document.getElementById(\"loop-feedback-prompt\");\n\n// ── GitHub Copilot DOM 引用 ──\nconst apikeySection = document.getElementById(\"apikey-section\");\nconst ghCopilotSection = document.getElementById(\"gh-copilot-section\");\nconst ghStatusText = document.getElementById(\"gh-status-text\");\nconst ghAuthFlow = document.getElementById(\"gh-auth-flow\");\nconst ghVerifyLink = document.getElementById(\"gh-verify-link\");\nconst ghUserCode = document.getElementById(\"gh-user-code\");\nconst ghCopyBtn = document.getElementById(\"gh-copy-btn\");\nconst ghOpenBtn = document.getElementById(\"gh-open-btn\");\nconst ghWaitingText = document.getElementById(\"gh-waiting-text\");\nconst ghConnectBtn = document.getElementById(\"gh-connect-btn\");\nconst ghDisconnectBtn = document.getElementById(\"gh-disconnect-btn\");\n\n// 当前所有平台已保存的 Key(切换平台时需要保留其他平台的值)\nlet currentApiKeys = { ...DEFAULT_API_KEYS };\n// 当前各平台的自定义常用模型列表\nlet currentCustomHints = {};\n\n// 设备授权轮询中止信号\nlet _pollAbort = false;\n\n// ─────────────────────────────────────────────\n// 存储操作\n// ─────────────────────────────────────────────\nfunction loadConfig() {\n return new Promise(resolve => chrome.storage.sync.get(null, resolve));\n}\n\nfunction saveConfig(data) {\n return new Promise(resolve => chrome.storage.sync.set(data, resolve));\n}\n\n// ─────────────────────────────────────────────\n// 切换平台时更新提示/Key 字段\n// ─────────────────────────────────────────────\nfunction onProviderChange() {\n const pid = providerSelect.value;\n const conf = PROVIDERS[pid];\n\n // GitHub Copilot:隐藏 API Key 区,显示 GitHub 授权区\n if (conf.noApiKey) {\n apikeySection.style.display = 'none';\n ghCopilotSection.style.display = '';\n } else {\n apikeySection.style.display = '';\n ghCopilotSection.style.display = 'none';\n\n // 更新 Key 字段(显示该平台已保存的 Key)\n apikeyInput.value = currentApiKeys[pid] || \"\";\n apikeyInput.placeholder = I18n.t('key_ph_' + pid);\n keyDesc.textContent = I18n.t('key_desc_' + pid);\n }\n\n // 渲染模型提示 chips\n renderHints();\n}\n\n// 渲染常用模型列表(支持删除 + 添加)\nfunction renderHints() {\n const pid = providerSelect.value;\n // 首次访问该平台时,用默认列表初始化\n if (!currentCustomHints[pid]) {\n currentCustomHints[pid] = [...(PROVIDERS[pid].hints || [])];\n }\n const hints = currentCustomHints[pid];\n\n modelHints.innerHTML = \"\";\n hints.forEach((hint, idx) => {\n const chip = document.createElement(\"div\");\n chip.className = \"hint-chip\";\n\n const label = document.createElement(\"span\");\n label.textContent = hint;\n label.addEventListener(\"click\", () => {\n modelInput.value = hint;\n modelInput.focus();\n });\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.className = \"hint-chip-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = \"移除\";\n removeBtn.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n hints.splice(idx, 1);\n renderHints();\n });\n\n chip.appendChild(label);\n chip.appendChild(removeBtn);\n chip.addEventListener(\"click\", (e) => {\n if (e.target !== removeBtn) {\n modelInput.value = hint;\n modelInput.focus();\n }\n });\n modelHints.appendChild(chip);\n });\n\n // 加号按钮\n const addBtn = document.createElement(\"button\");\n addBtn.className = \"hint-add-btn\";\n addBtn.textContent = \"+\";\n addBtn.title = I18n.t('btn_add_model_hint');\n addBtn.addEventListener(\"click\", () => {\n addBtn.remove();\n const inp = document.createElement(\"input\");\n inp.type = \"text\";\n inp.placeholder = I18n.t('model_hint_placeholder');\n inp.style.cssText = \"font-size:11.5px;font-family:Consolas,monospace;border:1px solid #1a73e8;border-radius:12px;padding:3px 10px;outline:none;width:150px;\";\n const confirm = () => {\n const val = inp.value.trim();\n if (val) hints.push(val);\n renderHints();\n };\n inp.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") confirm();\n if (e.key === \"Escape\") renderHints();\n });\n inp.addEventListener(\"blur\", confirm);\n modelHints.appendChild(inp);\n inp.focus();\n });\n modelHints.appendChild(addBtn);\n}\n\n// ─────────────────────────────────────────────\n// Skill 卡片\n// ─────────────────────────────────────────────\nfunction addSkillCard(name = \"\", content = \"\") {\n const card = document.createElement(\"div\");\n card.className = \"skill-card\";\n\n const header = document.createElement(\"div\");\n header.className = \"skill-header\";\n\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"skill-name\";\n nameInput.placeholder = I18n.t('skill_name_ph');\n nameInput.value = name;\n nameInput.spellcheck = false;\n nameInput.autocomplete = \"off\";\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.type = \"button\";\n removeBtn.className = \"skill-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = I18n.t('skill_remove_title');\n removeBtn.addEventListener(\"click\", () => card.remove());\n\n header.appendChild(nameInput);\n header.appendChild(removeBtn);\n\n const contentArea = document.createElement(\"textarea\");\n contentArea.className = \"skill-content\";\n contentArea.placeholder = I18n.t('skill_content_ph');\n contentArea.rows = 3;\n contentArea.spellcheck = false;\n contentArea.value = content;\n\n card.appendChild(header);\n card.appendChild(contentArea);\n skillsList.appendChild(card);\n}\n\nfunction collectSkills() {\n return Array.from(skillsList.querySelectorAll(\".skill-card\")).map(card => ({\n name: card.querySelector(\".skill-name\").value.trim(),\n content: card.querySelector(\".skill-content\").value.trim(),\n })).filter(s => s.name || s.content);\n}\n\naddSkillBtn.addEventListener(\"click\", () => addSkillCard());\n\n// ─────────────────────────────────────────────\n// GitHub Copilot 设备授权\n// ─────────────────────────────────────────────\n\n// 刷新 GitHub Copilot 授权状态 UI\nasync function refreshGhStatus() {\n const user = await GitHubAuth.getConnectedUser();\n if (user) {\n ghStatusText.textContent = `${I18n.t('gh_connected_as')}: @${user}`;\n ghStatusText.style.color = '#1e8e3e';\n ghConnectBtn.style.display = 'none';\n ghDisconnectBtn.style.display = '';\n ghAuthFlow.style.display = 'none';\n } else {\n ghStatusText.textContent = I18n.t('gh_not_connected');\n ghStatusText.style.color = '#80868b';\n ghConnectBtn.style.display = '';\n ghDisconnectBtn.style.display = 'none';\n ghAuthFlow.style.display = 'none';\n }\n}\n\n// 复制验证码\nghCopyBtn.addEventListener('click', () => {\n const code = ghUserCode.textContent;\n if (code) {\n navigator.clipboard.writeText(code).then(() => {\n ghCopyBtn.textContent = '✓';\n setTimeout(() => { ghCopyBtn.textContent = I18n.t('gh_copy_code'); }, 1500);\n });\n }\n});\n\n// 连接 GitHub 账号(开始设备授权流程)\nghConnectBtn.addEventListener('click', async () => {\n _pollAbort = false;\n ghConnectBtn.disabled = true;\n ghConnectBtn.textContent = '...';\n\n try {\n const { device_code, user_code, verification_uri, expires_in, interval } =\n await GitHubAuth.startDeviceFlow();\n\n // 显示验证码和授权链接\n ghUserCode.textContent = user_code;\n ghVerifyLink.textContent = verification_uri;\n ghVerifyLink.href = verification_uri;\n ghOpenBtn.href = verification_uri;\n ghAuthFlow.style.display = '';\n ghWaitingText.textContent = I18n.t('gh_waiting');\n\n // 轮询直到授权完成或超时\n const expiresAt = Date.now() + expires_in * 1000;\n const oauthToken = await GitHubAuth.pollForToken(device_code, interval, () => {\n if (_pollAbort) throw new Error('aborted');\n if (Date.now() > expiresAt) throw new Error('expired');\n });\n\n // 授权成功\n await GitHubAuth.saveOAuthToken(oauthToken);\n ghWaitingText.textContent = I18n.t('gh_auth_success');\n ghWaitingText.style.color = '#1e8e3e';\n setTimeout(async () => {\n ghWaitingText.style.color = '';\n await refreshGhStatus();\n }, 1500);\n\n } catch (e) {\n ghAuthFlow.style.display = 'none';\n const msg = e.message === 'expired' ? I18n.t('gh_auth_expired')\n : e.message === 'denied' ? I18n.t('gh_auth_denied')\n : e.message === 'aborted' ? ''\n : I18n.t('gh_auth_error');\n if (msg) {\n ghStatusText.textContent = msg;\n ghStatusText.style.color = '#d93025';\n }\n ghConnectBtn.disabled = false;\n ghConnectBtn.textContent = I18n.t('gh_connect_btn');\n }\n});\n\n// 断开连接\nghDisconnectBtn.addEventListener('click', async () => {\n _pollAbort = true;\n await GitHubAuth.logout();\n await refreshGhStatus();\n});\n\n// 语言切换即时生效\nlanguageSelect.addEventListener(\"change\", () => {\n I18n.setLang(languageSelect.value);\n I18n.apply();\n onProviderChange(); // 重新渲染 keyDesc 和 hints\n});\n\n// ─────────────────────────────────────────────\n// 侧边栏内嵌模式:检测是否在 iframe 中运行\n// ─────────────────────────────────────────────\nconst inPanel = window.self !== window.top;\nif (inPanel) {\n document.body.classList.add('in-panel');\n const closePanel = () => window.parent.postMessage({ type: 'settings-close' }, '*');\n document.getElementById('btn-back').addEventListener('click', closePanel);\n document.getElementById('btn-back-bottom').addEventListener('click', closePanel);\n}\n\n// ─────────────────────────────────────────────\n// 保存\n// ─────────────────────────────────────────────\nasync function onSave() {\n const pid = providerSelect.value;\n\n // copilot 的认证由 GitHubAuth 独立管理,不写入 apiKeys\n if (pid !== 'copilot') {\n currentApiKeys[pid] = apikeyInput.value.trim();\n }\n\n await saveConfig({\n provider: pid,\n model: modelInput.value.trim(),\n apiKeys: { ...currentApiKeys },\n customHints: { ...currentCustomHints },\n systemPrompt: systemPromptInput.value.trim(),\n loopFeedbackPrompt: loopFeedbackPromptInput.value,\n skills: collectSkills(),\n language: languageSelect.value,\n userLang: languageSelect.value,\n historyRounds: historyRoundsInput.value.trim(),\n agentMaxSteps: agentMaxStepsInput.value.trim(),\n actionDelay: actionDelayInput.value !== '' ? (parseInt(actionDelayInput.value) || 0) : 0,\n });\n\n // 显示保存成功提示\n saveMsg.classList.add(\"visible\");\n setTimeout(() => saveMsg.classList.remove(\"visible\"), 2000);\n\n // 如果在侧边栏 iframe 中,通知父页面已保存\n if (inPanel) {\n window.parent.postMessage({ type: 'settings-saved' }, '*');\n }\n}\n\n// ─────────────────────────────────────────────\n// 显示/隐藏 Key\n// ─────────────────────────────────────────────\ntoggleKeyBtn.addEventListener(\"click\", () => {\n if (apikeyInput.type === \"password\") {\n apikeyInput.type = \"text\";\n toggleKeyBtn.textContent = \"🙈\";\n } else {\n apikeyInput.type = \"password\";\n toggleKeyBtn.textContent = \"👁\";\n }\n});\n\n// 实时同步输入的 Key 到内存(切换平台前自动保留)\napikeyInput.addEventListener(\"input\", () => {\n currentApiKeys[providerSelect.value] = apikeyInput.value;\n});\n\nproviderSelect.addEventListener(\"change\", () => {\n modelInput.value = ''; // 切换平台时清空模型名称\n onProviderChange();\n if (providerSelect.value === 'copilot') refreshGhStatus();\n});\n\n// 恢复系统提示词默认值\ndocument.getElementById('reset-prompt-btn').addEventListener('click', () => {\n systemPromptInput.value = DEFAULT_SYSTEM_PROMPT;\n systemPromptInput.focus();\n});\n\n// 恢复循环反馈提示词默认值\ndocument.getElementById('reset-loop-feedback-btn').addEventListener('click', () => {\n loopFeedbackPromptInput.value = DEFAULT_LOOP_FEEDBACK_PROMPT;\n loopFeedbackPromptInput.focus();\n});\nsaveBtn.addEventListener(\"click\", onSave);\n\n// ─────────────────────────────────────────────\n// 初始化:读取已保存配置并填入表单\n// ─────────────────────────────────────────────\n(async () => {\n const stored = await loadConfig();\n\n // 先应用语言;用 userLang 键(新键,旧存储没有此值时默认英文)\n const effectiveLang = stored.userLang || 'en';\n // 仅当存储语言与已渲染语言不同时才重新 apply,避免 DOM 变更关闭已打开的下拉框\n if (I18n.lang !== effectiveLang) {\n I18n.setLang(effectiveLang);\n I18n.apply();\n }\n\n if (stored.provider && PROVIDERS[stored.provider]) {\n providerSelect.value = stored.provider;\n }\n\n if (stored.model) {\n modelInput.value = stored.model;\n } else {\n // 首次未保存时,根据当前平台给出默认模型名\n const _defaultModels = { deepseek: 'deepseek-v4-flash', ollama: 'qwen3:8b', aistudio: 'gemini-2.0-flash' };\n modelInput.value = _defaultModels[providerSelect.value] || '';\n }\n\n if (stored.apiKeys) {\n Object.assign(currentApiKeys, stored.apiKeys);\n }\n\n if (stored.customHints && typeof stored.customHints === 'object') {\n Object.assign(currentCustomHints, stored.customHints);\n }\n\n // 系统提示词:显示已保存的值,首次未保存则显示默认值\n systemPromptInput.value = stored.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n // 循环反馈提示词\n loopFeedbackPromptInput.value = stored.loopFeedbackPrompt !== undefined ? stored.loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT;\n\n if (Array.isArray(stored.skills) && stored.skills.length > 0) {\n stored.skills.forEach(s => addSkillCard(s.name || \"\", s.content || \"\"));\n }\n\n languageSelect.value = effectiveLang;\n\n historyRoundsInput.value = stored.historyRounds != null ? stored.historyRounds : 3;\n\n if (stored.agentMaxSteps != null && stored.agentMaxSteps !== '') {\n agentMaxStepsInput.value = stored.agentMaxSteps;\n }\n\n actionDelayInput.value = stored.actionDelay != null ? stored.actionDelay : 500;\n\n onProviderChange(); // 渲染当前平台的 Key / 提示\n\n // 如果当前平台是 copilot,刷新授权状态\n if ((stored.provider || 'ollama') === 'copilot') {\n refreshGhStatus();\n }\n})();\n"}, "5c9828090c28cc0d5ac877ec00fda52c453976d3": {"t": 1785758641.4413111, "n": 19007, "h": "c897ce884715ff19", "c": "// ===== options.js =====\n\n// 同步立即应用英文(CSP 禁止 HTML 内联脚本,故在此处执行)\nI18n.apply();\n\nconst PROVIDERS = {\n ollama: {\n name: \"Ollama (本地)\",\n hints: [\"qwen3:8b\", \"qwen2.5:7b\", \"llama3.2:3b\", \"llama3.1:8b\", \"deepseek-r1:7b\", \"gemma3:4b\", \"mistral:7b\"],\n keyPlaceholder: \"ollama(本地服务无需 Key,留空即可)\",\n keyDesc: \"Ollama 本地服务不校验 API Key,留空或填任意字符均可。\",\n defaultKey: \"ollama\",\n },\n openrouter: {\n name: \"OpenRouter\",\n hints: [\n \"meta-llama/llama-3.3-70b-instruct:free\",\n \"deepseek/deepseek-r1:free\",\n \"google/gemini-2.0-flash-exp:free\",\n \"google/gemma-4-31b-it:free\",\n \"openrouter/auto\",\n \"meta-llama/llama-3-8b-instruct\",\n ],\n keyPlaceholder: \"sk-or-v1-...\",\n keyDesc: \"在 openrouter.ai → Keys 页面创建 API Key。\",\n defaultKey: \"\",\n },\n deepseek: {\n name: \"DeepSeek\",\n hints: [\"deepseek-v4-flash\", \"deepseek-v4-pro\", \"deepseek-chat\", \"deepseek-reasoner\"],\n keyPlaceholder: \"sk-...\",\n keyDesc: \"在 platform.deepseek.com → API Keys 页面获取。\",\n defaultKey: \"\",\n },\n aistudio: {\n name: \"Google AI Studio\",\n hints: [\"gemini-2.0-flash\", \"gemini-2.5-pro-exp-03-25\", \"gemini-1.5-pro\", \"gemini-1.5-flash\", \"gemma-4-31b-it\"],\n keyPlaceholder: \"AIzaSy...\",\n keyDesc: \"在 aistudio.google.com → Get API Key 页面创建。\",\n defaultKey: \"\",\n },\n copilot: {\n name: \"GitHub Copilot\",\n hints: [\"gpt-4o\", \"gpt-4o-mini\", \"claude-sonnet-4-5\", \"o3-mini\", \"gemini-2.0-flash\"],\n keyPlaceholder: \"\",\n keyDesc: \"\",\n defaultKey: \"\",\n noApiKey: true, // 授权由 GitHub 设备流管理\n },\n};\n\nconst DEFAULT_API_KEYS = { ollama: \"ollama\", openrouter: \"\", deepseek: \"\", aistudio: \"\", copilot: \"\" };\n\n// 内置系统提示词(发布前将该常量更新为目标内容)\nconst DEFAULT_SYSTEM_PROMPT =\n \"You are an intelligent assistant embedded in the browser sidebar, and also a page interaction tool.\\n\" +\n \"The user will provide the current page's \\\"DOM HTML\\\". Use the DOM structure to identify and locate elements to act on.\\n\\n\" +\n \"When you need to interact with the page, briefly describe what you are about to do, then append the action command at the end of your response:\\n\" +\n \" Click: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"button.submit-btn\\\"}</ACTION>\\n\" +\n \" Ctrl+Click: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"a.result\\\",\\\"modifiers\\\":[\\\"ctrl\\\"]}</ACTION>\\n\" +\n \" Double-click: <ACTION>{\\\"type\\\":\\\"double_click\\\",\\\"selector\\\":\\\".editable-cell\\\"}</ACTION>\\n\" +\n \" Right-click: <ACTION>{\\\"type\\\":\\\"right_click\\\",\\\"selector\\\":\\\".item\\\"}</ACTION>\\n\" +\n \" Type: <ACTION>{\\\"type\\\":\\\"type\\\",\\\"selector\\\":\\\"input[name='q']\\\",\\\"text\\\":\\\"search content\\\"}</ACTION>\\n\" +\n \" Select: <ACTION>{\\\"type\\\":\\\"select\\\",\\\"selector\\\":\\\"select#category\\\",\\\"value\\\":\\\"option\\\"}</ACTION>\\n\" +\n \" Press key: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"key\\\":\\\"Enter\\\"}</ACTION>\\n\" +\n \" Key on el: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"selector\\\":\\\".dropdown\\\",\\\"key\\\":\\\"ArrowDown\\\"}</ACTION>\\n\" +\n \" Navigate: <ACTION>{\\\"type\\\":\\\"navigate\\\",\\\"url\\\":\\\"https://example.com\\\"}</ACTION>\\n\" +\n \" Scroll: <ACTION>{\\\"type\\\":\\\"scroll\\\",\\\"y\\\":300}</ACTION>\\n\" +\n \" Hover: <ACTION>{\\\"type\\\":\\\"hover\\\",\\\"selector\\\":\\\".menu-item\\\"}</ACTION>\\n\" +\n \" Clear: <ACTION>{\\\"type\\\":\\\"clear\\\",\\\"selector\\\":\\\"input#search\\\"}</ACTION>\\n\\n\" +\n \"Rules:\\n\" +\n \"- selector must be a CSS selector string derived from the DOM HTML \\u2014 do not guess\\n\" +\n \"- Prefer stable attributes: id > name > aria-label > data-testid > class\\n\" +\n \"- Output one or more <ACTION> tags per turn; if a next action depends on the previous result (e.g. waiting for a page to load before locating an element), output them one at a time; if several actions are deterministic (e.g. filling multiple fields in sequence), you may output multiple <ACTION> tags in a single reply — all will be executed in order before the updated page state is fed back to you\\n\" +\n \"- If no page interaction is needed, reply in Markdown only \\u2014 do not output <ACTION>\\n\" +\n \"- If a suitable element is not found in the DOM, tell the user instead of guessing\\n\" +\n \"- Some elements (e.g. hover menus) are absent from the initial DOM; hover over the parent first to trigger rendering, then act on the target\\n\";\n\n// 内置循环反馈提示词默认值\nconst DEFAULT_LOOP_FEEDBACK_PROMPT =\n \"{{actions}}. Please continue completing the user's task. \" +\n \"If the task is fully completed, inform the user of the result and do not output any more <ACTION>.\";\n\n// ── DOM 引用 ──\nconst providerSelect = document.getElementById(\"provider-select\");\nconst modelInput = document.getElementById(\"model-input\");\nconst modelHints = document.getElementById(\"model-hints\");\nconst apikeyInput = document.getElementById(\"apikey-input\");\nconst toggleKeyBtn = document.getElementById(\"toggle-key\");\nconst keyDesc = document.getElementById(\"key-desc\");\nconst saveBtn = document.getElementById(\"save-btn\");\nconst saveMsg = document.getElementById(\"save-msg\");\nconst systemPromptInput = document.getElementById(\"system-prompt\");\nconst skillsList = document.getElementById(\"skills-list\");\nconst addSkillBtn = document.getElementById(\"add-skill-btn\");\nconst languageSelect = document.getElementById(\"language-select\");\nconst historyRoundsInput = document.getElementById(\"history-rounds\");\nconst agentMaxStepsInput = document.getElementById(\"agent-max-steps\");\nconst actionDelayInput = document.getElementById(\"action-delay\");\nconst loopFeedbackPromptInput = document.getElementById(\"loop-feedback-prompt\");\n\n// ── GitHub Copilot DOM 引用 ──\nconst apikeySection = document.getElementById(\"apikey-section\");\nconst ghCopilotSection = document.getElementById(\"gh-copilot-section\");\nconst ghStatusText = document.getElementById(\"gh-status-text\");\nconst ghAuthFlow = document.getElementById(\"gh-auth-flow\");\nconst ghVerifyLink = document.getElementById(\"gh-verify-link\");\nconst ghUserCode = document.getElementById(\"gh-user-code\");\nconst ghCopyBtn = document.getElementById(\"gh-copy-btn\");\nconst ghOpenBtn = document.getElementById(\"gh-open-btn\");\nconst ghWaitingText = document.getElementById(\"gh-waiting-text\");\nconst ghConnectBtn = document.getElementById(\"gh-connect-btn\");\nconst ghDisconnectBtn = document.getElementById(\"gh-disconnect-btn\");\n\n// 当前所有平台已保存的 Key(切换平台时需要保留其他平台的值)\nlet currentApiKeys = { ...DEFAULT_API_KEYS };\n// 当前各平台的自定义常用模型列表\nlet currentCustomHints = {};\n\n// 设备授权轮询中止信号\nlet _pollAbort = false;\n\n// ─────────────────────────────────────────────\n// 存储操作\n// ─────────────────────────────────────────────\nfunction loadConfig() {\n return new Promise(resolve => chrome.storage.sync.get(null, resolve));\n}\n\nfunction saveConfig(data) {\n return new Promise(resolve => chrome.storage.sync.set(data, resolve));\n}\n\n// ─────────────────────────────────────────────\n// 切换平台时更新提示/Key 字段\n// ─────────────────────────────────────────────\nfunction onProviderChange() {\n const pid = providerSelect.value;\n const conf = PROVIDERS[pid];\n\n // GitHub Copilot:隐藏 API Key 区,显示 GitHub 授权区\n if (conf.noApiKey) {\n apikeySection.style.display = 'none';\n ghCopilotSection.style.display = '';\n } else {\n apikeySection.style.display = '';\n ghCopilotSection.style.display = 'none';\n\n // 更新 Key 字段(显示该平台已保存的 Key)\n apikeyInput.value = currentApiKeys[pid] || \"\";\n apikeyInput.placeholder = I18n.t('key_ph_' + pid);\n keyDesc.textContent = I18n.t('key_desc_' + pid);\n }\n\n // 渲染模型提示 chips\n renderHints();\n}\n\n// 渲染常用模型列表(支持删除 + 添加)\nfunction renderHints() {\n const pid = providerSelect.value;\n // 首次访问该平台时,用默认列表初始化\n if (!currentCustomHints[pid]) {\n currentCustomHints[pid] = [...(PROVIDERS[pid].hints || [])];\n }\n const hints = currentCustomHints[pid];\n\n modelHints.innerHTML = \"\";\n hints.forEach((hint, idx) => {\n const chip = document.createElement(\"div\");\n chip.className = \"hint-chip\";\n\n const label = document.createElement(\"span\");\n label.textContent = hint;\n label.addEventListener(\"click\", () => {\n modelInput.value = hint;\n modelInput.focus();\n });\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.className = \"hint-chip-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = \"移除\";\n removeBtn.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n hints.splice(idx, 1);\n renderHints();\n });\n\n chip.appendChild(label);\n chip.appendChild(removeBtn);\n chip.addEventListener(\"click\", (e) => {\n if (e.target !== removeBtn) {\n modelInput.value = hint;\n modelInput.focus();\n }\n });\n modelHints.appendChild(chip);\n });\n\n // 加号按钮\n const addBtn = document.createElement(\"button\");\n addBtn.className = \"hint-add-btn\";\n addBtn.textContent = \"+\";\n addBtn.title = \"添加模型\";\n addBtn.addEventListener(\"click\", () => {\n addBtn.remove();\n const inp = document.createElement(\"input\");\n inp.type = \"text\";\n inp.placeholder = \"输入模型名…\";\n inp.style.cssText = \"font-size:11.5px;font-family:Consolas,monospace;border:1px solid #1a73e8;border-radius:12px;padding:3px 10px;outline:none;width:150px;\";\n const confirm = () => {\n const val = inp.value.trim();\n if (val) hints.push(val);\n renderHints();\n };\n inp.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") confirm();\n if (e.key === \"Escape\") renderHints();\n });\n inp.addEventListener(\"blur\", confirm);\n modelHints.appendChild(inp);\n inp.focus();\n });\n modelHints.appendChild(addBtn);\n}\n\n// ─────────────────────────────────────────────\n// Skill 卡片\n// ─────────────────────────────────────────────\nfunction addSkillCard(name = \"\", content = \"\") {\n const card = document.createElement(\"div\");\n card.className = \"skill-card\";\n\n const header = document.createElement(\"div\");\n header.className = \"skill-header\";\n\n const nameInput = document.createElement(\"input\");\n nameInput.type = \"text\";\n nameInput.className = \"skill-name\";\n nameInput.placeholder = I18n.t('skill_name_ph');\n nameInput.value = name;\n nameInput.spellcheck = false;\n nameInput.autocomplete = \"off\";\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.type = \"button\";\n removeBtn.className = \"skill-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = I18n.t('skill_remove_title');\n removeBtn.addEventListener(\"click\", () => card.remove());\n\n header.appendChild(nameInput);\n header.appendChild(removeBtn);\n\n const contentArea = document.createElement(\"textarea\");\n contentArea.className = \"skill-content\";\n contentArea.placeholder = I18n.t('skill_content_ph');\n contentArea.rows = 3;\n contentArea.spellcheck = false;\n contentArea.value = content;\n\n card.appendChild(header);\n card.appendChild(contentArea);\n skillsList.appendChild(card);\n}\n\nfunction collectSkills() {\n return Array.from(skillsList.querySelectorAll(\".skill-card\")).map(card => ({\n name: card.querySelector(\".skill-name\").value.trim(),\n content: card.querySelector(\".skill-content\").value.trim(),\n })).filter(s => s.name || s.content);\n}\n\naddSkillBtn.addEventListener(\"click\", () => addSkillCard());\n\n// ─────────────────────────────────────────────\n// GitHub Copilot 设备授权\n// ─────────────────────────────────────────────\n\n// 刷新 GitHub Copilot 授权状态 UI\nasync function refreshGhStatus() {\n const user = await GitHubAuth.getConnectedUser();\n if (user) {\n ghStatusText.textContent = `${I18n.t('gh_connected_as')}: @${user}`;\n ghStatusText.style.color = '#1e8e3e';\n ghConnectBtn.style.display = 'none';\n ghDisconnectBtn.style.display = '';\n ghAuthFlow.style.display = 'none';\n } else {\n ghStatusText.textContent = I18n.t('gh_not_connected');\n ghStatusText.style.color = '#80868b';\n ghConnectBtn.style.display = '';\n ghDisconnectBtn.style.display = 'none';\n ghAuthFlow.style.display = 'none';\n }\n}\n\n// 复制验证码\nghCopyBtn.addEventListener('click', () => {\n const code = ghUserCode.textContent;\n if (code) {\n navigator.clipboard.writeText(code).then(() => {\n ghCopyBtn.textContent = '✓';\n setTimeout(() => { ghCopyBtn.textContent = I18n.t('gh_copy_code'); }, 1500);\n });\n }\n});\n\n// 连接 GitHub 账号(开始设备授权流程)\nghConnectBtn.addEventListener('click', async () => {\n _pollAbort = false;\n ghConnectBtn.disabled = true;\n ghConnectBtn.textContent = '...';\n\n try {\n const { device_code, user_code, verification_uri, expires_in, interval } =\n await GitHubAuth.startDeviceFlow();\n\n // 显示验证码和授权链接\n ghUserCode.textContent = user_code;\n ghVerifyLink.textContent = verification_uri;\n ghVerifyLink.href = verification_uri;\n ghOpenBtn.href = verification_uri;\n ghAuthFlow.style.display = '';\n ghWaitingText.textContent = I18n.t('gh_waiting');\n\n // 轮询直到授权完成或超时\n const expiresAt = Date.now() + expires_in * 1000;\n const oauthToken = await GitHubAuth.pollForToken(device_code, interval, () => {\n if (_pollAbort) throw new Error('aborted');\n if (Date.now() > expiresAt) throw new Error('expired');\n });\n\n // 授权成功\n await GitHubAuth.saveOAuthToken(oauthToken);\n ghWaitingText.textContent = I18n.t('gh_auth_success');\n ghWaitingText.style.color = '#1e8e3e';\n setTimeout(async () => {\n ghWaitingText.style.color = '';\n await refreshGhStatus();\n }, 1500);\n\n } catch (e) {\n ghAuthFlow.style.display = 'none';\n const msg = e.message === 'expired' ? I18n.t('gh_auth_expired')\n : e.message === 'denied' ? I18n.t('gh_auth_denied')\n : e.message === 'aborted' ? ''\n : I18n.t('gh_auth_error');\n if (msg) {\n ghStatusText.textContent = msg;\n ghStatusText.style.color = '#d93025';\n }\n ghConnectBtn.disabled = false;\n ghConnectBtn.textContent = I18n.t('gh_connect_btn');\n }\n});\n\n// 断开连接\nghDisconnectBtn.addEventListener('click', async () => {\n _pollAbort = true;\n await GitHubAuth.logout();\n await refreshGhStatus();\n});\n\n// 语言切换即时生效\nlanguageSelect.addEventListener(\"change\", () => {\n I18n.setLang(languageSelect.value);\n I18n.apply();\n onProviderChange(); // 重新渲染 keyDesc 和 hints\n});\n\n// ─────────────────────────────────────────────\n// 侧边栏内嵌模式:检测是否在 iframe 中运行\n// ─────────────────────────────────────────────\nconst inPanel = window.self !== window.top;\nif (inPanel) {\n document.body.classList.add('in-panel');\n const closePanel = () => window.parent.postMessage({ type: 'settings-close' }, '*');\n document.getElementById('btn-back').addEventListener('click', closePanel);\n document.getElementById('btn-back-bottom').addEventListener('click', closePanel);\n}\n\n// ─────────────────────────────────────────────\n// 保存\n// ─────────────────────────────────────────────\nasync function onSave() {\n const pid = providerSelect.value;\n\n // copilot 的认证由 GitHubAuth 独立管理,不写入 apiKeys\n if (pid !== 'copilot') {\n currentApiKeys[pid] = apikeyInput.value.trim();\n }\n\n await saveConfig({\n provider: pid,\n model: modelInput.value.trim(),\n apiKeys: { ...currentApiKeys },\n customHints: { ...currentCustomHints },\n systemPrompt: systemPromptInput.value.trim(),\n loopFeedbackPrompt: loopFeedbackPromptInput.value,\n skills: collectSkills(),\n language: languageSelect.value,\n userLang: languageSelect.value,\n historyRounds: historyRoundsInput.value.trim(),\n agentMaxSteps: agentMaxStepsInput.value.trim(),\n actionDelay: actionDelayInput.value !== '' ? (parseInt(actionDelayInput.value) || 0) : 0,\n });\n\n // 显示保存成功提示\n saveMsg.classList.add(\"visible\");\n setTimeout(() => saveMsg.classList.remove(\"visible\"), 2000);\n\n // 如果在侧边栏 iframe 中,通知父页面已保存\n if (inPanel) {\n window.parent.postMessage({ type: 'settings-saved' }, '*');\n }\n}\n\n// ─────────────────────────────────────────────\n// 显示/隐藏 Key\n// ─────────────────────────────────────────────\ntoggleKeyBtn.addEventListener(\"click\", () => {\n if (apikeyInput.type === \"password\") {\n apikeyInput.type = \"text\";\n toggleKeyBtn.textContent = \"🙈\";\n } else {\n apikeyInput.type = \"password\";\n toggleKeyBtn.textContent = \"👁\";\n }\n});\n\n// 实时同步输入的 Key 到内存(切换平台前自动保留)\napikeyInput.addEventListener(\"input\", () => {\n currentApiKeys[providerSelect.value] = apikeyInput.value;\n});\n\nproviderSelect.addEventListener(\"change\", () => {\n modelInput.value = ''; // 切换平台时清空模型名称\n onProviderChange();\n if (providerSelect.value === 'copilot') refreshGhStatus();\n});\n\n// 恢复系统提示词默认值\ndocument.getElementById('reset-prompt-btn').addEventListener('click', () => {\n systemPromptInput.value = DEFAULT_SYSTEM_PROMPT;\n systemPromptInput.focus();\n});\n\n// 恢复循环反馈提示词默认值\ndocument.getElementById('reset-loop-feedback-btn').addEventListener('click', () => {\n loopFeedbackPromptInput.value = DEFAULT_LOOP_FEEDBACK_PROMPT;\n loopFeedbackPromptInput.focus();\n});\nsaveBtn.addEventListener(\"click\", onSave);\n\n// ─────────────────────────────────────────────\n// 初始化:读取已保存配置并填入表单\n// ─────────────────────────────────────────────\n(async () => {\n const stored = await loadConfig();\n\n // 先应用语言;用 userLang 键(新键,旧存储没有此值时默认英文)\n const effectiveLang = stored.userLang || 'en';\n // 仅当存储语言与已渲染语言不同时才重新 apply,避免 DOM 变更关闭已打开的下拉框\n if (I18n.lang !== effectiveLang) {\n I18n.setLang(effectiveLang);\n I18n.apply();\n }\n\n if (stored.provider && PROVIDERS[stored.provider]) {\n providerSelect.value = stored.provider;\n }\n\n if (stored.model) {\n modelInput.value = stored.model;\n } else {\n // 首次未保存时,根据当前平台给出默认模型名\n const _defaultModels = { deepseek: 'deepseek-v4-flash', ollama: 'qwen3:8b', aistudio: 'gemini-2.0-flash' };\n modelInput.value = _defaultModels[providerSelect.value] || '';\n }\n\n if (stored.apiKeys) {\n Object.assign(currentApiKeys, stored.apiKeys);\n }\n\n if (stored.customHints && typeof stored.customHints === 'object') {\n Object.assign(currentCustomHints, stored.customHints);\n }\n\n // 系统提示词:显示已保存的值,首次未保存则显示默认值\n systemPromptInput.value = stored.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n // 循环反馈提示词\n loopFeedbackPromptInput.value = stored.loopFeedbackPrompt !== undefined ? stored.loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT;\n\n if (Array.isArray(stored.skills) && stored.skills.length > 0) {\n stored.skills.forEach(s => addSkillCard(s.name || \"\", s.content || \"\"));\n }\n\n languageSelect.value = effectiveLang;\n\n historyRoundsInput.value = stored.historyRounds != null ? stored.historyRounds : 3;\n\n if (stored.agentMaxSteps != null && stored.agentMaxSteps !== '') {\n agentMaxStepsInput.value = stored.agentMaxSteps;\n }\n\n actionDelayInput.value = stored.actionDelay != null ? stored.actionDelay : 500;\n\n onProviderChange(); // 渲染当前平台的 Key / 提示\n\n // 如果当前平台是 copilot,刷新授权状态\n if ((stored.provider || 'ollama') === 'copilot') {\n refreshGhStatus();\n }\n})();\n"}}, "panel.js": {"24474f9d1effe3384ec18e4a496493654331c6b1": {"t": 1785745998.3326247, "n": 43663, "h": "3d9b7dc0f3c7336c", "c": "// ===== panel.js:Gemini 风格侧边栏完整逻辑 =====\n\n// 在任何异步操作之前,立即将语言设为英文并应用到 DOM\n// (CSP 禁止 HTML 内联脚本,故在此处同步执行)\nI18n.setLang('en');\nI18n.apply();\n\nconst systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');\n\n// ── Provider 配置(纯前端,无需服务器)─────────────────────────\nconst PROVIDERS = {\n ollama: {\n name: \"Ollama\",\n format: \"openai\",\n url: \"http://localhost:11434/v1/chat/completions\",\n defaultKey: \"ollama\",\n },\n cliproxy: {\n name: \"CLI Proxy API\",\n format: \"openai\",\n url: \"\",\n defaultKey: \"\",\n needsEndpoint: true,\n },\n openrouter: {\n name: \"OpenRouter\",\n format: \"openai\",\n url: \"https://openrouter.ai/api/v1/chat/completions\",\n defaultKey: \"\",\n },\n deepseek: {\n name: \"DeepSeek\",\n format: \"openai\",\n url: \"https://api.deepseek.com/chat/completions\",\n defaultKey: \"\",\n },\n siliconflow: {\n name: \"SiliconFlow\",\n format: \"openai\",\n url: \"https://api.siliconflow.cn/v1/chat/completions\",\n defaultKey: \"\",\n },\n aistudio: {\n name: \"Google AI Studio\",\n format: \"gemini\",\n url: \"https://generativelanguage.googleapis.com/v1beta/models\",\n defaultKey: \"\",\n },\n copilot: {\n name: \"GitHub Copilot\",\n format: \"copilot\",\n url: \"https://api.githubcopilot.com/chat/completions\",\n defaultKey: \"\",\n },\n};\n\nconst SYSTEM_PROMPT =\n \"你是嵌入在浏览器侧边栏的智能助手,同时也是页面操作工具。\\n\" +\n \"用户会提供当前页面的「DOM HTML」,你需要根据 DOM 结构来定位要操作的元素。\\n\\n\" +\n \"需要操作页面时,先用自然语言描述将要做的事,然后在回复末尾输出操作指令:\\n\" +\n \" 点击: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"button.submit-btn\\\"}</ACTION>\\n\" +\n \" Ctrl+点击: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"a.result\\\",\\\"modifiers\\\":[\\\"ctrl\\\"]}</ACTION>\\n\" +\n \" 双击: <ACTION>{\\\"type\\\":\\\"double_click\\\",\\\"selector\\\":\\\".editable-cell\\\"}</ACTION>\\n\" +\n \" 右键: <ACTION>{\\\"type\\\":\\\"right_click\\\",\\\"selector\\\":\\\".item\\\"}</ACTION>\\n\" +\n \" 输入: <ACTION>{\\\"type\\\":\\\"type\\\",\\\"selector\\\":\\\"input[name='q']\\\",\\\"text\\\":\\\"搜索内容\\\"}</ACTION>\\n\" +\n \" 选择: <ACTION>{\\\"type\\\":\\\"select\\\",\\\"selector\\\":\\\"select#category\\\",\\\"value\\\":\\\"选项\\\"}</ACTION>\\n\" +\n \" 按键: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"key\\\":\\\"Enter\\\"}</ACTION>\\n\" +\n \" 指定元素按键: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"selector\\\":\\\".dropdown\\\",\\\"key\\\":\\\"ArrowDown\\\"}</ACTION>\\n\" +\n \" 跳转: <ACTION>{\\\"type\\\":\\\"navigate\\\",\\\"url\\\":\\\"https://example.com\\\"}</ACTION>\\n\" +\n \" 滚动: <ACTION>{\\\"type\\\":\\\"scroll\\\",\\\"y\\\":300}</ACTION>\\n\" +\n \" 悬停: <ACTION>{\\\"type\\\":\\\"hover\\\",\\\"selector\\\":\\\".menu-item\\\"}</ACTION>\\n\" +\n \" 清空: <ACTION>{\\\"type\\\":\\\"clear\\\",\\\"selector\\\":\\\"input#search\\\"}</ACTION>\\n\\n\" +\n \"重要规则:\\n\" +\n \"- selector 使用 CSS 选择器字符串定位元素,如 #id、.classname、button[name='submit']、div > span:nth-of-type(2)\\n\" +\n \"- selector 必须从 DOM HTML 中推断,不要凭空猜测\\n\" +\n \"- 优先使用稳定的属性:id > name > aria-label > data-testid > class\\n\" +\n \"- 如果下一步依赖上一步的结果(如等待页面跳转后再定位元素),每次只输出一个 <ACTION>;如果多步操作是确定的(如依次填写多个字段),可在同一回复中输出多个 <ACTION>,系统会全部顺序执行后再反馈新页面状态\\n\" +\n \"- 不需要操作页面时,只用 Markdown 正常回答,不要输出 <ACTION> 标签\\n\" +\n \"- 如果在 DOM 中找不到合适的元素,告知用户而不是猜测\\n\" +\n \"- 某些元素(如悬浮菜单、hover 才出现的按钮)在初始 DOM 中不存在,需要先 hover 父元素触发渲染,再操作目标元素\\n\";\n\nconst DEFAULT_CONFIG = {\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n apiKeys: { ollama: \"ollama\", cliproxy: \"\", openrouter: \"\", deepseek: \"\", siliconflow: \"\", aistudio: \"\", copilot: \"\" },\n endpoints:{ cliproxy: \"\" },\n theme: \"light\",\n};\n\n// 每轮 action 执行完毕后反馈给模型的续接提示({{actions}} 替换为动作执行摘要)\nconst DEFAULT_LOOP_FEEDBACK_PROMPT =\n \"{{actions}}. Please continue completing the user's task. \" +\n \"If the task is fully completed, inform the user of the result and do not output any more <ACTION>.\";\n\nasync function loadConfig() {\n return new Promise(resolve => {\n chrome.storage.sync.get(null, stored => {\n // 首次加载(userLang 尚未写入)时强制保存 'en',相当于自动执行一次\"语言=英文\"的保存\n if (!stored.userLang) {\n chrome.storage.sync.set({ language: 'en', userLang: 'en' });\n stored.userLang = 'en'; // 确保在当前 tick 的结果中也立即生效\n }\n resolve({\n provider: stored.provider || DEFAULT_CONFIG.provider,\n model: stored.model || DEFAULT_CONFIG.model,\n apiKeys: Object.assign({}, DEFAULT_CONFIG.apiKeys, stored.apiKeys || {}),\n endpoints: Object.assign({}, DEFAULT_CONFIG.endpoints, stored.endpoints || {}),\n theme: normalizeTheme(stored.theme),\n systemPrompt: stored.systemPrompt ?? SYSTEM_PROMPT,\n skills: Array.isArray(stored.skills) ? stored.skills : [],\n language: stored.userLang || 'en',\n historyRounds: stored.historyRounds != null ? stored.historyRounds : 3,\n agentMaxSteps: stored.agentMaxSteps != null ? stored.agentMaxSteps : '',\n actionDelay: stored.actionDelay != null ? stored.actionDelay : 500,\n loopFeedbackPrompt: stored.loopFeedbackPrompt !== undefined ? stored.loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT,\n });\n });\n });\n}\n\n// 构建有效系统提示词(Skills 列表追加到末尾)\nfunction buildEffectiveSystemPrompt(cfg) {\n let prompt = cfg.systemPrompt || \"\";\n if (cfg.skills && cfg.skills.length > 0) {\n prompt += \"\\n\\n---\\n## Available Skills\\n\";\n cfg.skills.forEach(skill => {\n if (skill.name || skill.content) {\n prompt += `\\n### ${skill.name || \"(untitled)\"}\\n${skill.content || \"\"}\\n`;\n }\n });\n }\n return prompt;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:从对话历史构建 LLM 消息(不含页面 DOM 和附件内容)\n// ─────────────────────────────────────────────\nfunction buildLLMHistory(cfg) {\n const rawRounds = cfg.historyRounds;\n const unlimited = (rawRounds === '' || rawRounds == null);\n const rounds = unlimited ? Infinity : (parseInt(rawRounds) || 0);\n if (rounds <= 0 || conversationHistory.length === 0) return [];\n\n const eligible = unlimited ? conversationHistory : conversationHistory.slice(-rounds);\n const result = [];\n for (const round of eligible) {\n if (round.roundMessages && round.roundMessages.length > 0) {\n // 新格式:包含 Agent 循环内完整对话(无页面/附件内容)\n result.push(...round.roundMessages);\n } else {\n // 兼容旧格式\n result.push({ role: 'user', content: round.userText });\n result.push({ role: 'assistant', content: round.agentText });\n }\n }\n return result;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:持久化对话到 chrome.storage.session(浏览器关闭前有效)\n// ─────────────────────────────────────────────\nasync function saveSession() {\n try {\n await new Promise((resolve, reject) => {\n chrome.storage.session.set(\n { chatSession: { conversationHistory, sessionMessages } },\n () => chrome.runtime.lastError\n ? reject(new Error(chrome.runtime.lastError.message))\n : resolve()\n );\n });\n } catch (e) {\n console.warn('[panel] saveSession failed:', e);\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:恢复上次对话(侧边栏重新打开时调用)\n// ─────────────────────────────────────────────\nasync function restoreSession() {\n try {\n const data = await new Promise((resolve, reject) => {\n chrome.storage.session.get('chatSession', (items) => {\n chrome.runtime.lastError\n ? reject(new Error(chrome.runtime.lastError.message))\n : resolve(items);\n });\n });\n const session = data?.chatSession;\n if (!session?.sessionMessages?.length) return;\n\n conversationHistory = session.conversationHistory || [];\n sessionMessages = session.sessionMessages || [];\n\n welcomeEl.style.display = 'none';\n for (const msg of sessionMessages) {\n if (msg.type === 'user') {\n const el = document.createElement('div');\n el.className = 'msg-user';\n el.textContent = msg.text;\n messagesEl.appendChild(el);\n } else if (msg.type === 'agent' || msg.type === 'agent-steps') {\n const wrap = document.createElement('div');\n wrap.className = 'msg-agent-wrap';\n const icon = document.createElement('div');\n icon.className = 'msg-agent-icon';\n const content = document.createElement('div');\n content.className = 'msg-agent';\n\n if (msg.type === 'agent-steps') {\n // 多步 Agent 轮次:逐步重建分隔线 + 文本 + 操作芯片\n (msg.steps || []).forEach((step, i) => {\n if (i > 0) {\n const sep = document.createElement('div');\n sep.style.cssText = [\n 'margin:6px 0 4px', `border-top:1px dashed ${themeVar('--border')}`,\n 'font-size:11px', `color:${themeVar('--muted-2')}`, 'padding-top:4px',\n ].join(';');\n sep.textContent = '↩ Continuing…';\n content.appendChild(sep);\n }\n const stepEl = document.createElement('div');\n stepEl.innerHTML = renderMarkdown(step.text || '');\n content.appendChild(stepEl);\n // 支持新格式(actions 数组)和旧格式(单个 action 字符串)吉化入)\n const stepActions = step.actions || (step.action ? [step.action] : []);\n for (const actionStr of stepActions) {\n const chip = document.createElement('div');\n chip.style.cssText = [\n 'margin-top:8px', 'padding:5px 10px',\n `background:${themeVar('--surface-2')}`, `border-left:3px solid ${themeVar('--blue')}`,\n 'border-radius:4px', 'font-size:11.5px', `color:${themeVar('--text')}`,\n 'font-family:Consolas,monospace', 'word-break:break-all',\n ].join(';');\n chip.textContent = '⚡ ' + actionStr;\n content.appendChild(chip);\n }\n });\n } else {\n // 兼容旧格式(纯文本 or HTML)\n content.innerHTML = msg.html != null ? msg.html : renderMarkdown(msg.text || '');\n }\n\n wrap.appendChild(icon);\n wrap.appendChild(content);\n messagesEl.appendChild(wrap);\n }\n }\n scrollToBottom();\n } catch (e) {\n console.warn('[panel] restoreSession failed:', e);\n }\n}\n\n// ── 状态 ──\nlet isWaiting = false;\nlet abortController = null;\nlet attachedFiles = [];\nlet conversationHistory = []; // [{ userText, agentText }] 跨轮次历史(不含 DOM)\nlet sessionMessages = []; // [{ type, text?, html? }] 用于界面恢复\n\n// ── DOM 引用 ──\nconst mainEl = document.getElementById(\"main\");\nconst welcomeEl = document.getElementById(\"welcome\");\nconst messagesEl = document.getElementById(\"messages\");\nconst inputEl = document.getElementById(\"agent-input\");\nconst runBtn = document.getElementById(\"agent-run\");\nconst btnNew = document.getElementById(\"btn-new\");\nconst attachmentsEl = document.getElementById(\"attachments\");\nconst footerEl = document.getElementById(\"footer\");\nconst modelInfoEl = document.getElementById(\"model-info\");\nconst btnAddPage = document.getElementById(\"btn-add-page\");\n\n// 立即用默认值覆盖「加载配置中」占位文字\n{\n const _c = PROVIDERS[DEFAULT_CONFIG.provider];\n modelInfoEl.textContent = (_c ? _c.name : DEFAULT_CONFIG.provider) + ' · ' + DEFAULT_CONFIG.model;\n}\n\n// ── 初始化:显示当前配置 ──\nloadConfig().then(cfg => {\n applyTheme(cfg.theme);\n if (I18n.lang !== cfg.language) {\n I18n.setLang(cfg.language);\n I18n.apply();\n }\n const conf = PROVIDERS[cfg.provider];\n modelInfoEl.textContent = (conf ? conf.name : cfg.provider) + \" · \" + (cfg.model || I18n.t('model_not_set'));\n});\n\n// ─────────────────────────────────────────────\n// 附件管理\n// ─────────────────────────────────────────────\n\n// 重新渲染附件芯片列表\nfunction renderAttachments() {\n attachmentsEl.innerHTML = \"\";\n attachedFiles.forEach((file, idx) => {\n const chip = document.createElement(\"div\");\n chip.className = \"file-chip\";\n chip.title = file.name;\n\n const icon = document.createElement(\"span\");\n icon.className = \"file-chip-icon\";\n icon.textContent = file.isPage ? \"🌐\" : \"📄\";\n\n const name = document.createElement(\"span\");\n name.className = \"file-chip-name\";\n name.textContent = file.name;\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.className = \"file-chip-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = I18n.t('remove_chip');\n removeBtn.addEventListener(\"click\", () => {\n attachedFiles.splice(idx, 1);\n renderAttachments();\n });\n\n chip.appendChild(icon);\n chip.appendChild(name);\n chip.appendChild(removeBtn);\n attachmentsEl.appendChild(chip);\n });\n // 将加号按钮始终放在最后一个芯片之后,随芯片一起换行\n attachmentsEl.appendChild(btnAddPage);\n}\n\n// 获取当前标签页内容并作为默认附件\n// refreshOnly=true:仅刷新已有页面附件,不重新添加用户已手动删除的页面\nasync function addCurrentPage(refreshOnly = false) {\n try {\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab) return;\n\n let pageContent = \"\";\n try {\n const resp = await chrome.tabs.sendMessage(tab.id, { type: \"GET_PAGE_CONTENT\" });\n pageContent = resp?.content ?? \"\";\n } catch {\n // content script 未注入(扩展重载后的旧标签页),尝试动态注入后重试\n try {\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['i18n.js'] });\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['element-scanner.js'] });\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });\n // 等待脚本初始化完成\n await new Promise(r => setTimeout(r, 150));\n const resp2 = await chrome.tabs.sendMessage(tab.id, { type: \"GET_PAGE_CONTENT\" });\n pageContent = resp2?.content ?? \"\";\n } catch {\n // 动态注入也失败(chrome:// 等不可注入页面),用 URL+标题兜底\n pageContent = `标题:${tab.title}\\nURL:${tab.url}`;\n }\n }\n\n const fileObj = { name: tab.title || tab.url, content: pageContent, isPage: true };\n if (refreshOnly) {\n const existing = attachedFiles.findIndex(f => f.isPage);\n if (existing >= 0) {\n attachedFiles[existing] = fileObj; // 刷新模式:仅更新已有页面,用户删除后不重新添加\n }\n // else: 用户已删除页面附件,不重新添加\n } else {\n attachedFiles.unshift(fileObj); // 按钮点击:始终添加新页面\n }\n renderAttachments();\n return fileObj; // 返回文件对象供内部使用\n } catch (e) {\n console.warn(\"addCurrentPage failed:\", e);\n return null;\n }\n}\n\n// 加号按钮:强制添加/刷新当前页面附件\nbtnAddPage.addEventListener(\"click\", () => addCurrentPage(false));\n\n// 初始化:恢复上次会话,然后刷新当前页面附件\nrestoreSession().then(() => addCurrentPage());\n\n// ─────────────────────────────────────────────\n// 辅助:向消息列表追加用户气泡\n// ─────────────────────────────────────────────\nfunction appendUserMessage(text) {\n // 有消息时隐藏欢迎页\n welcomeEl.style.display = \"none\";\n\n const el = document.createElement(\"div\");\n el.className = \"msg-user\";\n el.textContent = text; // 用户消息直接显示纯文本,安全\n messagesEl.appendChild(el);\n scrollToBottom();\n}\n\n// ─────────────────────────────────────────────\n// 辅助:向消息列表追加 Agent 气泡(含图标)\n// 返回内容容器,方便后续流式更新\n// ─────────────────────────────────────────────\nfunction appendAgentMessage(html = \"\") {\n const wrap = document.createElement(\"div\");\n wrap.className = \"msg-agent-wrap\";\n\n // 左侧 Gemini 彩色小图标\n const icon = document.createElement(\"div\");\n icon.className = \"msg-agent-icon\";\n\n // 右侧内容区\n const content = document.createElement(\"div\");\n content.className = \"msg-agent typing-cursor\"; // 打字光标\n content.innerHTML = html;\n\n wrap.appendChild(icon);\n wrap.appendChild(content);\n messagesEl.appendChild(wrap);\n scrollToBottom();\n\n return content; // 返回内容节点,流式输出时用来更新\n}\n\n// ─────────────────────────────────────────────\n// 辅助:滚动到底部\n// ─────────────────────────────────────────────\nfunction scrollToBottom() {\n mainEl.scrollTop = mainEl.scrollHeight;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:用 marked 把 Markdown 渲染为 HTML\n// ─────────────────────────────────────────────\nfunction renderMarkdown(text) {\n // marked 已通过 CDN 在 panel.html 中引入\n return typeof marked !== \"undefined\" ? marked.parse(text) : text;\n}\n\nfunction normalizeTheme(theme) {\n return theme === 'dark' || theme === 'light' || theme === 'device' ? theme : DEFAULT_CONFIG.theme;\n}\n\nfunction resolveTheme(theme) {\n const normalizedTheme = normalizeTheme(theme);\n if (normalizedTheme === 'device') {\n return systemThemeMedia.matches ? 'dark' : 'light';\n }\n return normalizedTheme;\n}\n\nfunction applyTheme(theme) {\n document.body.dataset.theme = resolveTheme(theme);\n}\n\nfunction themeVar(name) {\n return getComputedStyle(document.body).getPropertyValue(name).trim();\n}\n\n// ─────────────────────────────────────────────\n// 辅助:切换发送/停止按鈕状态\n// ─────────────────────────────────────────────\nconst SEND_ICON = '<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M2.01 21L23 12 2.01 3 2 10l15 2-15 2z\"/></svg>';\nconst STOP_ICON = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\"/></svg>';\n\nfunction setRunBtnState(state) {\n if (state === 'stop') {\n runBtn.innerHTML = STOP_ICON;\n runBtn.style.background = themeVar('--muted');\n runBtn.disabled = false;\n } else {\n runBtn.innerHTML = SEND_ICON;\n runBtn.style.background = '';\n runBtn.disabled = false;\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:构建发给服务端的完整 user content(含附件)\n// ─────────────────────────────────────────────\nfunction buildUserContent(cmd, files) {\n const nonPageFiles = (files || []).filter(f => !f.isPage);\n if (!nonPageFiles.length) return cmd;\n const parts = nonPageFiles.map(f => `=== ${f.name} ===\\n${f.content}`);\n return parts.join(\"\\n\\n\") + `\\n\\n---\\n\\nUser question: ${cmd}`;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:所有 action 执行完毕后构建续接 user 消息(含重扫页面状态)\n// loopFeedbackPrompt 为空字符串时返回空(不向模型发送续接消息)\n// ─────────────────────────────────────────────\nfunction buildContinuationContent(actions, loopFeedbackPrompt) {\n // 设置里没填(空字符串)→ 返回空\n if (loopFeedbackPrompt === '') return '';\n const template = (loopFeedbackPrompt != null) ? loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT;\n const count = actions.length;\n const list = actions.map(a => JSON.stringify(a)).join(', ');\n const actionsDesc = count === 1\n ? `Action ${list} has been executed`\n : `${count} actions [${list}] have all been executed in sequence`;\n return template.replace('{{actions}}', actionsDesc);\n}\n\n// ─────────────────────────────────────────────\n// 辅助:将当前页面内容临时注入到最后一条 user 消息(不修改原数组)\n// ─────────────────────────────────────────────\nfunction injectPageIntoMessages(messages, pageFileOverride) {\n const pageFile = pageFileOverride !== undefined ? pageFileOverride : attachedFiles.find(f => f.isPage);\n if (!pageFile || !pageFile.content) return messages;\n const msgs = messages.map(m => ({ ...m }));\n for (let i = msgs.length - 1; i >= 0; i--) {\n if (msgs[i].role === 'user') {\n msgs[i] = { ...msgs[i], content: `=== Current Page ===\\n${pageFile.content}\\n\\n---\\n\\n${msgs[i].content}` };\n break;\n }\n }\n return msgs;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:显示请求报文弹框\n// ─────────────────────────────────────────────\nfunction showPayloadModal(payload) {\n document.getElementById('_payload_overlay')?.remove();\n\n const border = themeVar('--border');\n const overlayBg = themeVar('--overlay');\n const modalPanel = themeVar('--modal-panel');\n const modalHeader = themeVar('--modal-header');\n const modalText = themeVar('--modal-text');\n const modalCode = themeVar('--modal-code');\n const muted = themeVar('--muted');\n\n const overlay = document.createElement('div');\n overlay.id = '_payload_overlay';\n overlay.style.cssText = [\n 'position:fixed', 'inset:0', `background:${overlayBg}`,\n 'z-index:9999', 'display:flex', 'align-items:center', 'justify-content:center',\n ].join(';');\n\n overlay.innerHTML = `\n<div style=\"background:${modalPanel};border-radius:12px;width:92%;max-width:720px;\n max-height:82vh;display:flex;flex-direction:column;\n box-shadow:0 8px 40px ${overlayBg};overflow:hidden;\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;\n padding:12px 16px;border-bottom:1px solid ${border};flex-shrink:0;background:${modalHeader};\">\n <span style=\"font-size:13px;font-weight:600;color:${modalText};\n font-family:'Google Sans','Segoe UI',sans-serif;\">📋 Request Payload</span>\n <button id=\"_payload_close\" style=\"background:none;border:none;cursor:pointer;\n font-size:18px;color:${muted};padding:2px 8px;border-radius:4px;line-height:1;\">✕</button>\n </div>\n <pre id=\"_payload_pre\" style=\"overflow:auto;padding:16px;margin:0;font-size:11.5px;\n line-height:1.65;color:${modalCode};background:${modalPanel};flex:1;\n white-space:pre-wrap;word-break:break-word;font-family:Consolas,monospace;\"></pre>\n</div>`;\n\n // 用 textContent 写入避免 XSS\n overlay.querySelector('#_payload_pre').textContent = JSON.stringify(payload, null, 2);\n\n document.body.appendChild(overlay);\n overlay.querySelector('#_payload_close').onclick = () => overlay.remove();\n overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });\n}\n\n// ─────────────────────────────────────────────\n// 辅助:执行一轮 LLM 调用并流式输出到 agentContent\n// 返回 { fullText, action }\n// ─────────────────────────────────────────────\nasync function streamAgentTurn(history, agentContent, isContinuation, signal) {\n // 续轮时插入分隔条\n if (isContinuation) {\n const sep = document.createElement('div');\n sep.style.cssText = [\n 'margin:6px 0 4px', `border-top:1px dashed ${themeVar('--border')}`,\n 'font-size:11px', `color:${themeVar('--muted-2')}`, 'padding-top:4px',\n ].join(';');\n sep.textContent = '↩ Continuing…';\n agentContent.appendChild(sep);\n scrollToBottom();\n }\n\n // ── 加载配置 ──\n const cfg = await loadConfig();\n const conf = PROVIDERS[cfg.provider];\n if (!conf) throw new Error(`Unknown provider: ${cfg.provider}`);\n\n const requestUrl = conf.needsEndpoint\n ? (cfg.endpoints && typeof cfg.endpoints[cfg.provider] === 'string' ? cfg.endpoints[cfg.provider].trim() : '')\n : conf.url;\n\n const apiKey = (cfg.apiKeys && cfg.apiKeys[cfg.provider]) || conf.defaultKey || \"\";\n if (!apiKey && cfg.provider !== \"ollama\" && cfg.provider !== \"copilot\") {\n throw new Error(`${conf.name}: API Key not configured. Please open the settings to configure it.`);\n }\n if (conf.needsEndpoint && !requestUrl) {\n throw new Error(`${conf.name}: Endpoint URL not configured. Please open the settings to configure it.`);\n }\n const model = cfg.model;\n if (!model) throw new Error(\"Model name not set. Please open the settings to configure it.\");\n\n // 更新顶栏\n modelInfoEl.textContent = conf.name + \" · \" + model;\n\n const effectivePrompt = buildEffectiveSystemPrompt(cfg);\n const allMessages = effectivePrompt\n ? [{ role: \"system\", content: effectivePrompt }, ...history]\n : [...history];\n const requestPayload = { provider: cfg.provider, model, url: requestUrl, messages: allMessages };\n\n // 本轮流式输出容器\n const stepEl = document.createElement('div');\n agentContent.appendChild(stepEl);\n\n let fullText = \"\";\n let inReasoning = false;\n\n if (conf.format === \"copilot\") {\n // ── GitHub Copilot (OpenAI 局式 + 特殊认证头) ─────────────────\n let copilotToken;\n try {\n copilotToken = await GitHubAuth.getValidCopilotToken();\n } catch (e) {\n if (e.message === 'NOT_AUTHED') {\n throw new Error('GitHub Copilot 未授权,请在设置中连接 GitHub 账号');\n }\n throw e;\n }\n\n const res = await fetch(conf.url, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${copilotToken}`,\n 'Content-Type': 'application/json',\n 'Editor-Version': 'vscode/1.97.2',\n 'Editor-Plugin-Version': 'copilot-chat/0.26.1',\n 'Copilot-Integration-Id': 'vscode-chat',\n 'Openai-Intent': 'conversation-panel',\n 'User-Agent': 'GitHubCopilotChat/0.26.1',\n 'X-Github-Api-Version': '2024-12-15',\n },\n signal,\n body: JSON.stringify({ model, messages: allMessages, stream: true, max_tokens: 4096 }),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n if (res.status === 401 || res.status === 403) {\n // Copilot token 失效,清理缓存让下次重新获取\n await new Promise(r => chrome.storage.sync.remove(['copilotToken', 'copilotTokenExpiry'], r));\n throw new Error(`Copilot 认证失效 (${res.status}),请重新连接 GitHub 账号`);\n }\n throw new Error(`HTTP ${res.status}${body ? ': ' + body.slice(0, 300) : ''}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed === 'data: [DONE]') continue;\n const jsonStr = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;\n try {\n const parsed = JSON.parse(jsonStr);\n const content = parsed?.choices?.[0]?.delta?.content || '';\n if (content) { fullText += content; stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n // AbortError:带着已累积的部分文本继续执行,下方 signal.aborted 检查会提前返回\n }\n\n } else if (conf.format === \"gemini\") {\n // ── Google Gemini ─────────────────────────────────────────\n const geminiUrl = `${conf.url}/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;\n const geminiContents = history.map(m => ({\n role: m.role === \"assistant\" ? \"model\" : \"user\",\n parts: [{ text: m.content }],\n }));\n\n const res = await fetch(geminiUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n signal,\n body: JSON.stringify({\n ...(effectivePrompt ? { systemInstruction: { parts: [{ text: effectivePrompt }] } } : {}),\n contents: geminiContents,\n generationConfig: { maxOutputTokens: 2000 },\n }),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => \"\");\n throw new Error(`HTTP ${res.status}${body ? \": \" + body.slice(0, 300) : \"\"}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = lines.pop();\n for (const line of lines) {\n if (!line.trim().startsWith(\"data: \")) continue;\n try {\n const chunk = JSON.parse(line.trim().slice(6));\n const text = chunk?.candidates?.[0]?.content?.parts?.[0]?.text || \"\";\n if (text) { fullText += text; stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n }\n\n } else {\n // ── OpenAI 兼容格式(Ollama / OpenRouter / DeepSeek)─────\n const isDeepSeek = cfg.provider === \"deepseek\";\n const reqBody = {\n model,\n messages: allMessages,\n stream: true,\n };\n if (cfg.provider === \"ollama\") {\n reqBody.options = { num_predict: 2000 };\n } else {\n reqBody.max_tokens = 2000;\n }\n\n const res = await fetch(requestUrl, {\n method: \"POST\",\n headers: {\n \"Authorization\": \"Bearer \" + apiKey,\n \"Content-Type\": \"application/json\",\n },\n signal,\n body: JSON.stringify(reqBody),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => \"\");\n throw new Error(`HTTP ${res.status}${body ? \": \" + body.slice(0, 300) : \"\"}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = lines.pop();\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed === \"data: [DONE]\") continue;\n const jsonStr = trimmed.startsWith(\"data: \") ? trimmed.slice(6) : trimmed;\n try {\n const parsed = JSON.parse(jsonStr);\n const delta = parsed?.choices?.[0]?.delta;\n const reasoning = isDeepSeek ? (delta?.reasoning_content || \"\") : \"\";\n const content = delta?.content || \"\";\n\n if (reasoning) {\n if (!inReasoning) { inReasoning = true; fullText += \"*思考中...*\\n\\n> \"; }\n fullText += reasoning.replace(/\\n/g, \"\\n> \");\n } else if (content) {\n if (inReasoning) { inReasoning = false; fullText += \"\\n\\n---\\n\\n\"; }\n fullText += content;\n }\n if (reasoning || content) { stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n }\n }\n\n // 用户中途停止:带着已累积内容提前返回,不解析 action 也不添加报文按钮\n if (signal?.aborted) {\n return { fullText, actions: [] };\n }\n\n // 解析全部 <ACTION> 标签(支持一次多个动作)\n const actionMatches = [...fullText.matchAll(/<ACTION>([\\s\\S]*?)<\\/ACTION>/g)];\n const actions = [];\n if (actionMatches.length > 0) {\n const displayText = fullText.replace(/<ACTION>[\\s\\S]*?<\\/ACTION>/g, '').trim();\n stepEl.innerHTML = renderMarkdown(displayText || (actionMatches.length > 1 ? 'Executing actions…' : 'Executing action…'));\n for (const match of actionMatches) {\n try {\n const parsed = JSON.parse(match[1].trim());\n actions.push(parsed);\n // 每个动作显示一个芯片\n const chip = document.createElement('div');\n chip.style.cssText = [\n 'margin-top:8px', 'padding:5px 10px',\n `background:${themeVar('--surface-2')}`, `border-left:3px solid ${themeVar('--blue')}`,\n 'border-radius:4px', 'font-size:11.5px', `color:${themeVar('--text')}`,\n 'font-family:Consolas,monospace', 'word-break:break-all',\n ].join(';');\n chip.textContent = '⚡ ' + match[1].trim();\n agentContent.appendChild(chip);\n } catch (e) {\n console.warn('Failed to parse <ACTION>:', e);\n }\n }\n scrollToBottom();\n }\n\n // 每轮结束后追加\"查看报文\"按钮\n const payloadBtn = document.createElement('button');\n payloadBtn.className = 'msg-payload-btn';\n payloadBtn.style.cssText = [\n 'margin-top:5px', 'padding:2px 8px', 'display:inline-block',\n 'background:none', `border:1px solid ${themeVar('--border-strong')}`, 'border-radius:4px',\n 'font-size:11px', `color:${themeVar('--muted')}`, 'cursor:pointer', 'font-family:inherit',\n 'transition:all .15s',\n ].join(';');\n payloadBtn.textContent = '📋 View Payload';\n payloadBtn.onmouseenter = () => {\n payloadBtn.style.background = themeVar('--surface');\n payloadBtn.style.borderColor = themeVar('--blue');\n payloadBtn.style.color = themeVar('--blue');\n };\n payloadBtn.onmouseleave = () => {\n payloadBtn.style.background = 'none';\n payloadBtn.style.borderColor = themeVar('--border-strong');\n payloadBtn.style.color = themeVar('--muted');\n };\n payloadBtn.onclick = () => showPayloadModal(requestPayload);\n agentContent.appendChild(payloadBtn);\n scrollToBottom();\n\n return { fullText, actions };\n}\n\n// ─────────────────────────────────────────────\n// 核心:发送消息(含 agentic loop)\n// ─────────────────────────────────────────────\nasync function sendMessage() {\n if (isWaiting) return;\n const cmd = inputEl.value.trim();\n if (!cmd) return;\n\n // 1. 显示用户气泡\n appendUserMessage(cmd);\n\n // 2. 清空输入框并重置高度\n inputEl.value = \"\";\n inputEl.style.height = \"auto\";\n\n // 3. 创建 Agent 气泡\n const agentContent = appendAgentMessage(\"\");\n\n // 4. 切换为停止按鈕\n isWaiting = true;\n abortController = new AbortController();\n setRunBtnState('stop');\n\n // 声明在 try 外部,确保 catch 块也能访问(let 是块级作用域,在 try 内声明则 catch 不可见)\n let roundAgentText = '';\n let roundSteps = [];\n // roundMessages 保存本轮完整对话(消息不含页面 DOM 和附件)\n let roundMessages = [{ role: 'user', content: cmd }];\n\n try {\n // 5. 刷新页面快照\n await addCurrentPage(true);\n\n // 6. 加载配置(含 historyRounds、agentMaxSteps)\n const cfg = await loadConfig();\n\n // 7. 从历史对话构建上文(仅用户文本,不含 DOM)\n const historyMessages = buildLLMHistory(cfg);\n\n // 8. 构建本轮消息(不含页面;页面在每次 LLM 调用前临时注入)\n const loopHistory = [\n ...historyMessages,\n { role: \"user\", content: buildUserContent(cmd, attachedFiles) },\n ];\n\n // 快照本轮页面文件(清空附件内容后供 loop 使用)\n let loopPageFile = attachedFiles.find(f => f.isPage) || null;\n\n // 发送后清空附件栏,让用户重新添加\n attachedFiles = [];\n renderAttachments();\n\n // 9. 确定 Agent 最大步数(空 = 不限;0 = 不执行任何动作)\n const rawMax = cfg.agentMaxSteps;\n const maxSteps = (rawMax === '' || rawMax == null)\n ? Infinity\n : Math.max(0, parseInt(rawMax) || 0);\n\n // roundAgentText、roundSteps、roundMessages 已在 try 外声明,此处无需重复声明\n\n const _rawHist = cfg.historyRounds;\n const histRounds = (_rawHist === '' || _rawHist == null) ? Infinity : (parseInt(_rawHist) || 0);\n const actionDelay = typeof cfg.actionDelay === 'number' ? cfg.actionDelay : 500;\n\n // 10. Agentic loop\n for (let step = 0; step < maxSteps; step++) {\n // 页面内容临时注入到最后一条 user 消息,不存入 loopHistory\n const { fullText, actions } = await streamAgentTurn(injectPageIntoMessages(loopHistory, loopPageFile), agentContent, step > 0, abortController.signal);\n loopHistory.push({ role: \"assistant\", content: fullText });\n roundMessages.push({ role: \"assistant\", content: fullText });\n roundAgentText = fullText; // 持续更新,保留最后一次 LLM 回复\n\n // 将本步展示文本(去掉全部 ACTION 标签)和操作记录下来,供界面恢复\n const stepDisplay = fullText.replace(/<ACTION>[\\s\\S]*?<\\/ACTION>/g, '').trim();\n roundSteps.push({\n text: stepDisplay || (actions.length > 0 ? (actions.length > 1 ? `Executing ${actions.length} actions…` : 'Executing action…') : ''),\n actions: actions.map(a => JSON.stringify(a)), // 保存所有动作供恢复\n });\n\n if (actions.length === 0) break; // 无操作指令 → 任务完成或需要用户确认\n\n // 逐个执行所有动作\n for (const action of actions) {\n await dispatchAction(action);\n // navigate 已在 dispatchAction 内等待页面加载完成,无需额外延迟\n // hover 需要较长等待让下拉菜单出现;其他动作按用户配置延迟\n const waitMs = action.type === 'navigate' ? 0\n : action.type === 'hover' ? Math.max(actionDelay, 800)\n : actionDelay;\n if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));\n if (abortController.signal.aborted) break;\n }\n\n if (abortController.signal.aborted) break;\n\n // 刷新页面快照(供下一步 injectPageIntoMessages 使用)\n loopPageFile = await addCurrentPage(true) || loopPageFile;\n\n // 续接消息(不含页面;页面由 injectPageIntoMessages 临时注入)\n const continuationMsg = buildContinuationContent(actions, cfg.loopFeedbackPrompt);\n if (continuationMsg) {\n loopHistory.push({ role: \"user\", content: continuationMsg });\n roundMessages.push({ role: \"user\", content: continuationMsg });\n }\n }\n\n agentContent.classList.remove(\"typing-cursor\");\n const wasAborted = abortController?.signal.aborted;\n if (wasAborted) {\n // 中途停止:保留已输出内容,无内容时显示提示\n if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = `<span style=\"color:${themeVar('--muted-2')}\">⏹ Stopped</span>`;\n }\n // 不将 Stopped 文字写入 roundSteps,避免恢复时显示\n } else if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = `<span style=\"color:${themeVar('--warning')}\">⚠️ Server returned no content. The model may have returned an empty response, or the stream format is unexpected.</span>`;\n }\n\n // 11 & 12. 有内容时才保存(停止且无内容则不存入历史)\n if (roundSteps.length > 0) {\n conversationHistory.push({ userText: cmd, agentText: roundAgentText, roundMessages });\n sessionMessages.push({ type: 'user', text: cmd });\n sessionMessages.push({ type: 'agent-steps', steps: roundSteps });\n await saveSession();\n }\n\n } catch (err) {\n agentContent.classList.remove(\"typing-cursor\");\n if (err.name === 'AbortError') {\n // 用户主动停止,保留已输出内容\n if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = `<span style=\"color:${themeVar('--muted-2')}\">⏹ Stopped</span>`;\n }\n // 无有任何已完成的步骤时,跳过保存(回复时水假无界面恢复)\n if (roundSteps.length === 0) {\n // 跳过保存\n } else {\n // 不将 Stopped 写入最后一步,恢复时不显示\n sessionMessages.push({ type: 'user', text: cmd });\n sessionMessages.push({ type: 'agent-steps', steps: roundSteps });\n conversationHistory.push({ userText: cmd, agentText: roundAgentText, roundMessages });\n await saveSession();\n }\n } else {\n const errMsg = (err && typeof err.message === 'string') ? err.message : String(err || '');\n const errName = (err && err.name) ? err.name : (typeof err === 'string' ? 'Error' : (err && err.constructor && err.constructor.name) || 'Error');\n const hint = (errMsg.includes(\"Key\") || errMsg.includes(\"settings\"))\n ? \"Right-click the page → open the extension settings to configure API Key and model.\"\n : (err instanceof TypeError)\n ? \"Network connection failed. Check the API URL and your network.\"\n : \"\";\n const errEl = document.createElement('div');\n errEl.style.cssText = [\n 'margin-top:8px', 'padding:8px 10px',\n `background:${themeVar('--danger-bg')}`, `border-left:3px solid ${themeVar('--danger')}`,\n 'border-radius:4px', 'font-size:12.5px', `color:${themeVar('--danger')}`, 'line-height:1.6',\n ].join(';');\n errEl.textContent = `❌ Request failed · ${errName}: ${errMsg}`;\n if (hint) {\n const hintEl = document.createElement('div');\n hintEl.style.cssText = `margin-top:4px;font-size:12px;color:${themeVar('--muted-2')};`;\n hintEl.textContent = 'Hint: ' + hint;\n errEl.appendChild(hintEl);\n }\n agentContent.appendChild(errEl);\n scrollToBottom();\n console.error(\"Agent request failed:\", err);\n }\n } finally {\n isWaiting = false;\n abortController = null;\n setRunBtnState('send');\n inputEl.focus();\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:等待标签页加载完成(用于 navigate 后同步)\n// ─────────────────────────────────────────────\nfunction waitForTabLoad(tabId, timeout = 10000) {\n return new Promise((resolve) => {\n const timer = setTimeout(() => {\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }, timeout);\n function onUpdated(id, info) {\n if (id === tabId && info.status === 'complete') {\n clearTimeout(timer);\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }\n }\n chrome.tabs.onUpdated.addListener(onUpdated);\n // 也立即检查一次,防止导航已经完成\n chrome.tabs.get(tabId).then(tab => {\n if (tab.status === 'complete') {\n clearTimeout(timer);\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }\n }).catch(() => {});\n });\n}\n\n// ─────────────────────────────────────────────\n// 辅助:把 action 转发给当前活动标签页执行\n// navigate 动作用 chrome.tabs.update 绕过 content script(实现在空白/特殊页上也可导航)\n// ─────────────────────────────────────────────\nasync function dispatchAction(action) {\n try {\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab) return;\n\n // navigate 用 chrome.tabs.update 直接操作标签页 URL,\n // 不依赖 content script(空白页、chrome:// 页也能正常导航)\n if (action.type === 'navigate') {\n await chrome.tabs.update(tab.id, { url: action.url });\n // 稍候待浏览器开始转跑后,再检测完成事件\n await new Promise(r => setTimeout(r, 150));\n await waitForTabLoad(tab.id);\n // 等待 content script 初始化(document_idle 之后)\n await new Promise(r => setTimeout(r, 300));\n return;\n }\n\n return await chrome.tabs.sendMessage(tab.id, { type: \"EXECUTE_ACTION\", action });\n } catch (e) {\n console.warn(\"dispatchAction failed:\", e);\n }\n}\n\n// ─────────────────────────────────────────────\n// 事件绑定\n// ─────────────────────────────────────────────\n\n// 发送/停止按钮(等待中变为停止按钮)\nrunBtn.addEventListener(\"click\", () => {\n if (isWaiting) {\n abortController?.abort();\n } else {\n sendMessage();\n }\n});\n\n// 配置按钮——在侧边栏内打开设置面板\n// 每次打开都重载 iframe,确保显示的是已保存的设置,而不是上次未保存的表单状态\ndocument.getElementById(\"btn-settings\").addEventListener(\"click\", () => {\n const frame = document.getElementById(\"settings-frame\");\n frame.src = 'options.html'; // 重载以从 storage 读取最新保存值\n document.getElementById(\"settings-overlay\").style.display = \"flex\";\n});\n\n// 接收 options.html(iframe)发来的关闭/保存消息\nwindow.addEventListener(\"message\", (e) => {\n if (e.data?.type === \"settings-saved\" || e.data?.type === \"settings-close\") {\n document.getElementById(\"settings-overlay\").style.display = \"none\";\n // 仅保存时才重新加载配置,刷新语言 + 顶部模型信息栏\n if (e.data.type === \"settings-saved\") {\n loadConfig().then(cfg => {\n applyTheme(cfg.theme);\n I18n.setLang(cfg.language);\n I18n.apply();\n const p = PROVIDERS[cfg.provider] || PROVIDERS.ollama;\n modelInfoEl.textContent = `${p.name} · ${cfg.model || I18n.t('model_not_set')}`;\n });\n }\n }\n});\n\nchrome.storage.onChanged.addListener((changes, areaName) => {\n if (areaName !== 'sync') return;\n\n if (changes.theme) {\n applyTheme(normalizeTheme(changes.theme.newValue));\n }\n\n if (changes.userLang) {\n I18n.setLang(changes.userLang.newValue || 'en');\n I18n.apply();\n }\n\n if (changes.provider || changes.model) {\n loadConfig().then(cfg => {\n const p = PROVIDERS[cfg.provider] || PROVIDERS.ollama;\n modelInfoEl.textContent = `${p.name} · ${cfg.model || I18n.t('model_not_set')}`;\n });\n }\n});\n\nsystemThemeMedia.addEventListener('change', () => {\n loadConfig().then(cfg => {\n if (cfg.theme === 'device') {\n applyTheme('device');\n }\n });\n});\n\n// Enter 发送,Shift+Enter 换行;自动增高\ninputEl.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n});\ninputEl.addEventListener(\"input\", () => {\n inputEl.style.height = \"auto\";\n inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + \"px\";\n});\n\n// 新建对话:清空消息列表,重新显示欢迎页\nbtnNew.addEventListener(\"click\", () => {\n messagesEl.innerHTML = \"\";\n welcomeEl.style.display = \"flex\";\n inputEl.value = \"\";\n inputEl.style.height = \"auto\";\n attachedFiles = [];\n conversationHistory = [];\n sessionMessages = [];\n chrome.storage.local.remove('chatSession');\n renderAttachments();\n addCurrentPage();\n inputEl.focus();\n});\n\n// ─────────────────────────────\n// 拖放文件进入\n// ─────────────────────────────\nfooterEl.addEventListener(\"dragover\", (e) => {\n e.preventDefault();\n footerEl.classList.add(\"drag-over\");\n});\nfooterEl.addEventListener(\"dragleave\", (e) => {\n // 只有离开 footer 整个区域才移除高亮\n if (!footerEl.contains(e.relatedTarget)) {\n footerEl.classList.remove(\"drag-over\");\n }\n});\nfooterEl.addEventListener(\"drop\", (e) => {\n e.preventDefault();\n footerEl.classList.remove(\"drag-over\");\n const files = Array.from(e.dataTransfer.files);\n files.forEach(file => {\n const reader = new FileReader();\n reader.onload = (ev) => {\n attachedFiles.push({ name: file.name, content: ev.target.result, isPage: false });\n renderAttachments();\n };\n reader.readAsText(file);\n });\n});\n\n// 建议词点击:直接填入输入框并发送\ndocument.getElementById(\"suggestions\").addEventListener(\"click\", (e) => {\n const chip = e.target.closest(\".suggestion-chip\");\n if (!chip) return;\n inputEl.value = chip.textContent;\n sendMessage();\n});"}, "b916dc06631fbd2bef8311f03ff2bd90e985fca3": {"t": 1785758638.9769804, "n": 40490, "h": "9d36f3bc11bf26e2", "c": "// ===== panel.js:Gemini 风格侧边栏完整逻辑 =====\n\n// 在任何异步操作之前,立即将语言设为英文并应用到 DOM\n// (CSP 禁止 HTML 内联脚本,故在此处同步执行)\nI18n.setLang('en');\nI18n.apply();\n\n// ── Provider 配置(纯前端,无需服务器)─────────────────────────\nconst PROVIDERS = {\n ollama: {\n name: \"Ollama\",\n format: \"openai\",\n url: \"http://localhost:11434/v1/chat/completions\",\n defaultKey: \"ollama\",\n },\n openrouter: {\n name: \"OpenRouter\",\n format: \"openai\",\n url: \"https://openrouter.ai/api/v1/chat/completions\",\n defaultKey: \"\",\n },\n deepseek: {\n name: \"DeepSeek\",\n format: \"openai\",\n url: \"https://api.deepseek.com/chat/completions\",\n defaultKey: \"\",\n },\n aistudio: {\n name: \"Google AI Studio\",\n format: \"gemini\",\n url: \"https://generativelanguage.googleapis.com/v1beta/models\",\n defaultKey: \"\",\n },\n copilot: {\n name: \"GitHub Copilot\",\n format: \"copilot\",\n url: \"https://api.githubcopilot.com/chat/completions\",\n defaultKey: \"\",\n },\n};\n\nconst SYSTEM_PROMPT =\n \"你是嵌入在浏览器侧边栏的智能助手,同时也是页面操作工具。\\n\" +\n \"用户会提供当前页面的「DOM HTML」,你需要根据 DOM 结构来定位要操作的元素。\\n\\n\" +\n \"需要操作页面时,先用自然语言描述将要做的事,然后在回复末尾输出操作指令:\\n\" +\n \" 点击: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"button.submit-btn\\\"}</ACTION>\\n\" +\n \" Ctrl+点击: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"a.result\\\",\\\"modifiers\\\":[\\\"ctrl\\\"]}</ACTION>\\n\" +\n \" 双击: <ACTION>{\\\"type\\\":\\\"double_click\\\",\\\"selector\\\":\\\".editable-cell\\\"}</ACTION>\\n\" +\n \" 右键: <ACTION>{\\\"type\\\":\\\"right_click\\\",\\\"selector\\\":\\\".item\\\"}</ACTION>\\n\" +\n \" 输入: <ACTION>{\\\"type\\\":\\\"type\\\",\\\"selector\\\":\\\"input[name='q']\\\",\\\"text\\\":\\\"搜索内容\\\"}</ACTION>\\n\" +\n \" 选择: <ACTION>{\\\"type\\\":\\\"select\\\",\\\"selector\\\":\\\"select#category\\\",\\\"value\\\":\\\"选项\\\"}</ACTION>\\n\" +\n \" 按键: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"key\\\":\\\"Enter\\\"}</ACTION>\\n\" +\n \" 指定元素按键: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"selector\\\":\\\".dropdown\\\",\\\"key\\\":\\\"ArrowDown\\\"}</ACTION>\\n\" +\n \" 跳转: <ACTION>{\\\"type\\\":\\\"navigate\\\",\\\"url\\\":\\\"https://example.com\\\"}</ACTION>\\n\" +\n \" 滚动: <ACTION>{\\\"type\\\":\\\"scroll\\\",\\\"y\\\":300}</ACTION>\\n\" +\n \" 悬停: <ACTION>{\\\"type\\\":\\\"hover\\\",\\\"selector\\\":\\\".menu-item\\\"}</ACTION>\\n\" +\n \" 清空: <ACTION>{\\\"type\\\":\\\"clear\\\",\\\"selector\\\":\\\"input#search\\\"}</ACTION>\\n\\n\" +\n \"重要规则:\\n\" +\n \"- selector 使用 CSS 选择器字符串定位元素,如 #id、.classname、button[name='submit']、div > span:nth-of-type(2)\\n\" +\n \"- selector 必须从 DOM HTML 中推断,不要凭空猜测\\n\" +\n \"- 优先使用稳定的属性:id > name > aria-label > data-testid > class\\n\" +\n \"- 如果下一步依赖上一步的结果(如等待页面跳转后再定位元素),每次只输出一个 <ACTION>;如果多步操作是确定的(如依次填写多个字段),可在同一回复中输出多个 <ACTION>,系统会全部顺序执行后再反馈新页面状态\\n\" +\n \"- 不需要操作页面时,只用 Markdown 正常回答,不要输出 <ACTION> 标签\\n\" +\n \"- 如果在 DOM 中找不到合适的元素,告知用户而不是猜测\\n\" +\n \"- 某些元素(如悬浮菜单、hover 才出现的按钮)在初始 DOM 中不存在,需要先 hover 父元素触发渲染,再操作目标元素\\n\";\n\nconst DEFAULT_CONFIG = {\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n apiKeys: { ollama: \"ollama\", openrouter: \"\", deepseek: \"\", aistudio: \"\", copilot: \"\" },\n};\n\n// 每轮 action 执行完毕后反馈给模型的续接提示({{actions}} 替换为动作执行摘要)\nconst DEFAULT_LOOP_FEEDBACK_PROMPT =\n \"{{actions}}. Please continue completing the user's task. \" +\n \"If the task is fully completed, inform the user of the result and do not output any more <ACTION>.\";\n\nasync function loadConfig() {\n return new Promise(resolve => {\n chrome.storage.sync.get(null, stored => {\n // 首次加载(userLang 尚未写入)时强制保存 'en',相当于自动执行一次\"语言=英文\"的保存\n if (!stored.userLang) {\n chrome.storage.sync.set({ language: 'en', userLang: 'en' });\n stored.userLang = 'en'; // 确保在当前 tick 的结果中也立即生效\n }\n resolve({\n provider: stored.provider || DEFAULT_CONFIG.provider,\n model: stored.model || DEFAULT_CONFIG.model,\n apiKeys: Object.assign({}, DEFAULT_CONFIG.apiKeys, stored.apiKeys || {}),\n systemPrompt: stored.systemPrompt ?? SYSTEM_PROMPT,\n skills: Array.isArray(stored.skills) ? stored.skills : [],\n language: stored.userLang || 'en',\n historyRounds: stored.historyRounds != null ? stored.historyRounds : 3,\n agentMaxSteps: stored.agentMaxSteps != null ? stored.agentMaxSteps : '',\n actionDelay: stored.actionDelay != null ? stored.actionDelay : 500,\n loopFeedbackPrompt: stored.loopFeedbackPrompt !== undefined ? stored.loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT,\n });\n });\n });\n}\n\n// 构建有效系统提示词(Skills 列表追加到末尾)\nfunction buildEffectiveSystemPrompt(cfg) {\n let prompt = cfg.systemPrompt || \"\";\n if (cfg.skills && cfg.skills.length > 0) {\n prompt += \"\\n\\n---\\n## Available Skills\\n\";\n cfg.skills.forEach(skill => {\n if (skill.name || skill.content) {\n prompt += `\\n### ${skill.name || \"(untitled)\"}\\n${skill.content || \"\"}\\n`;\n }\n });\n }\n return prompt;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:从对话历史构建 LLM 消息(不含页面 DOM 和附件内容)\n// ─────────────────────────────────────────────\nfunction buildLLMHistory(cfg) {\n const rawRounds = cfg.historyRounds;\n const unlimited = (rawRounds === '' || rawRounds == null);\n const rounds = unlimited ? Infinity : (parseInt(rawRounds) || 0);\n if (rounds <= 0 || conversationHistory.length === 0) return [];\n\n const eligible = unlimited ? conversationHistory : conversationHistory.slice(-rounds);\n const result = [];\n for (const round of eligible) {\n if (round.roundMessages && round.roundMessages.length > 0) {\n // 新格式:包含 Agent 循环内完整对话(无页面/附件内容)\n result.push(...round.roundMessages);\n } else {\n // 兼容旧格式\n result.push({ role: 'user', content: round.userText });\n result.push({ role: 'assistant', content: round.agentText });\n }\n }\n return result;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:持久化对话到 chrome.storage.session(浏览器关闭前有效)\n// ─────────────────────────────────────────────\nasync function saveSession() {\n try {\n await new Promise((resolve, reject) => {\n chrome.storage.session.set(\n { chatSession: { conversationHistory, sessionMessages } },\n () => chrome.runtime.lastError\n ? reject(new Error(chrome.runtime.lastError.message))\n : resolve()\n );\n });\n } catch (e) {\n console.warn('[panel] saveSession failed:', e);\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:恢复上次对话(侧边栏重新打开时调用)\n// ─────────────────────────────────────────────\nasync function restoreSession() {\n try {\n const data = await new Promise((resolve, reject) => {\n chrome.storage.session.get('chatSession', (items) => {\n chrome.runtime.lastError\n ? reject(new Error(chrome.runtime.lastError.message))\n : resolve(items);\n });\n });\n const session = data?.chatSession;\n if (!session?.sessionMessages?.length) return;\n\n conversationHistory = session.conversationHistory || [];\n sessionMessages = session.sessionMessages || [];\n\n welcomeEl.style.display = 'none';\n for (const msg of sessionMessages) {\n if (msg.type === 'user') {\n const el = document.createElement('div');\n el.className = 'msg-user';\n el.textContent = msg.text;\n messagesEl.appendChild(el);\n } else if (msg.type === 'agent' || msg.type === 'agent-steps') {\n const wrap = document.createElement('div');\n wrap.className = 'msg-agent-wrap';\n const icon = document.createElement('div');\n icon.className = 'msg-agent-icon';\n const content = document.createElement('div');\n content.className = 'msg-agent';\n\n if (msg.type === 'agent-steps') {\n // 多步 Agent 轮次:逐步重建分隔线 + 文本 + 操作芯片\n (msg.steps || []).forEach((step, i) => {\n if (i > 0) {\n const sep = document.createElement('div');\n sep.style.cssText = [\n 'margin:6px 0 4px', 'border-top:1px dashed #e8eaed',\n 'font-size:11px', 'color:#80868b', 'padding-top:4px',\n ].join(';');\n sep.textContent = '↩ Continuing…';\n content.appendChild(sep);\n }\n const stepEl = document.createElement('div');\n stepEl.innerHTML = renderMarkdown(step.text || '');\n content.appendChild(stepEl);\n // 支持新格式(actions 数组)和旧格式(单个 action 字符串)吉化入)\n const stepActions = step.actions || (step.action ? [step.action] : []);\n for (const actionStr of stepActions) {\n const chip = document.createElement('div');\n chip.style.cssText = [\n 'margin-top:8px', 'padding:5px 10px',\n 'background:#f1f3f4', 'border-left:3px solid #1a73e8',\n 'border-radius:4px', 'font-size:11.5px', 'color:#3c4043',\n 'font-family:Consolas,monospace', 'word-break:break-all',\n ].join(';');\n chip.textContent = '⚡ ' + actionStr;\n content.appendChild(chip);\n }\n });\n } else {\n // 兼容旧格式(纯文本 or HTML)\n content.innerHTML = msg.html != null ? msg.html : renderMarkdown(msg.text || '');\n }\n\n wrap.appendChild(icon);\n wrap.appendChild(content);\n messagesEl.appendChild(wrap);\n }\n }\n scrollToBottom();\n } catch (e) {\n console.warn('[panel] restoreSession failed:', e);\n }\n}\n\n// ── 状态 ──\nlet isWaiting = false;\nlet abortController = null;\nlet attachedFiles = [];\nlet conversationHistory = []; // [{ userText, agentText }] 跨轮次历史(不含 DOM)\nlet sessionMessages = []; // [{ type, text?, html? }] 用于界面恢复\n\n// ── DOM 引用 ──\nconst mainEl = document.getElementById(\"main\");\nconst welcomeEl = document.getElementById(\"welcome\");\nconst messagesEl = document.getElementById(\"messages\");\nconst inputEl = document.getElementById(\"agent-input\");\nconst runBtn = document.getElementById(\"agent-run\");\nconst btnNew = document.getElementById(\"btn-new\");\nconst attachmentsEl = document.getElementById(\"attachments\");\nconst footerEl = document.getElementById(\"footer\");\nconst modelInfoEl = document.getElementById(\"model-info\");\nconst btnAddPage = document.getElementById(\"btn-add-page\");\n\n// 立即用默认值覆盖「加载配置中」占位文字\n{\n const _c = PROVIDERS[DEFAULT_CONFIG.provider];\n modelInfoEl.textContent = (_c ? _c.name : DEFAULT_CONFIG.provider) + ' · ' + DEFAULT_CONFIG.model;\n}\n\n// ── 初始化:显示当前配置 ──\nloadConfig().then(cfg => {\n if (I18n.lang !== cfg.language) {\n I18n.setLang(cfg.language);\n I18n.apply();\n }\n const conf = PROVIDERS[cfg.provider];\n modelInfoEl.textContent = (conf ? conf.name : cfg.provider) + \" · \" + (cfg.model || I18n.t('model_not_set'));\n});\n\n// ─────────────────────────────────────────────\n// 附件管理\n// ─────────────────────────────────────────────\n\n// 重新渲染附件芯片列表\nfunction renderAttachments() {\n attachmentsEl.innerHTML = \"\";\n attachedFiles.forEach((file, idx) => {\n const chip = document.createElement(\"div\");\n chip.className = \"file-chip\";\n chip.title = file.name;\n\n const icon = document.createElement(\"span\");\n icon.className = \"file-chip-icon\";\n icon.textContent = file.isPage ? \"🌐\" : \"📄\";\n\n const name = document.createElement(\"span\");\n name.className = \"file-chip-name\";\n name.textContent = file.name;\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.className = \"file-chip-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = I18n.t('remove_chip');\n removeBtn.addEventListener(\"click\", () => {\n attachedFiles.splice(idx, 1);\n renderAttachments();\n });\n\n chip.appendChild(icon);\n chip.appendChild(name);\n chip.appendChild(removeBtn);\n attachmentsEl.appendChild(chip);\n });\n // 将加号按钮始终放在最后一个芯片之后,随芯片一起换行\n attachmentsEl.appendChild(btnAddPage);\n}\n\n// 获取当前标签页内容并作为默认附件\n// refreshOnly=true:仅刷新已有页面附件,不重新添加用户已手动删除的页面\nasync function addCurrentPage(refreshOnly = false) {\n try {\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab) return;\n\n let pageContent = \"\";\n try {\n const resp = await chrome.tabs.sendMessage(tab.id, { type: \"GET_PAGE_CONTENT\" });\n pageContent = resp?.content ?? \"\";\n } catch {\n // content script 未注入(扩展重载后的旧标签页),尝试动态注入后重试\n try {\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['i18n.js'] });\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['element-scanner.js'] });\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });\n // 等待脚本初始化完成\n await new Promise(r => setTimeout(r, 150));\n const resp2 = await chrome.tabs.sendMessage(tab.id, { type: \"GET_PAGE_CONTENT\" });\n pageContent = resp2?.content ?? \"\";\n } catch {\n // 动态注入也失败(chrome:// 等不可注入页面),用 URL+标题兜底\n pageContent = `标题:${tab.title}\\nURL:${tab.url}`;\n }\n }\n\n const fileObj = { name: tab.title || tab.url, content: pageContent, isPage: true };\n if (refreshOnly) {\n const existing = attachedFiles.findIndex(f => f.isPage);\n if (existing >= 0) {\n attachedFiles[existing] = fileObj; // 刷新模式:仅更新已有页面,用户删除后不重新添加\n }\n // else: 用户已删除页面附件,不重新添加\n } else {\n attachedFiles.unshift(fileObj); // 按钮点击:始终添加新页面\n }\n renderAttachments();\n return fileObj; // 返回文件对象供内部使用\n } catch (e) {\n console.warn(\"addCurrentPage failed:\", e);\n return null;\n }\n}\n\n// 加号按钮:强制添加/刷新当前页面附件\nbtnAddPage.addEventListener(\"click\", () => addCurrentPage(false));\n\n// 初始化:恢复上次会话,然后刷新当前页面附件\nrestoreSession().then(() => addCurrentPage());\n\n// ─────────────────────────────────────────────\n// 辅助:向消息列表追加用户气泡\n// ─────────────────────────────────────────────\nfunction appendUserMessage(text) {\n // 有消息时隐藏欢迎页\n welcomeEl.style.display = \"none\";\n\n const el = document.createElement(\"div\");\n el.className = \"msg-user\";\n el.textContent = text; // 用户消息直接显示纯文本,安全\n messagesEl.appendChild(el);\n scrollToBottom();\n}\n\n// ─────────────────────────────────────────────\n// 辅助:向消息列表追加 Agent 气泡(含图标)\n// 返回内容容器,方便后续流式更新\n// ─────────────────────────────────────────────\nfunction appendAgentMessage(html = \"\") {\n const wrap = document.createElement(\"div\");\n wrap.className = \"msg-agent-wrap\";\n\n // 左侧 Gemini 彩色小图标\n const icon = document.createElement(\"div\");\n icon.className = \"msg-agent-icon\";\n\n // 右侧内容区\n const content = document.createElement(\"div\");\n content.className = \"msg-agent typing-cursor\"; // 打字光标\n content.innerHTML = html;\n\n wrap.appendChild(icon);\n wrap.appendChild(content);\n messagesEl.appendChild(wrap);\n scrollToBottom();\n\n return content; // 返回内容节点,流式输出时用来更新\n}\n\n// ─────────────────────────────────────────────\n// 辅助:滚动到底部\n// ─────────────────────────────────────────────\nfunction scrollToBottom() {\n mainEl.scrollTop = mainEl.scrollHeight;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:用 marked 把 Markdown 渲染为 HTML\n// ─────────────────────────────────────────────\nfunction renderMarkdown(text) {\n // marked 已通过 CDN 在 panel.html 中引入\n return typeof marked !== \"undefined\" ? marked.parse(text) : text;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:切换发送/停止按鈕状态\n// ─────────────────────────────────────────────\nconst SEND_ICON = '<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M2.01 21L23 12 2.01 3 2 10l15 2-15 2z\"/></svg>';\nconst STOP_ICON = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\"/></svg>';\n\nfunction setRunBtnState(state) {\n if (state === 'stop') {\n runBtn.innerHTML = STOP_ICON;\n runBtn.style.background = '#5f6368';\n runBtn.disabled = false;\n } else {\n runBtn.innerHTML = SEND_ICON;\n runBtn.style.background = '';\n runBtn.disabled = false;\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:构建发给服务端的完整 user content(含附件)\n// ─────────────────────────────────────────────\nfunction buildUserContent(cmd, files) {\n const nonPageFiles = (files || []).filter(f => !f.isPage);\n if (!nonPageFiles.length) return cmd;\n const parts = nonPageFiles.map(f => `=== ${f.name} ===\\n${f.content}`);\n return parts.join(\"\\n\\n\") + `\\n\\n---\\n\\nUser question: ${cmd}`;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:所有 action 执行完毕后构建续接 user 消息(含重扫页面状态)\n// loopFeedbackPrompt 为空字符串时返回空(不向模型发送续接消息)\n// ─────────────────────────────────────────────\nfunction buildContinuationContent(actions, loopFeedbackPrompt) {\n // 设置里没填(空字符串)→ 返回空\n if (loopFeedbackPrompt === '') return '';\n const template = (loopFeedbackPrompt != null) ? loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT;\n const count = actions.length;\n const list = actions.map(a => JSON.stringify(a)).join(', ');\n const actionsDesc = count === 1\n ? `Action ${list} has been executed`\n : `${count} actions [${list}] have all been executed in sequence`;\n return template.replace('{{actions}}', actionsDesc);\n}\n\n// ─────────────────────────────────────────────\n// 辅助:将当前页面内容临时注入到最后一条 user 消息(不修改原数组)\n// ─────────────────────────────────────────────\nfunction injectPageIntoMessages(messages, pageFileOverride) {\n const pageFile = pageFileOverride !== undefined ? pageFileOverride : attachedFiles.find(f => f.isPage);\n if (!pageFile || !pageFile.content) return messages;\n const msgs = messages.map(m => ({ ...m }));\n for (let i = msgs.length - 1; i >= 0; i--) {\n if (msgs[i].role === 'user') {\n msgs[i] = { ...msgs[i], content: `=== Current Page ===\\n${pageFile.content}\\n\\n---\\n\\n${msgs[i].content}` };\n break;\n }\n }\n return msgs;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:显示请求报文弹框\n// ─────────────────────────────────────────────\nfunction showPayloadModal(payload) {\n document.getElementById('_payload_overlay')?.remove();\n\n const overlay = document.createElement('div');\n overlay.id = '_payload_overlay';\n overlay.style.cssText = [\n 'position:fixed', 'inset:0', 'background:rgba(0,0,0,0.55)',\n 'z-index:9999', 'display:flex', 'align-items:center', 'justify-content:center',\n ].join(';');\n\n overlay.innerHTML = `\n<div style=\"background:#1e1e1e;border-radius:12px;width:92%;max-width:720px;\n max-height:82vh;display:flex;flex-direction:column;\n box-shadow:0 8px 40px rgba(0,0,0,0.5);overflow:hidden;\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;\n padding:12px 16px;border-bottom:1px solid #333;flex-shrink:0;background:#252526;\">\n <span style=\"font-size:13px;font-weight:600;color:#e8eaed;\n font-family:'Google Sans','Segoe UI',sans-serif;\">📋 Request Payload</span>\n <button id=\"_payload_close\" style=\"background:none;border:none;cursor:pointer;\n font-size:18px;color:#9aa0a6;padding:2px 8px;border-radius:4px;line-height:1;\">✕</button>\n </div>\n <pre id=\"_payload_pre\" style=\"overflow:auto;padding:16px;margin:0;font-size:11.5px;\n line-height:1.65;color:#d4d4d4;background:#1e1e1e;flex:1;\n white-space:pre-wrap;word-break:break-word;font-family:Consolas,monospace;\"></pre>\n</div>`;\n\n // 用 textContent 写入避免 XSS\n overlay.querySelector('#_payload_pre').textContent = JSON.stringify(payload, null, 2);\n\n document.body.appendChild(overlay);\n overlay.querySelector('#_payload_close').onclick = () => overlay.remove();\n overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });\n}\n\n// ─────────────────────────────────────────────\n// 辅助:执行一轮 LLM 调用并流式输出到 agentContent\n// 返回 { fullText, action }\n// ─────────────────────────────────────────────\nasync function streamAgentTurn(history, agentContent, isContinuation, signal) {\n // 续轮时插入分隔条\n if (isContinuation) {\n const sep = document.createElement('div');\n sep.style.cssText = [\n 'margin:6px 0 4px', 'border-top:1px dashed #e8eaed',\n 'font-size:11px', 'color:#80868b', 'padding-top:4px',\n ].join(';');\n sep.textContent = '↩ Continuing…';\n agentContent.appendChild(sep);\n scrollToBottom();\n }\n\n // ── 加载配置 ──\n const cfg = await loadConfig();\n const conf = PROVIDERS[cfg.provider];\n if (!conf) throw new Error(`Unknown provider: ${cfg.provider}`);\n\n const apiKey = (cfg.apiKeys && cfg.apiKeys[cfg.provider]) || conf.defaultKey || \"\";\n if (!apiKey && cfg.provider !== \"ollama\" && cfg.provider !== \"copilot\") {\n throw new Error(`${conf.name}: API Key not configured. Please open the settings to configure it.`);\n }\n const model = cfg.model;\n if (!model) throw new Error(\"Model name not set. Please open the settings to configure it.\");\n\n // 更新顶栏\n modelInfoEl.textContent = conf.name + \" · \" + model;\n\n const effectivePrompt = buildEffectiveSystemPrompt(cfg);\n const allMessages = effectivePrompt\n ? [{ role: \"system\", content: effectivePrompt }, ...history]\n : [...history];\n const requestPayload = { provider: cfg.provider, model, url: conf.url, messages: allMessages };\n\n // 本轮流式输出容器\n const stepEl = document.createElement('div');\n agentContent.appendChild(stepEl);\n\n let fullText = \"\";\n let inReasoning = false;\n\n if (conf.format === \"copilot\") {\n // ── GitHub Copilot (OpenAI 局式 + 特殊认证头) ─────────────────\n let copilotToken;\n try {\n copilotToken = await GitHubAuth.getValidCopilotToken();\n } catch (e) {\n if (e.message === 'NOT_AUTHED') {\n throw new Error('GitHub Copilot 未授权,请在设置中连接 GitHub 账号');\n }\n throw e;\n }\n\n const res = await fetch(conf.url, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${copilotToken}`,\n 'Content-Type': 'application/json',\n 'Editor-Version': 'vscode/1.97.2',\n 'Editor-Plugin-Version': 'copilot-chat/0.26.1',\n 'Copilot-Integration-Id': 'vscode-chat',\n 'Openai-Intent': 'conversation-panel',\n 'User-Agent': 'GitHubCopilotChat/0.26.1',\n 'X-Github-Api-Version': '2024-12-15',\n },\n signal,\n body: JSON.stringify({ model, messages: allMessages, stream: true, max_tokens: 4096 }),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n if (res.status === 401 || res.status === 403) {\n // Copilot token 失效,清理缓存让下次重新获取\n await new Promise(r => chrome.storage.sync.remove(['copilotToken', 'copilotTokenExpiry'], r));\n throw new Error(`Copilot 认证失效 (${res.status}),请重新连接 GitHub 账号`);\n }\n throw new Error(`HTTP ${res.status}${body ? ': ' + body.slice(0, 300) : ''}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed === 'data: [DONE]') continue;\n const jsonStr = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;\n try {\n const parsed = JSON.parse(jsonStr);\n const content = parsed?.choices?.[0]?.delta?.content || '';\n if (content) { fullText += content; stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n // AbortError:带着已累积的部分文本继续执行,下方 signal.aborted 检查会提前返回\n }\n\n } else if (conf.format === \"gemini\") {\n // ── Google Gemini ─────────────────────────────────────────\n const geminiUrl = `${conf.url}/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;\n const geminiContents = history.map(m => ({\n role: m.role === \"assistant\" ? \"model\" : \"user\",\n parts: [{ text: m.content }],\n }));\n\n const res = await fetch(geminiUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n signal,\n body: JSON.stringify({\n ...(effectivePrompt ? { systemInstruction: { parts: [{ text: effectivePrompt }] } } : {}),\n contents: geminiContents,\n generationConfig: { maxOutputTokens: 2000 },\n }),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => \"\");\n throw new Error(`HTTP ${res.status}${body ? \": \" + body.slice(0, 300) : \"\"}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = lines.pop();\n for (const line of lines) {\n if (!line.trim().startsWith(\"data: \")) continue;\n try {\n const chunk = JSON.parse(line.trim().slice(6));\n const text = chunk?.candidates?.[0]?.content?.parts?.[0]?.text || \"\";\n if (text) { fullText += text; stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n }\n\n } else {\n // ── OpenAI 兼容格式(Ollama / OpenRouter / DeepSeek)─────\n const isDeepSeek = cfg.provider === \"deepseek\";\n const reqBody = {\n model,\n messages: allMessages,\n stream: true,\n };\n if (cfg.provider === \"ollama\") {\n reqBody.options = { num_predict: 2000 };\n } else {\n reqBody.max_tokens = 2000;\n }\n\n const res = await fetch(conf.url, {\n method: \"POST\",\n headers: {\n \"Authorization\": \"Bearer \" + apiKey,\n \"Content-Type\": \"application/json\",\n },\n signal,\n body: JSON.stringify(reqBody),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => \"\");\n throw new Error(`HTTP ${res.status}${body ? \": \" + body.slice(0, 300) : \"\"}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = lines.pop();\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed === \"data: [DONE]\") continue;\n const jsonStr = trimmed.startsWith(\"data: \") ? trimmed.slice(6) : trimmed;\n try {\n const parsed = JSON.parse(jsonStr);\n const delta = parsed?.choices?.[0]?.delta;\n const reasoning = isDeepSeek ? (delta?.reasoning_content || \"\") : \"\";\n const content = delta?.content || \"\";\n\n if (reasoning) {\n if (!inReasoning) { inReasoning = true; fullText += \"*思考中...*\\n\\n> \"; }\n fullText += reasoning.replace(/\\n/g, \"\\n> \");\n } else if (content) {\n if (inReasoning) { inReasoning = false; fullText += \"\\n\\n---\\n\\n\"; }\n fullText += content;\n }\n if (reasoning || content) { stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n }\n }\n\n // 用户中途停止:带着已累积内容提前返回,不解析 action 也不添加报文按钮\n if (signal?.aborted) {\n return { fullText, actions: [] };\n }\n\n // 解析全部 <ACTION> 标签(支持一次多个动作)\n const actionMatches = [...fullText.matchAll(/<ACTION>([\\s\\S]*?)<\\/ACTION>/g)];\n const actions = [];\n if (actionMatches.length > 0) {\n const displayText = fullText.replace(/<ACTION>[\\s\\S]*?<\\/ACTION>/g, '').trim();\n stepEl.innerHTML = renderMarkdown(displayText || (actionMatches.length > 1 ? 'Executing actions…' : 'Executing action…'));\n for (const match of actionMatches) {\n try {\n const parsed = JSON.parse(match[1].trim());\n actions.push(parsed);\n // 每个动作显示一个芯片\n const chip = document.createElement('div');\n chip.style.cssText = [\n 'margin-top:8px', 'padding:5px 10px',\n 'background:#f1f3f4', 'border-left:3px solid #1a73e8',\n 'border-radius:4px', 'font-size:11.5px', 'color:#3c4043',\n 'font-family:Consolas,monospace', 'word-break:break-all',\n ].join(';');\n chip.textContent = '⚡ ' + match[1].trim();\n agentContent.appendChild(chip);\n } catch (e) {\n console.warn('Failed to parse <ACTION>:', e);\n }\n }\n scrollToBottom();\n }\n\n // 每轮结束后追加\"查看报文\"按钮\n const payloadBtn = document.createElement('button');\n payloadBtn.className = 'msg-payload-btn';\n payloadBtn.style.cssText = [\n 'margin-top:5px', 'padding:2px 8px', 'display:inline-block',\n 'background:none', 'border:1px solid #dadce0', 'border-radius:4px',\n 'font-size:11px', 'color:#5f6368', 'cursor:pointer', 'font-family:inherit',\n 'transition:all .15s',\n ].join(';');\n payloadBtn.textContent = '📋 View Payload';\n payloadBtn.onmouseenter = () => {\n payloadBtn.style.background = '#f8f9fa';\n payloadBtn.style.borderColor = '#1a73e8';\n payloadBtn.style.color = '#1a73e8';\n };\n payloadBtn.onmouseleave = () => {\n payloadBtn.style.background = 'none';\n payloadBtn.style.borderColor = '#dadce0';\n payloadBtn.style.color = '#5f6368';\n };\n payloadBtn.onclick = () => showPayloadModal(requestPayload);\n agentContent.appendChild(payloadBtn);\n scrollToBottom();\n\n return { fullText, actions };\n}\n\n// ─────────────────────────────────────────────\n// 核心:发送消息(含 agentic loop)\n// ─────────────────────────────────────────────\nasync function sendMessage() {\n if (isWaiting) return;\n const cmd = inputEl.value.trim();\n if (!cmd) return;\n\n // 1. 显示用户气泡\n appendUserMessage(cmd);\n\n // 2. 清空输入框并重置高度\n inputEl.value = \"\";\n inputEl.style.height = \"auto\";\n\n // 3. 创建 Agent 气泡\n const agentContent = appendAgentMessage(\"\");\n\n // 4. 切换为停止按鈕\n isWaiting = true;\n abortController = new AbortController();\n setRunBtnState('stop');\n\n // 声明在 try 外部,确保 catch 块也能访问(let 是块级作用域,在 try 内声明则 catch 不可见)\n let roundAgentText = '';\n let roundSteps = [];\n // roundMessages 保存本轮完整对话(消息不含页面 DOM 和附件)\n let roundMessages = [{ role: 'user', content: cmd }];\n\n try {\n // 5. 刷新页面快照\n await addCurrentPage(true);\n\n // 6. 加载配置(含 historyRounds、agentMaxSteps)\n const cfg = await loadConfig();\n\n // 7. 从历史对话构建上文(仅用户文本,不含 DOM)\n const historyMessages = buildLLMHistory(cfg);\n\n // 8. 构建本轮消息(不含页面;页面在每次 LLM 调用前临时注入)\n const loopHistory = [\n ...historyMessages,\n { role: \"user\", content: buildUserContent(cmd, attachedFiles) },\n ];\n\n // 快照本轮页面文件(清空附件内容后供 loop 使用)\n let loopPageFile = attachedFiles.find(f => f.isPage) || null;\n\n // 发送后清空附件栏,让用户重新添加\n attachedFiles = [];\n renderAttachments();\n\n // 9. 确定 Agent 最大步数(空 = 不限;0 = 不执行任何动作)\n const rawMax = cfg.agentMaxSteps;\n const maxSteps = (rawMax === '' || rawMax == null)\n ? Infinity\n : Math.max(0, parseInt(rawMax) || 0);\n\n // roundAgentText、roundSteps、roundMessages 已在 try 外声明,此处无需重复声明\n\n const _rawHist = cfg.historyRounds;\n const histRounds = (_rawHist === '' || _rawHist == null) ? Infinity : (parseInt(_rawHist) || 0);\n const actionDelay = typeof cfg.actionDelay === 'number' ? cfg.actionDelay : 500;\n\n // 10. Agentic loop\n for (let step = 0; step < maxSteps; step++) {\n // 页面内容临时注入到最后一条 user 消息,不存入 loopHistory\n const { fullText, actions } = await streamAgentTurn(injectPageIntoMessages(loopHistory, loopPageFile), agentContent, step > 0, abortController.signal);\n loopHistory.push({ role: \"assistant\", content: fullText });\n roundMessages.push({ role: \"assistant\", content: fullText });\n roundAgentText = fullText; // 持续更新,保留最后一次 LLM 回复\n\n // 将本步展示文本(去掉全部 ACTION 标签)和操作记录下来,供界面恢复\n const stepDisplay = fullText.replace(/<ACTION>[\\s\\S]*?<\\/ACTION>/g, '').trim();\n roundSteps.push({\n text: stepDisplay || (actions.length > 0 ? (actions.length > 1 ? `Executing ${actions.length} actions…` : 'Executing action…') : ''),\n actions: actions.map(a => JSON.stringify(a)), // 保存所有动作供恢复\n });\n\n if (actions.length === 0) break; // 无操作指令 → 任务完成或需要用户确认\n\n // 逐个执行所有动作\n for (const action of actions) {\n await dispatchAction(action);\n // navigate 已在 dispatchAction 内等待页面加载完成,无需额外延迟\n // hover 需要较长等待让下拉菜单出现;其他动作按用户配置延迟\n const waitMs = action.type === 'navigate' ? 0\n : action.type === 'hover' ? Math.max(actionDelay, 800)\n : actionDelay;\n if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));\n if (abortController.signal.aborted) break;\n }\n\n if (abortController.signal.aborted) break;\n\n // 刷新页面快照(供下一步 injectPageIntoMessages 使用)\n loopPageFile = await addCurrentPage(true) || loopPageFile;\n\n // 续接消息(不含页面;页面由 injectPageIntoMessages 临时注入)\n const continuationMsg = buildContinuationContent(actions, cfg.loopFeedbackPrompt);\n if (continuationMsg) {\n loopHistory.push({ role: \"user\", content: continuationMsg });\n roundMessages.push({ role: \"user\", content: continuationMsg });\n }\n }\n\n agentContent.classList.remove(\"typing-cursor\");\n const wasAborted = abortController?.signal.aborted;\n if (wasAborted) {\n // 中途停止:保留已输出内容,无内容时显示提示\n if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = '<span style=\"color:#80868b\">⏹ Stopped</span>';\n }\n // 不将 Stopped 文字写入 roundSteps,避免恢复时显示\n } else if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = `<span style=\"color:#b36200\">⚠️ Server returned no content. The model may have returned an empty response, or the stream format is unexpected.</span>`;\n }\n\n // 11 & 12. 有内容时才保存(停止且无内容则不存入历史)\n if (roundSteps.length > 0) {\n conversationHistory.push({ userText: cmd, agentText: roundAgentText, roundMessages });\n sessionMessages.push({ type: 'user', text: cmd });\n sessionMessages.push({ type: 'agent-steps', steps: roundSteps });\n await saveSession();\n }\n\n } catch (err) {\n agentContent.classList.remove(\"typing-cursor\");\n if (err.name === 'AbortError') {\n // 用户主动停止,保留已输出内容\n if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = '<span style=\"color:#80868b\">⏹ Stopped</span>';\n }\n // 无有任何已完成的步骤时,跳过保存(回复时水假无界面恢复)\n if (roundSteps.length === 0) {\n // 跳过保存\n } else {\n // 不将 Stopped 写入最后一步,恢复时不显示\n sessionMessages.push({ type: 'user', text: cmd });\n sessionMessages.push({ type: 'agent-steps', steps: roundSteps });\n conversationHistory.push({ userText: cmd, agentText: roundAgentText, roundMessages });\n await saveSession();\n }\n } else {\n const hint = err.message.includes(\"Key\") || err.message.includes(\"settings\")\n ? \"Right-click the page → open the extension settings to configure API Key and model.\"\n : err instanceof TypeError\n ? \"Network connection failed. Check the API URL and your network.\"\n : \"\";\n const errEl = document.createElement('div');\n errEl.style.cssText = [\n 'margin-top:8px', 'padding:8px 10px',\n 'background:#fce8e6', 'border-left:3px solid #c62828',\n 'border-radius:4px', 'font-size:12.5px', 'color:#c62828', 'line-height:1.6',\n ].join(';');\n errEl.textContent = `❌ Request failed · ${err.name}: ${err.message}`;\n if (hint) {\n const hintEl = document.createElement('div');\n hintEl.style.cssText = 'margin-top:4px;font-size:12px;color:#80868b;';\n hintEl.textContent = 'Hint: ' + hint;\n errEl.appendChild(hintEl);\n }\n agentContent.appendChild(errEl);\n scrollToBottom();\n console.error(\"Agent request failed:\", err);\n }\n } finally {\n isWaiting = false;\n abortController = null;\n setRunBtnState('send');\n inputEl.focus();\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:等待标签页加载完成(用于 navigate 后同步)\n// ─────────────────────────────────────────────\nfunction waitForTabLoad(tabId, timeout = 10000) {\n return new Promise((resolve) => {\n const timer = setTimeout(() => {\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }, timeout);\n function onUpdated(id, info) {\n if (id === tabId && info.status === 'complete') {\n clearTimeout(timer);\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }\n }\n chrome.tabs.onUpdated.addListener(onUpdated);\n // 也立即检查一次,防止导航已经完成\n chrome.tabs.get(tabId).then(tab => {\n if (tab.status === 'complete') {\n clearTimeout(timer);\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }\n }).catch(() => {});\n });\n}\n\n// ─────────────────────────────────────────────\n// 辅助:把 action 转发给当前活动标签页执行\n// navigate 动作用 chrome.tabs.update 绕过 content script(实现在空白/特殊页上也可导航)\n// ─────────────────────────────────────────────\nasync function dispatchAction(action) {\n try {\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab) return;\n\n // navigate 用 chrome.tabs.update 直接操作标签页 URL,\n // 不依赖 content script(空白页、chrome:// 页也能正常导航)\n if (action.type === 'navigate') {\n await chrome.tabs.update(tab.id, { url: action.url });\n // 稍候待浏览器开始转跑后,再检测完成事件\n await new Promise(r => setTimeout(r, 150));\n await waitForTabLoad(tab.id);\n // 等待 content script 初始化(document_idle 之后)\n await new Promise(r => setTimeout(r, 300));\n return;\n }\n\n return await chrome.tabs.sendMessage(tab.id, { type: \"EXECUTE_ACTION\", action });\n } catch (e) {\n console.warn(\"dispatchAction failed:\", e);\n }\n}\n\n// ─────────────────────────────────────────────\n// 事件绑定\n// ─────────────────────────────────────────────\n\n// 发送/停止按钮(等待中变为停止按钮)\nrunBtn.addEventListener(\"click\", () => {\n if (isWaiting) {\n abortController?.abort();\n } else {\n sendMessage();\n }\n});\n\n// 配置按钮——在侧边栏内打开设置面板\n// 每次打开都重载 iframe,确保显示的是已保存的设置,而不是上次未保存的表单状态\ndocument.getElementById(\"btn-settings\").addEventListener(\"click\", () => {\n const frame = document.getElementById(\"settings-frame\");\n frame.src = 'options.html'; // 重载以从 storage 读取最新保存值\n document.getElementById(\"settings-overlay\").style.display = \"flex\";\n});\n\n// 接收 options.html(iframe)发来的关闭/保存消息\nwindow.addEventListener(\"message\", (e) => {\n if (e.data?.type === \"settings-saved\" || e.data?.type === \"settings-close\") {\n document.getElementById(\"settings-overlay\").style.display = \"none\";\n // 仅保存时才重新加载配置,刷新语言 + 顶部模型信息栏\n if (e.data.type === \"settings-saved\") {\n loadConfig().then(cfg => {\n I18n.setLang(cfg.language);\n I18n.apply();\n const p = PROVIDERS[cfg.provider] || PROVIDERS.ollama;\n modelInfoEl.textContent = `${p.name} · ${cfg.model || I18n.t('model_not_set')}`;\n });\n }\n }\n});\n\n// Enter 发送,Shift+Enter 换行;自动增高\ninputEl.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n});\ninputEl.addEventListener(\"input\", () => {\n inputEl.style.height = \"auto\";\n inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + \"px\";\n});\n\n// 新建对话:清空消息列表,重新显示欢迎页\nbtnNew.addEventListener(\"click\", () => {\n messagesEl.innerHTML = \"\";\n welcomeEl.style.display = \"flex\";\n inputEl.value = \"\";\n inputEl.style.height = \"auto\";\n attachedFiles = [];\n conversationHistory = [];\n sessionMessages = [];\n chrome.storage.local.remove('chatSession');\n renderAttachments();\n addCurrentPage();\n inputEl.focus();\n});\n\n// ─────────────────────────────\n// 拖放文件进入\n// ─────────────────────────────\nfooterEl.addEventListener(\"dragover\", (e) => {\n e.preventDefault();\n footerEl.classList.add(\"drag-over\");\n});\nfooterEl.addEventListener(\"dragleave\", (e) => {\n // 只有离开 footer 整个区域才移除高亮\n if (!footerEl.contains(e.relatedTarget)) {\n footerEl.classList.remove(\"drag-over\");\n }\n});\nfooterEl.addEventListener(\"drop\", (e) => {\n e.preventDefault();\n footerEl.classList.remove(\"drag-over\");\n const files = Array.from(e.dataTransfer.files);\n files.forEach(file => {\n const reader = new FileReader();\n reader.onload = (ev) => {\n attachedFiles.push({ name: file.name, content: ev.target.result, isPage: false });\n renderAttachments();\n };\n reader.readAsText(file);\n });\n});\n\n// 建议词点击:直接填入输入框并发送\ndocument.getElementById(\"suggestions\").addEventListener(\"click\", (e) => {\n const chip = e.target.closest(\".suggestion-chip\");\n if (!chip) return;\n inputEl.value = chip.textContent;\n sendMessage();\n});"}, "5c9828090c28cc0d5ac877ec00fda52c453976d3": {"t": 1785758642.2103653, "n": 40495, "h": "d4449ac8a745e0c2", "c": "// ===== panel.js:Gemini 风格侧边栏完整逻辑 =====\n\n// 在任何异步操作之前,立即将语言设为英文并应用到 DOM\n// (CSP 禁止 HTML 内联脚本,故在此处同步执行)\nI18n.setLang('en');\nI18n.apply();\n\n// ── Provider 配置(纯前端,无需服务器)─────────────────────────\nconst PROVIDERS = {\n ollama: {\n name: \"Ollama (本地)\",\n format: \"openai\",\n url: \"http://localhost:11434/v1/chat/completions\",\n defaultKey: \"ollama\",\n },\n openrouter: {\n name: \"OpenRouter\",\n format: \"openai\",\n url: \"https://openrouter.ai/api/v1/chat/completions\",\n defaultKey: \"\",\n },\n deepseek: {\n name: \"DeepSeek\",\n format: \"openai\",\n url: \"https://api.deepseek.com/chat/completions\",\n defaultKey: \"\",\n },\n aistudio: {\n name: \"Google AI Studio\",\n format: \"gemini\",\n url: \"https://generativelanguage.googleapis.com/v1beta/models\",\n defaultKey: \"\",\n },\n copilot: {\n name: \"GitHub Copilot\",\n format: \"copilot\",\n url: \"https://api.githubcopilot.com/chat/completions\",\n defaultKey: \"\",\n },\n};\n\nconst SYSTEM_PROMPT =\n \"你是嵌入在浏览器侧边栏的智能助手,同时也是页面操作工具。\\n\" +\n \"用户会提供当前页面的「DOM HTML」,你需要根据 DOM 结构来定位要操作的元素。\\n\\n\" +\n \"需要操作页面时,先用自然语言描述将要做的事,然后在回复末尾输出操作指令:\\n\" +\n \" 点击: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"button.submit-btn\\\"}</ACTION>\\n\" +\n \" Ctrl+点击: <ACTION>{\\\"type\\\":\\\"click\\\",\\\"selector\\\":\\\"a.result\\\",\\\"modifiers\\\":[\\\"ctrl\\\"]}</ACTION>\\n\" +\n \" 双击: <ACTION>{\\\"type\\\":\\\"double_click\\\",\\\"selector\\\":\\\".editable-cell\\\"}</ACTION>\\n\" +\n \" 右键: <ACTION>{\\\"type\\\":\\\"right_click\\\",\\\"selector\\\":\\\".item\\\"}</ACTION>\\n\" +\n \" 输入: <ACTION>{\\\"type\\\":\\\"type\\\",\\\"selector\\\":\\\"input[name='q']\\\",\\\"text\\\":\\\"搜索内容\\\"}</ACTION>\\n\" +\n \" 选择: <ACTION>{\\\"type\\\":\\\"select\\\",\\\"selector\\\":\\\"select#category\\\",\\\"value\\\":\\\"选项\\\"}</ACTION>\\n\" +\n \" 按键: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"key\\\":\\\"Enter\\\"}</ACTION>\\n\" +\n \" 指定元素按键: <ACTION>{\\\"type\\\":\\\"key\\\",\\\"selector\\\":\\\".dropdown\\\",\\\"key\\\":\\\"ArrowDown\\\"}</ACTION>\\n\" +\n \" 跳转: <ACTION>{\\\"type\\\":\\\"navigate\\\",\\\"url\\\":\\\"https://example.com\\\"}</ACTION>\\n\" +\n \" 滚动: <ACTION>{\\\"type\\\":\\\"scroll\\\",\\\"y\\\":300}</ACTION>\\n\" +\n \" 悬停: <ACTION>{\\\"type\\\":\\\"hover\\\",\\\"selector\\\":\\\".menu-item\\\"}</ACTION>\\n\" +\n \" 清空: <ACTION>{\\\"type\\\":\\\"clear\\\",\\\"selector\\\":\\\"input#search\\\"}</ACTION>\\n\\n\" +\n \"重要规则:\\n\" +\n \"- selector 使用 CSS 选择器字符串定位元素,如 #id、.classname、button[name='submit']、div > span:nth-of-type(2)\\n\" +\n \"- selector 必须从 DOM HTML 中推断,不要凭空猜测\\n\" +\n \"- 优先使用稳定的属性:id > name > aria-label > data-testid > class\\n\" +\n \"- 如果下一步依赖上一步的结果(如等待页面跳转后再定位元素),每次只输出一个 <ACTION>;如果多步操作是确定的(如依次填写多个字段),可在同一回复中输出多个 <ACTION>,系统会全部顺序执行后再反馈新页面状态\\n\" +\n \"- 不需要操作页面时,只用 Markdown 正常回答,不要输出 <ACTION> 标签\\n\" +\n \"- 如果在 DOM 中找不到合适的元素,告知用户而不是猜测\\n\" +\n \"- 某些元素(如悬浮菜单、hover 才出现的按钮)在初始 DOM 中不存在,需要先 hover 父元素触发渲染,再操作目标元素\\n\";\n\nconst DEFAULT_CONFIG = {\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n apiKeys: { ollama: \"ollama\", openrouter: \"\", deepseek: \"\", aistudio: \"\", copilot: \"\" },\n};\n\n// 每轮 action 执行完毕后反馈给模型的续接提示({{actions}} 替换为动作执行摘要)\nconst DEFAULT_LOOP_FEEDBACK_PROMPT =\n \"{{actions}}. Please continue completing the user's task. \" +\n \"If the task is fully completed, inform the user of the result and do not output any more <ACTION>.\";\n\nasync function loadConfig() {\n return new Promise(resolve => {\n chrome.storage.sync.get(null, stored => {\n // 首次加载(userLang 尚未写入)时强制保存 'en',相当于自动执行一次\"语言=英文\"的保存\n if (!stored.userLang) {\n chrome.storage.sync.set({ language: 'en', userLang: 'en' });\n stored.userLang = 'en'; // 确保在当前 tick 的结果中也立即生效\n }\n resolve({\n provider: stored.provider || DEFAULT_CONFIG.provider,\n model: stored.model || DEFAULT_CONFIG.model,\n apiKeys: Object.assign({}, DEFAULT_CONFIG.apiKeys, stored.apiKeys || {}),\n systemPrompt: stored.systemPrompt ?? SYSTEM_PROMPT,\n skills: Array.isArray(stored.skills) ? stored.skills : [],\n language: stored.userLang || 'en',\n historyRounds: stored.historyRounds != null ? stored.historyRounds : 3,\n agentMaxSteps: stored.agentMaxSteps != null ? stored.agentMaxSteps : '',\n actionDelay: stored.actionDelay != null ? stored.actionDelay : 500,\n loopFeedbackPrompt: stored.loopFeedbackPrompt !== undefined ? stored.loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT,\n });\n });\n });\n}\n\n// 构建有效系统提示词(Skills 列表追加到末尾)\nfunction buildEffectiveSystemPrompt(cfg) {\n let prompt = cfg.systemPrompt || \"\";\n if (cfg.skills && cfg.skills.length > 0) {\n prompt += \"\\n\\n---\\n## Available Skills\\n\";\n cfg.skills.forEach(skill => {\n if (skill.name || skill.content) {\n prompt += `\\n### ${skill.name || \"(untitled)\"}\\n${skill.content || \"\"}\\n`;\n }\n });\n }\n return prompt;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:从对话历史构建 LLM 消息(不含页面 DOM 和附件内容)\n// ─────────────────────────────────────────────\nfunction buildLLMHistory(cfg) {\n const rawRounds = cfg.historyRounds;\n const unlimited = (rawRounds === '' || rawRounds == null);\n const rounds = unlimited ? Infinity : (parseInt(rawRounds) || 0);\n if (rounds <= 0 || conversationHistory.length === 0) return [];\n\n const eligible = unlimited ? conversationHistory : conversationHistory.slice(-rounds);\n const result = [];\n for (const round of eligible) {\n if (round.roundMessages && round.roundMessages.length > 0) {\n // 新格式:包含 Agent 循环内完整对话(无页面/附件内容)\n result.push(...round.roundMessages);\n } else {\n // 兼容旧格式\n result.push({ role: 'user', content: round.userText });\n result.push({ role: 'assistant', content: round.agentText });\n }\n }\n return result;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:持久化对话到 chrome.storage.session(浏览器关闭前有效)\n// ─────────────────────────────────────────────\nasync function saveSession() {\n try {\n await new Promise((resolve, reject) => {\n chrome.storage.session.set(\n { chatSession: { conversationHistory, sessionMessages } },\n () => chrome.runtime.lastError\n ? reject(new Error(chrome.runtime.lastError.message))\n : resolve()\n );\n });\n } catch (e) {\n console.warn('[panel] saveSession failed:', e);\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:恢复上次对话(侧边栏重新打开时调用)\n// ─────────────────────────────────────────────\nasync function restoreSession() {\n try {\n const data = await new Promise((resolve, reject) => {\n chrome.storage.session.get('chatSession', (items) => {\n chrome.runtime.lastError\n ? reject(new Error(chrome.runtime.lastError.message))\n : resolve(items);\n });\n });\n const session = data?.chatSession;\n if (!session?.sessionMessages?.length) return;\n\n conversationHistory = session.conversationHistory || [];\n sessionMessages = session.sessionMessages || [];\n\n welcomeEl.style.display = 'none';\n for (const msg of sessionMessages) {\n if (msg.type === 'user') {\n const el = document.createElement('div');\n el.className = 'msg-user';\n el.textContent = msg.text;\n messagesEl.appendChild(el);\n } else if (msg.type === 'agent' || msg.type === 'agent-steps') {\n const wrap = document.createElement('div');\n wrap.className = 'msg-agent-wrap';\n const icon = document.createElement('div');\n icon.className = 'msg-agent-icon';\n const content = document.createElement('div');\n content.className = 'msg-agent';\n\n if (msg.type === 'agent-steps') {\n // 多步 Agent 轮次:逐步重建分隔线 + 文本 + 操作芯片\n (msg.steps || []).forEach((step, i) => {\n if (i > 0) {\n const sep = document.createElement('div');\n sep.style.cssText = [\n 'margin:6px 0 4px', 'border-top:1px dashed #e8eaed',\n 'font-size:11px', 'color:#80868b', 'padding-top:4px',\n ].join(';');\n sep.textContent = '↩ Continuing…';\n content.appendChild(sep);\n }\n const stepEl = document.createElement('div');\n stepEl.innerHTML = renderMarkdown(step.text || '');\n content.appendChild(stepEl);\n // 支持新格式(actions 数组)和旧格式(单个 action 字符串)吉化入)\n const stepActions = step.actions || (step.action ? [step.action] : []);\n for (const actionStr of stepActions) {\n const chip = document.createElement('div');\n chip.style.cssText = [\n 'margin-top:8px', 'padding:5px 10px',\n 'background:#f1f3f4', 'border-left:3px solid #1a73e8',\n 'border-radius:4px', 'font-size:11.5px', 'color:#3c4043',\n 'font-family:Consolas,monospace', 'word-break:break-all',\n ].join(';');\n chip.textContent = '⚡ ' + actionStr;\n content.appendChild(chip);\n }\n });\n } else {\n // 兼容旧格式(纯文本 or HTML)\n content.innerHTML = msg.html != null ? msg.html : renderMarkdown(msg.text || '');\n }\n\n wrap.appendChild(icon);\n wrap.appendChild(content);\n messagesEl.appendChild(wrap);\n }\n }\n scrollToBottom();\n } catch (e) {\n console.warn('[panel] restoreSession failed:', e);\n }\n}\n\n// ── 状态 ──\nlet isWaiting = false;\nlet abortController = null;\nlet attachedFiles = [];\nlet conversationHistory = []; // [{ userText, agentText }] 跨轮次历史(不含 DOM)\nlet sessionMessages = []; // [{ type, text?, html? }] 用于界面恢复\n\n// ── DOM 引用 ──\nconst mainEl = document.getElementById(\"main\");\nconst welcomeEl = document.getElementById(\"welcome\");\nconst messagesEl = document.getElementById(\"messages\");\nconst inputEl = document.getElementById(\"agent-input\");\nconst runBtn = document.getElementById(\"agent-run\");\nconst btnNew = document.getElementById(\"btn-new\");\nconst attachmentsEl = document.getElementById(\"attachments\");\nconst footerEl = document.getElementById(\"footer\");\nconst modelInfoEl = document.getElementById(\"model-info\");\nconst btnAddPage = document.getElementById(\"btn-add-page\");\n\n// 立即用默认值覆盖「加载配置中」占位文字\n{\n const _c = PROVIDERS[DEFAULT_CONFIG.provider];\n modelInfoEl.textContent = (_c ? _c.name : DEFAULT_CONFIG.provider) + ' · ' + DEFAULT_CONFIG.model;\n}\n\n// ── 初始化:显示当前配置 ──\nloadConfig().then(cfg => {\n if (I18n.lang !== cfg.language) {\n I18n.setLang(cfg.language);\n I18n.apply();\n }\n const conf = PROVIDERS[cfg.provider];\n modelInfoEl.textContent = (conf ? conf.name : cfg.provider) + \" · \" + (cfg.model || I18n.t('model_not_set'));\n});\n\n// ─────────────────────────────────────────────\n// 附件管理\n// ─────────────────────────────────────────────\n\n// 重新渲染附件芯片列表\nfunction renderAttachments() {\n attachmentsEl.innerHTML = \"\";\n attachedFiles.forEach((file, idx) => {\n const chip = document.createElement(\"div\");\n chip.className = \"file-chip\";\n chip.title = file.name;\n\n const icon = document.createElement(\"span\");\n icon.className = \"file-chip-icon\";\n icon.textContent = file.isPage ? \"🌐\" : \"📄\";\n\n const name = document.createElement(\"span\");\n name.className = \"file-chip-name\";\n name.textContent = file.name;\n\n const removeBtn = document.createElement(\"button\");\n removeBtn.className = \"file-chip-remove\";\n removeBtn.textContent = \"×\";\n removeBtn.title = I18n.t('remove_chip');\n removeBtn.addEventListener(\"click\", () => {\n attachedFiles.splice(idx, 1);\n renderAttachments();\n });\n\n chip.appendChild(icon);\n chip.appendChild(name);\n chip.appendChild(removeBtn);\n attachmentsEl.appendChild(chip);\n });\n // 将加号按钮始终放在最后一个芯片之后,随芯片一起换行\n attachmentsEl.appendChild(btnAddPage);\n}\n\n// 获取当前标签页内容并作为默认附件\n// refreshOnly=true:仅刷新已有页面附件,不重新添加用户已手动删除的页面\nasync function addCurrentPage(refreshOnly = false) {\n try {\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab) return;\n\n let pageContent = \"\";\n try {\n const resp = await chrome.tabs.sendMessage(tab.id, { type: \"GET_PAGE_CONTENT\" });\n pageContent = resp?.content ?? \"\";\n } catch {\n // content script 未注入(扩展重载后的旧标签页),尝试动态注入后重试\n try {\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['i18n.js'] });\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['element-scanner.js'] });\n await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });\n // 等待脚本初始化完成\n await new Promise(r => setTimeout(r, 150));\n const resp2 = await chrome.tabs.sendMessage(tab.id, { type: \"GET_PAGE_CONTENT\" });\n pageContent = resp2?.content ?? \"\";\n } catch {\n // 动态注入也失败(chrome:// 等不可注入页面),用 URL+标题兜底\n pageContent = `标题:${tab.title}\\nURL:${tab.url}`;\n }\n }\n\n const fileObj = { name: tab.title || tab.url, content: pageContent, isPage: true };\n if (refreshOnly) {\n const existing = attachedFiles.findIndex(f => f.isPage);\n if (existing >= 0) {\n attachedFiles[existing] = fileObj; // 刷新模式:仅更新已有页面,用户删除后不重新添加\n }\n // else: 用户已删除页面附件,不重新添加\n } else {\n attachedFiles.unshift(fileObj); // 按钮点击:始终添加新页面\n }\n renderAttachments();\n return fileObj; // 返回文件对象供内部使用\n } catch (e) {\n console.warn(\"addCurrentPage failed:\", e);\n return null;\n }\n}\n\n// 加号按钮:强制添加/刷新当前页面附件\nbtnAddPage.addEventListener(\"click\", () => addCurrentPage(false));\n\n// 初始化:恢复上次会话,然后刷新当前页面附件\nrestoreSession().then(() => addCurrentPage());\n\n// ─────────────────────────────────────────────\n// 辅助:向消息列表追加用户气泡\n// ─────────────────────────────────────────────\nfunction appendUserMessage(text) {\n // 有消息时隐藏欢迎页\n welcomeEl.style.display = \"none\";\n\n const el = document.createElement(\"div\");\n el.className = \"msg-user\";\n el.textContent = text; // 用户消息直接显示纯文本,安全\n messagesEl.appendChild(el);\n scrollToBottom();\n}\n\n// ─────────────────────────────────────────────\n// 辅助:向消息列表追加 Agent 气泡(含图标)\n// 返回内容容器,方便后续流式更新\n// ─────────────────────────────────────────────\nfunction appendAgentMessage(html = \"\") {\n const wrap = document.createElement(\"div\");\n wrap.className = \"msg-agent-wrap\";\n\n // 左侧 Gemini 彩色小图标\n const icon = document.createElement(\"div\");\n icon.className = \"msg-agent-icon\";\n\n // 右侧内容区\n const content = document.createElement(\"div\");\n content.className = \"msg-agent typing-cursor\"; // 打字光标\n content.innerHTML = html;\n\n wrap.appendChild(icon);\n wrap.appendChild(content);\n messagesEl.appendChild(wrap);\n scrollToBottom();\n\n return content; // 返回内容节点,流式输出时用来更新\n}\n\n// ─────────────────────────────────────────────\n// 辅助:滚动到底部\n// ─────────────────────────────────────────────\nfunction scrollToBottom() {\n mainEl.scrollTop = mainEl.scrollHeight;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:用 marked 把 Markdown 渲染为 HTML\n// ─────────────────────────────────────────────\nfunction renderMarkdown(text) {\n // marked 已通过 CDN 在 panel.html 中引入\n return typeof marked !== \"undefined\" ? marked.parse(text) : text;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:切换发送/停止按鈕状态\n// ─────────────────────────────────────────────\nconst SEND_ICON = '<svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M2.01 21L23 12 2.01 3 2 10l15 2-15 2z\"/></svg>';\nconst STOP_ICON = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\"/></svg>';\n\nfunction setRunBtnState(state) {\n if (state === 'stop') {\n runBtn.innerHTML = STOP_ICON;\n runBtn.style.background = '#5f6368';\n runBtn.disabled = false;\n } else {\n runBtn.innerHTML = SEND_ICON;\n runBtn.style.background = '';\n runBtn.disabled = false;\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:构建发给服务端的完整 user content(含附件)\n// ─────────────────────────────────────────────\nfunction buildUserContent(cmd, files) {\n const nonPageFiles = (files || []).filter(f => !f.isPage);\n if (!nonPageFiles.length) return cmd;\n const parts = nonPageFiles.map(f => `=== ${f.name} ===\\n${f.content}`);\n return parts.join(\"\\n\\n\") + `\\n\\n---\\n\\nUser question: ${cmd}`;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:所有 action 执行完毕后构建续接 user 消息(含重扫页面状态)\n// loopFeedbackPrompt 为空字符串时返回空(不向模型发送续接消息)\n// ─────────────────────────────────────────────\nfunction buildContinuationContent(actions, loopFeedbackPrompt) {\n // 设置里没填(空字符串)→ 返回空\n if (loopFeedbackPrompt === '') return '';\n const template = (loopFeedbackPrompt != null) ? loopFeedbackPrompt : DEFAULT_LOOP_FEEDBACK_PROMPT;\n const count = actions.length;\n const list = actions.map(a => JSON.stringify(a)).join(', ');\n const actionsDesc = count === 1\n ? `Action ${list} has been executed`\n : `${count} actions [${list}] have all been executed in sequence`;\n return template.replace('{{actions}}', actionsDesc);\n}\n\n// ─────────────────────────────────────────────\n// 辅助:将当前页面内容临时注入到最后一条 user 消息(不修改原数组)\n// ─────────────────────────────────────────────\nfunction injectPageIntoMessages(messages, pageFileOverride) {\n const pageFile = pageFileOverride !== undefined ? pageFileOverride : attachedFiles.find(f => f.isPage);\n if (!pageFile || !pageFile.content) return messages;\n const msgs = messages.map(m => ({ ...m }));\n for (let i = msgs.length - 1; i >= 0; i--) {\n if (msgs[i].role === 'user') {\n msgs[i] = { ...msgs[i], content: `=== Current Page ===\\n${pageFile.content}\\n\\n---\\n\\n${msgs[i].content}` };\n break;\n }\n }\n return msgs;\n}\n\n// ─────────────────────────────────────────────\n// 辅助:显示请求报文弹框\n// ─────────────────────────────────────────────\nfunction showPayloadModal(payload) {\n document.getElementById('_payload_overlay')?.remove();\n\n const overlay = document.createElement('div');\n overlay.id = '_payload_overlay';\n overlay.style.cssText = [\n 'position:fixed', 'inset:0', 'background:rgba(0,0,0,0.55)',\n 'z-index:9999', 'display:flex', 'align-items:center', 'justify-content:center',\n ].join(';');\n\n overlay.innerHTML = `\n<div style=\"background:#1e1e1e;border-radius:12px;width:92%;max-width:720px;\n max-height:82vh;display:flex;flex-direction:column;\n box-shadow:0 8px 40px rgba(0,0,0,0.5);overflow:hidden;\">\n <div style=\"display:flex;align-items:center;justify-content:space-between;\n padding:12px 16px;border-bottom:1px solid #333;flex-shrink:0;background:#252526;\">\n <span style=\"font-size:13px;font-weight:600;color:#e8eaed;\n font-family:'Google Sans','Segoe UI',sans-serif;\">📋 Request Payload</span>\n <button id=\"_payload_close\" style=\"background:none;border:none;cursor:pointer;\n font-size:18px;color:#9aa0a6;padding:2px 8px;border-radius:4px;line-height:1;\">✕</button>\n </div>\n <pre id=\"_payload_pre\" style=\"overflow:auto;padding:16px;margin:0;font-size:11.5px;\n line-height:1.65;color:#d4d4d4;background:#1e1e1e;flex:1;\n white-space:pre-wrap;word-break:break-word;font-family:Consolas,monospace;\"></pre>\n</div>`;\n\n // 用 textContent 写入避免 XSS\n overlay.querySelector('#_payload_pre').textContent = JSON.stringify(payload, null, 2);\n\n document.body.appendChild(overlay);\n overlay.querySelector('#_payload_close').onclick = () => overlay.remove();\n overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });\n}\n\n// ─────────────────────────────────────────────\n// 辅助:执行一轮 LLM 调用并流式输出到 agentContent\n// 返回 { fullText, action }\n// ─────────────────────────────────────────────\nasync function streamAgentTurn(history, agentContent, isContinuation, signal) {\n // 续轮时插入分隔条\n if (isContinuation) {\n const sep = document.createElement('div');\n sep.style.cssText = [\n 'margin:6px 0 4px', 'border-top:1px dashed #e8eaed',\n 'font-size:11px', 'color:#80868b', 'padding-top:4px',\n ].join(';');\n sep.textContent = '↩ Continuing…';\n agentContent.appendChild(sep);\n scrollToBottom();\n }\n\n // ── 加载配置 ──\n const cfg = await loadConfig();\n const conf = PROVIDERS[cfg.provider];\n if (!conf) throw new Error(`Unknown provider: ${cfg.provider}`);\n\n const apiKey = (cfg.apiKeys && cfg.apiKeys[cfg.provider]) || conf.defaultKey || \"\";\n if (!apiKey && cfg.provider !== \"ollama\" && cfg.provider !== \"copilot\") {\n throw new Error(`${conf.name}: API Key not configured. Please open the settings to configure it.`);\n }\n const model = cfg.model;\n if (!model) throw new Error(\"Model name not set. Please open the settings to configure it.\");\n\n // 更新顶栏\n modelInfoEl.textContent = conf.name + \" · \" + model;\n\n const effectivePrompt = buildEffectiveSystemPrompt(cfg);\n const allMessages = effectivePrompt\n ? [{ role: \"system\", content: effectivePrompt }, ...history]\n : [...history];\n const requestPayload = { provider: cfg.provider, model, url: conf.url, messages: allMessages };\n\n // 本轮流式输出容器\n const stepEl = document.createElement('div');\n agentContent.appendChild(stepEl);\n\n let fullText = \"\";\n let inReasoning = false;\n\n if (conf.format === \"copilot\") {\n // ── GitHub Copilot (OpenAI 局式 + 特殊认证头) ─────────────────\n let copilotToken;\n try {\n copilotToken = await GitHubAuth.getValidCopilotToken();\n } catch (e) {\n if (e.message === 'NOT_AUTHED') {\n throw new Error('GitHub Copilot 未授权,请在设置中连接 GitHub 账号');\n }\n throw e;\n }\n\n const res = await fetch(conf.url, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${copilotToken}`,\n 'Content-Type': 'application/json',\n 'Editor-Version': 'vscode/1.97.2',\n 'Editor-Plugin-Version': 'copilot-chat/0.26.1',\n 'Copilot-Integration-Id': 'vscode-chat',\n 'Openai-Intent': 'conversation-panel',\n 'User-Agent': 'GitHubCopilotChat/0.26.1',\n 'X-Github-Api-Version': '2024-12-15',\n },\n signal,\n body: JSON.stringify({ model, messages: allMessages, stream: true, max_tokens: 4096 }),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n if (res.status === 401 || res.status === 403) {\n // Copilot token 失效,清理缓存让下次重新获取\n await new Promise(r => chrome.storage.sync.remove(['copilotToken', 'copilotTokenExpiry'], r));\n throw new Error(`Copilot 认证失效 (${res.status}),请重新连接 GitHub 账号`);\n }\n throw new Error(`HTTP ${res.status}${body ? ': ' + body.slice(0, 300) : ''}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop();\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed === 'data: [DONE]') continue;\n const jsonStr = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;\n try {\n const parsed = JSON.parse(jsonStr);\n const content = parsed?.choices?.[0]?.delta?.content || '';\n if (content) { fullText += content; stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n // AbortError:带着已累积的部分文本继续执行,下方 signal.aborted 检查会提前返回\n }\n\n } else if (conf.format === \"gemini\") {\n // ── Google Gemini ─────────────────────────────────────────\n const geminiUrl = `${conf.url}/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;\n const geminiContents = history.map(m => ({\n role: m.role === \"assistant\" ? \"model\" : \"user\",\n parts: [{ text: m.content }],\n }));\n\n const res = await fetch(geminiUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n signal,\n body: JSON.stringify({\n ...(effectivePrompt ? { systemInstruction: { parts: [{ text: effectivePrompt }] } } : {}),\n contents: geminiContents,\n generationConfig: { maxOutputTokens: 2000 },\n }),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => \"\");\n throw new Error(`HTTP ${res.status}${body ? \": \" + body.slice(0, 300) : \"\"}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = lines.pop();\n for (const line of lines) {\n if (!line.trim().startsWith(\"data: \")) continue;\n try {\n const chunk = JSON.parse(line.trim().slice(6));\n const text = chunk?.candidates?.[0]?.content?.parts?.[0]?.text || \"\";\n if (text) { fullText += text; stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n }\n\n } else {\n // ── OpenAI 兼容格式(Ollama / OpenRouter / DeepSeek)─────\n const isDeepSeek = cfg.provider === \"deepseek\";\n const reqBody = {\n model,\n messages: allMessages,\n stream: true,\n };\n if (cfg.provider === \"ollama\") {\n reqBody.options = { num_predict: 2000 };\n } else {\n reqBody.max_tokens = 2000;\n }\n\n const res = await fetch(conf.url, {\n method: \"POST\",\n headers: {\n \"Authorization\": \"Bearer \" + apiKey,\n \"Content-Type\": \"application/json\",\n },\n signal,\n body: JSON.stringify(reqBody),\n });\n\n if (!res.ok) {\n const body = await res.text().catch(() => \"\");\n throw new Error(`HTTP ${res.status}${body ? \": \" + body.slice(0, 300) : \"\"}`);\n }\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(\"\\n\");\n buffer = lines.pop();\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed === \"data: [DONE]\") continue;\n const jsonStr = trimmed.startsWith(\"data: \") ? trimmed.slice(6) : trimmed;\n try {\n const parsed = JSON.parse(jsonStr);\n const delta = parsed?.choices?.[0]?.delta;\n const reasoning = isDeepSeek ? (delta?.reasoning_content || \"\") : \"\";\n const content = delta?.content || \"\";\n\n if (reasoning) {\n if (!inReasoning) { inReasoning = true; fullText += \"*思考中...*\\n\\n> \"; }\n fullText += reasoning.replace(/\\n/g, \"\\n> \");\n } else if (content) {\n if (inReasoning) { inReasoning = false; fullText += \"\\n\\n---\\n\\n\"; }\n fullText += content;\n }\n if (reasoning || content) { stepEl.innerHTML = renderMarkdown(fullText); scrollToBottom(); }\n } catch { /* 非 JSON 行忽略 */ }\n }\n }\n } catch (err) {\n if (err.name !== 'AbortError') throw err;\n }\n }\n\n // 用户中途停止:带着已累积内容提前返回,不解析 action 也不添加报文按钮\n if (signal?.aborted) {\n return { fullText, actions: [] };\n }\n\n // 解析全部 <ACTION> 标签(支持一次多个动作)\n const actionMatches = [...fullText.matchAll(/<ACTION>([\\s\\S]*?)<\\/ACTION>/g)];\n const actions = [];\n if (actionMatches.length > 0) {\n const displayText = fullText.replace(/<ACTION>[\\s\\S]*?<\\/ACTION>/g, '').trim();\n stepEl.innerHTML = renderMarkdown(displayText || (actionMatches.length > 1 ? 'Executing actions…' : 'Executing action…'));\n for (const match of actionMatches) {\n try {\n const parsed = JSON.parse(match[1].trim());\n actions.push(parsed);\n // 每个动作显示一个芯片\n const chip = document.createElement('div');\n chip.style.cssText = [\n 'margin-top:8px', 'padding:5px 10px',\n 'background:#f1f3f4', 'border-left:3px solid #1a73e8',\n 'border-radius:4px', 'font-size:11.5px', 'color:#3c4043',\n 'font-family:Consolas,monospace', 'word-break:break-all',\n ].join(';');\n chip.textContent = '⚡ ' + match[1].trim();\n agentContent.appendChild(chip);\n } catch (e) {\n console.warn('Failed to parse <ACTION>:', e);\n }\n }\n scrollToBottom();\n }\n\n // 每轮结束后追加\"查看报文\"按钮\n const payloadBtn = document.createElement('button');\n payloadBtn.className = 'msg-payload-btn';\n payloadBtn.style.cssText = [\n 'margin-top:5px', 'padding:2px 8px', 'display:inline-block',\n 'background:none', 'border:1px solid #dadce0', 'border-radius:4px',\n 'font-size:11px', 'color:#5f6368', 'cursor:pointer', 'font-family:inherit',\n 'transition:all .15s',\n ].join(';');\n payloadBtn.textContent = '📋 View Payload';\n payloadBtn.onmouseenter = () => {\n payloadBtn.style.background = '#f8f9fa';\n payloadBtn.style.borderColor = '#1a73e8';\n payloadBtn.style.color = '#1a73e8';\n };\n payloadBtn.onmouseleave = () => {\n payloadBtn.style.background = 'none';\n payloadBtn.style.borderColor = '#dadce0';\n payloadBtn.style.color = '#5f6368';\n };\n payloadBtn.onclick = () => showPayloadModal(requestPayload);\n agentContent.appendChild(payloadBtn);\n scrollToBottom();\n\n return { fullText, actions };\n}\n\n// ─────────────────────────────────────────────\n// 核心:发送消息(含 agentic loop)\n// ─────────────────────────────────────────────\nasync function sendMessage() {\n if (isWaiting) return;\n const cmd = inputEl.value.trim();\n if (!cmd) return;\n\n // 1. 显示用户气泡\n appendUserMessage(cmd);\n\n // 2. 清空输入框并重置高度\n inputEl.value = \"\";\n inputEl.style.height = \"auto\";\n\n // 3. 创建 Agent 气泡\n const agentContent = appendAgentMessage(\"\");\n\n // 4. 切换为停止按鈕\n isWaiting = true;\n abortController = new AbortController();\n setRunBtnState('stop');\n\n // 声明在 try 外部,确保 catch 块也能访问(let 是块级作用域,在 try 内声明则 catch 不可见)\n let roundAgentText = '';\n let roundSteps = [];\n // roundMessages 保存本轮完整对话(消息不含页面 DOM 和附件)\n let roundMessages = [{ role: 'user', content: cmd }];\n\n try {\n // 5. 刷新页面快照\n await addCurrentPage(true);\n\n // 6. 加载配置(含 historyRounds、agentMaxSteps)\n const cfg = await loadConfig();\n\n // 7. 从历史对话构建上文(仅用户文本,不含 DOM)\n const historyMessages = buildLLMHistory(cfg);\n\n // 8. 构建本轮消息(不含页面;页面在每次 LLM 调用前临时注入)\n const loopHistory = [\n ...historyMessages,\n { role: \"user\", content: buildUserContent(cmd, attachedFiles) },\n ];\n\n // 快照本轮页面文件(清空附件内容后供 loop 使用)\n let loopPageFile = attachedFiles.find(f => f.isPage) || null;\n\n // 发送后清空附件栏,让用户重新添加\n attachedFiles = [];\n renderAttachments();\n\n // 9. 确定 Agent 最大步数(空 = 不限;0 = 不执行任何动作)\n const rawMax = cfg.agentMaxSteps;\n const maxSteps = (rawMax === '' || rawMax == null)\n ? Infinity\n : Math.max(0, parseInt(rawMax) || 0);\n\n // roundAgentText、roundSteps、roundMessages 已在 try 外声明,此处无需重复声明\n\n const _rawHist = cfg.historyRounds;\n const histRounds = (_rawHist === '' || _rawHist == null) ? Infinity : (parseInt(_rawHist) || 0);\n const actionDelay = typeof cfg.actionDelay === 'number' ? cfg.actionDelay : 500;\n\n // 10. Agentic loop\n for (let step = 0; step < maxSteps; step++) {\n // 页面内容临时注入到最后一条 user 消息,不存入 loopHistory\n const { fullText, actions } = await streamAgentTurn(injectPageIntoMessages(loopHistory, loopPageFile), agentContent, step > 0, abortController.signal);\n loopHistory.push({ role: \"assistant\", content: fullText });\n roundMessages.push({ role: \"assistant\", content: fullText });\n roundAgentText = fullText; // 持续更新,保留最后一次 LLM 回复\n\n // 将本步展示文本(去掉全部 ACTION 标签)和操作记录下来,供界面恢复\n const stepDisplay = fullText.replace(/<ACTION>[\\s\\S]*?<\\/ACTION>/g, '').trim();\n roundSteps.push({\n text: stepDisplay || (actions.length > 0 ? (actions.length > 1 ? `Executing ${actions.length} actions…` : 'Executing action…') : ''),\n actions: actions.map(a => JSON.stringify(a)), // 保存所有动作供恢复\n });\n\n if (actions.length === 0) break; // 无操作指令 → 任务完成或需要用户确认\n\n // 逐个执行所有动作\n for (const action of actions) {\n await dispatchAction(action);\n // navigate 已在 dispatchAction 内等待页面加载完成,无需额外延迟\n // hover 需要较长等待让下拉菜单出现;其他动作按用户配置延迟\n const waitMs = action.type === 'navigate' ? 0\n : action.type === 'hover' ? Math.max(actionDelay, 800)\n : actionDelay;\n if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));\n if (abortController.signal.aborted) break;\n }\n\n if (abortController.signal.aborted) break;\n\n // 刷新页面快照(供下一步 injectPageIntoMessages 使用)\n loopPageFile = await addCurrentPage(true) || loopPageFile;\n\n // 续接消息(不含页面;页面由 injectPageIntoMessages 临时注入)\n const continuationMsg = buildContinuationContent(actions, cfg.loopFeedbackPrompt);\n if (continuationMsg) {\n loopHistory.push({ role: \"user\", content: continuationMsg });\n roundMessages.push({ role: \"user\", content: continuationMsg });\n }\n }\n\n agentContent.classList.remove(\"typing-cursor\");\n const wasAborted = abortController?.signal.aborted;\n if (wasAborted) {\n // 中途停止:保留已输出内容,无内容时显示提示\n if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = '<span style=\"color:#80868b\">⏹ Stopped</span>';\n }\n // 不将 Stopped 文字写入 roundSteps,避免恢复时显示\n } else if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = `<span style=\"color:#b36200\">⚠️ Server returned no content. The model may have returned an empty response, or the stream format is unexpected.</span>`;\n }\n\n // 11 & 12. 有内容时才保存(停止且无内容则不存入历史)\n if (roundSteps.length > 0) {\n conversationHistory.push({ userText: cmd, agentText: roundAgentText, roundMessages });\n sessionMessages.push({ type: 'user', text: cmd });\n sessionMessages.push({ type: 'agent-steps', steps: roundSteps });\n await saveSession();\n }\n\n } catch (err) {\n agentContent.classList.remove(\"typing-cursor\");\n if (err.name === 'AbortError') {\n // 用户主动停止,保留已输出内容\n if (!agentContent.textContent.trim()) {\n agentContent.innerHTML = '<span style=\"color:#80868b\">⏹ Stopped</span>';\n }\n // 无有任何已完成的步骤时,跳过保存(回复时水假无界面恢复)\n if (roundSteps.length === 0) {\n // 跳过保存\n } else {\n // 不将 Stopped 写入最后一步,恢复时不显示\n sessionMessages.push({ type: 'user', text: cmd });\n sessionMessages.push({ type: 'agent-steps', steps: roundSteps });\n conversationHistory.push({ userText: cmd, agentText: roundAgentText, roundMessages });\n await saveSession();\n }\n } else {\n const hint = err.message.includes(\"Key\") || err.message.includes(\"settings\")\n ? \"Right-click the page → open the extension settings to configure API Key and model.\"\n : err instanceof TypeError\n ? \"Network connection failed. Check the API URL and your network.\"\n : \"\";\n const errEl = document.createElement('div');\n errEl.style.cssText = [\n 'margin-top:8px', 'padding:8px 10px',\n 'background:#fce8e6', 'border-left:3px solid #c62828',\n 'border-radius:4px', 'font-size:12.5px', 'color:#c62828', 'line-height:1.6',\n ].join(';');\n errEl.textContent = `❌ Request failed · ${err.name}: ${err.message}`;\n if (hint) {\n const hintEl = document.createElement('div');\n hintEl.style.cssText = 'margin-top:4px;font-size:12px;color:#80868b;';\n hintEl.textContent = 'Hint: ' + hint;\n errEl.appendChild(hintEl);\n }\n agentContent.appendChild(errEl);\n scrollToBottom();\n console.error(\"Agent request failed:\", err);\n }\n } finally {\n isWaiting = false;\n abortController = null;\n setRunBtnState('send');\n inputEl.focus();\n }\n}\n\n// ─────────────────────────────────────────────\n// 辅助:等待标签页加载完成(用于 navigate 后同步)\n// ─────────────────────────────────────────────\nfunction waitForTabLoad(tabId, timeout = 10000) {\n return new Promise((resolve) => {\n const timer = setTimeout(() => {\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }, timeout);\n function onUpdated(id, info) {\n if (id === tabId && info.status === 'complete') {\n clearTimeout(timer);\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }\n }\n chrome.tabs.onUpdated.addListener(onUpdated);\n // 也立即检查一次,防止导航已经完成\n chrome.tabs.get(tabId).then(tab => {\n if (tab.status === 'complete') {\n clearTimeout(timer);\n chrome.tabs.onUpdated.removeListener(onUpdated);\n resolve();\n }\n }).catch(() => {});\n });\n}\n\n// ─────────────────────────────────────────────\n// 辅助:把 action 转发给当前活动标签页执行\n// navigate 动作用 chrome.tabs.update 绕过 content script(实现在空白/特殊页上也可导航)\n// ─────────────────────────────────────────────\nasync function dispatchAction(action) {\n try {\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab) return;\n\n // navigate 用 chrome.tabs.update 直接操作标签页 URL,\n // 不依赖 content script(空白页、chrome:// 页也能正常导航)\n if (action.type === 'navigate') {\n await chrome.tabs.update(tab.id, { url: action.url });\n // 稍候待浏览器开始转跑后,再检测完成事件\n await new Promise(r => setTimeout(r, 150));\n await waitForTabLoad(tab.id);\n // 等待 content script 初始化(document_idle 之后)\n await new Promise(r => setTimeout(r, 300));\n return;\n }\n\n return await chrome.tabs.sendMessage(tab.id, { type: \"EXECUTE_ACTION\", action });\n } catch (e) {\n console.warn(\"dispatchAction failed:\", e);\n }\n}\n\n// ─────────────────────────────────────────────\n// 事件绑定\n// ─────────────────────────────────────────────\n\n// 发送/停止按钮(等待中变为停止按钮)\nrunBtn.addEventListener(\"click\", () => {\n if (isWaiting) {\n abortController?.abort();\n } else {\n sendMessage();\n }\n});\n\n// 配置按钮——在侧边栏内打开设置面板\n// 每次打开都重载 iframe,确保显示的是已保存的设置,而不是上次未保存的表单状态\ndocument.getElementById(\"btn-settings\").addEventListener(\"click\", () => {\n const frame = document.getElementById(\"settings-frame\");\n frame.src = 'options.html'; // 重载以从 storage 读取最新保存值\n document.getElementById(\"settings-overlay\").style.display = \"flex\";\n});\n\n// 接收 options.html(iframe)发来的关闭/保存消息\nwindow.addEventListener(\"message\", (e) => {\n if (e.data?.type === \"settings-saved\" || e.data?.type === \"settings-close\") {\n document.getElementById(\"settings-overlay\").style.display = \"none\";\n // 仅保存时才重新加载配置,刷新语言 + 顶部模型信息栏\n if (e.data.type === \"settings-saved\") {\n loadConfig().then(cfg => {\n I18n.setLang(cfg.language);\n I18n.apply();\n const p = PROVIDERS[cfg.provider] || PROVIDERS.ollama;\n modelInfoEl.textContent = `${p.name} · ${cfg.model || I18n.t('model_not_set')}`;\n });\n }\n }\n});\n\n// Enter 发送,Shift+Enter 换行;自动增高\ninputEl.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n sendMessage();\n }\n});\ninputEl.addEventListener(\"input\", () => {\n inputEl.style.height = \"auto\";\n inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + \"px\";\n});\n\n// 新建对话:清空消息列表,重新显示欢迎页\nbtnNew.addEventListener(\"click\", () => {\n messagesEl.innerHTML = \"\";\n welcomeEl.style.display = \"flex\";\n inputEl.value = \"\";\n inputEl.style.height = \"auto\";\n attachedFiles = [];\n conversationHistory = [];\n sessionMessages = [];\n chrome.storage.local.remove('chatSession');\n renderAttachments();\n addCurrentPage();\n inputEl.focus();\n});\n\n// ─────────────────────────────\n// 拖放文件进入\n// ─────────────────────────────\nfooterEl.addEventListener(\"dragover\", (e) => {\n e.preventDefault();\n footerEl.classList.add(\"drag-over\");\n});\nfooterEl.addEventListener(\"dragleave\", (e) => {\n // 只有离开 footer 整个区域才移除高亮\n if (!footerEl.contains(e.relatedTarget)) {\n footerEl.classList.remove(\"drag-over\");\n }\n});\nfooterEl.addEventListener(\"drop\", (e) => {\n e.preventDefault();\n footerEl.classList.remove(\"drag-over\");\n const files = Array.from(e.dataTransfer.files);\n files.forEach(file => {\n const reader = new FileReader();\n reader.onload = (ev) => {\n attachedFiles.push({ name: file.name, content: ev.target.result, isPage: false });\n renderAttachments();\n };\n reader.readAsText(file);\n });\n});\n\n// 建议词点击:直接填入输入框并发送\ndocument.getElementById(\"suggestions\").addEventListener(\"click\", (e) => {\n const chip = e.target.closest(\".suggestion-chip\");\n if (!chip) return;\n inputEl.value = chip.textContent;\n sendMessage();\n});"}}}}