文章

浏览器-豆包翻译插件-prompt-insert

ScriptCat

// ==UserScript==
// @name         豆包 URL 参数调用 (Raycast 友好)
// @namespace    https://www.doubao.com/
// @version      1.0.0
// @description  通过 URL 参数 type 和 content 调用豆包,支持页面可视化配置提示词模板。type 为提示词类型(可在页面弹窗中配置),content 为传递的内容。兼容 Raycast / Alfred / 浏览器书签等 URL 调用方式。
// @author       boommanpro
// @match        https://www.doubao.com/chat*
// @match        https://www.doubao.com/chat/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    /* ====================== 配置存储 ====================== */
    const STORAGE_KEY = 'doubao_url_prompt_templates';
    const SETTINGS_KEY = 'doubao_url_prompt_settings';

    // 默认提示词模板:{type: {label, prompt}},prompt 中 {content} 会被替换为实际内容
    const DEFAULT_TEMPLATES = {
        translate: {
            label: '英文翻译',
            prompt: '请将以下英文翻译成中文,仅输出译文,不要附加任何解释:\n\n{content}'
        },
        polish: {
            label: '英文润色',
            prompt: '请润色以下英文文本,使其更地道自然,并简要说明修改点:\n\n{content}'
        },
        summarize: {
            label: '内容总结',
            prompt: '请用简洁的中文总结以下内容的核心要点(用项目符号列出):\n\n{content}'
        },
        explain: {
            label: '概念解释',
            prompt: '请用通俗易懂的中文解释以下内容:\n\n{content}'
        }
    };

    const DEFAULT_SETTINGS = {
        autoSend: true,        // 组装好提示词后是否自动点击发送
        cleanUrl: true,        // 发送后是否清理 URL 参数(避免刷新重复发送)
        sendDelay: 300,        // 自动发送前等待毫秒数(确保 input 事件被 React 处理)
        showConfigButton: true // 显示右下角配置按钮
    };

    function loadTemplates() {
        const stored = GM_getValue(STORAGE_KEY, null);
        if (!stored || typeof stored !== 'object') {
            GM_setValue(STORAGE_KEY, DEFAULT_TEMPLATES);
            return JSON.parse(JSON.stringify(DEFAULT_TEMPLATES));
        }
        // 合并默认模板(保证 translate 等内置类型始终存在)
        const merged = JSON.parse(JSON.stringify(DEFAULT_TEMPLATES));
        for (const k of Object.keys(stored)) {
            merged[k] = stored[k];
        }
        return merged;
    }

    function saveTemplates(tpls) {
        GM_setValue(STORAGE_KEY, tpls);
    }

    function loadSettings() {
        const s = GM_getValue(SETTINGS_KEY, null);
        return Object.assign({}, DEFAULT_SETTINGS, s && typeof s === 'object' ? s : {});
    }

    function saveSettings(s) {
        GM_setValue(SETTINGS_KEY, s);
    }

    /* ====================== URL 参数解析 ====================== */
    // 在脚本启动时(document-start)立即捕获原始 URL 参数,
    // 因为豆包 SPA 路由会在加载后重定向到 /chat/{id},清除 query string。
    const RAW_PARAMS = (() => {
        try {
            const u = new URL(location.href);
            return {
                type: (u.searchParams.get('type') || '').trim(),
                content: (u.searchParams.get('content') || '').trim()
            };
        } catch (e) {
            return { type: '', content: '' };
        }
    })();

    function getParams() {
        return RAW_PARAMS;
    }

    function cleanUrlParams() {
        const url = location.pathname + location.hash;
        history.replaceState({}, '', url);
    }

    /* ====================== 豆包输入框操作 ====================== */
    function getTextarea() {
        return document.querySelector('textarea.semi-input-textarea');
    }

    // 向 React 受控 textarea 写入内容
    // 关键:必须用 execCommand('insertText') 触发真实的 InputEvent,
    // 否则豆包 React 组件的内部 state 不会同步,发送时读取的是空值。
    function setTextareaValue(text) {
        const ta = getTextarea();
        if (!ta) return false;

        // 1. 先清空(用 native setter + input 事件,让 React 感知清空)
        const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
        setter.call(ta, '');
        ta.dispatchEvent(new Event('input', { bubbles: true }));

        // 2. 聚焦并定位光标
        ta.focus();
        ta.click();
        try { ta.setSelectionRange(0, 0); } catch (e) { /* ignore */ }

        // 3. 优先用 execCommand 插入文本(触发真实 InputEvent,React state 同步)
        let inserted = false;
        try {
            inserted = document.execCommand('insertText', false, text);
        } catch (e) {
            inserted = false;
        }

        // 4. execCommand 失败或未生效时,回退到 native setter + input 事件
        if (!inserted || ta.value !== text) {
            setter.call(ta, text);
            ta.dispatchEvent(new Event('input', { bubbles: true }));
            ta.dispatchEvent(new Event('change', { bubbles: true }));
        }

        return ta.value === text;
    }

    // 查找发送按钮:submit 类型且包含向上箭头 SVG 的按钮
    // 豆包发送按钮的 SVG path 以 "M12.0005" 开头(向上箭头图标)
    function findSendButton() {
        const all = document.querySelectorAll('button[type="submit"]');
        for (const b of all) {
            const path = b.querySelector('svg path');
            if (path && (path.getAttribute('d') || '').startsWith('M12.0005')) return b;
        }
        return all[all.length - 1] || null;
    }

    // 通过 React fiber 找到豆包真正的发送函数 onSubmit
    // 豆包外层组件(带 allowEmptySubmit / attachmentCanNotSend 标志)持有 onSubmit,
    // 直接调用它最可靠,不受 isTrusted 检查、事件委托等影响。
    function findReactOnSubmit() {
        const ta = getTextarea();
        if (!ta) return null;
        const fiberKey = Object.keys(ta).find(k => k.startsWith('__reactFiber'));
        if (!fiberKey) return null;
        let fiber = ta[fiberKey];
        let depth = 0;
        while (fiber && depth < 40) {
            const props = fiber.memoizedProps || (fiber.stateNode && fiber.stateNode.props);
            // 豆包发送组件的特征:同时有 onSubmit 函数和 allowEmptySubmit 属性
            if (props && typeof props === 'object' &&
                typeof props.onSubmit === 'function' &&
                'allowEmptySubmit' in props) {
                return { onSubmit: props.onSubmit, hasAllowEmpty: props.allowEmptySubmit };
            }
            fiber = fiber.return;
            depth++;
        }
        // 兜底:只找 onSubmit 函数
        fiber = ta[fiberKey];
        depth = 0;
        while (fiber && depth < 40) {
            const props = fiber.memoizedProps || (fiber.stateNode && fiber.stateNode.props);
            if (props && typeof props === 'object' && typeof props.onSubmit === 'function') {
                return { onSubmit: props.onSubmit, hasAllowEmpty: null };
            }
            fiber = fiber.return;
            depth++;
        }
        return null;
    }

    // 优先方案:直接调用豆包组件的 onSubmit 函数(已验证最可靠)
    function sendViaReactOnSubmit() {
        try {
            const handler = findReactOnSubmit();
            if (!handler || typeof handler.onSubmit !== 'function') return false;
            const ta = getTextarea();
            ta && ta.focus();
            handler.onSubmit();
            return true;
        } catch (e) {
            console.warn('[豆包URL调用] React onSubmit 调用失败:', e);
            return false;
        }
    }

    // 备用方案1:模拟 Enter 键
    function sendViaEnter() {
        const ta = getTextarea();
        if (!ta) return false;
        ta.focus();
        const opts = {
            key: 'Enter', code: 'Enter', keyCode: 13, which: 13,
            bubbles: true, cancelable: true,
            isComposing: false, shiftKey: false, altKey: false,
            ctrlKey: false, metaKey: false
        };
        ta.dispatchEvent(new KeyboardEvent('keydown', opts));
        ta.dispatchEvent(new KeyboardEvent('keyup', opts));
        return true;
    }

    // 备用方案2:点击发送按钮
    function clickSendButton() {
        const btn = findSendButton();
        if (btn && !btn.disabled) {
            try {
                const rect = btn.getBoundingClientRect();
                const cx = rect.left + rect.width / 2;
                const cy = rect.top + rect.height / 2;
                const popts = { bubbles: true, cancelable: true, clientX: cx, clientY: cy, button: 0 };
                btn.dispatchEvent(new PointerEvent('pointerdown', popts));
                btn.dispatchEvent(new MouseEvent('mousedown', popts));
                btn.dispatchEvent(new PointerEvent('pointerup', popts));
                btn.dispatchEvent(new MouseEvent('mouseup', popts));
                btn.dispatchEvent(new MouseEvent('click', popts));
            } catch (e) {
                btn.click();
            }
            return true;
        }
        return false;
    }

    function send() {
        // 优先:直接调用 React onSubmit(最可靠)
        if (sendViaReactOnSubmit()) return true;
        // 兜底1:模拟 Enter 键
        if (sendViaEnter()) return true;
        // 兜底2:点击发送按钮
        return clickSendButton();
    }

    /* ====================== 等待元素出现 ====================== */
    function waitForTextarea(timeout = 15000) {
        return new Promise((resolve) => {
            const start = Date.now();
            const check = () => {
                const ta = getTextarea();
                if (ta) return resolve(ta);
                if (Date.now() - start > timeout) return resolve(null);
                setTimeout(check, 300);
            };
            check();
        });
    }

    /* ====================== 主流程:处理 URL 调用 ====================== */
    async function handleUrlCall() {
        const { type, content } = getParams();
        if (!type && !content) return; // 无参数,不处理

        // 只有 content 没有 type 时,默认用 translate
        const effectiveType = type || 'translate';
        if (!content) {
            console.warn('[豆包URL调用] 缺少 content 参数');
            showToast('缺少 content 参数', 'warn');
            return;
        }

        const templates = loadTemplates();
        const tpl = templates[effectiveType];
        if (!tpl) {
            console.warn(`[豆包URL调用] 未找到类型 "${effectiveType}",可用类型:`, Object.keys(templates));
            showToast(`未找到提示词类型: ${effectiveType}`, 'error');
            return;
        }

        const finalPrompt = tpl.prompt.replace(/\{content\}/g, content);
        const settings = loadSettings();

        const ta = await waitForTextarea();
        if (!ta) {
            console.error('[豆包URL调用] 未找到输入框');
            showToast('未找到输入框', 'error');
            return;
        }

        // 等待输入框可用(有时刚加载会被禁用)
        await sleep(300);
        ta.focus();
        let writeOk = setTextareaValue(finalPrompt);
        // 确认写入成功,最多重试 3 次
        for (let i = 0; i < 3 && !writeOk; i++) {
            await sleep(150);
            writeOk = setTextareaValue(finalPrompt);
        }
        if (!writeOk) {
            console.error('[豆包URL调用] 写入输入框失败');
            showToast('写入输入框失败', 'error');
            return;
        }

        showToast(`已填入 [${tpl.label || effectiveType}]${settings.autoSend ? ',正在发送...' : ''}`, 'info');

        if (settings.autoSend) {
            await sleep(settings.sendDelay);
            sendViaEnter();
            // 验证是否发送成功:textarea 应在 1.5s 内清空
            let sent = false;
            for (let i = 0; i < 5; i++) {
                await sleep(300);
                const cur = getTextarea();
                if (!cur || cur.value === '') { sent = true; break; }
            }
            if (!sent) {
                // 回退:尝试点击发送按钮
                console.warn('[豆包URL调用] Enter 发送未生效,尝试点击发送按钮');
                const clicked = clickSendButton();
                if (clicked) {
                    await sleep(800);
                    const cur = getTextarea();
                    sent = !cur || cur.value === '';
                }
            }
            if (!sent) {
                console.warn('[豆包URL调用] 自动发送未成功,请手动发送');
                showToast('自动发送未成功,请手动点击发送', 'warn');
            }
        } else {
            showToast('已填入输入框(自动发送已关闭)', 'info');
        }

        if (settings.cleanUrl) {
            // 延迟清理(豆包会重定向到 /chat/{id},2 秒后兜底清理残留参数)
            setTimeout(() => {
                const u = new URL(location.href);
                if (u.searchParams.get('type') || u.searchParams.get('content')) {
                    cleanUrlParams();
                }
            }, 2000);
        }
    }

    function sleep(ms) {
        return new Promise(r => setTimeout(r, ms));
    }

    /* ====================== Toast 提示 ====================== */
    function showToast(msg, type = 'info') {
        const colors = {
            info: '#3b82f6',
            success: '#22c55e',
            warn: '#f59e0b',
            error: '#ef4444'
        };
        const el = document.createElement('div');
        el.className = 'dbx-toast';
        el.style.cssText = `
            position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
            background: ${colors[type] || colors.info}; color: #fff;
            padding: 10px 18px; border-radius: 8px; font-size: 14px;
            z-index: 999999; box-shadow: 0 4px 12px rgba(0,0,0,0.2);
            opacity: 0; transition: opacity 0.25s; font-family: -apple-system, sans-serif;
        `;
        el.textContent = msg;
        document.body.appendChild(el);
        requestAnimationFrame(() => { el.style.opacity = '1'; });
        setTimeout(() => {
            el.style.opacity = '0';
            setTimeout(() => el.remove(), 300);
        }, 2500);
    }

    /* ====================== 配置面板 UI ====================== */
    GM_addStyle(`
        .dbx-config-fab {
            position: fixed; right: 24px; bottom: 96px; z-index: 999998;
            width: 44px; height: 44px; border-radius: 50%;
            background: #6366f1; color: #fff; border: none; cursor: pointer;
            box-shadow: 0 4px 12px rgba(99,102,241,0.4);
            display: flex; align-items: center; justify-content: center;
            font-size: 20px; transition: transform 0.2s, background 0.2s;
        }
        .dbx-config-fab:hover { transform: scale(1.1); background: #4f46e5; }
        .dbx-mask {
            position: fixed; inset: 0; z-index: 999999;
            background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
            font-family: -apple-system, "PingFang SC", sans-serif;
        }
        .dbx-modal {
            background: #fff; border-radius: 12px; width: 640px; max-width: 92vw;
            max-height: 85vh; display: flex; flex-direction: column; overflow: hidden;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
        }
        .dbx-modal-header {
            padding: 16px 20px; border-bottom: 1px solid #eee; font-size: 16px;
            font-weight: 600; color: #111; display: flex; justify-content: space-between; align-items: center;
        }
        .dbx-modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: #999; line-height: 1; }
        .dbx-modal-close:hover { color: #333; }
        .dbx-modal-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
        .dbx-section-title { font-size: 13px; color: #666; margin: 12px 0 8px; font-weight: 600; }
        .dbx-tpl-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
        .dbx-tpl-item {
            border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px;
            display: flex; flex-direction: column; gap: 6px; background: #fafafa;
        }
        .dbx-tpl-row { display: flex; gap: 8px; align-items: center; }
        .dbx-tpl-key {
            font-family: monospace; font-size: 12px; background: #eef2ff; color: #4338ca;
            padding: 2px 8px; border-radius: 4px; font-weight: 600;
        }
        .dbx-tpl-label { font-size: 13px; color: #333; flex: 1; }
        .dbx-tpl-actions { display: flex; gap: 6px; }
        .dbx-btn {
            border: 1px solid #ddd; background: #fff; border-radius: 6px;
            padding: 4px 10px; font-size: 12px; cursor: pointer; color: #333;
        }
        .dbx-btn:hover { background: #f3f4f6; }
        .dbx-btn-danger { color: #dc2626; border-color: #fecaca; }
        .dbx-btn-danger:hover { background: #fef2f2; }
        .dbx-btn-primary { background: #6366f1; color: #fff; border-color: #6366f1; }
        .dbx-btn-primary:hover { background: #4f46e5; }
        .dbx-tpl-prompt {
            font-size: 12px; color: #666; font-family: monospace; white-space: pre-wrap;
            background: #fff; padding: 6px 8px; border-radius: 4px; border: 1px solid #eee;
            max-height: 60px; overflow-y: auto;
        }
        .dbx-form { display: flex; flex-direction: column; gap: 10px; padding: 12px; border: 1px dashed #d1d5db; border-radius: 8px; margin-top: 8px; background: #fff; }
        .dbx-form-row { display: flex; gap: 8px; }
        .dbx-form input, .dbx-form textarea {
            border: 1px solid #ddd; border-radius: 6px; padding: 6px 10px; font-size: 13px;
            font-family: inherit;
        }
        .dbx-form input { width: 140px; }
        .dbx-form textarea { width: 100%; min-height: 60px; font-family: monospace; resize: vertical; }
        .dbx-form input:focus, .dbx-form textarea:focus { outline: none; border-color: #6366f1; }
        .dbx-settings-row { display: flex; flex-direction: column; gap: 8px; padding: 12px 0; border-top: 1px solid #eee; }
        .dbx-setting-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #333; }
        .dbx-setting-item input[type="checkbox"] { width: 16px; height: 16px; }
        .dbx-setting-item input[type="number"] { width: 80px; }
        .dbx-hint { font-size: 11px; color: #999; margin-top: 4px; line-height: 1.5; }
        .dbx-url-preview {
            font-family: monospace; font-size: 11px; background: #f9fafb; border: 1px solid #eee;
            padding: 8px; border-radius: 6px; color: #555; word-break: break-all; margin-top: 8px;
        }
        .dbx-modal-footer {
            padding: 12px 20px; border-top: 1px solid #eee; display: flex; justify-content: space-between; align-items: center;
        }
        .dbx-call-url {
            font-family: monospace; font-size: 11px; color: #4338ca; background: #eef2ff;
            padding: 4px 8px; border-radius: 4px; word-break: break-all; cursor: pointer;
        }
    `);

    function openConfigPanel() {
        // 移除已存在
        document.querySelector('.dbx-mask')?.remove();

        const templates = loadTemplates();
        const settings = loadSettings();

        const mask = document.createElement('div');
        mask.className = 'dbx-mask';

        const modal = document.createElement('div');
        modal.className = 'dbx-modal';

        modal.innerHTML = `
            <div class="dbx-modal-header">
                <span>豆包 URL 调用配置</span>
                <button class="dbx-modal-close" data-act="close">×</button>
            </div>
            <div class="dbx-modal-body">
                <div class="dbx-section-title">提示词模板(type 与 prompt 模板,{content} 会被替换为传入内容)</div>
                <div class="dbx-tpl-list" id="dbx-tpl-list"></div>

                <div class="dbx-section-title">新增 / 编辑模板</div>
                <div class="dbx-form" id="dbx-form">
                    <div class="dbx-form-row">
                        <input id="dbx-f-key" placeholder="type 键名(如 translate)" />
                        <input id="dbx-f-label" placeholder="显示名称(如 英文翻译)" style="flex:1" />
                    </div>
                    <textarea id="dbx-f-prompt" placeholder="提示词模板,必须包含 {content},例如:&#10;请将以下英文翻译成中文:&#10;&#10;{content}"></textarea>
                    <div class="dbx-form-row">
                        <button class="dbx-btn dbx-btn-primary" data-act="save">保存模板</button>
                        <button class="dbx-btn" data-act="reset">重置为默认</button>
                    </div>
                </div>

                <div class="dbx-settings-row">
                    <div class="dbx-section-title" style="margin-top:0">运行设置</div>
                    <label class="dbx-setting-item">
                        <input type="checkbox" id="dbx-s-auto" ${settings.autoSend ? 'checked' : ''}/>
                        自动发送(填入后自动点击发送按钮)
                    </label>
                    <label class="dbx-setting-item">
                        <input type="checkbox" id="dbx-s-clean" ${settings.cleanUrl ? 'checked' : ''}/>
                        发送后清理 URL 参数(避免刷新重复发送)
                    </label>
                    <label class="dbx-setting-item">
                        <input type="checkbox" id="dbx-s-showfab" ${settings.showConfigButton ? 'checked' : ''}/>
                        显示右下角配置按钮
                    </label>
                    <label class="dbx-setting-item">
                        发送延迟
                        <input type="number" id="dbx-s-delay" value="${settings.sendDelay}" min="0" step="100"/>
                        毫秒
                    </label>
                </div>

                <div class="dbx-section-title">调用方式</div>
                <div class="dbx-hint">
                    通过 URL 传递参数:<code>?type=类型&amp;content=内容</code>。content 建议 URL 编码。
                </div>
                <div class="dbx-url-preview" id="dbx-url-preview"></div>
            </div>
            <div class="dbx-modal-footer">
                <span class="dbx-hint">点击预览 URL 可复制</span>
                <button class="dbx-btn dbx-btn-primary" data-act="close">完成</button>
            </div>
        `;

        mask.appendChild(modal);
        document.body.appendChild(mask);

        const listEl = modal.querySelector('#dbx-tpl-list');
        const previewEl = modal.querySelector('#dbx-url-preview');

        function renderList() {
            const tpls = loadTemplates();
            listEl.innerHTML = '';
            for (const key of Object.keys(tpls)) {
                const t = tpls[key];
                const item = document.createElement('div');
                item.className = 'dbx-tpl-item';
                const hasContent = t.prompt.includes('{content}');
                item.innerHTML = `
                    <div class="dbx-tpl-row">
                        <span class="dbx-tpl-key">${escapeHtml(key)}</span>
                        <span class="dbx-tpl-label">${escapeHtml(t.label || '')}</span>
                        <div class="dbx-tpl-actions">
                            <button class="dbx-btn" data-edit="${escapeHtml(key)}">编辑</button>
                            <button class="dbx-btn dbx-btn-danger" data-del="${escapeHtml(key)}">删除</button>
                        </div>
                    </div>
                    <div class="dbx-tpl-prompt">${escapeHtml(t.prompt)}${hasContent ? '' : '<span style="color:#dc2626"> ⚠ 缺少 {content}</span>'}</div>
                `;
                listEl.appendChild(item);
            }
            updatePreview();
        }

        function updatePreview() {
            const tpls = loadTemplates();
            const sample = encodeURIComponent('hello world');
            const lines = Object.keys(tpls).map(k =>
                `https://www.doubao.com/chat/?type=${encodeURIComponent(k)}&content=${sample}`
            );
            previewEl.innerHTML = lines.map(l => `<div class="dbx-call-url" data-url="${escapeHtml(l)}">${escapeHtml(l)}</div>`).join('');
        }

        function escapeHtml(s) {
            return String(s).replace(/[&<>"']/g, c => ({
                '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
            }[c]));
        }

        // 事件委托
        modal.addEventListener('click', async (e) => {
            const act = e.target.dataset.act;
            const editKey = e.target.dataset.edit;
            const delKey = e.target.dataset.del;
            const url = e.target.dataset.url;

            if (act === 'close' || e.target === mask) {
                mask.remove();
                return;
            }
            if (delKey) {
                if (!confirm(`确认删除模板 "${delKey}"?`)) return;
                const tpls = loadTemplates();
                if (DEFAULT_TEMPLATES[delKey]) {
                    if (!confirm(`"${delKey}" 是内置模板,删除后将无法恢复内置默认值。继续?`)) return;
                }
                delete tpls[delKey];
                saveTemplates(tpls);
                renderList();
                showToast('已删除', 'success');
                return;
            }
            if (editKey) {
                const tpls = loadTemplates();
                const t = tpls[editKey];
                modal.querySelector('#dbx-f-key').value = editKey;
                modal.querySelector('#dbx-f-label').value = t.label || '';
                modal.querySelector('#dbx-f-prompt').value = t.prompt;
                return;
            }
            if (act === 'save') {
                const key = modal.querySelector('#dbx-f-key').value.trim();
                const label = modal.querySelector('#dbx-f-label').value.trim();
                const prompt = modal.querySelector('#dbx-f-prompt').value;
                if (!key) { showToast('请填写 type 键名', 'error'); return; }
                if (!/^[a-zA-Z0-9_-]+$/.test(key)) { showToast('键名只允许字母数字、下划线、短横线', 'error'); return; }
                if (!prompt.includes('{content}')) { showToast('提示词必须包含 {content}', 'error'); return; }
                const tpls = loadTemplates();
                tpls[key] = { label: label || key, prompt };
                saveTemplates(tpls);
                modal.querySelector('#dbx-f-key').value = '';
                modal.querySelector('#dbx-f-label').value = '';
                modal.querySelector('#dbx-f-prompt').value = '';
                renderList();
                showToast('已保存', 'success');
                return;
            }
            if (act === 'reset') {
                if (!confirm('重置所有模板为默认值?现有自定义模板将丢失。')) return;
                saveTemplates(JSON.parse(JSON.stringify(DEFAULT_TEMPLATES)));
                renderList();
                showToast('已重置', 'success');
                return;
            }
            if (url) {
                try {
                    await navigator.clipboard.writeText(url);
                    showToast('URL 已复制', 'success');
                } catch {
                    showToast('复制失败,请手动选择', 'warn');
                }
            }
        });

        // 设置变更实时保存
        modal.querySelector('#dbx-s-auto').addEventListener('change', (e) => {
            const s = loadSettings(); s.autoSend = e.target.checked; saveSettings(s);
        });
        modal.querySelector('#dbx-s-clean').addEventListener('change', (e) => {
            const s = loadSettings(); s.cleanUrl = e.target.checked; saveSettings(s);
        });
        modal.querySelector('#dbx-s-showfab').addEventListener('change', (e) => {
            const s = loadSettings(); s.showConfigButton = e.target.checked; saveSettings(s);
            toggleFab(s.showConfigButton);
        });
        modal.querySelector('#dbx-s-delay').addEventListener('change', (e) => {
            const s = loadSettings(); s.sendDelay = Math.max(0, parseInt(e.target.value) || 0); saveSettings(s);
        });

        renderList();
    }

    function toggleFab(show) {
        let fab = document.querySelector('.dbx-config-fab');
        if (show) {
            if (!fab) {
                fab = document.createElement('button');
                fab.className = 'dbx-config-fab';
                fab.innerHTML = '⚙';
                fab.title = '豆包 URL 调用配置';
                fab.addEventListener('click', openConfigPanel);
                document.body.appendChild(fab);
            }
            fab.style.display = 'flex';
        } else if (fab) {
            fab.style.display = 'none';
        }
    }

    /* ====================== 启动 ====================== */
    function init() {
        const settings = loadSettings();
        toggleFab(settings.showConfigButton);

        // 处理 URL 调用
        handleUrlCall();
    }

    // 等待 DOM 就绪后启动
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

指令

https://www.doubao.com/chat/?type=translate&content={selection}