// ==UserScript== // @name 饺子 AI 网页摘要 + 连续对话 // @namespace https://space.bilibili.com/38389107 // @version 2.0.0 // @description 指定网站自动弹出 AI 网页摘要,支持全局配置、网址规则管理、提示词模板、停止/重试、正文预览、模型拉取。 // @author 次元饺子 // @icon https://img.icons8.com/?size=100&id=90385&format=png&color=000000 // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @connect * // @run-at document-idle // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; /****************************************************************** * 固定配置 Key ******************************************************************/ const STORAGE_KEY = 'tabbit_ai_summary_config'; const LEGACY_STORAGE_KEYS = [ 'tabbit_ai_summary_config_v24', 'tabbit_ai_summary_config_v23', 'tabbit_ai_summary_config_v22', 'tabbit_ai_summary_config_v21', 'tabbit_ai_summary_config_v2' ]; const DEFAULT_PROMPT_TEXT = '我是一个有轻微理解障碍的人,没有耐心,不想动脑子。请你用很短、很直白的话解释这个网页到底在说什么。' + '\n\n请输出:' + '\n1. 这个网页一句话总结' + '\n2. 关键点' + '\n3. 对我有什么用' + '\n4. 原始链接'; const DEFAULT_CONFIG = { apiUrl: 'https://api.xiaomimimo.com/v1/chat/completions', apiKey: '', currentModel: 'mimo-v2-flash', temperature: 0.7, maxTokens: 2000, models: [ { name: 'mimo-v2-flash', value: 'mimo-v2-flash', temperature: '', maxTokens: '' } ], promptTemplates: [ { id: 'default', name: '默认总结', text: DEFAULT_PROMPT_TEXT }, { id: 'plain', name: '大白话解释', text: '请用非常简单、直白、短句的方式解释这个网页。不要绕弯子,不要讲废话。' + '\n\n请输出:' + '\n1. 一句话说明它在说什么' + '\n2. 三个最重要的点' + '\n3. 普通人应该怎么理解' }, { id: 'forum', name: '论坛讨论总结', text: '请总结这个帖子或讨论页面。重点提炼楼主观点、主要争议、支持方观点、反对方观点,以及最后值得关注的结论。' }, { id: 'investment', name: '投资视角', text: '请从投资和商业角度总结这个网页。重点关注公司、行业、数据、增长、风险、市场预期,以及对普通投资者有什么参考价值。' } ], defaultPromptTemplateId: 'default', urlRules: [ 'https://mp.weixin.qq.com/*', 'https://nga.178.com/read.php*', 'https://www.jisilu.cn/*', 'https://www.gelonghui.com/*', 'https://bbs.nga.cn/read.php*', 'https://www.youxituoluo.com/*', 'https://www.vrtuoluo.cn/*', 'https://sspai.com/post/*', 'https://www.ifanr.com/*', 'http://www.gamelook.com.cn/*' ], rulePromptBindings: [], autoRun: true, floatButton: { side: 'right', y: null, opacity: 0.55 }, panel: { width: 460, heightRatio: 0.82 }, extractMaxChars: 16000 }; let config = loadConfig(); let panelEl = null; let floatBtnEl = null; let settingsEl = null; let addRuleModalEl = null; let previewModalEl = null; let chatMessages = []; let summaryStarted = false; let currentPageUrl = location.href; let lastUrl = location.href; let isRequesting = false; let currentRequest = null; let currentReject = null; let lastRequestPayload = null; let lastExtractedText = ''; init(); /****************************************************************** * 初始化 ******************************************************************/ function init() { createStyles(); createFloatButton(); registerMenus(); if (config.autoRun && isUrlMatched(location.href, config.urlRules)) { openPanel(true); } watchUrlChange(); } function registerMenus() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('饺子 AI:打开面板', () => openPanel(false)); GM_registerMenuCommand('饺子 AI:设置', openSettings); GM_registerMenuCommand('饺子 AI:加入当前网址', () => openAddUrlRuleModal()); GM_registerMenuCommand('饺子 AI:导出配置文件', exportConfigToFile); GM_registerMenuCommand('饺子 AI:导入配置文件', importConfigFromFile); GM_registerMenuCommand('饺子 AI:重置配置', resetConfig); } function watchUrlChange() { setInterval(() => { if (location.href === lastUrl) return; lastUrl = location.href; currentPageUrl = location.href; summaryStarted = false; chatMessages = []; lastRequestPayload = null; lastExtractedText = ''; if (panelEl) { const list = panelEl.querySelector('#tabbit-chat-list'); if (list) list.innerHTML = ''; setStatus(''); } if (config.autoRun && isUrlMatched(location.href, config.urlRules)) { openPanel(true); } }, 1000); } /****************************************************************** * 配置读写:只使用 GM_getValue / GM_setValue ******************************************************************/ function loadConfig() { try { if (typeof GM_getValue !== 'function') { console.warn('[饺子 AI] 当前环境不支持 GM_getValue。'); return clone(DEFAULT_CONFIG); } let raw = GM_getValue(STORAGE_KEY, ''); if (!raw) { for (const key of LEGACY_STORAGE_KEYS) { const legacyRaw = GM_getValue(key, ''); if (legacyRaw) { raw = legacyRaw; GM_setValue(STORAGE_KEY, legacyRaw); console.log('[饺子 AI] 已迁移旧配置:', key); break; } } } if (!raw) return clone(DEFAULT_CONFIG); const saved = JSON.parse(raw); return mergeConfig(clone(DEFAULT_CONFIG), saved); } catch (err) { console.warn('[饺子 AI] 配置读取失败:', err); return clone(DEFAULT_CONFIG); } } function saveConfig() { try { if (typeof GM_setValue !== 'function') { alert('当前环境不支持 GM_setValue,配置无法保存。'); return; } config.urlRules = normalizeUrlRules(config.urlRules); config.rulePromptBindings = normalizeRulePromptBindings(config.rulePromptBindings); config.promptTemplates = normalizePromptTemplates(config.promptTemplates); config.models = normalizeModels(config.models); GM_setValue(STORAGE_KEY, JSON.stringify(config)); } catch (err) { console.warn('[饺子 AI] 配置保存失败:', err); alert('配置保存失败:' + err.message); } } function resetConfig() { if (!confirm('确定要重置 饺子 AI 的所有配置吗?')) return; try { if (typeof GM_deleteValue === 'function') { GM_deleteValue(STORAGE_KEY); } config = clone(DEFAULT_CONFIG); saveConfig(); alert('配置已重置。'); location.reload(); } catch (err) { alert('重置失败:' + err.message); } } function mergeConfig(base, saved) { const result = { ...base, ...saved }; if (!Array.isArray(result.models)) result.models = base.models; if (!Array.isArray(result.urlRules)) result.urlRules = base.urlRules; if (!Array.isArray(result.promptTemplates)) result.promptTemplates = base.promptTemplates; if (!Array.isArray(result.rulePromptBindings)) result.rulePromptBindings = []; // 兼容旧 promptText。 if (saved.promptText && !saved.promptTemplates) { result.promptTemplates = [ { id: 'default', name: '默认总结', text: saved.promptText }, ...base.promptTemplates.filter(t => t.id !== 'default') ]; } result.models = normalizeModels(result.models); result.urlRules = normalizeUrlRules(result.urlRules); result.promptTemplates = normalizePromptTemplates(result.promptTemplates); result.rulePromptBindings = normalizeRulePromptBindings(result.rulePromptBindings); result.floatButton = { ...base.floatButton, ...(saved.floatButton || {}) }; result.panel = { ...base.panel, ...(saved.panel || {}) }; if (!result.defaultPromptTemplateId) { result.defaultPromptTemplateId = result.promptTemplates[0]?.id || 'default'; } if (!result.promptTemplates.some(t => t.id === result.defaultPromptTemplateId)) { result.defaultPromptTemplateId = result.promptTemplates[0]?.id || 'default'; } if (!result.currentModel && result.models.length) { result.currentModel = result.models[0].value; } if (!result.models.some(m => m.value === result.currentModel)) { result.currentModel = result.models[0]?.value || ''; } result.extractMaxChars = Number(result.extractMaxChars || 16000); return result; } function clone(obj) { return JSON.parse(JSON.stringify(obj)); } /****************************************************************** * URL 规则 ******************************************************************/ function normalizeUrlRules(rules) { if (!Array.isArray(rules)) return []; const result = []; rules .map(rule => String(rule || '').trim()) .filter(Boolean) .forEach(rule => { if (!result.includes(rule)) result.push(rule); }); return result; } function normalizeRulePromptBindings(bindings) { if (!Array.isArray(bindings)) return []; const result = []; bindings.forEach(item => { const rule = String(item?.rule || '').trim(); const templateId = String(item?.templateId || '').trim(); if (!rule || !templateId) return; const old = result.find(x => x.rule === rule); if (old) { old.templateId = templateId; } else { result.push({ rule, templateId }); } }); return result; } function isUrlMatched(url, rules) { return findMatchedUrlRules(url, rules).length > 0; } function findMatchedUrlRules(url, rules = config.urlRules) { return normalizeUrlRules(rules).filter(rule => testUrlRule(url, rule)); } function testUrlRule(url, rule) { rule = String(rule || '').trim(); if (!rule) return false; if (rule.includes('*')) { const escaped = rule .replace(/[.+?^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*'); return new RegExp('^' + escaped).test(url); } return url.startsWith(rule); } function getMatchedRuleForCurrentPage() { return findMatchedUrlRules(location.href)[0] || ''; } function buildUrlRuleCandidates(rawUrl) { let url; try { url = new URL(rawUrl); } catch (err) { return [rawUrl.split('#')[0].split('?')[0]]; } const origin = url.origin; const fullNoHash = rawUrl.split('#')[0]; const path = url.pathname || '/'; const pathNoSlash = path.replace(/\/+$/, '') || '/'; const segments = pathNoSlash.split('/').filter(Boolean); const last = segments[segments.length - 1] || ''; const candidates = []; const recommended = buildSmartUrlRule(rawUrl); pushUnique(candidates, recommended); pushUnique(candidates, fullNoHash); if (path !== '/') { pushUnique(candidates, origin + pathNoSlash + '*'); } if (segments.length > 1) { pushUnique(candidates, origin + '/' + segments.slice(0, -1).join('/') + '/*'); } pushUnique(candidates, origin + '/*'); pushUnique(candidates, origin + '/'); if (/\.(php|asp|aspx|jsp|html|htm)$/i.test(last)) { pushUnique(candidates, origin + pathNoSlash + '*'); } return candidates; } function buildSmartUrlRule(rawUrl) { let url; try { url = new URL(rawUrl); } catch (err) { return rawUrl.split('#')[0].split('?')[0]; } const origin = url.origin; let pathname = url.pathname || '/'; pathname = pathname.replace(/\/{2,}/g, '/'); if (!pathname || pathname === '/') { return origin + '/'; } const pathWithoutTrailingSlash = pathname.replace(/\/+$/, ''); const segments = pathWithoutTrailingSlash.split('/').filter(Boolean); const last = segments[segments.length - 1] || ''; const hasQuery = !!url.search; const isFileLike = /\.[a-z0-9]{2,8}$/i.test(last); const isPhpLike = /\.(php|asp|aspx|jsp|html|htm)$/i.test(last); const isNumericId = /^\d+$/.test(last); const isHexId = /^[a-f0-9]{8,}$/i.test(last); const isSlugWithId = /(^|[-_])\d{3,}($|[-_])/i.test(last); const isLongToken = last.length >= 24 && /^[a-z0-9_-]+$/i.test(last); const isIdLike = isNumericId || isHexId || isSlugWithId || isLongToken; if (isPhpLike || isFileLike) { return origin + pathWithoutTrailingSlash + '*'; } if (isIdLike) { if (segments.length <= 1) return origin + '/*'; return origin + '/' + segments.slice(0, -1).join('/') + '/*'; } if (hasQuery) { return origin + pathWithoutTrailingSlash + '*'; } if (pathname.endsWith('/')) { return origin + pathname; } return origin + pathWithoutTrailingSlash + '*'; } function pushUnique(arr, value) { value = String(value || '').trim(); if (value && !arr.includes(value)) arr.push(value); } function addUrlRule(rule, templateId = '') { rule = String(rule || '').trim(); if (!rule) return false; config.urlRules = normalizeUrlRules([...config.urlRules, rule]); if (templateId) { setRuleTemplateBinding(rule, templateId); } saveConfig(); if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) { renderSettingsUrlRules(); } setStatus('已加入网址规则', 'ok', 1800); return true; } function setRuleTemplateBinding(rule, templateId) { rule = String(rule || '').trim(); templateId = String(templateId || '').trim(); config.rulePromptBindings = normalizeRulePromptBindings(config.rulePromptBindings); config.rulePromptBindings = config.rulePromptBindings.filter(x => x.rule !== rule); if (templateId) { config.rulePromptBindings.push({ rule, templateId }); } } function getTemplateIdForRule(rule) { const binding = config.rulePromptBindings.find(x => x.rule === rule); return binding?.templateId || ''; } /****************************************************************** * 提示词模板 ******************************************************************/ function normalizePromptTemplates(templates) { if (!Array.isArray(templates)) templates = []; const result = []; templates.forEach(item => { const id = String(item?.id || '').trim() || makeId('tpl'); const name = String(item?.name || '').trim() || '未命名模板'; const text = String(item?.text || '').trim(); if (!text) return; if (!result.some(t => t.id === id)) { result.push({ id, name, text }); } }); if (!result.length) { result.push({ id: 'default', name: '默认总结', text: DEFAULT_PROMPT_TEXT }); } return result; } function getPromptTemplateById(id) { return config.promptTemplates.find(t => t.id === id) || config.promptTemplates[0]; } function getPromptForCurrentPage() { const matchedRule = getMatchedRuleForCurrentPage(); const boundTemplateId = matchedRule ? getTemplateIdForRule(matchedRule) : ''; const templateId = boundTemplateId || config.defaultPromptTemplateId; return getPromptTemplateById(templateId)?.text || DEFAULT_PROMPT_TEXT; } function makeId(prefix) { return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2)}`; } /****************************************************************** * 模型 ******************************************************************/ function normalizeModels(models) { if (!Array.isArray(models)) models = []; const result = []; models.forEach(model => { const value = String(model?.value || '').trim(); if (!value) return; const item = { name: String(model?.name || value).trim(), value, temperature: model?.temperature ?? '', maxTokens: model?.maxTokens ?? '' }; if (!result.some(m => m.value === value)) { result.push(item); } }); if (!result.length) { result.push({ name: 'mimo-v2-flash', value: 'mimo-v2-flash', temperature: '', maxTokens: '' }); } return result; } function getCurrentModelConfig() { return config.models.find(m => m.value === config.currentModel) || config.models[0] || {}; } function getCurrentModelDisplayName() { const model = getCurrentModelConfig(); return model?.name || model?.value || config.currentModel || '未知模型'; } function getCurrentTemperature() { const model = getCurrentModelConfig(); const value = model?.temperature !== '' && model?.temperature !== undefined ? model.temperature : config.temperature; return Number(value || 0.7); } function getCurrentMaxTokens() { const model = getCurrentModelConfig(); const value = model?.maxTokens !== '' && model?.maxTokens !== undefined ? model.maxTokens : config.maxTokens; return Number(value || 2000); } /****************************************************************** * 悬浮按钮 ******************************************************************/ function createFloatButton() { const old = document.querySelector('#tabbit-ai-float-btn'); if (old) old.remove(); floatBtnEl = document.createElement('button'); floatBtnEl.id = 'tabbit-ai-float-btn'; floatBtnEl.innerHTML = 'AI'; floatBtnEl.title = '打开 饺子 AI。可拖拽,右键恢复默认位置。'; document.body.appendChild(floatBtnEl); applyFloatButtonPosition(); let dragging = false; let moved = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; floatBtnEl.addEventListener('mousedown', e => { if (e.button !== 0) return; dragging = true; moved = false; const rect = floatBtnEl.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; document.body.classList.add('tabbit-dragging'); e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true; let left = startLeft + dx; let top = startTop + dy; const rect = floatBtnEl.getBoundingClientRect(); const maxLeft = window.innerWidth - rect.width; const maxTop = window.innerHeight - rect.height; left = Math.max(0, Math.min(maxLeft, left)); top = Math.max(0, Math.min(maxTop, top)); floatBtnEl.style.left = left + 'px'; floatBtnEl.style.top = top + 'px'; floatBtnEl.style.right = 'auto'; floatBtnEl.style.bottom = 'auto'; floatBtnEl.style.transform = 'none'; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; document.body.classList.remove('tabbit-dragging'); const rect = floatBtnEl.getBoundingClientRect(); const stickToRight = rect.left + rect.width / 2 > window.innerWidth / 2; config.floatButton.side = stickToRight ? 'right' : 'left'; config.floatButton.y = Math.round(rect.top); saveConfig(); applyFloatButtonPosition(); }); floatBtnEl.addEventListener('click', e => { if (moved) { e.preventDefault(); e.stopPropagation(); return; } openPanel(false); }); floatBtnEl.addEventListener('contextmenu', e => { e.preventDefault(); config.floatButton = { side: 'right', y: null, opacity: 0.55 }; saveConfig(); applyFloatButtonPosition(); }); window.addEventListener('resize', applyFloatButtonPosition); } function applyFloatButtonPosition() { if (!floatBtnEl) return; const fb = config.floatButton || {}; floatBtnEl.style.left = 'auto'; floatBtnEl.style.right = 'auto'; floatBtnEl.style.top = 'auto'; floatBtnEl.style.bottom = 'auto'; floatBtnEl.style.transform = 'none'; if (typeof fb.y === 'number') { const btnHeight = 72; const safeY = Math.max(10, Math.min(window.innerHeight - btnHeight - 10, fb.y)); floatBtnEl.style.top = safeY + 'px'; if (fb.side === 'left') { floatBtnEl.style.left = '0px'; floatBtnEl.classList.add('tabbit-float-left'); floatBtnEl.classList.remove('tabbit-float-right'); } else { floatBtnEl.style.right = '0px'; floatBtnEl.classList.add('tabbit-float-right'); floatBtnEl.classList.remove('tabbit-float-left'); } } else { floatBtnEl.style.top = '50%'; floatBtnEl.style.right = '0px'; floatBtnEl.style.transform = 'translateY(-50%)'; floatBtnEl.classList.add('tabbit-float-right'); floatBtnEl.classList.remove('tabbit-float-left'); } floatBtnEl.style.opacity = String(fb.opacity ?? 0.55); } /****************************************************************** * 主面板 ******************************************************************/ function openPanel(autoRun) { if (!panelEl) { panelEl = createPanel(); document.body.appendChild(panelEl); } panelEl.classList.remove('tabbit-hidden'); renderModelSelect(); if (autoRun && !summaryStarted) { runSummary(); } } function closePanel() { if (panelEl) { panelEl.classList.add('tabbit-hidden'); } } function createPanel() { const panel = document.createElement('div'); panel.id = 'tabbit-ai-panel'; const width = Number(config.panel?.width || 460); const heightRatio = Number(config.panel?.heightRatio || 0.82); panel.style.width = width + 'px'; panel.style.height = Math.round(window.innerHeight * heightRatio) + 'px'; panel.innerHTML = `
📖 饺子 AI
`; panel.querySelector('#tabbit-close-btn').addEventListener('click', closePanel); panel.querySelector('#tabbit-settings-btn').addEventListener('click', openSettings); panel.querySelector('#tabbit-summary-btn').addEventListener('click', runSummary); panel.querySelector('#tabbit-stop-btn').addEventListener('click', stopCurrentRequest); panel.querySelector('#tabbit-retry-btn').addEventListener('click', retryLastRequest); panel.querySelector('#tabbit-preview-btn').addEventListener('click', openPreviewModal); panel.querySelector('#tabbit-add-url-rule-btn').addEventListener('click', () => openAddUrlRuleModal()); panel.querySelector('#tabbit-clear-btn').addEventListener('click', clearChat); panel.querySelector('#tabbit-copy-btn').addEventListener('click', copyChat); panel.querySelector('#tabbit-send-btn').addEventListener('click', sendUserMessage); const input = panel.querySelector('#tabbit-user-input'); input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendUserMessage(); } }); return panel; } function renderModelSelect() { if (!panelEl) return; const select = panelEl.querySelector('#tabbit-model-select'); if (!select) return; select.innerHTML = ''; normalizeModels(config.models).forEach(model => { const option = document.createElement('option'); option.value = model.value; option.textContent = model.name || model.value; if (model.value === config.currentModel) { option.selected = true; } select.appendChild(option); }); select.onchange = function () { config.currentModel = this.value; saveConfig(); }; } function setStatus(text, type = '', autoHideMs = 0) { if (!panelEl) return; const bar = panelEl.querySelector('#tabbit-status-bar'); if (!bar) return; if (!text) { bar.textContent = ''; bar.className = 'tabbit-status-bar tabbit-hidden'; return; } bar.textContent = text; bar.className = `tabbit-status-bar tabbit-status-${type || 'normal'}`; if (autoHideMs) { setTimeout(() => { if (bar.textContent === text) setStatus(''); }, autoHideMs); } } /****************************************************************** * 总结与对话 ******************************************************************/ async function runSummary() { if (isRequesting) return; summaryStarted = true; currentPageUrl = location.href; if (!checkApiConfig()) return; const pageContent = extractPageContent(); lastExtractedText = pageContent; if (!pageContent || pageContent.length < 80) { appendErrorMessage('没有提取到足够的网页正文。'); setStatus('正文过短', 'error', 2500); return; } const modelLabel = getCurrentModelDisplayName(); const fullPrompt = `${getPromptForCurrentPage()} 网页标题: ${document.title || ''} 网页 URL: ${currentPageUrl} 网页正文: ${pageContent}`; const userMsg = { role: 'user', content: fullPrompt }; const payloadMessages = [...chatMessages, userMsg]; lastRequestPayload = { messages: payloadMessages, modelLabel }; try { setInputLoading(true); setStatus(`分析中 · ${pageContent.length} 字 · ${modelLabel}`, 'loading'); const answer = await callChatApi(payloadMessages); chatMessages.push(userMsg); chatMessages.push({ role: 'assistant', content: answer }); appendAssistantMessage(answer, modelLabel); setStatus('完成', 'ok', 1200); } catch (err) { if (String(err?.message || '').includes('请求已取消')) { setStatus('已停止', 'normal', 1600); } else { appendErrorMessage(err.message || String(err)); setStatus('请求失败,可重试', 'error'); } } finally { setInputLoading(false); } } async function sendUserMessage() { if (isRequesting) return; if (!checkApiConfig()) return; if (!panelEl) return; const input = panelEl.querySelector('#tabbit-user-input'); const text = input.value.trim(); if (!text) return; input.value = ''; appendUserMessage(text); const userMsg = { role: 'user', content: text }; const modelLabel = getCurrentModelDisplayName(); const payloadMessages = [...chatMessages, userMsg]; lastRequestPayload = { messages: payloadMessages, modelLabel }; try { setInputLoading(true); setStatus(`请求中 · ${modelLabel}`, 'loading'); const answer = await callChatApi(payloadMessages); chatMessages.push(userMsg); chatMessages.push({ role: 'assistant', content: answer }); appendAssistantMessage(answer, modelLabel); setStatus('完成', 'ok', 1200); } catch (err) { if (String(err?.message || '').includes('请求已取消')) { setStatus('已停止', 'normal', 1600); } else { appendErrorMessage(err.message || String(err)); setStatus('请求失败,可重试', 'error'); } } finally { setInputLoading(false); } } async function retryLastRequest() { if (isRequesting) return; if (!lastRequestPayload) return; if (!checkApiConfig()) return; try { setInputLoading(true); setStatus(`重试中 · ${lastRequestPayload.modelLabel || getCurrentModelDisplayName()}`, 'loading'); const answer = await callChatApi(lastRequestPayload.messages); const lastUser = [...lastRequestPayload.messages].reverse().find(m => m.role === 'user'); if (lastUser && !chatMessages.includes(lastUser)) { chatMessages.push(lastUser); } chatMessages.push({ role: 'assistant', content: answer }); appendAssistantMessage(answer, lastRequestPayload.modelLabel || getCurrentModelDisplayName()); setStatus('重试完成', 'ok', 1200); } catch (err) { if (String(err?.message || '').includes('请求已取消')) { setStatus('已停止', 'normal', 1600); } else { appendErrorMessage(err.message || String(err)); setStatus('重试失败', 'error'); } } finally { setInputLoading(false); } } function stopCurrentRequest() { if (!isRequesting) return; try { if (currentRequest && typeof currentRequest.abort === 'function') { currentRequest.abort(); } if (typeof currentReject === 'function') { currentReject(new Error('请求已取消')); } } catch (err) { console.warn('[饺子 AI] 停止请求失败:', err); } finally { currentRequest = null; currentReject = null; setInputLoading(false); setStatus('已停止', 'normal', 1600); } } function checkApiConfig() { if (!config.apiUrl || !config.apiKey || !config.currentModel) { openSettings(); setStatus('请先配置 API', 'error', 2500); return false; } return true; } function callChatApi(messages) { const body = { model: config.currentModel, messages, temperature: getCurrentTemperature(), max_tokens: getCurrentMaxTokens() }; return new Promise((resolve, reject) => { currentReject = reject; currentRequest = GM_xmlhttpRequest({ method: 'POST', url: config.apiUrl, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` }, data: JSON.stringify(body), timeout: 120000, onload(res) { currentRequest = null; currentReject = null; try { if (res.status < 200 || res.status >= 300) { reject(new Error(formatApiError(res.status, res.responseText))); return; } const data = JSON.parse(res.responseText); const content = data?.choices?.[0]?.message?.content; if (!content) { reject(new Error('API 响应格式异常:没有找到 choices[0].message.content。')); return; } resolve(content); } catch (err) { reject(err); } }, onerror(err) { currentRequest = null; currentReject = null; reject(new Error('网络请求失败:' + JSON.stringify(err))); }, ontimeout() { currentRequest = null; currentReject = null; reject(new Error('API 请求超时。')); } }); }); } function formatApiError(status, text) { const map = { 400: '400:请求参数可能有误。', 401: '401:API Key 可能不正确。', 403: '403:当前 API Key 可能没有权限。', 404: '404:API 地址或模型名可能错误。', 429: '429:请求过于频繁或额度不足。', 500: '500:服务商内部错误。', 502: '502:服务商网关错误。', 503: '503:服务暂不可用。' }; return `${map[status] || `API 请求失败:${status}`}\n${text || ''}`; } function extractPageContent() { const clonedBody = document.body.cloneNode(true); const removeSelectors = [ 'script', 'style', 'noscript', 'iframe', 'svg', 'canvas', 'video', 'audio', 'nav', 'header', 'footer', 'form', 'button', 'input', 'textarea', 'select', '#tabbit-ai-panel', '#tabbit-ai-float-btn', '#tabbit-settings-modal', '#tabbit-add-rule-modal', '#tabbit-preview-modal' ]; removeSelectors.forEach(selector => { clonedBody.querySelectorAll(selector).forEach(el => el.remove()); }); const candidates = [ 'article', 'main', '.rich_media_content', '#js_content', '.post-content', '.article-content', '.entry-content', '.content', '#content', '.post', '.article', '.articleBody', '.article-body' ]; let bestText = ''; for (const selector of candidates) { const nodes = clonedBody.querySelectorAll(selector); nodes.forEach(node => { const text = cleanText(node.innerText || node.textContent || ''); if (text.length > bestText.length) bestText = text; }); } if (!bestText || bestText.length < 200) { bestText = cleanText(clonedBody.innerText || clonedBody.textContent || ''); } return bestText.substring(0, Number(config.extractMaxChars || 16000)); } function cleanText(text) { return String(text || '') .replace(/\r/g, '') .replace(/[ \t]{2,}/g, ' ') .replace(/\n[ \t]+/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } /****************************************************************** * 消息展示 ******************************************************************/ function appendUserMessage(text) { appendMessage({ type: 'user', label: '我', text }); } function appendAssistantMessage(text, modelLabel) { appendMessage({ type: 'assistant', label: modelLabel || getCurrentModelDisplayName(), text }); } function appendErrorMessage(text) { appendMessage({ type: 'error', label: '错误', text: `⚠️ ${text}` }); } function appendMessage({ type, label, text }) { if (!panelEl) openPanel(false); const list = panelEl.querySelector('#tabbit-chat-list'); if (!list) return; const item = document.createElement('div'); item.className = `tabbit-msg tabbit-msg-${type}`; item.dataset.msgType = type; item.dataset.msgLabel = label || ''; item.innerHTML = `
${escapeHtml(label || '')}
${renderMarkdown(text)}
`; list.appendChild(item); list.scrollTop = list.scrollHeight; } function clearChat() { chatMessages = []; summaryStarted = false; lastRequestPayload = null; const list = panelEl?.querySelector('#tabbit-chat-list'); if (list) list.innerHTML = ''; setStatus(''); updateRetryButton(); } function copyChat() { if (!panelEl) return; const nodes = [ ...panelEl.querySelectorAll('.tabbit-msg-user, .tabbit-msg-assistant') ]; const text = nodes .map(el => { const label = el.dataset.msgLabel || ''; const body = el.querySelector('.tabbit-msg-body')?.innerText?.trim() || ''; return `${label}:\n${body}`; }) .filter(Boolean) .join('\n\n'); copyText(text || ''); setStatus('已复制', 'ok', 1200); } function setInputLoading(loading) { isRequesting = loading; if (!panelEl) return; const sendBtn = panelEl.querySelector('#tabbit-send-btn'); const summaryBtn = panelEl.querySelector('#tabbit-summary-btn'); const stopBtn = panelEl.querySelector('#tabbit-stop-btn'); const retryBtn = panelEl.querySelector('#tabbit-retry-btn'); const input = panelEl.querySelector('#tabbit-user-input'); if (sendBtn) { sendBtn.disabled = loading; sendBtn.textContent = loading ? '等待' : '发送'; } if (summaryBtn) summaryBtn.disabled = loading; if (stopBtn) stopBtn.disabled = !loading; if (retryBtn) retryBtn.disabled = loading || !lastRequestPayload; if (input) input.disabled = loading; } function updateRetryButton() { if (!panelEl) return; const retryBtn = panelEl.querySelector('#tabbit-retry-btn'); if (retryBtn) retryBtn.disabled = isRequesting || !lastRequestPayload; } /****************************************************************** * 加入网址规则弹窗 ******************************************************************/ function openAddUrlRuleModal() { if (!addRuleModalEl) { addRuleModalEl = createAddRuleModal(); document.body.appendChild(addRuleModalEl); } renderAddRuleModal(); addRuleModalEl.classList.remove('tabbit-hidden'); } function closeAddRuleModal() { if (addRuleModalEl) addRuleModalEl.classList.add('tabbit-hidden'); } function createAddRuleModal() { const modal = document.createElement('div'); modal.id = 'tabbit-add-rule-modal'; modal.innerHTML = `
加入当前网址
选择规则
`; modal.querySelector('#tabbit-add-rule-close').addEventListener('click', closeAddRuleModal); modal.querySelector('#tabbit-cancel-add-rule').addEventListener('click', closeAddRuleModal); modal.querySelector('#tabbit-confirm-add-rule').addEventListener('click', () => { const input = modal.querySelector('#tabbit-custom-rule-input'); const templateSelect = modal.querySelector('#tabbit-add-rule-template'); const rule = input.value.trim(); const templateId = templateSelect.value; if (!rule) return; addUrlRule(rule, templateId); closeAddRuleModal(); }); modal.addEventListener('click', e => { if (e.target === modal) closeAddRuleModal(); }); return modal; } function renderAddRuleModal() { const candidates = buildUrlRuleCandidates(location.href); const box = addRuleModalEl.querySelector('#tabbit-rule-candidates'); const input = addRuleModalEl.querySelector('#tabbit-custom-rule-input'); const templateSelect = addRuleModalEl.querySelector('#tabbit-add-rule-template'); box.innerHTML = ''; candidates.forEach((rule, index) => { const item = document.createElement('label'); item.className = 'tabbit-rule-candidate'; item.innerHTML = ` ${escapeHtml(rule)} `; item.querySelector('input').addEventListener('change', () => { input.value = rule; }); box.appendChild(item); }); input.value = candidates[0] || ''; templateSelect.innerHTML = ``; config.promptTemplates.forEach(t => { const option = document.createElement('option'); option.value = t.id; option.textContent = t.name; templateSelect.appendChild(option); }); } /****************************************************************** * 正文预览 ******************************************************************/ function openPreviewModal() { if (!previewModalEl) { previewModalEl = createPreviewModal(); document.body.appendChild(previewModalEl); } const text = lastExtractedText || extractPageContent(); lastExtractedText = text; previewModalEl.querySelector('#tabbit-preview-count').textContent = `${text.length} 字符`; previewModalEl.querySelector('#tabbit-preview-text').value = text; previewModalEl.classList.remove('tabbit-hidden'); } function closePreviewModal() { if (previewModalEl) previewModalEl.classList.add('tabbit-hidden'); } function createPreviewModal() { const modal = document.createElement('div'); modal.id = 'tabbit-preview-modal'; modal.innerHTML = `
正文预览
`; modal.querySelector('#tabbit-preview-close').addEventListener('click', closePreviewModal); modal.querySelector('#tabbit-close-preview').addEventListener('click', closePreviewModal); modal.querySelector('#tabbit-copy-preview').addEventListener('click', () => { copyText(modal.querySelector('#tabbit-preview-text').value || ''); setStatus('正文已复制', 'ok', 1200); }); modal.addEventListener('click', e => { if (e.target === modal) closePreviewModal(); }); return modal; } /****************************************************************** * 设置页面 ******************************************************************/ function openSettings() { if (!settingsEl) { settingsEl = createSettingsModal(); document.body.appendChild(settingsEl); } fillSettingsForm(); settingsEl.classList.remove('tabbit-hidden'); } function closeSettings() { if (settingsEl) settingsEl.classList.add('tabbit-hidden'); } function createSettingsModal() { const modal = document.createElement('div'); modal.id = 'tabbit-settings-modal'; modal.innerHTML = `
⚙️ 饺子 AI 设置
模型预设
提示词模板
指定网址
一行一条规则。不带 * 时按前缀匹配;带 * 时按通配符匹配。每条规则可绑定提示词模板。
配置文件
`; modal.querySelector('#tabbit-settings-close').addEventListener('click', closeSettings); modal.querySelector('#tabbit-cancel-settings').addEventListener('click', closeSettings); modal.querySelector('#tabbit-save-settings').addEventListener('click', saveSettingsFromForm); modal.querySelector('#tabbit-add-model').addEventListener('click', () => { syncModelsFromSettings(); config.models.push({ name: '新模型', value: '', temperature: '', maxTokens: '' }); renderSettingsModels(); }); modal.querySelector('#tabbit-add-template').addEventListener('click', () => { syncTemplatesFromSettings(); config.promptTemplates.push({ id: makeId('tpl'), name: '新模板', text: '请总结这个网页的核心内容。' }); renderSettingsTemplates(); renderSettingsUrlRules(); }); modal.querySelector('#tabbit-settings-add-current-url').addEventListener('click', () => { openAddUrlRuleModal(); }); modal.querySelector('#tabbit-settings-add-empty-url').addEventListener('click', () => { syncUrlRulesFromSettings(); config.urlRules.push(''); renderSettingsUrlRules(); }); modal.querySelector('#tabbit-settings-dedupe-url').addEventListener('click', () => { syncUrlRulesFromSettings(); config.urlRules = normalizeUrlRules(config.urlRules); config.rulePromptBindings = config.rulePromptBindings.filter(b => config.urlRules.includes(b.rule)); renderSettingsUrlRules(); }); modal.querySelector('#tabbit-test-api').addEventListener('click', testApiConnection); modal.querySelector('#tabbit-fetch-models').addEventListener('click', fetchModelsFromApi); modal.querySelector('#tabbit-export-file').addEventListener('click', exportConfigToFile); modal.querySelector('#tabbit-import-file').addEventListener('click', importConfigFromFile); modal.querySelector('#tabbit-reset-config').addEventListener('click', resetConfig); modal.addEventListener('click', e => { if (e.target === modal) closeSettings(); }); return modal; } function fillSettingsForm() { if (!settingsEl) return; settingsEl.querySelector('#tabbit-set-api-url').value = config.apiUrl || ''; settingsEl.querySelector('#tabbit-set-api-key').value = config.apiKey || ''; settingsEl.querySelector('#tabbit-set-temperature').value = config.temperature ?? 0.7; settingsEl.querySelector('#tabbit-set-max-tokens').value = config.maxTokens ?? 2000; settingsEl.querySelector('#tabbit-set-panel-width').value = config.panel?.width || 460; settingsEl.querySelector('#tabbit-set-extract-max').value = config.extractMaxChars || 16000; settingsEl.querySelector('#tabbit-set-auto-run').checked = !!config.autoRun; renderSettingsModels(); renderSettingsTemplates(); renderSettingsUrlRules(); } function renderSettingsModels() { if (!settingsEl) return; const box = settingsEl.querySelector('#tabbit-model-list'); box.innerHTML = ''; config.models.forEach((model, index) => { const row = document.createElement('div'); row.className = 'tabbit-model-row'; row.innerHTML = ` `; row.querySelector('.tabbit-remove-model').addEventListener('click', () => { syncModelsFromSettings(); config.models.splice(index, 1); if (!config.models.length) { config.models.push({ name: 'mimo-v2-flash', value: 'mimo-v2-flash', temperature: '', maxTokens: '' }); } if (!config.models.some(m => m.value === config.currentModel)) { config.currentModel = config.models[0].value; } renderSettingsModels(); }); box.appendChild(row); }); } function syncModelsFromSettings() { if (!settingsEl) return; const rows = [...settingsEl.querySelectorAll('.tabbit-model-row')]; let nextCurrent = config.currentModel; const models = rows .map(row => { const name = row.querySelector('.tabbit-model-name').value.trim(); const value = row.querySelector('.tabbit-model-value').value.trim(); const temperature = row.querySelector('.tabbit-model-temp').value.trim(); const maxTokens = row.querySelector('.tabbit-model-tokens').value.trim(); const checked = row.querySelector('input[type="radio"]').checked; if (checked && value) nextCurrent = value; return { name: name || value, value, temperature, maxTokens }; }) .filter(model => model.value); config.models = normalizeModels(models); if (!config.models.some(m => m.value === nextCurrent)) { nextCurrent = config.models[0]?.value || ''; } config.currentModel = nextCurrent; } function renderSettingsTemplates() { if (!settingsEl) return; const select = settingsEl.querySelector('#tabbit-default-template'); const box = settingsEl.querySelector('#tabbit-template-list'); select.innerHTML = ''; box.innerHTML = ''; config.promptTemplates.forEach(t => { const option = document.createElement('option'); option.value = t.id; option.textContent = t.name; option.selected = t.id === config.defaultPromptTemplateId; select.appendChild(option); }); config.promptTemplates.forEach((tpl, index) => { const row = document.createElement('div'); row.className = 'tabbit-template-row'; row.dataset.templateId = tpl.id; row.innerHTML = `
`; row.querySelector('.tabbit-remove-template').addEventListener('click', () => { syncTemplatesFromSettings(); if (config.promptTemplates.length <= 1) { alert('至少保留一个提示词模板。'); return; } const removed = config.promptTemplates[index]; config.promptTemplates.splice(index, 1); if (removed?.id === config.defaultPromptTemplateId) { config.defaultPromptTemplateId = config.promptTemplates[0]?.id || 'default'; } config.rulePromptBindings = config.rulePromptBindings.filter(b => b.templateId !== removed?.id); renderSettingsTemplates(); renderSettingsUrlRules(); }); box.appendChild(row); }); select.onchange = () => { config.defaultPromptTemplateId = select.value; }; } function syncTemplatesFromSettings() { if (!settingsEl) return; const rows = [...settingsEl.querySelectorAll('.tabbit-template-row')]; config.promptTemplates = normalizePromptTemplates( rows.map(row => ({ id: row.dataset.templateId || makeId('tpl'), name: row.querySelector('.tabbit-template-name').value.trim(), text: row.querySelector('.tabbit-template-text').value.trim() })) ); const defaultSelect = settingsEl.querySelector('#tabbit-default-template'); if (defaultSelect?.value) { config.defaultPromptTemplateId = defaultSelect.value; } if (!config.promptTemplates.some(t => t.id === config.defaultPromptTemplateId)) { config.defaultPromptTemplateId = config.promptTemplates[0]?.id || 'default'; } } function renderSettingsUrlRules() { if (!settingsEl) return; const box = settingsEl.querySelector('#tabbit-url-rule-list'); box.innerHTML = ''; config.urlRules.forEach((rule, index) => { const row = document.createElement('div'); row.className = 'tabbit-url-rule-row'; const matched = rule && testUrlRule(location.href, rule); row.innerHTML = ` ${matched ? '匹配当前页' : ''} `; const select = row.querySelector('.tabbit-url-rule-template'); const currentTemplateId = getTemplateIdForRule(rule); config.promptTemplates.forEach(t => { const option = document.createElement('option'); option.value = t.id; option.textContent = t.name; option.selected = t.id === currentTemplateId; select.appendChild(option); }); row.querySelector('.tabbit-remove-url-rule').addEventListener('click', () => { syncUrlRulesFromSettings(); const removedRule = config.urlRules[index]; config.urlRules.splice(index, 1); config.rulePromptBindings = config.rulePromptBindings.filter(b => b.rule !== removedRule); renderSettingsUrlRules(); }); box.appendChild(row); }); } function syncUrlRulesFromSettings() { if (!settingsEl) return; const rows = [...settingsEl.querySelectorAll('.tabbit-url-rule-row')]; const rules = []; const bindings = []; rows.forEach(row => { const rule = row.querySelector('.tabbit-url-rule-input').value.trim(); const templateId = row.querySelector('.tabbit-url-rule-template').value; if (!rule) return; rules.push(rule); if (templateId) { bindings.push({ rule, templateId }); } }); config.urlRules = normalizeUrlRules(rules); config.rulePromptBindings = normalizeRulePromptBindings(bindings); } function saveSettingsFromForm() { syncModelsFromSettings(); syncTemplatesFromSettings(); syncUrlRulesFromSettings(); config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim(); config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim(); config.temperature = Number(settingsEl.querySelector('#tabbit-set-temperature').value || 0.7); config.maxTokens = Number(settingsEl.querySelector('#tabbit-set-max-tokens').value || 2000); config.autoRun = settingsEl.querySelector('#tabbit-set-auto-run').checked; config.extractMaxChars = Number(settingsEl.querySelector('#tabbit-set-extract-max').value || 16000); config.panel = { ...config.panel, width: Math.max(320, Number(settingsEl.querySelector('#tabbit-set-panel-width').value || 460)) }; if (!config.currentModel && config.models.length) { config.currentModel = config.models[0].value; } saveConfig(); renderModelSelect(); applyFloatButtonPosition(); if (panelEl) { panelEl.style.width = config.panel.width + 'px'; } closeSettings(); setStatus('设置已保存', 'ok', 1200); } /****************************************************************** * API 测试与模型拉取 ******************************************************************/ async function testApiConnection() { syncModelsFromSettings(); config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim(); config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim(); config.temperature = Number(settingsEl.querySelector('#tabbit-set-temperature').value || 0.7); config.maxTokens = Number(settingsEl.querySelector('#tabbit-set-max-tokens').value || 2000); if (!config.apiUrl || !config.apiKey || !config.currentModel) { alert('请先填写 API 地址、API Key,并设置当前模型。'); return; } try { const btn = settingsEl.querySelector('#tabbit-test-api'); btn.disabled = true; btn.textContent = '测试中…'; await callChatApi([ { role: 'user', content: '请只回复 OK' } ]); alert('API 测试成功。'); } catch (err) { alert('API 测试失败:\n\n' + (err.message || String(err))); } finally { const btn = settingsEl.querySelector('#tabbit-test-api'); btn.disabled = false; btn.textContent = '测试 API'; setInputLoading(false); } } function buildModelsUrl(apiUrl) { const url = new URL(apiUrl); if (/\/chat\/completions\/?$/i.test(url.pathname)) { url.pathname = url.pathname.replace(/\/chat\/completions\/?$/i, '/models'); return url.toString(); } if (/\/completions\/?$/i.test(url.pathname)) { url.pathname = url.pathname.replace(/\/completions\/?$/i, '/models'); return url.toString(); } url.pathname = url.pathname.replace(/\/+$/, '') + '/models'; return url.toString(); } function fetchModelsFromApi() { config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim(); config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim(); if (!config.apiUrl || !config.apiKey) { alert('请先填写 API 地址和 API Key。'); return; } let modelsUrl = ''; try { modelsUrl = buildModelsUrl(config.apiUrl); } catch (err) { alert('API 地址格式不正确。'); return; } const btn = settingsEl.querySelector('#tabbit-fetch-models'); btn.disabled = true; btn.textContent = '获取中…'; GM_xmlhttpRequest({ method: 'GET', url: modelsUrl, headers: { Authorization: `Bearer ${config.apiKey}` }, timeout: 60000, onload(res) { btn.disabled = false; btn.textContent = '获取模型列表'; try { if (res.status < 200 || res.status >= 300) { alert(`获取失败:${res.status}\n${res.responseText || ''}`); return; } const data = JSON.parse(res.responseText); const ids = Array.isArray(data?.data) ? data.data.map(x => x.id || x.name || x.model).filter(Boolean) : []; if (!ids.length) { alert('没有从响应中识别到模型列表。'); return; } syncModelsFromSettings(); ids.forEach(id => { if (!config.models.some(m => m.value === id)) { config.models.push({ name: id, value: id, temperature: '', maxTokens: '' }); } }); if (!config.currentModel) { config.currentModel = config.models[0]?.value || ''; } renderSettingsModels(); alert(`已获取 ${ids.length} 个模型。`); } catch (err) { alert('解析模型列表失败:' + err.message); } }, onerror(err) { btn.disabled = false; btn.textContent = '获取模型列表'; alert('获取模型列表失败:' + JSON.stringify(err)); }, ontimeout() { btn.disabled = false; btn.textContent = '获取模型列表'; alert('获取模型列表超时。'); } }); } /****************************************************************** * 文件导入导出 ******************************************************************/ function exportConfigToFile() { try { saveConfig(); const data = JSON.stringify(config, null, 2); const blob = new Blob([data], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const date = new Date(); const pad = n => String(n).padStart(2, '0'); const fileName = `tabbit-ai-config-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}.json`; a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { alert('导出失败:' + err.message); } } function importConfigFromFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.style.cssText = 'position:fixed;left:-9999px;top:-9999px;'; document.body.appendChild(input); input.addEventListener('change', () => { const file = input.files && input.files[0]; if (!file) { input.remove(); return; } const reader = new FileReader(); reader.onload = e => { try { const imported = JSON.parse(e.target.result); config = mergeConfig(clone(DEFAULT_CONFIG), imported); saveConfig(); if (settingsEl) fillSettingsForm(); if (panelEl) renderModelSelect(); applyFloatButtonPosition(); alert('配置导入成功。'); } catch (err) { alert('导入失败:JSON 格式错误。\n\n' + err.message); } finally { input.remove(); } }; reader.onerror = () => { alert('读取文件失败。'); input.remove(); }; reader.readAsText(file, 'utf-8'); }); input.click(); } /****************************************************************** * 工具函数 ******************************************************************/ function copyText(text) { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text); return; } if (navigator.clipboard) { navigator.clipboard.writeText(text); return; } const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } function renderMarkdown(text) { let html = escapeHtml(text || ''); html = html.replace(/^###### (.+)$/gm, '
$1
'); html = html.replace(/^##### (.+)$/gm, '
$1
'); html = html.replace(/^#### (.+)$/gm, '

$1

'); html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1' ); html = html .split('\n') .map(line => { if (/^• ${line.replace(/^\s*[-*]\s+/, '')}`; } if (!line.trim()) return '
'; return `

${line}

`; }) .join(''); return html; } function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeAttr(str) { return escapeHtml(str).replace(/`/g, '`'); } /****************************************************************** * 样式 ******************************************************************/ function createStyles() { if (document.querySelector('#tabbit-ai-style')) return; const style = document.createElement('style'); style.id = 'tabbit-ai-style'; style.textContent = ` #tabbit-ai-float-btn { position: fixed; z-index: 2147483645; width: 28px; height: 72px; border: none; padding: 0; margin: 0; cursor: pointer; background: linear-gradient(160deg, #667eea 0%, #764ba2 100%); color: #fff; font-size: 13px; font-weight: 700; box-shadow: 0 4px 16px rgba(0, 0, 0, .18); transition: opacity .2s ease, width .2s ease, filter .2s ease, box-shadow .2s ease; user-select: none; writing-mode: vertical-rl; letter-spacing: 2px; } #tabbit-ai-float-btn.tabbit-float-right { border-radius: 10px 0 0 10px; } #tabbit-ai-float-btn.tabbit-float-left { border-radius: 0 10px 10px 0; } #tabbit-ai-float-btn:hover { opacity: 1 !important; width: 38px; filter: brightness(1.05); box-shadow: 0 6px 22px rgba(0, 0, 0, .25); } #tabbit-ai-float-btn span { pointer-events: none; } body.tabbit-dragging, body.tabbit-dragging * { cursor: grabbing !important; } #tabbit-ai-panel { position: fixed; top: 20px; right: 20px; max-width: calc(100vw - 40px); max-height: calc(100vh - 40px); background: #fff; color: #222; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,.18); z-index: 2147483646; display: flex; flex-direction: column; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .tabbit-hidden { display: none !important; } .tabbit-header, .tabbit-settings-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; } .tabbit-title, .tabbit-settings-title { font-weight: 700; font-size: 16px; } .tabbit-header-actions { display: flex; align-items: center; gap: 8px; } .tabbit-model-select { max-width: 150px; border: none; border-radius: 8px; padding: 5px 8px; font-size: 12px; } .tabbit-icon-btn { border: none; border-radius: 8px; background: rgba(255,255,255,.18); color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 6px 9px; } .tabbit-icon-btn:hover { background: rgba(255,255,255,.28); } .tabbit-toolbar { display: flex; flex-wrap: wrap; gap: 7px; padding: 9px 10px; border-bottom: 1px solid #eee; background: #fafafa; } .tabbit-primary-btn, .tabbit-secondary-btn, .tabbit-danger-btn { border: none; border-radius: 8px; padding: 7px 10px; cursor: pointer; font-size: 13px; } .tabbit-primary-btn { background: #667eea; color: #fff; } .tabbit-primary-btn:hover { background: #5a6fd6; } .tabbit-secondary-btn { background: #f0f0f5; color: #444; } .tabbit-secondary-btn:hover { background: #e6e6ef; } .tabbit-danger-btn { background: #fff1f1; color: #c00000; border: 1px solid #ffcaca; } .tabbit-danger-btn:hover { background: #ffe1e1; } .tabbit-primary-btn:disabled, .tabbit-secondary-btn:disabled { opacity: .55; cursor: not-allowed; } .tabbit-status-bar { padding: 6px 12px; font-size: 12px; border-bottom: 1px solid #eee; background: #f8f8fc; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tabbit-status-loading { color: #5a43c8; background: #f4f1ff; } .tabbit-status-ok { color: #0d7a3a; background: #effaf3; } .tabbit-status-error { color: #b00000; background: #fff1f1; } .tabbit-chat-list { flex: 1; overflow-y: auto; padding: 14px; background: #f7f8fb; } .tabbit-msg { margin-bottom: 12px; } .tabbit-msg-label { font-size: 12px; color: #777; margin-bottom: 4px; } .tabbit-msg-body { border-radius: 12px; padding: 10px 12px; font-size: 14px; line-height: 1.65; word-break: break-word; } .tabbit-msg-body p { margin: 6px 0; } .tabbit-msg-body h1, .tabbit-msg-body h2, .tabbit-msg-body h3, .tabbit-msg-body h4 { margin: 10px 0 6px; line-height: 1.4; } .tabbit-msg-body code { background: #ececf4; padding: 2px 5px; border-radius: 4px; color: #c7254e; } .tabbit-msg-body a { color: #667eea; } .tabbit-list-item { margin: 4px 0; } .tabbit-msg-user .tabbit-msg-body { background: #e8f0ff; border: 1px solid #d8e4ff; } .tabbit-msg-assistant .tabbit-msg-body { background: #fff; border: 1px solid #e9e9ef; } .tabbit-msg-error .tabbit-msg-body { background: #fff1f1; border: 1px solid #ffc9c9; color: #b00000; } .tabbit-input-area { display: flex; gap: 8px; padding: 12px; background: #fff; border-top: 1px solid #eee; } #tabbit-user-input { flex: 1; resize: none; min-height: 42px; max-height: 120px; border: 1px solid #ddd; border-radius: 10px; padding: 9px 10px; font-size: 14px; line-height: 1.5; outline: none; } #tabbit-user-input:focus { border-color: #667eea; box-shadow: 0 0 0 2px rgba(102,126,234,.12); } #tabbit-send-btn { width: 72px; border: none; border-radius: 10px; background: #667eea; color: #fff; cursor: pointer; } #tabbit-send-btn:disabled { opacity: .65; cursor: not-allowed; } #tabbit-settings-modal, #tabbit-add-rule-modal, #tabbit-preview-modal { position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 2147483647; display: flex; align-items: center; justify-content: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .tabbit-settings-card { width: 860px; max-width: calc(100vw - 36px); max-height: 88vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,.28); } .tabbit-small-card { width: 620px; max-width: calc(100vw - 36px); max-height: 86vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,.28); } .tabbit-preview-card { width: 820px; max-width: calc(100vw - 36px); height: 80vh; display: flex; flex-direction: column; background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,.28); } .tabbit-settings-body { padding: 16px 18px; overflow-y: auto; } .tabbit-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; font-size: 13px; color: #444; } .tabbit-field small, .tabbit-help { color: #888; line-height: 1.5; font-size: 12px; } .tabbit-checkbox-field { flex-direction: row; align-items: center; } .tabbit-checkbox-field input { width: auto !important; } .tabbit-field input, .tabbit-field textarea, .tabbit-field select, .tabbit-url-rule-row input, .tabbit-url-rule-row select, .tabbit-model-row input, .tabbit-template-row input, .tabbit-template-row textarea { width: 100%; box-sizing: border-box; border: 1px solid #ddd; border-radius: 10px; padding: 8px 9px; font-size: 13px; outline: none; font-family: inherit; } .tabbit-field textarea, .tabbit-template-row textarea { resize: vertical; } .tabbit-field input:focus, .tabbit-field textarea:focus, .tabbit-field select:focus, .tabbit-url-rule-row input:focus, .tabbit-url-rule-row select:focus, .tabbit-model-row input:focus, .tabbit-template-row input:focus, .tabbit-template-row textarea:focus { border-color: #667eea; box-shadow: 0 0 0 2px rgba(102,126,234,.12); } .tabbit-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .tabbit-section-title { margin: 18px 0 10px; font-weight: 700; font-size: 14px; color: #333; } .tabbit-model-list, .tabbit-template-list, .tabbit-url-rule-list { display: flex; flex-direction: column; gap: 8px; } .tabbit-model-row { display: grid; grid-template-columns: 1fr 1.3fr 90px 100px auto 34px; gap: 8px; align-items: center; } .tabbit-current-model { display: flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 12px; } .tabbit-remove-model, .tabbit-remove-url-rule, .tabbit-remove-template { width: 34px; height: 34px; border: none; border-radius: 8px; background: #f2f2f2; cursor: pointer; font-size: 18px; color: #666; } .tabbit-remove-model:hover, .tabbit-remove-url-rule:hover, .tabbit-remove-template:hover { background: #ffe8e8; color: #c00; } .tabbit-template-row { padding: 10px; border: 1px solid #eee; border-radius: 12px; background: #fafafa; } .tabbit-template-head { display: grid; grid-template-columns: 1fr 34px; gap: 8px; margin-bottom: 8px; } .tabbit-url-rule-row { display: grid; grid-template-columns: 1.6fr 150px 84px 34px; gap: 8px; align-items: center; padding: 8px; border: 1px solid #eee; border-radius: 12px; background: #fafafa; } .tabbit-rule-match { font-size: 12px; color: #aaa; white-space: nowrap; } .tabbit-rule-match.matched { color: #0d7a3a; } .tabbit-settings-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 14px; } .tabbit-settings-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 18px; border-top: 1px solid #eee; background: #fafafa; } .tabbit-rule-candidates { display: flex; flex-direction: column; gap: 8px; } .tabbit-rule-candidate { display: flex; gap: 8px; align-items: flex-start; padding: 8px 10px; border: 1px solid #eee; border-radius: 10px; background: #fafafa; font-size: 13px; word-break: break-all; cursor: pointer; } #tabbit-preview-text { width: 100%; height: calc(80vh - 150px); box-sizing: border-box; resize: none; border: 1px solid #ddd; border-radius: 10px; padding: 12px; font-size: 13px; line-height: 1.6; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } #tabbit-preview-count { font-size: 12px; opacity: .85; margin-left: 8px; } @media (max-width: 640px) { #tabbit-ai-panel { top: 10px; right: 10px; left: 10px; width: auto !important; max-width: none; height: 82vh !important; } .tabbit-row-2, .tabbit-model-row, .tabbit-url-rule-row { grid-template-columns: 1fr; } .tabbit-settings-card, .tabbit-small-card, .tabbit-preview-card { max-width: calc(100vw - 20px); max-height: 92vh; } } `; document.head.appendChild(style); } })();