1 line
222 KiB
JSON
1 line
222 KiB
JSON
{"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});"}}}} |