// ==UserScript== // @name 饺子 AI 网页摘要助手 // @namespace https://github.com/moonjoin/tampermonkey-scripts // @version 3.0.10 // @description 指定网站自动弹出 AI 网页摘要,支持连续对话、多预设、多模板、SPA路由、摘要生图、flomo、坚果云双文件云同步。Shadow DOM 隔离样式。 // @author 次元饺子 // @icon https://img.icons8.com/?size=100&id=90385&format=png&color=000000 // @match *://*/* // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect * // @run-at document-idle // @license MIT // @downloadURL https://update.greasyfork.org/scripts/575620/%E9%A5%BA%E5%AD%90%20AI%20%E7%BD%91%E9%A1%B5%E6%91%98%E8%A6%81%E5%8A%A9%E6%89%8B.user.js // @updateURL https://update.greasyfork.org/scripts/575620/%E9%A5%BA%E5%AD%90%20AI%20%E7%BD%91%E9%A1%B5%E6%91%98%E8%A6%81%E5%8A%A9%E6%89%8B.meta.js // ==/UserScript== (function () { 'use strict'; /****************************************************************** * 0. 常量 & Shadow DOM 容器 ******************************************************************/ const STORAGE_KEY = 'tabbit_ai_summary_config_v2'; const PANEL_ID = 'tabbit-ai-panel'; const FLOAT_BTN_ID = 'tabbit-ai-float-btn'; const SETTINGS_ID = 'tabbit-ai-settings'; const STYLE_ID = 'tabbit-ai-style'; // Shadow DOM 宿主 & 根 let shadowHost = null; let shadowRoot = null; let shadowContainer = null; const DEFAULT_PROMPT_TEXT = '请阅读这个网页,并为我提供一份结构化的中文摘要。' + '\n\n请按以下格式输出:' + '\n\n## 一句话总结\n用一句话说明这个网页讲了什么。' + '\n\n## 核心要点\n用 3-5 个要点列出这个网页的核心信息。' + '\n\n## 值得关注的细节\n如果有数据、案例、引用、关键人物,请单独列出。' + '\n\n## 我的解读建议\n如果可能,给出一段独立思考或建议(可选)。'; const IMAGE_GEN_PROMPT_TEXT = '根据以下网页摘要,生成一张信息可视化的精美配图,风格清晰美观,适合作为网页摘要的封面图:\n\n{summary}'; const IMAGE_GEN_SUMMARY_MAX_LEN = 5000; const DEFAULT_PROFILE = { id: 'default', name: '默认配置', 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: '' } ] }; const DEFAULT_CONFIG = { profiles: [clone(DEFAULT_PROFILE)], currentProfileId: 'default', flomoApiUrl: '', flomoAutoSend: false, 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://sspai.com/post/*', 'https://www.ifanr.com/*' ], rulePromptBindings: [], autoRun: true, floatButton: { layoutVersion: 1, side: 'right', x: null, y: null, offsetX: 16, offsetY: 70, opacity: 1 }, panel: { width: 420, height: null, heightRatio: 0.8, left: null, top: null }, extractMaxChars: 16000, cloudSync: { account: '', appPassword: '', lastSyncAt: 0, lastSyncDirection: '' }, autoCopy: { enabled: true, withSource: false }, enableImageGen: false, imageGenApiUrl: '', imageGenApiKey: '', imageGenModel: 'gemini-3.1-flash-image-preview', imageGenSize: '1024x1024', enableImageAutoDownload: true, imageGenPromptText: IMAGE_GEN_PROMPT_TEXT, flomoTags: '#饺子AI摘要', silentAutoRun: false, enableThinking: true, systemPromptMd: '', systemPromptMdEnabled: false, systemPromptMdAsSystem: true, // 🆕 可调参数 apiTimeout: 120000, acCooldown: 2000, acMinLen: 2, acTempMinutes: 10 }; function clone(obj) { return JSON.parse(JSON.stringify(obj)); } /****************************************************************** * 1. 内联 Markdown 渲染器 ******************************************************************/ const _md = (function () { function escapeHtml(str) { var _amp = '&' + 'amp;', _lt = '&' + 'lt;', _gt = '&' + 'gt;', _qt = '&' + 'quot;', _39 = '&' + '#39;'; return String(str) .replace(/&/g, _amp).replace(//g, _gt) .replace(/"/g, _qt).replace(/'/g, _39); } function escapeAttr(str) { return escapeHtml(str); } function safeUrl(raw, image) { const value = String(raw || '').trim(); const compact = value.replace(/[\u0000-\u0020\u007f]+/g, ''); if (/^(https?:\/\/|\/|\.\.?\/|#)/i.test(compact)) return escapeAttr(value); if (image && /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(compact)) return escapeAttr(value); if (!image && /^mailto:/i.test(compact)) return escapeAttr(value); return '#'; } function findClosing(text, start, open, close) { let depth = 1; for (let i = start; i < text.length; i++) { if (text[i] === '\\') { i++; continue; } if (text[i] === open) depth++; else if (text[i] === close && --depth === 0) return i; } return -1; } function renderInline(text) { const tokens = []; function token(html) { const id = '\u0000MD' + tokens.length + '\u0000'; tokens.push(html); return id; } let source = String(text || '').replace(/`([^`\n]+?)`/g, (_, code) => token('' + escapeHtml(code) + '')); let linked = '', cursor = 0; while (cursor < source.length) { let start = source.indexOf('[', cursor), image = false; const imageStart = source.indexOf('![', cursor); if (imageStart >= 0 && (start < 0 || imageStart <= start)) { start = imageStart; image = true; } if (start < 0) { linked += source.slice(cursor); break; } linked += source.slice(cursor, start); const labelStart = start + (image ? 2 : 1); const labelEnd = findClosing(source, labelStart, '[', ']'); if (labelEnd < 0 || source[labelEnd + 1] !== '(') { linked += source.slice(start, labelStart); cursor = labelStart; continue; } const targetEnd = findClosing(source, labelEnd + 2, '(', ')'); if (targetEnd < 0) { linked += source.slice(start, labelStart); cursor = labelStart; continue; } const label = source.slice(labelStart, labelEnd).replace(/\\([\[\]])/g, '$1'); const targetRaw = source.slice(labelEnd + 2, targetEnd).trim(); const target = targetRaw.replace(/\s+(?:"[^"]*"|'[^']*')\s*$/, '').trim(); linked += image ? token('' + escapeAttr(label) + '') : token('' + escapeHtml(label) + ''); cursor = targetEnd + 1; } source = linked; source = source.replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, (_, ch) => token(escapeHtml(ch))); let s = escapeHtml(source); s = s.replace(/\*\*([^\*]+?)\*\*/g, '$1'); s = s.replace(/__([^_]+?)__/g, '$1'); s = s.replace(/(^|[^\*])\*([^\*\n]+?)\*(?!\*)/g, '$1$2'); s = s.replace(/(^|[^_])_([^_\n]+?)_(?!_)/g, '$1$2'); s = s.replace(/~~([^~]+?)~~/g, '$1'); s = s.replace(/(^|[\s>])(https?:\/\/[^\s<]+)/g, (_, lead, url) => { let tail = ''; while (/[.,!?;:]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } return lead + '' + url + '' + tail; }); s = s.replace(/\u0000MD(\d+)\u0000/g, (_, idx) => tokens[Number(idx)] || ''); return s; } function parseTableRow(line) { let s = line.trim(); if (s.startsWith('|')) s = s.slice(1); if (s.endsWith('|')) s = s.slice(0, -1); const cells = []; let buf = '', inCode = false; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === '\\' && s[i + 1] === '|') { buf += '|'; i++; continue; } if (ch === '`') { inCode = !inCode; buf += ch; continue; } if (ch === '|' && !inCode) { cells.push(buf.trim()); buf = ''; continue; } buf += ch; } cells.push(buf.trim()); return cells; } function isTableSeparator(line) { if (!/\|/.test(line)) return false; const cells = parseTableRow(line); if (cells.length === 0) return false; return cells.every(c => /^:?-{1,}:?$/.test(c.trim())); } function parseAligns(sepLine) { return parseTableRow(sepLine).map(c => { const t = c.trim(); const left = t.startsWith(':'); const right = t.endsWith(':'); if (left && right) return 'center'; if (right) return 'right'; if (left) return 'left'; return ''; }); } return function parse(md) { if (!md) return ''; md = String(md).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = md.split('\n'); let html = '', i = 0, inCode = false, codeLang = '', codeBuf = []; let listStack = []; function closeAllLists() { while (listStack.length) html += ''; } while (i < lines.length) { const line = lines[i]; const fence = line.match(/^```\s*([^\s`]*)[^`]*$/); if (fence) { if (!inCode) { closeAllLists(); inCode = true; codeLang = fence[1] || ''; codeBuf = []; } else { html += '
' + escapeHtml(codeBuf.join('\n')) + '
'; inCode = false; codeLang = ''; codeBuf = []; } i++; continue; } if (inCode) { codeBuf.push(line); i++; continue; } if (/^\s*$/.test(line)) { let next = i + 1; while (next < lines.length && /^\s*$/.test(lines[next])) next++; if (listStack.length && next < lines.length && (/^\s+\S/.test(lines[next]) || /^(\s*)(?:[-*+]|\d+\.)\s+/.test(lines[next]))) { i++; continue; } closeAllLists(); i++; continue; } if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) { closeAllLists(); const headers = parseTableRow(line); const aligns = parseAligns(lines[i + 1]); i += 2; const rows = []; while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) { if (/^```/.test(lines[i]) || /^#{1,6}\s+/.test(lines[i])) break; rows.push(parseTableRow(lines[i])); i++; } let t = '
'; headers.forEach((h, idx) => { const a = aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''; t += `${renderInline(h)}`; }); t += ''; rows.forEach(r => { t += ''; for (let c = 0; c < headers.length; c++) { const cell = r[c] != null ? r[c] : ''; const a = aligns[c] ? ` style="text-align:${aligns[c]}"` : ''; t += `${renderInline(cell)}`; } t += ''; }); t += '
'; html += t; continue; } const h = line.match(/^(#{1,6})\s+(.*)$/); if (h) { closeAllLists(); const lv = h[1].length; html += '' + renderInline(h[2].trim()) + ''; i++; continue; } if (/^\s*([-*_])\s*\1\s*\1[-*_\s]*$/.test(line)) { closeAllLists(); html += '
'; i++; continue; } if (/^\s*>\s?/.test(line)) { closeAllLists(); let buf = []; while (i < lines.length && /^\s*>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += '
' + parse(buf.join('\n')) + '
'; continue; } const ul = line.match(/^(\s*)[-*+]\s+(.*)$/); const ol = line.match(/^(\s*)(\d+)\.\s+(.*)$/); if (ul || ol) { const m = ul || ol; const type = ul ? 'ul' : 'ol'; const indent = m[1].length; const content = ul ? m[2] : m[3]; const startNumber = ol ? Number(m[2]) : 1; while (listStack.length && listStack[listStack.length - 1].indent > indent) html += ''; if (listStack.length && listStack[listStack.length - 1].indent === indent && listStack[listStack.length - 1].type !== type) html += ''; if (!listStack.length || listStack[listStack.length - 1].indent < indent) { html += '<' + type + (type === 'ol' && startNumber !== 1 ? ' start="' + startNumber + '"' : '') + '>
  • '; listStack.push({ type: type, indent: indent }); } else html += '
  • '; const task = content.match(/^\[([ xX])\]\s+(.*)$/); html += task ? ' ' + renderInline(task[2]) : renderInline(content); i++; continue; } if (listStack.length && /^\s+\S/.test(line)) { html += '
    ' + renderInline(line.trim()); i++; continue; } closeAllLists(); let pBuf = [line]; i++; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```/.test(lines[i]) && !/^#{1,6}\s+/.test(lines[i]) && !/^\s*>\s?/.test(lines[i]) && !/^(\s*)[-*+]\s+/.test(lines[i]) && !/^(\s*)\d+\.\s+/.test(lines[i])) { if (/\|/.test(lines[i]) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) break; pBuf.push(lines[i]); i++; } html += '

    ' + renderInline(pBuf.join(' ').trim()) + '

    '; } if (inCode) html += '
    ' + escapeHtml(codeBuf.join('\n')) + '
    '; closeAllLists(); return html; }; })(); /****************************************************************** * 2. 工具函数 ******************************************************************/ function makeId(prefix) { return (prefix || 'id') + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7); } function escapeAttr(v) { var _amp = '&' + 'amp;', _lt = '&' + 'lt;', _gt = '&' + 'gt;', _qt = '&' + 'quot;'; return String(v ?? '').replace(/&/g, _amp).replace(/"/g, _qt).replace(//g, _gt); } function buildModelsUrl(apiUrl) { if (apiUrl.includes('/chat/completions')) return apiUrl.replace(/\/chat\/completions.*$/, '/models'); if (apiUrl.endsWith('/')) return apiUrl + 'models'; return apiUrl + '/v1/models'; } function normalizeImageSizeInput(raw) { const value = String(raw || '').trim().replace(/[×**]/g, 'x').toLowerCase(); if (!value) return '1024x1024'; if (value === 'auto') return value; if (!/^\d{2,5}x\d{2,5}$/.test(value)) return ''; return value; } function normalizeApiUrlInput(apiUrl) { return String(apiUrl || '').trim().replace(/\/+$/, ''); } function escapeRegExp(text) { return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function truncateText(text, maxLen) { const s = String(text || '').trim(); const max = Number(maxLen || 0); if (!max || s.length <= max) return s; return s.slice(0, max) + `\n\n(已截断到 ${max} 字符)`; } function buildApiUrl(apiUrl, endpointPath) { const endpoint = String(endpointPath || '').replace(/^\/+/, ''); let url = normalizeApiUrlInput(apiUrl); if (!url || !endpoint) return url; const exactEndpointReg = new RegExp('/' + escapeRegExp(endpoint) + '$', 'i'); if (exactEndpointReg.test(url)) return url; const versionMatch = url.match(/^(.*\/v\d+)(?:\/.*)?$/i); if (versionMatch) return versionMatch[1] + '/' + endpoint; if (/\/(?:chat\/completions|completions|images\/generations|images\/edits|responses|models)$/i.test(url)) { return url.replace(/\/(?:chat\/completions|completions|images\/generations|images\/edits|responses|models)$/i, '/' + endpoint); } return url + '/' + endpoint; } function formatApiError(status, body) { let msg = `HTTP ${status}`; try { const data = JSON.parse(body); if (data?.error?.message) msg += `\n${data.error.message}`; else msg += `\n${body.substring(0, 200)}`; } catch (e) { msg += `\n${(body || '').substring(0, 200)}`; } return msg; } function urlPatternToRegExp(pattern) { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); return new RegExp('^' + escaped + '$'); } function matchUrl(url, patterns) { return patterns.some(p => { try { return urlPatternToRegExp(p).test(url); } catch (e) { return false; } }); } function sanitizeFilename(str) { return String(str || '').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim(); } function writeClipboard(text) { if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text); } /****************************************************************** * 3. 配置加载 / 保存 / 归一化 ******************************************************************/ function normalizeModels(models) { if (!Array.isArray(models)) return []; return models.filter(m => m && m.value).map(m => ({ name: String(m.name || m.value).trim(), value: String(m.value).trim(), temperature: m.temperature === '' || m.temperature == null ? '' : String(m.temperature), maxTokens: m.maxTokens === '' || m.maxTokens == null ? '' : String(m.maxTokens) })); } function normalizeUrlRules(rules) { if (!Array.isArray(rules)) return []; const seen = new Set(), out = []; rules.forEach(r => { const v = String(r || '').trim(); if (v && !seen.has(v)) { seen.add(v); out.push(v); } }); return out; } function normalizePromptTemplates(templates) { if (!Array.isArray(templates)) return []; const out = [], usedIds = new Set(); templates.forEach(t => { if (!t) return; const name = String(t.name || '').trim(); const text = String(t.text || '').trim(); if (!name || !text) return; let id = String(t.id || '').trim(); if (!id || usedIds.has(id)) id = makeId('tpl'); usedIds.add(id); out.push({ id, name, text }); }); return out; } function normalizeRulePromptBindings(bindings) { if (!Array.isArray(bindings)) return []; const out = [], seen = new Set(); bindings.forEach(b => { if (!b) return; const rule = String(b.rule || '').trim(); const templateId = String(b.templateId || '').trim(); if (!rule || !templateId || seen.has(rule)) return; seen.add(rule); out.push({ rule, templateId }); }); return out; } function normalizeProfiles(profiles) { if (!Array.isArray(profiles) || !profiles.length) return [clone(DEFAULT_PROFILE)]; const result = []; profiles.forEach(p => { if (!p || typeof p !== 'object') return; const id = String(p.id || '').trim() || makeId('prof'); const item = { id, name: String(p.name || '').trim() || '未命名配置', apiUrl: String(p.apiUrl || '').trim(), apiKey: String(p.apiKey || '').trim(), currentModel: String(p.currentModel || '').trim(), temperature: Number(p.temperature ?? 0.7), maxTokens: Number(p.maxTokens ?? 2000), models: normalizeModels(p.models) }; if (!item.currentModel && item.models.length) item.currentModel = item.models[0].value; if (!result.some(x => x.id === id)) result.push(item); }); if (!result.length) result.push(clone(DEFAULT_PROFILE)); return result; } function mergeConfig(base, saved) { const result = { ...base, ...saved }; 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 = []; if (saved.promptText && !saved.promptTemplates) { result.promptTemplates = [ { id: 'default', name: '默认总结', text: saved.promptText }, ...base.promptTemplates.filter(t => t.id !== 'default') ]; } if (!Array.isArray(saved.profiles)) { const legacyHasApi = !!(saved.apiUrl || saved.apiKey || saved.models); if (legacyHasApi) { result.profiles = [{ id: 'default', name: '默认配置(已迁移)', apiUrl: saved.apiUrl || '', apiKey: saved.apiKey || '', currentModel: saved.currentModel || '', temperature: Number(saved.temperature ?? 0.7), maxTokens: Number(saved.maxTokens ?? 2000), models: normalizeModels(saved.models) }]; result.currentProfileId = 'default'; } else { result.profiles = base.profiles; result.currentProfileId = base.currentProfileId; } } result.profiles = normalizeProfiles(result.profiles); if (!result.profiles.some(p => p.id === result.currentProfileId)) { result.currentProfileId = result.profiles[0].id; } 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 || {}) }; const savedCloud = saved.cloudSync || {}; result.cloudSync = { account: typeof savedCloud.account === 'string' ? savedCloud.account : '', appPassword: typeof savedCloud.appPassword === 'string' ? savedCloud.appPassword : '', lastSyncAt: Number(savedCloud.lastSyncAt || 0), lastSyncDirection: savedCloud.lastSyncDirection || '' }; if (!result.defaultPromptTemplateId || !result.promptTemplates.some(t => t.id === result.defaultPromptTemplateId)) { result.defaultPromptTemplateId = result.promptTemplates[0]?.id || 'default'; } result.extractMaxChars = Number(result.extractMaxChars || 16000); result.autoCopy = { ...base.autoCopy, ...(saved.autoCopy || {}) }; result.enableImageGen = saved.enableImageGen === true; result.imageGenApiUrl = String(saved.imageGenApiUrl || ''); result.imageGenApiKey = String(saved.imageGenApiKey || ''); result.imageGenModel = String(saved.imageGenModel || base.imageGenModel || '').trim() || base.imageGenModel; result.imageGenSize = normalizeImageSizeInput(saved.imageGenSize || base.imageGenSize) || base.imageGenSize; result.enableImageAutoDownload = saved.enableImageAutoDownload !== false; result.imageGenPromptText = String(saved.imageGenPromptText || base.imageGenPromptText || IMAGE_GEN_PROMPT_TEXT); // 🆕 可调参数归一化 result.apiTimeout = Number(result.apiTimeout || 120000); result.acCooldown = Number(result.acCooldown || 2000); result.acMinLen = Number(result.acMinLen || 2); result.acTempMinutes = Number(result.acTempMinutes || 10); result.silentAutoRun = saved.silentAutoRun === true; result.enableThinking = saved.enableThinking !== false; return result; } function loadConfig() { try { if (typeof GM_getValue !== 'function') return clone(DEFAULT_CONFIG); const raw = GM_getValue(STORAGE_KEY, ''); if (!raw) return clone(DEFAULT_CONFIG); const saved = JSON.parse(raw); const merged = mergeConfig(clone(DEFAULT_CONFIG), saved); if (saved.floatButton?.layoutVersion !== 1) merged.floatButton = { ...clone(DEFAULT_CONFIG.floatButton), opacity: merged.floatButton.opacity }; return merged; } 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.profiles = normalizeProfiles(config.profiles); config.imageGenSize = normalizeImageSizeInput(config.imageGenSize) || DEFAULT_CONFIG.imageGenSize; config.imageGenPromptText = String(config.imageGenPromptText || IMAGE_GEN_PROMPT_TEXT); if (!config.profiles.some(p => p.id === config.currentProfileId)) { config.currentProfileId = config.profiles[0].id; } GM_setValue(STORAGE_KEY, JSON.stringify(config)); } catch (err) { console.warn('[饺子 AI] 配置保存失败:', err); } } let config = loadConfig(); /* ─── 🤫 静默分析:待展示结果 ─── */ let pendingSummary = null; // { conversation, pageContextLoaded } function showFloatBadge() { if (floatBtn) floatBtn.classList.add('tabbit-has-badge'); } function hideFloatBadge() { if (floatBtn) floatBtn.classList.remove('tabbit-has-badge'); } /****************************************************************** * 4. Profiles 管理 ******************************************************************/ function getCurrentProfile() { return config.profiles.find(x => x.id === config.currentProfileId) || config.profiles[0]; } function setCurrentProfile(id) { if (!config.profiles.some(p => p.id === id)) return false; config.currentProfileId = id; saveConfig(); return true; } function addProfile(name, fromCurrent) { const base = fromCurrent ? clone(getCurrentProfile()) : clone(DEFAULT_PROFILE); base.id = makeId('prof'); base.name = String(name || '').trim() || '新配置'; config.profiles.push(base); config.currentProfileId = base.id; saveConfig(); return base; } function deleteProfile(id) { if (config.profiles.length <= 1) { alert('至少保留一个配置预设。'); return false; } const idx = config.profiles.findIndex(p => p.id === id); if (idx === -1) return false; config.profiles.splice(idx, 1); if (config.currentProfileId === id) config.currentProfileId = config.profiles[0].id; saveConfig(); return true; } function renameProfile(id, newName) { const p = config.profiles.find(x => x.id === id); if (!p) return false; p.name = String(newName || '').trim() || p.name; saveConfig(); return true; } function getCurrentModelConfig() { const profile = getCurrentProfile(); return profile.models.find(m => m.value === profile.currentModel) || profile.models[0] || {}; } function getCurrentModelDisplayName() { const m = getCurrentModelConfig(); return m?.name || m?.value || getCurrentProfile().currentModel || '未知模型'; } function getCurrentTemperature() { const profile = getCurrentProfile(); const model = getCurrentModelConfig(); const v = (model?.temperature !== '' && model?.temperature != null) ? model.temperature : profile.temperature; return Number(v || 0.7); } function getCurrentMaxTokens() { const profile = getCurrentProfile(); const model = getCurrentModelConfig(); const v = (model?.maxTokens !== '' && model?.maxTokens != null) ? model.maxTokens : profile.maxTokens; return Number(v || 2000); } function checkApiConfig() { const profile = getCurrentProfile(); if (!profile.apiUrl || !profile.apiKey || !profile.currentModel) { openSettings(); setStatus(`请先配置 API(当前预设:${profile.name})`, 'error', 2500); return false; } return true; } function getDefaultTemplate() { return config.promptTemplates.find(t => t.id === config.defaultPromptTemplateId) || config.promptTemplates[0]; } function getTemplateForUrl(url) { if (!url || !config.rulePromptBindings?.length) return getDefaultTemplate(); for (const bind of config.rulePromptBindings) { try { if (urlPatternToRegExp(bind.rule).test(url)) { const tpl = config.promptTemplates.find(t => t.id === bind.templateId); if (tpl) return tpl; } } catch (e) {} } return getDefaultTemplate(); } /****************************************************************** * 5. 网页正文提取 ******************************************************************/ function getPageText() { try { const cloned = document.body.cloneNode(true); cloned.querySelectorAll('script, style, noscript, iframe, svg, nav, header, footer, aside, .nav, .navbar, .header, .footer, .sidebar, .comment, .comments, .ad, .ads').forEach(el => el.remove()); let mainNode = cloned.querySelector('article') || cloned.querySelector('[itemprop="articleBody"]') || cloned.querySelector('.post-content, .entry-content, .article-content, .article-body, .markdown-body, .rich_media_content') || cloned.querySelector('main') || cloned; let text = (mainNode.innerText || mainNode.textContent || '').trim(); text = text.replace(/\n{3,}/g, '\n\n').replace(/[ \t]+/g, ' '); const max = Number(config.extractMaxChars || 16000); return truncateText(text, max); } catch (err) { return document.body?.innerText || ''; } } function cleanNodeText(node) { if (!node) return ''; const cloned = node.cloneNode(true); cloned.querySelectorAll('script, style, noscript, iframe, svg, nav, header, footer, aside, .ad, .ads').forEach(el => el.remove()); return (cloned.innerText || cloned.textContent || '') .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]+/g, ' ') .trim(); } function getCommentText() { const selectors = [ '#comments', '.comments', '.comment-list', '.commentlist', '.comment-area', '.comment-section', '.comment-container', '.comment-content', '.comment', '#replies', '.replies', '.reply-list', '.reply', '.discussion', '.discussions', '[id*="comment" i]', '[class*="comment" i]', '[id*="reply" i]', '[class*="reply" i]', '[id*="discussion" i]', '[class*="discussion" i]', '[data-testid*="comment" i]', '[aria-label*="comment" i]', '[aria-label*="评论"]' ]; const candidates = []; selectors.forEach(selector => { try { document.querySelectorAll(selector).forEach(node => { if ( node && node !== document.body && node !== document.documentElement && !candidates.includes(node) ) { candidates.push(node); } }); } catch (e) {} }); const topNodes = candidates.filter(node => !candidates.some(other => other !== node && other.contains(node))); const seen = new Set(); const parts = []; topNodes.forEach(node => { const text = cleanNodeText(node); if (text.length < 20 || seen.has(text)) return; seen.add(text); parts.push(text); }); return truncateText(parts.join('\n\n---\n\n'), Math.min(Number(config.extractMaxChars || 16000), 12000)); } /****************************************************************** * 6. 调用 Chat API ******************************************************************/ let currentRequest = null; let currentReject = null; function textFromContent(value) { if (typeof value === 'string') return value; if (Array.isArray(value)) return value.map(part => textFromContent(part?.text ?? part)).join(''); return ''; } function extractChatContent(data) { const choice = data?.choices?.[0] || {}; const candidates = [ choice?.delta?.content, choice?.message?.content, choice?.delta?.reasoning_content, choice?.message?.reasoning_content, choice?.reasoning_content, data?.candidates?.[0]?.content?.parts ]; for (const candidate of candidates) { const text = textFromContent(candidate); if (text) return text; } return ''; } function extractApiErrorMessage(data) { const error = data?.error; if (typeof error === 'string') return error; if (error?.message) return String(error.message); if (data?.message) return String(data.message); return ''; } function sanitizeApiErrorContext(value, apiKey) { let text = String(value || '').replace(/\s+/g, ' ').trim(); if (apiKey) text = text.split(apiKey).join('[REDACTED]'); return text .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer [REDACTED]') .replace(/(["']?(?:api[_-]?key|authorization)["']?\s*[:=]\s*["']?)[^\s,"'}]+/gi, '$1[REDACTED]') .slice(0, 500); } function getGMResponseText(response) { if (typeof response?.responseText === 'string') return response.responseText; return typeof response?.response === 'string' ? response.response : ''; } function formatUnparseableStreamResponse(text, apiKey) { const context = sanitizeApiErrorContext(text, apiKey); return context ? '服务端返回了无法解析的响应:' + context : '服务端返回了无法解析的响应(响应正文为空,可能被浏览器或代理拦截)'; } function formatMissingStreamContent(text, apiKey) { const context = sanitizeApiErrorContext(text, apiKey); return context ? '服务端响应中没有可用内容:' + context : '服务端响应中没有可用内容(响应正文为空,可能被浏览器或代理拦截)'; } function formatStreamApiError(status, body, apiKey) { let detail = ''; try { detail = extractApiErrorMessage(JSON.parse(body)); } catch (e) {} const context = sanitizeApiErrorContext(detail || body, apiKey); return `HTTP ${status}${context ? '\n' + context : ''}`; } function makeStreamPayloadError(message) { const error = new Error(message || '流式响应错误'); error.isStreamPayloadError = true; return error; } function createStreamPayloadParser(onDelta) { let fullText = ''; let buffer = ''; let ended = false; const appendContent = (content) => { if (!content) return; fullText += content; try { onDelta(content, fullText); } catch (e) { console.warn(e); } }; const processLine = (line) => { line = line.replace(/\r$/, '').trim(); if (!line || line.startsWith(':') || !line.startsWith('data:')) return true; const payload = line.slice(5).trim(); if (payload === '[DONE]') { ended = true; return false; } try { const data = JSON.parse(payload); const errorMessage = extractApiErrorMessage(data); if (errorMessage) throw makeStreamPayloadError(errorMessage); appendContent(extractChatContent(data)); } catch (error) { if (error?.isStreamPayloadError) throw error; // 某些 SSE 服务会混入非 JSON 的 keep-alive 行,忽略即可。 } return true; }; const drainLines = () => { let index; while (!ended && (index = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); if (!processLine(line)) return false; } return !ended; }; return { push(chunk) { if (!ended) { buffer += String(chunk || ''); drainLines(); } return !ended; }, flush() { if (!ended && buffer.trim()) { const tail = buffer; buffer = ''; processLine(tail); } return !ended; }, get fullText() { return fullText; }, get ended() { return ended; } }; } function callChatApi(messages, onDelta) { const profile = getCurrentProfile(); const useStream = typeof onDelta === 'function'; const apiUrl = profile.apiUrl; const apiKey = profile.apiKey; const body = { model: profile.currentModel, messages, temperature: getCurrentTemperature(), max_tokens: getCurrentMaxTokens() }; if (!config.enableThinking) { body.thinking = { type: "disabled" }; body.enable_thinking = false; } return new Promise((resolve, reject) => { currentReject = reject; if (!useStream) { currentRequest = GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, data: JSON.stringify(body), timeout: config.apiTimeout || 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 响应格式异常')); 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 请求超时。')); } }); return; } const doFetchStream = async () => { const controller = new AbortController(); currentRequest = { abort: () => controller.abort() }; const parser = createStreamPayloadParser(onDelta); try { const resp = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey, 'Accept': 'text/event-stream' }, body: JSON.stringify({ ...body, stream: true }), signal: controller.signal }); if (!resp.ok) { const errText = await resp.text(); const error = new Error(formatStreamApiError(resp.status, errText, apiKey)); error.isResponseError = true; throw error; } const ctype = resp.headers.get('content-type') || ''; const canReadStream = resp.body && typeof resp.body.getReader === 'function'; if (!ctype.toLowerCase().includes('text/event-stream') || !canReadStream) { const text = await resp.text(); try { const data = JSON.parse(text); const errorMessage = extractApiErrorMessage(data); if (errorMessage) throw makeStreamPayloadError(errorMessage); const content = extractChatContent(data); if (content) { try { onDelta(content, content); } catch (e) {} currentRequest = null; currentReject = null; resolve(content); return true; } } catch (error) { if (error?.isStreamPayloadError) throw error; } const error = new Error('服务端返回非流式响应:' + sanitizeApiErrorContext(text, apiKey)); error.isResponseError = true; throw error; } const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let done = false; while (!done) { const { value, done: rDone } = await reader.read(); done = rDone; if (value) { if (!parser.push(decoder.decode(value, { stream: !done }))) done = true; } } parser.flush(); currentRequest = null; currentReject = null; if (parser.fullText) { resolve(parser.fullText); } else { reject(new Error('流式响应为空')); } return true; } catch (e) { if (e.name === 'AbortError') { currentRequest = null; currentReject = null; reject(new Error('已取消')); return true; } if (e?.isResponseError || e?.isStreamPayloadError || parser.fullText) { currentRequest = null; currentReject = null; reject(e); return true; } console.warn('[饺子AI] fetch 流式失败,尝试降级 GM_xmlhttpRequest:', e); return { fallback: true, error: e }; } }; const doGMFallback = () => { let receivedLen = 0; const parser = createStreamPayloadParser(onDelta); let failed = false; const fail = (error) => { if (failed) return; failed = true; try { currentRequest?.abort?.(); } catch (e) {} currentRequest = null; currentReject = null; reject(error); }; currentRequest = GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey, 'Accept': 'text/event-stream' }, data: JSON.stringify({ ...body, stream: true }), timeout: Math.max(config.apiTimeout || 120000, 180000), onprogress: (e) => { if (failed || parser.ended) return; const text = getGMResponseText(e); if (text.length <= receivedLen) return; const newChunk = text.substring(receivedLen); receivedLen = text.length; try { parser.push(newChunk); } catch (error) { fail(error); } }, onload: (res) => { currentRequest = null; currentReject = null; if (failed) return; const text = getGMResponseText(res); if (res.status < 200 || res.status >= 300) { reject(new Error(formatStreamApiError(res.status, text, apiKey))); return; } if (text.length > receivedLen) { const tail = text.substring(receivedLen); receivedLen = text.length; try { parser.push(tail); } catch (error) { reject(error); return; } } try { parser.flush(); } catch (error) { reject(error); return; } if (parser.fullText) { resolve(parser.fullText); return; } try { const data = JSON.parse(text); const errorMessage = extractApiErrorMessage(data); if (errorMessage) { reject(makeStreamPayloadError(errorMessage)); return; } const content = extractChatContent(data); if (content) { try { onDelta(content, content); } catch (e) {} resolve(content); } else { reject(new Error(formatMissingStreamContent(text, apiKey))); } } catch (e) { reject(new Error(formatUnparseableStreamResponse(text, apiKey))); } }, onerror: () => { currentRequest = null; currentReject = null; reject(new Error('网络请求失败')); }, ontimeout: () => { currentRequest = null; currentReject = null; reject(new Error('API 请求超时')); } }); }; doFetchStream().then(result => { if (result && result.fallback) { doGMFallback(); } }); }); } function abortCurrentRequest() { try { currentRequest?.abort?.(); } catch (e) {} if (currentReject) { try { currentReject(new Error('已取消')); } catch (e) {} } currentRequest = null; currentReject = null; } /****************************************************************** * 6.5 摘要生图 ******************************************************************/ function buildImagePrompt(textContent) { const summaryForImage = String(textContent || '') .slice(0, IMAGE_GEN_SUMMARY_MAX_LEN) .replace(/[#*_\[\]()]/g, ''); const promptTemplate = config.imageGenPromptText || IMAGE_GEN_PROMPT_TEXT; return promptTemplate.includes('{summary}') ? promptTemplate.replace(/\{summary\}/g, summaryForImage) : promptTemplate + '\n\n' + summaryForImage; } function addUniqueImageCandidate(candidates, kind, url) { url = normalizeApiUrlInput(url); if (!url) return; const key = kind + '|' + url; if (candidates.some(item => item.key === key)) return; candidates.push({ key, kind, url }); } function buildImageApiCandidates(apiUrl) { const url = normalizeApiUrlInput(apiUrl); const candidates = []; if (!url) return candidates; if (/\/images\/generations$/i.test(url)) addUniqueImageCandidate(candidates, 'images', url); if (/\/responses$/i.test(url)) addUniqueImageCandidate(candidates, 'responses', url); addUniqueImageCandidate(candidates, 'images', buildApiUrl(url, 'images/generations')); addUniqueImageCandidate(candidates, 'responses', buildApiUrl(url, 'responses')); if (/\/chat\/completions$/i.test(url)) addUniqueImageCandidate(candidates, 'chat', url); addUniqueImageCandidate(candidates, 'chat', buildApiUrl(url, 'chat/completions')); return candidates; } function getImageRequestBodies(kind, model, imagePrompt) { const size = config.imageGenSize || '1024x1024'; if (kind === 'images') { const baseBody = { model, prompt: imagePrompt, n: 1, size }; return [ { ...baseBody, response_format: 'b64_json' }, baseBody ]; } if (kind === 'responses') { const tool = { type: 'image_generation' }; if (size && size !== 'auto') tool.size = size; return [ { model, input: imagePrompt, tools: [tool] }, { model, input: imagePrompt, tools: [{ type: 'image_generation' }] } ]; } const sizeHint = size && size !== 'auto' ? '\n\n请严格按 ' + size + ' 画布尺寸生成,保持对应宽高比。' : ''; return [ { model, messages: [{ role: 'user', content: imagePrompt + sizeHint }] }, { model, messages: [{ role: 'user', content: imagePrompt + sizeHint }], modalities: ['text', 'image'] } ]; } function normalizeExtractedImage(value) { const text = String(value || '').trim(); if (!text) return ''; if (/^data:image\//i.test(text) || /^https?:\/\//i.test(text)) return text; const compact = text.replace(/\s/g, ''); if (compact.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(compact)) { return 'data:image/png;base64,' + compact; } return ''; } function extractImageDataUrlFromResponse(data) { if (!data) return ''; const seen = new Set(); function walk(node, key, parent) { if (node == null) return ''; if (typeof node === 'string') { if (/^(b64_json|base64|image_base64|result|data)$/i.test(key || '')) { const parentType = String(parent && (parent.type || parent.kind || parent.object || parent.mime_type || parent.mimeType) || ''); if (!parentType || /image|generation|inline/i.test(parentType)) { const direct = normalizeExtractedImage(node); if (direct) return direct; } } if (/^(url|image_url|output_url)$/i.test(key || '')) { const direct = normalizeExtractedImage(node); if (direct) return direct; } const dataUrlMatch = node.match(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+/i); if (dataUrlMatch) return normalizeExtractedImage(dataUrlMatch[0]); const markdownImageMatch = node.match(/!\[[^\]]*]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+)\)/i); if (markdownImageMatch) return normalizeExtractedImage(markdownImageMatch[1]); return ''; } if (typeof node !== 'object') return ''; if (seen.has(node)) return ''; seen.add(node); if (node.image_url && typeof node.image_url === 'object') { const directImageUrl = walk(node.image_url.url || node.image_url.data || node.image_url.b64_json, 'image_url', node.image_url); if (directImageUrl) return directImageUrl; } if (Array.isArray(node)) { for (const item of node) { const found = walk(item, key, parent); if (found) return found; } return ''; } for (const prop of Object.keys(node)) { const found = walk(node[prop], prop, node); if (found) return found; } return ''; } return walk(data, '', null); } function requestImageFromCandidate(candidate, apiKey, model, imagePrompt) { const bodies = getImageRequestBodies(candidate.kind, model, imagePrompt); let lastError = ''; const requestOne = (body) => new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: candidate.url, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }, data: JSON.stringify(body), timeout: Math.max(config.apiTimeout || 120000, 180000), onload(res) { const text = res.responseText || ''; let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) {} if (res.status < 200 || res.status >= 300) { reject(new Error(formatApiError(res.status, text))); return; } const imageDataUrl = extractImageDataUrlFromResponse(data || text); if (imageDataUrl) resolve(imageDataUrl); else reject(new Error('响应里没有解析到图片数据')); }, onerror() { reject(new Error('网络请求失败')); }, ontimeout() { reject(new Error('生图请求超时')); } }); }); return bodies.reduce((chain, body) => { return chain.catch(async () => { try { return await requestOne(body); } catch (err) { lastError = err.message || String(err); throw err; } }); }, Promise.reject()).catch(() => { throw new Error(lastError || '生图请求失败'); }); } async function generateImageByApi(apiUrl, apiKey, model, imagePrompt) { const candidates = buildImageApiCandidates(apiUrl); let lastError = ''; for (const candidate of candidates) { try { console.log('[饺子AI-生图] 尝试 ' + candidate.kind + ' 接口:', candidate.url); const imageDataUrl = await requestImageFromCandidate(candidate, apiKey, model, imagePrompt); console.log('[饺子AI-生图] ✅ ' + candidate.kind + ' 接口返回图片'); return imageDataUrl; } catch (err) { lastError = candidate.kind + ' ' + candidate.url + ' -> ' + (err.message || String(err)); console.warn('[饺子AI-生图] ' + lastError); } } throw new Error(lastError || '生图 API 未返回图片数据'); } function downloadGeneratedImage(imageDataUrl, title, suffix) { if (!imageDataUrl || imageDataUrl === 'ERROR') return false; const safeTitle = sanitizeFilename(title || document.title || '网页摘要') || '网页摘要'; const a = document.createElement('a'); a.href = imageDataUrl; a.download = safeTitle + (suffix || '_配图') + '.png'; document.body.appendChild(a); a.click(); document.body.removeChild(a); return true; } function openImagePreview(imageDataUrl) { const overlay = document.createElement('div'); overlay.className = 'tabbit-image-preview-overlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:2147483647;display:flex;align-items:center;justify-content:center;cursor:zoom-out;'; const bigImg = document.createElement('img'); bigImg.src = imageDataUrl; bigImg.style.cssText = 'max-width:95vw;max-height:95vh;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);'; overlay.appendChild(bigImg); overlay.addEventListener('click', () => overlay.remove()); document.body.appendChild(overlay); } function getLatestAssistantMessageIndex() { for (let i = conversation.length - 1; i >= 0; i--) { const m = conversation[i]; if (m && m.role === 'assistant' && !m.meta?.hidden && !m.meta?.streaming && String(m.content || '').trim()) return i; } return -1; } function renderImageResult(slot, imageDataUrl) { if (!slot) return; slot.innerHTML = `
    `; const img = slot.querySelector('img'); if (img) img.addEventListener('click', () => openImagePreview(imageDataUrl)); const saveBtn = slot.querySelector('.tabbit-save-img-btn'); if (saveBtn) saveBtn.addEventListener('click', () => downloadGeneratedImage(imageDataUrl, document.title, '_配图')); } async function triggerImageGenForMessage(msgIndex, btn, opts) { opts = opts || {}; const msg = conversation[msgIndex]; if (!msg || msg.role !== 'assistant' || !String(msg.content || '').trim()) { setStatus('没有可用于生图的 AI 回复', 'error', 1800); return; } const profile = getCurrentProfile(); const apiUrl = config.imageGenApiUrl || profile.apiUrl; const apiKey = config.imageGenApiKey || profile.apiKey; const model = config.imageGenModel || 'gemini-3.1-flash-image-preview'; if (!apiUrl || !apiKey || !model) { openSettings(); setStatus('请先配置生图 API URL、Key 和模型', 'error', 2500); return; } const root = shadowRoot; const slot = root?.querySelector(`#tabbit-img-slot-${msgIndex}`); const originalText = btn?.textContent || ''; if (btn) { btn.disabled = true; btn.textContent = '⏳ 生成中'; } if (slot) { slot.innerHTML = `
    配图生成中,请稍候...
    使用当前 AI 回复作为生图内容
    `; } setStatus('正在生成配图...', 'loading'); try { const imagePrompt = buildImagePrompt(msg.content); const imageDataUrl = await generateImageByApi(apiUrl, apiKey, model, imagePrompt); msg.meta = { ...(msg.meta || {}), imageDataUrl }; if (slot) renderImageResult(slot, imageDataUrl); if (config.enableImageAutoDownload !== false) downloadGeneratedImage(imageDataUrl, document.title, '_配图'); if (btn) btn.textContent = '🔁 重新生成'; setStatus(opts.auto ? '摘要和配图已完成' : '配图生成完成', 'ok', 1800); } catch (err) { if (slot) { slot.innerHTML = `
    ⚠️ 配图生成失败:${escapeAttr(err.message || err)}
    `; } if (btn) btn.textContent = originalText || '🖼️ 生成配图'; setStatus('配图生成失败', 'error', 2500); } finally { if (btn) btn.disabled = false; } } function bindMessageImageActions() { const root = shadowRoot; if (!root) return; root.querySelectorAll('.tabbit-gen-image-btn').forEach(btn => { btn.addEventListener('click', () => { triggerImageGenForMessage(Number(btn.dataset.msgIndex), btn); }); }); root.querySelectorAll('.tabbit-copy-image-prompt-btn').forEach(btn => { btn.addEventListener('click', () => { const msg = conversation[Number(btn.dataset.msgIndex)]; const text = buildImagePrompt(msg?.content || ''); if (!text.trim()) { setStatus('没有可复制的生图提示词', 'error', 1500); return; } writeClipboard(text); const old = btn.textContent; btn.textContent = '✅ 已复制'; setTimeout(() => { btn.textContent = old || '复制生图提示词'; }, 1600); }); }); } /****************************************************************** * 7. 💬 对话状态管理 + 对话缓存 ******************************************************************/ let conversation = []; let pageContextLoaded = false; // —— 对话核心 —— function resetConversation(reason) { conversation = []; pageContextLoaded = false; if (shadowRoot) { const body = shadowRoot.querySelector('#tabbit-body'); if (body) { body.innerHTML = `

    ${reason || '点击「✨ 总结当前页面」开始,或在下方输入框直接提问。'}

    `; } const input = shadowRoot.querySelector('#tabbit-chat-input'); if (input) input.value = ''; const presets = shadowRoot.querySelector('#tabbit-followup-presets'); if (presets) { presets.classList.add('tabbit-hidden'); presets.innerHTML = ''; } } } function buildPageSystemPrompt() { const pageText = getPageText(); return ( 'You are a helpful assistant that summarizes and discusses web pages in Chinese. ' + 'All page content is provided directly below — you do not have web access. ' + 'When the user asks follow-up questions, answer based on this page content and prior conversation.\n\n' + '==== 网页元信息 ====\n' + `标题:${document.title}\n` + `URL:${location.href}\n\n` + '==== 网页正文 ====\n' + pageText ); } function getSystemPromptMd() { if (!config.systemPromptMdEnabled) return ''; return (config.systemPromptMd || '').trim(); } function ensurePageContext() { if (pageContextLoaded) return; const mdContent = getSystemPromptMd(); if (mdContent && config.systemPromptMdAsSystem !== false) { conversation.unshift({ role: 'system', content: '==== 预加载上下文 ====' + '\n' + mdContent }); } conversation.unshift({ role: 'system', content: buildPageSystemPrompt() }); pageContextLoaded = true; } function isPageTitleQuestion() { const title = String(document.title || '').trim(); return /[??]/.test(title) || /^(为什么|为何|怎么|怎样|如何|是否|是不是|有没有|能不能|该不该|谁|什么|哪|几|多少|吗|如何看待)/.test(title); } function buildCommentFollowupPrompt() { const comments = getCommentText(); if (!comments) { return '请尝试总结这篇页面的评论区。当前脚本没有抓到明确的评论区文本;如果已有上下文里没有评论或讨论内容,请直接说“没抓到评论区”,不要编造。'; } return ( '请专门总结这篇页面的评论区,不要重复正文摘要。\n\n' + '请输出:\n' + '1. 评论区主要观点\n' + '2. 支持、反对、补充意见分别是什么\n' + '3. 有没有反复出现的共识或高价值信息\n' + '4. 对我理解正文有什么帮助\n\n' + '==== 评论区文本 ====\n' + comments ); } function buildTitleFollowupPrompt() { const title = String(document.title || '').trim() || '(无标题)'; const questionTitle = isPageTitleQuestion(); return ( `${questionTitle ? '页面标题本身是一个问题' : '页面标题是'}:${title}\n\n` + '请围绕这个标题继续追问并回答。' + `${questionTitle ? '优先直接回答标题里的问题' : '先提炼这个标题真正想问的核心问题'},` + '结合页面正文和刚才的总结,用大白话输出:\n' + '1. 直接结论\n' + '2. 关键依据\n' + '3. 还需要警惕的盲点或反例' ); } function hasCompletedInitialSummary() { const hasInitialPrompt = conversation.some(m => m.role === 'user' && m.meta?.hidden && String(m.content || '').includes('请按以下要求总结当前页面') ); const hasFinishedAssistant = conversation.some(m => m.role === 'assistant' && !m.meta?.hidden && !m.meta?.streaming && String(m.content || '').trim() ); return hasInitialPrompt && hasFinishedAssistant; } function mapMessagesForApi() { return conversation .filter(m => !m.meta?.streaming) .map(m => ({ role: m.role, content: m.meta?.apiContent || m.content })); } function setFollowupPresetsDisabled(disabled) { shadowRoot?.querySelectorAll('.tabbit-followup-btn').forEach(btn => { btn.disabled = disabled; }); } function renderFollowupPresets() { const root = shadowRoot; if (!root) return; const wrap = root.querySelector('#tabbit-followup-presets'); if (!wrap) return; if (!hasCompletedInitialSummary()) { wrap.classList.add('tabbit-hidden'); wrap.innerHTML = ''; return; } const titleBtnText = isPageTitleQuestion() ? '回答标题问题' : '按标题追问'; wrap.classList.remove('tabbit-hidden'); wrap.innerHTML = ` `; wrap.querySelectorAll('.tabbit-followup-btn').forEach(btn => { btn.addEventListener('click', () => handlePresetFollowup(btn.dataset.preset)); }); } function renderConversation() { const root = shadowRoot; if (!root) return; const body = root.querySelector('#tabbit-body'); if (!body) return; const visibleMsgs = conversation .map((m, index) => ({ m, index })) .filter(item => item.m.role !== 'system' && !item.m.meta?.hidden); if (!visibleMsgs.length) { body.innerHTML = `

    点击「✨ 总结」开始,或在下方输入框直接提问。

    `; renderFollowupPresets(); return; } body.innerHTML = visibleMsgs.map(({ m, index }) => { if (m.role === 'user') { return `
    🙋 我
    ${_md(m.content)}
    `; } else { const cursor = m.meta?.streaming ? '▍' : ''; const imageActions = m.meta?.streaming ? '' : `
    `; return `
    🤖 ${escapeAttr(m.meta?.model || 'AI')}${m.meta?.streaming ? ' · 输出中…' : ''}
    ${_md(m.content)}${cursor}
    ${imageActions}
    `; } }).join(''); visibleMsgs.forEach(({ m, index }) => { if (m.role === 'assistant' && m.meta?.imageDataUrl) { renderImageResult(body.querySelector(`#tabbit-img-slot-${index}`), m.meta.imageDataUrl); } }); bindMessageImageActions(); renderFollowupPresets(); } function appendMessage(role, content, meta) { conversation.push({ role, content, meta: meta || {} }); renderConversation(); // 用户发送新消息时自动滚动到底部 const body = shadowRoot?.querySelector('#tabbit-body'); if (body) body.scrollTop = body.scrollHeight; } /****************************************************************** * 8. ☁️ 坚果云同步 ******************************************************************/ const JGY_BASE = 'https://dav.jianguoyun.com/dav/'; const JGY_SHARED_DIR = 'tabbit-shared/'; const JGY_PROFILES_FILE = 'ai-profiles.json'; const PROFILES_SCHEMA = 'tabbit-ai-profiles-v1'; const JGY_USER_PROFILE_FILE = 'user-profile.json'; const JGY_DIR = 'tabbit-ai-summary/'; const JGY_FILE = 'config.json'; function jgyUrl(path) { return JGY_BASE + (path || ''); } function jgyAuthHeader() { const cs = config.cloudSync || {}; return 'Basic ' + btoa(unescape(encodeURIComponent(cs.account + ':' + cs.appPassword))); } function jgyRequest(method, url, opts) { opts = opts || {}; return new Promise((resolve, reject) => { const reqOpts = { method, url, headers: { Authorization: jgyAuthHeader(), ...(opts.headers || {}) }, timeout: opts.timeout || 30000, onload(res) { if (res.status >= 200 && res.status < 300) resolve(res); else if (res.status === 404 && opts.allow404) resolve(res); else if (res.status === 405 && opts.allow405) resolve(res); else reject(new Error(`坚果云返回 ${res.status}:${(res.responseText || '').substring(0, 200)}`)); }, onerror() { reject(new Error('坚果云网络错误')); }, ontimeout() { reject(new Error('坚果云请求超时')); } }; if (opts.data !== undefined && opts.data !== null) reqOpts.data = opts.data; GM_xmlhttpRequest(reqOpts); }); } async function jgyMkcolIfNeeded(dirPath) { try { await jgyRequest('MKCOL', jgyUrl(dirPath), { allow404: true, allow405: true }); } catch (e) { if (e.message?.includes('401')) throw e; } } async function jgyDownloadJson(filePath) { const res = await jgyRequest('GET', jgyUrl(filePath), { allow404: true }); if (res.status === 404) return null; try { return JSON.parse(res.responseText); } catch (err) { throw new Error(`云端文件解析失败:${err.message}`); } } async function jgyUploadJson(filePath, payload) { await jgyRequest('PUT', jgyUrl(filePath), { headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(payload, null, 2) }); } async function downloadProfilesFile() { return jgyDownloadJson(JGY_SHARED_DIR + JGY_PROFILES_FILE); } async function downloadUserProfileFile() { return jgyDownloadJson(JGY_SHARED_DIR + JGY_USER_PROFILE_FILE); } async function uploadProfilesFile(profiles, currentProfileId) { await jgyMkcolIfNeeded(JGY_SHARED_DIR); await jgyUploadJson(JGY_SHARED_DIR + JGY_PROFILES_FILE, { version: 1, schema: PROFILES_SCHEMA, updatedAt: Date.now(), profiles: normalizeProfiles(profiles), currentProfileId }); } async function downloadAppFile() { return jgyDownloadJson(JGY_DIR + JGY_FILE); } async function uploadAppFile(payload) { await jgyMkcolIfNeeded(JGY_DIR); await jgyUploadJson(JGY_DIR + JGY_FILE, payload); } function pickCloudCredsFromForm() { const el = settingsEl; if (!el) return; config.cloudSync = { ...(config.cloudSync || {}), account: el.querySelector('#tabbit-set-jgy-account').value.trim(), appPassword: el.querySelector('#tabbit-set-jgy-password').value.trim() }; } function readSyncScopeFromForm() { const el = settingsEl; if (!el) return { profiles: true, app: true }; return { profiles: el.querySelector('#tabbit-sync-profiles')?.checked !== false, app: el.querySelector('#tabbit-sync-app')?.checked !== false }; } function mergeTemplates(local, remote, prefer) { const map = new Map(); const order = prefer === 'remote' ? [local, remote] : [remote, local]; order.forEach(arr => arr.forEach(t => map.set(t.name, t))); return Array.from(map.values()); } function mergeBindings(local, remote, prefer) { const map = new Map(); const order = prefer === 'remote' ? [local, remote] : [remote, local]; order.forEach(arr => arr.forEach(b => map.set(b.rule, b))); return Array.from(map.values()); } function mergeProfiles(local, remote, prefer) { const map = new Map(); const order = prefer === 'remote' ? [local, remote] : [remote, local]; order.forEach(arr => arr.forEach(p => map.set(p.id, p))); return Array.from(map.values()); } async function cloudTest() { setStatus('正在测试坚果云连接…', 'loading'); await jgyMkcolIfNeeded(JGY_SHARED_DIR); await jgyMkcolIfNeeded(JGY_DIR); await jgyRequest('PROPFIND', jgyUrl(JGY_DIR), { headers: { Depth: '0' }, data: '', allow404: true }); } async function cloudPull(scope) { scope = scope || { profiles: true, app: true }; const result = { profilesCount: 0, tplsCount: 0, rulesCount: 0, hasProfiles: false, hasApp: false }; if (scope.profiles) { setStatus('正在拉取 API 预设…', 'loading'); const remote = await downloadProfilesFile(); if (remote && Array.isArray(remote.profiles)) { result.hasProfiles = true; const merged = mergeProfiles(normalizeProfiles(config.profiles), normalizeProfiles(remote.profiles), 'remote'); config.profiles = merged; if (remote.currentProfileId && merged.some(p => p.id === remote.currentProfileId)) { config.currentProfileId = remote.currentProfileId; } else if (!merged.some(p => p.id === config.currentProfileId)) { config.currentProfileId = merged[0].id; } result.profilesCount = merged.length; } } if (scope.app) { setStatus('正在拉取模板和规则…', 'loading'); const remoteApp = await downloadAppFile(); if (remoteApp) { result.hasApp = true; const mergedTpls = mergeTemplates(normalizePromptTemplates(config.promptTemplates), normalizePromptTemplates(remoteApp.promptTemplates || []), 'remote'); const mergedRules = Array.from(new Set([...normalizeUrlRules(remoteApp.urlRules || []), ...normalizeUrlRules(config.urlRules)])); const mergedBinds = mergeBindings(normalizeRulePromptBindings(config.rulePromptBindings), normalizeRulePromptBindings(remoteApp.rulePromptBindings || []), 'remote'); config.promptTemplates = mergedTpls; config.urlRules = mergedRules; config.rulePromptBindings = mergedBinds; result.tplsCount = mergedTpls.length; result.rulesCount = mergedRules.length; } } config.cloudSync.lastSyncAt = Date.now(); config.cloudSync.lastSyncDirection = 'pull'; saveConfig(); return result; } async function cloudPush(scope) { scope = scope || { profiles: true, app: true }; if (scope.profiles) { setStatus('正在合并并上传 API 预设…', 'loading'); let remote = null; try { remote = await downloadProfilesFile(); } catch (e) {} let mergedProfiles = normalizeProfiles(config.profiles); if (remote?.profiles) mergedProfiles = mergeProfiles(mergedProfiles, normalizeProfiles(remote.profiles), 'local'); await uploadProfilesFile(mergedProfiles, config.currentProfileId); config.profiles = mergedProfiles; } if (scope.app) { setStatus('正在合并并上传模板/规则…', 'loading'); let remote = null; try { remote = await downloadAppFile(); } catch (e) {} let mergedTpls = normalizePromptTemplates(config.promptTemplates); let mergedRules = normalizeUrlRules(config.urlRules); let mergedBinds = normalizeRulePromptBindings(config.rulePromptBindings); if (remote) { mergedTpls = mergeTemplates(mergedTpls, normalizePromptTemplates(remote.promptTemplates || []), 'local'); mergedRules = Array.from(new Set([...mergedRules, ...normalizeUrlRules(remote.urlRules || [])])); mergedBinds = mergeBindings(mergedBinds, normalizeRulePromptBindings(remote.rulePromptBindings || []), 'local'); } await uploadAppFile({ version: 1, source: 'tabbit-ai-summary', updatedAt: Date.now(), promptTemplates: mergedTpls, urlRules: mergedRules, rulePromptBindings: mergedBinds }); config.promptTemplates = mergedTpls; config.urlRules = mergedRules; config.rulePromptBindings = mergedBinds; } config.cloudSync.lastSyncAt = Date.now(); config.cloudSync.lastSyncDirection = 'push'; saveConfig(); } async function cloudForcePush(scope) { scope = scope || { profiles: true, app: true }; if (scope.profiles) { setStatus('正在强制覆盖 API 预设…', 'loading'); await uploadProfilesFile(config.profiles, config.currentProfileId); } if (scope.app) { setStatus('正在强制覆盖模板/规则…', 'loading'); await uploadAppFile({ version: 1, source: 'tabbit-ai-summary', updatedAt: Date.now(), forcePush: true, promptTemplates: normalizePromptTemplates(config.promptTemplates), urlRules: normalizeUrlRules(config.urlRules), rulePromptBindings: normalizeRulePromptBindings(config.rulePromptBindings) }); } config.cloudSync.lastSyncAt = Date.now(); config.cloudSync.lastSyncDirection = 'force-push'; saveConfig(); } async function handleCloudTest() { pickCloudCredsFromForm(); if (!config.cloudSync.account || !config.cloudSync.appPassword) { alert('请先填写坚果云账号和应用密码。'); return; } const btn = settingsEl?.querySelector('#tabbit-cloud-test'); if (btn) { btn.disabled = true; btn.textContent = '测试中…'; } try { await cloudTest(); alert('✅ 坚果云连接成功。'); setStatus('坚果云连接成功', 'ok', 2000); } catch (err) { alert('❌ 坚果云连接失败:\n' + (err.message || err)); } finally { if (btn) { btn.disabled = false; btn.textContent = '🔌 测试连接'; } } } async function handleCloudPull() { pickCloudCredsFromForm(); if (!config.cloudSync.account || !config.cloudSync.appPassword) { alert('请先填写坚果云账号和应用密码。'); return; } const scope = readSyncScopeFromForm(); if (!scope.profiles && !scope.app) { alert('请至少勾选一项。'); return; } if (!confirm('从云端拉取并合并?(同名/同 ID 以云端为准)')) return; const btn = settingsEl?.querySelector('#tabbit-cloud-pull'); if (btn) { btn.disabled = true; btn.textContent = '拉取中…'; } try { const r = await cloudPull(scope); const lines = []; if (scope.profiles) lines.push(r.hasProfiles ? `✅ API 预设:${r.profilesCount} 个` : '⚠️ 云端无 API 预设'); if (scope.app) lines.push(r.hasApp ? `✅ 模板:${r.tplsCount},规则:${r.rulesCount}` : '⚠️ 云端无模板/规则'); alert('拉取完成:\n\n' + lines.join('\n')); fillSettingsForm(); renderModelSelect(); } catch (err) { alert('❌ 拉取失败:\n' + (err.message || err)); } finally { if (btn) { btn.disabled = false; btn.textContent = '⬇️ 从云端拉取'; } } } async function handleCloudPush() { pickCloudCredsFromForm(); if (!config.cloudSync.account || !config.cloudSync.appPassword) { alert('请先填写坚果云账号和应用密码。'); return; } const scope = readSyncScopeFromForm(); if (!scope.profiles && !scope.app) { alert('请至少勾选一项。'); return; } if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) { syncCurrentProfileFromForm(); syncTemplatesFromSettings(); syncUrlRulesFromSettings(); } const btn = settingsEl?.querySelector('#tabbit-cloud-push'); if (btn) { btn.disabled = true; btn.textContent = '上传中…'; } try { await cloudPush(scope); alert('✅ 已增量上传到云端。'); } catch (err) { alert('❌ 上传失败:\n' + (err.message || err)); } finally { if (btn) { btn.disabled = false; btn.textContent = '⬆️ 增量上传'; } } } async function handleCloudForcePush() { pickCloudCredsFromForm(); if (!config.cloudSync?.account || !config.cloudSync?.appPassword) { alert('请先填写坚果云账号和应用密码'); return; } const scope = readSyncScopeFromForm(); if (!scope.profiles && !scope.app) { alert('请至少勾选一项。'); return; } if (!confirm('⚠️ 强制覆盖云端,云端独有数据将丢失,确认?')) return; if (!confirm('再次确认:此操作不可恢复,是否继续?')) return; if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) { syncCurrentProfileFromForm(); syncTemplatesFromSettings(); syncUrlRulesFromSettings(); } const btn = settingsEl?.querySelector('#tabbit-cloud-force-push'); if (btn) { btn.disabled = true; btn.textContent = '覆盖中…'; } try { await cloudForcePush(scope); alert('✅ 已强制覆盖云端。'); } catch (err) { alert('❌ 强制覆盖失败:\n' + (err.message || err)); } finally { if (btn) { btn.disabled = false; btn.textContent = '⚠️ 强制覆盖上传'; } } } function extractUserProfileMarkdown(payload) { if (!payload) return ''; if (typeof payload === 'string') return payload.trim(); return String(payload.markdown || payload.content || '').trim(); } async function handleLoadUserProfileMd() { pickCloudCredsFromForm(); if (!config.cloudSync?.account || !config.cloudSync?.appPassword) { alert('请先填写坚果云账号和应用密码'); return; } const btn = settingsEl?.querySelector('#tabbit-md-load-profile'); if (btn) { btn.disabled = true; btn.textContent = '读取中…'; } try { const remote = await downloadUserProfileFile(); const markdown = extractUserProfileMarkdown(remote); if (!markdown) { alert('云端还没有用户画像。'); return; } const mdTextarea = settingsEl?.querySelector('#tabbit-set-system-md'); const mdCharsEl = settingsEl?.querySelector('#tabbit-md-chars'); const mdEnabled = settingsEl?.querySelector('#tabbit-set-md-enabled'); if (mdTextarea) mdTextarea.value = markdown; if (mdCharsEl) mdCharsEl.textContent = '字数:' + markdown.length; if (mdEnabled) mdEnabled.checked = true; config.systemPromptMd = markdown; config.systemPromptMdEnabled = true; saveConfig(); setStatus('已从坚果云读取用户画像', 'ok', 1800); alert('✅ 已从坚果云读取用户画像,并启用上下文注入。'); } catch (err) { alert('❌ 从坚果云读取用户画像失败:\n' + (err.message || err)); } finally { if (btn) { btn.disabled = false; btn.textContent = '☁️ 从坚果云读取用户画像'; } } } /****************************************************************** * 9. 状态条 ******************************************************************/ let statusTimer = null; function setStatus(msg, level, duration) { const root = shadowRoot; if (!root) return; const el = root.querySelector('#tabbit-status'); if (!el) return; el.textContent = msg || ''; el.className = 'tabbit-status ' + (level || ''); if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; } if (duration) statusTimer = setTimeout(() => { el.textContent = ''; el.className = 'tabbit-status'; }, duration); } /****************************************************************** * 10. 样式(Shadow DOM 内面板样式 + 主文档外部 UI 样式) ******************************************************************/ function createPanelStyles() { // 面板、消息、输入区、表格等 → 注入 Shadow DOM return ` :host { all: initial; position: fixed; top: 0; left: 0; width: 0; height: 0; overflow: visible; pointer-events: none; z-index: 2147483646; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 14px; line-height: 1.5; color: #222; } *, *::before, *::after { box-sizing: border-box; } #${PANEL_ID} { pointer-events: auto; position: fixed; right: 16px; bottom: 70px; width: 420px; height: 80vh; min-width: 340px; min-height: 400px; max-width: calc(100vw - 32px); max-height: calc(100vh - 100px); background: #fff; color: #222; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,.25); display: flex; flex-direction: column; z-index: 2147483646; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; overflow: hidden; } .tabbit-hidden { display: none !important; } .tabbit-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; gap: 8px; background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: white; cursor: move; flex-shrink: 0; } .tabbit-title { font-weight: 700; font-size: 14px; white-space: nowrap; flex-shrink: 0; } .tabbit-header-actions { display: flex; gap: 6px; align-items: center; min-width: 0; } .tabbit-icon-btn { background: rgba(255,255,255,.18); color: #fff; border: none; width: 28px; height: 28px; flex-shrink: 0; border-radius: 6px; cursor: pointer; font-size: 16px; pointer-events: auto; } .tabbit-icon-btn:hover { background: rgba(255,255,255,.32); } .tabbit-model-select, .tabbit-prompt-select, .tabbit-profile-select { max-width: 130px; min-width: 0; border: none; border-radius: 8px; padding: 5px 8px; font-size: 12px; background: rgba(255,255,255,.92); color: #333; cursor: pointer; pointer-events: auto; } .tabbit-profile-select { font-weight: 600; } .tabbit-toolbar { display: flex; gap: 6px; padding: 8px 12px; border-bottom: 1px solid #eee; flex-wrap: wrap; align-items: center; flex-shrink: 0; } .tabbit-primary-btn { background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: #fff; border: none; padding: 7px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600; pointer-events: auto; } .tabbit-primary-btn:disabled { opacity: .55; cursor: not-allowed; } .tabbit-secondary-btn { background: #f5f5f7; color: #333; border: 1px solid #e5e5ea; padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 12px; pointer-events: auto; } .tabbit-secondary-btn:hover { background: #ececf0; } .tabbit-danger-btn { background: #fee2e2; color: #b91c1c; border: 1px solid #fca5a5; padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 600; pointer-events: auto; } .tabbit-danger-btn:hover { background: #fecaca; } .tabbit-status { font-size: 12px; color: #888; padding: 0 12px 6px; flex-shrink: 0; } .tabbit-status.loading { color: #8b5cf6; } .tabbit-status.ok { color: #16a34a; } .tabbit-status.error { color: #dc2626; } .tabbit-body { flex: 1; overflow-y: auto; padding: 10px 14px; font-size: 14px; line-height: 1.7; min-height: 0; pointer-events: auto; user-select: text; -webkit-user-select: text; cursor: text; } .tabbit-placeholder { color: #888; } .tabbit-msg { margin: 12px 0; } .tabbit-msg-role { font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 4px; } .tabbit-msg-user .tabbit-msg-role { color: #2563eb; } .tabbit-msg-assistant .tabbit-msg-role { color: #7c3aed; } .tabbit-msg-content { padding: 10px 14px; border-radius: 12px; background: #f7f8fc; pointer-events: auto; user-select: text; -webkit-user-select: text; cursor: text; } .tabbit-msg-user .tabbit-msg-content { background: linear-gradient(135deg, #eef2ff, #e0e7ff); border: 1px solid #c7d2fe; } .tabbit-msg-assistant .tabbit-msg-content { background: #fafafa; border: 1px solid #eee; } .tabbit-msg-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; } .tabbit-image-slot:empty { display: none; } .tabbit-image-slot { margin-top: 8px; } .tabbit-img-wrap { text-align: center; padding: 10px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fafafa; } .tabbit-img-wrap img { max-width: 100%; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); cursor: pointer; } .tabbit-img-actions { display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; margin-top: 8px; } .tabbit-img-loading { padding: 24px 14px; border: 1px dashed #c5d3ff; background: linear-gradient(135deg, #f0f4ff, #fff5f8); } .tabbit-img-loading-title { font-size: 13px; color: #667eea; font-weight: 700; margin-top: 8px; } .tabbit-img-loading-sub { font-size: 11px; color: #888; margin-top: 4px; } .tabbit-img-error { margin-top: 8px; padding: 10px 12px; background: #fff3f3; border: 1px solid #ffcccc; border-radius: 8px; color: #c00; font-size: 12px; } .tabbit-spinner { width: 24px; height: 24px; margin: 0 auto; border: 3px solid rgba(102,126,234,.2); border-top-color: #667eea; border-radius: 50%; animation: tabbitSpin .8s linear infinite; } @keyframes tabbitSpin { to { transform: rotate(360deg); } } .tabbit-msg-content > *:first-child { margin-top: 0; } .tabbit-msg-content > *:last-child { margin-bottom: 0; } .tabbit-body h1, .tabbit-body h2, .tabbit-body h3 { font-weight: 700; margin: .8em 0 .4em; color: #5a43c8; } .tabbit-body h1 { font-size: 1.2rem; } .tabbit-body h2 { font-size: 1.1rem; } .tabbit-body h3 { font-size: 1rem; } .tabbit-body p { margin: .4em 0; } .tabbit-body strong { color: #7c3aed; } .tabbit-body ul, .tabbit-body ol { padding-left: 1.5em; margin: .4em 0; } .tabbit-body code { background: rgba(139,92,246,.12); padding: 1px 6px; border-radius: 4px; font-size: .88em; color: #be185d; } .tabbit-body pre { background: rgba(15,23,42,.05); padding: .7em; border-radius: 8px; overflow-x: auto; } .tabbit-body blockquote { border-left: 3px solid #7c3aed; padding: .3em .8em; background: rgba(139,92,246,.08); margin: .5em 0; border-radius: 0 6px 6px 0; } .tabbit-body a { color: #2563eb; text-decoration: underline; cursor: pointer; } .tabbit-input-area { flex-shrink: 0; border-top: 1px solid #eee; padding: 8px 10px 10px; background: #fafafa; } .tabbit-followup-presets { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 7px; } .tabbit-followup-btn { border: 1px solid #d8dce8; background: #fff; color: #374151; border-radius: 999px; padding: 5px 10px; font-size: 12px; cursor: pointer; pointer-events: auto; max-width: 100%; } .tabbit-followup-btn:hover { background: #f1f5ff; border-color: #bfcbff; } .tabbit-followup-btn:disabled { opacity: .5; cursor: not-allowed; } .tabbit-input-row { display: flex; gap: 6px; align-items: flex-end; } #tabbit-chat-input { flex: 1; min-width: 0; border: 1px solid #ddd; border-radius: 10px; padding: 8px 10px; font-size: 13px; font-family: inherit; resize: none; min-height: 38px; max-height: 140px; line-height: 1.5; outline: none; transition: border-color .15s; pointer-events: auto; } #tabbit-chat-input:focus { border-color: #8b5cf6; } .tabbit-send-btn { background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: #fff; border: none; width: 60px; height: 38px; border-radius: 10px; cursor: pointer; font-size: 13px; font-weight: 600; flex-shrink: 0; pointer-events: auto; } .tabbit-send-btn:disabled { opacity: .5; cursor: not-allowed; } .tabbit-input-hint { font-size: 11px; color: #999; margin-top: 4px; text-align: right; } .tabbit-resize-handle { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 50%, rgba(139,92,246,0.4) 50%); border-bottom-right-radius: 14px; z-index: 10; pointer-events: auto; } .tabbit-resize-handle:hover { background: linear-gradient(135deg, transparent 50%, rgba(139,92,246,0.7) 50%); } .tabbit-body .md-table-wrap, .tabbit-msg-content .md-table-wrap { overflow-x: auto; margin: .8em 0; border-radius: 8px; border: 1px solid rgba(139, 92, 246, .25); background: rgba(255, 255, 255, .6); } .tabbit-body .md-table, .tabbit-msg-content .md-table { width: 100%; border-collapse: collapse; font-size: .88em; line-height: 1.55; } .tabbit-body .md-table th, .tabbit-body .md-table td, .tabbit-msg-content .md-table th, .tabbit-msg-content .md-table td { padding: 8px 12px; border-bottom: 1px solid rgba(139, 92, 246, .15); border-right: 1px solid rgba(139, 92, 246, .10); vertical-align: top; text-align: left; word-break: break-word; } .tabbit-body .md-table th:last-child, .tabbit-body .md-table td:last-child, .tabbit-msg-content .md-table th:last-child, .tabbit-msg-content .md-table td:last-child { border-right: none; } .tabbit-body .md-table thead th, .tabbit-msg-content .md-table thead th { background: linear-gradient(135deg, rgba(124, 58, 237, .12), rgba(59, 130, 246, .10)); color: #5a43c8; font-weight: 700; white-space: nowrap; border-bottom: 2px solid rgba(124, 58, 237, .35); } .tabbit-body .md-table tbody tr:nth-child(even), .tabbit-msg-content .md-table tbody tr:nth-child(even) { background: rgba(139, 92, 246, .04); } .tabbit-body .md-table tbody tr:hover, .tabbit-msg-content .md-table tbody tr:hover { background: rgba(124, 58, 237, .08); } .tabbit-body .md-table tbody tr:last-child td, .tabbit-msg-content .md-table tbody tr:last-child td { border-bottom: none; } .tabbit-body .md-table code, .tabbit-msg-content .md-table code { font-size: .85em; padding: 1px 5px; } .tabbit-cursor { display: inline-block; width: 6px; margin-left: 2px; animation: tabbitBlink 1s steps(2, start) infinite; color: #8b5cf6; font-weight: bold; } @keyframes tabbitBlink { to { visibility: hidden; } } /* 📋 自动复制 toast(Shadow DOM 内) */ .tabbit-copy-toast { pointer-events: none; position: fixed; bottom: 90px; right: 16px; background: rgba(0, 0, 0, 0.82); color: #fff; padding: 8px 18px; border-radius: 8px; font-size: 13px; z-index: 2147483647; opacity: 0; transform: translateY(8px); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); backdrop-filter: blur(4px); font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; } .tabbit-copy-toast.show { opacity: 1; transform: translateY(0); } .tabbit-copy-ctx { pointer-events: auto; position: fixed; z-index: 2147483647; background: rgba(30, 30, 30, 0.95); backdrop-filter: blur(8px); border-radius: 10px; padding: 10px 14px; box-shadow: 0 4px 16px rgba(0,0,0,0.3); font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; min-width: 160px; animation: tabbitCtxIn 0.15s ease-out; } @keyframes tabbitCtxIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } .tabbit-copy-ctx-row { display: flex; align-items: center; gap: 8px; color: #eee; font-size: 13px; cursor: pointer; padding: 4px 0; transition: color 0.15s; } .tabbit-copy-ctx-row:hover { color: #fff; } .tabbit-copy-ctx-chk { width: 14px; height: 14px; border: 1.5px solid #888; border-radius: 3px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s ease; } .tabbit-copy-ctx-chk.on { background: #4fc3f7; border-color: #4fc3f7; } .tabbit-copy-ctx-chk.on::after { content: ''; display: block; width: 4px; height: 7px; border: solid #fff; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg) translateY(-1px); } .tabbit-copy-ctx-hint { margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.1); color: #888; font-size: 11px; } /* 📌 加规则小卡片 */ #tabbit-addrule-card { pointer-events: auto; position: fixed; inset: 0; z-index: 2147483647; display: flex; align-items: center; justify-content: center; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; } .tabbit-addrule-mask { position: absolute; inset: 0; background: rgba(0,0,0,.4); backdrop-filter: blur(3px); } .tabbit-addrule-panel { position: relative; background: #fff; color: #222; width: 460px; max-width: 92vw; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,.3); padding: 16px 18px; animation: tabbitAddruleIn .18s ease-out; } @keyframes tabbitAddruleIn { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } } .tabbit-addrule-header { display: flex; align-items: center; justify-content: space-between; font-weight: 700; font-size: 15px; color: #5a43c8; margin-bottom: 10px; } .tabbit-addrule-url { font-size: 12px; color: #666; background: #f5f5f7; border: 1px solid #e5e5ea; border-radius: 8px; padding: 6px 10px; margin-bottom: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tabbit-addrule-list { display: flex; flex-direction: column; gap: 8px; } .tabbit-addrule-option { display: flex; flex-direction: column; align-items: stretch; gap: 4px; text-align: left; padding: 10px 12px; background: #fafafa; border: 1.5px solid #e5e5ea; border-radius: 10px; cursor: pointer; transition: all .15s; font-family: inherit; } .tabbit-addrule-option:hover:not(:disabled) { background: linear-gradient(135deg, #eef2ff, #ede9fe); border-color: #8b5cf6; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(139,92,246,.18); } .tabbit-addrule-option:disabled, .tabbit-addrule-option.is-existing { opacity: .5; cursor: not-allowed; } .tabbit-addrule-option-main { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } .tabbit-addrule-option-label { font-weight: 600; font-size: 14px; color: #222; } .tabbit-addrule-option-desc { font-size: 11px; color: #888; } .tabbit-addrule-option-pattern { font-size: 11px; color: #be185d; background: rgba(139,92,246,.08); padding: 2px 6px; border-radius: 4px; word-break: break-all; align-self: flex-start; } .tabbit-addrule-footer { margin-top: 12px; padding-top: 10px; border-top: 1px dashed #e5e5ea; font-size: 11px; color: #999; } .tabbit-addrule-footer code { background: rgba(139,92,246,.12); padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #be185d; } /* 设置弹窗 */ #${SETTINGS_ID} { pointer-events: auto; position: fixed; inset: 0; z-index: 2147483647; background: rgba(0,0,0,.45); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; } .tabbit-settings-content { background: #fff; color: #222; width: min(920px, 96vw); max-width: 96vw; max-height: 90vh; overflow-y: auto; box-sizing: border-box; border-radius: 14px; padding: 16px 18px; } .tabbit-settings-header { display: flex; justify-content: space-between; align-items: center; font-weight: 700; font-size: 16px; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 12px; } .tabbit-section-title { font-weight: 700; margin: 14px 0 6px; color: #5a43c8; border-top: 1px dashed #e5e5ea; padding-top: 12px; } .tabbit-settings-section-group { margin: 0 0 18px; } .tabbit-settings-group-label { margin: 0 2px 8px; color: #5a43c8; font-size: 12px; font-weight: 800; letter-spacing: .08em; } .tabbit-help { display:block; color: #888; font-size: 12px; margin-bottom: 8px; line-height: 1.6; } .tabbit-help code { background: rgba(139,92,246,.12); padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #be185d; } .tabbit-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; font-size: 13px; } .tabbit-field span { color: #555; font-weight: 600; } .tabbit-field input, .tabbit-field textarea, .tabbit-field select { border: 1px solid #ddd; border-radius: 8px; padding: 7px 9px; font-size: 13px; font-family: inherit; } .tabbit-field small { color: #888; font-size: 11px; } .tabbit-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .tabbit-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; } .tabbit-profile-row { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; } .tabbit-profile-select-wide { flex: 1; min-width: 180px; border: 1px solid #ddd; border-radius: 10px; padding: 8px 9px; font-size: 13px; background: #fff; font-weight: 600; color: #5a43c8; } .tabbit-sync-scope { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: #f9fafb; border: 1px solid #e5e5ea; border-radius: 8px; margin: 8px 0; font-size: 12px; } .tabbit-sync-scope-title { font-weight: 600; color: #5a43c8; margin-bottom: 2px; } .tabbit-sync-scope label { display: flex; align-items: center; gap: 6px; cursor: pointer; } .tabbit-settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } .tabbit-md-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } .tabbit-md-textarea { width: 100%; min-height: 120px; padding: 8px; border: 1px solid #d9d9d9; border-radius: 6px; font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 12px; line-height: 1.5; resize: vertical; box-sizing: border-box; background: #fafafa; color: #333; } .tabbit-model-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; margin-bottom: 8px; align-items: center; min-width: 0; } .tabbit-model-row > .tabbit-model-value { width: 100%; min-width: 0; box-sizing: border-box; padding: 7px 9px; font-size: 13px; border: 1px solid #ddd; border-radius: 7px; } .tabbit-model-row input { padding: 5px 7px; font-size: 12px; border: 1px solid #ddd; border-radius: 6px; } .tabbit-current-model { font-size: 12px; white-space: nowrap; display: flex; align-items: center; gap: 3px; } .tabbit-model-advanced { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 7px 9px; background: #f8f7fc; border-radius: 7px; } .tabbit-model-advanced summary { color: #71678f; font-size: 11px; cursor: pointer; } .tabbit-model-advanced-fields { display: flex; gap: 6px; flex: 1; min-width: 200px; } .tabbit-model-advanced-fields input { min-width: 0; width: 50%; box-sizing: border-box; } .tabbit-remove-model { background: #fee2e2; color: #b91c1c; border: none; border-radius: 6px; cursor: pointer; padding: 6px 10px; } @media (max-width: 520px) { .tabbit-model-advanced-fields { min-width: 100%; } .tabbit-settings-content { padding: 14px; } } .tabbit-tpl-row { display: grid; grid-template-columns: 1.2fr 3fr auto auto; gap: 6px; margin-bottom: 8px; align-items: start; } .tabbit-tpl-row input, .tabbit-tpl-row textarea { border: 1px solid #ddd; border-radius: 6px; padding: 6px 8px; font-size: 12px; font-family: inherit; } .tabbit-tpl-row textarea { min-height: 60px; resize: vertical; } .tabbit-tpl-default { font-size: 11px; display: flex; align-items: center; gap: 3px; } .tabbit-rule-row { display: grid; grid-template-columns: 2fr 1.3fr auto; gap: 6px; margin-bottom: 6px; align-items: center; } .tabbit-rule-row input, .tabbit-rule-row select { padding: 5px 7px; font-size: 12px; border: 1px solid #ddd; border-radius: 6px; } .tabbit-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid #eee; } .tabbit-collapse { border: 1px solid #e5e5ea; border-radius: 10px; margin-bottom: 8px; overflow: hidden; background: #fff; } .tabbit-collapse-header { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; background: linear-gradient(135deg, rgba(139, 92, 246, .06), rgba(59, 130, 246, .04)); cursor: pointer; user-select: none; transition: background .2s; border-bottom: 1px solid transparent; } .tabbit-collapse-header:hover { background: linear-gradient(135deg, rgba(139, 92, 246, .1), rgba(59, 130, 246, .08)); } .tabbit-collapse-header .tabbit-collapse-title { font-weight: 700; font-size: 13px; color: #5a43c8; display: flex; align-items: center; gap: 6px; } .tabbit-collapse-header .tabbit-collapse-arrow { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; transition: transform .25s ease; color: #8b5cf6; font-size: 12px; } .tabbit-collapse.open > .tabbit-collapse-header { border-bottom-color: #e5e5ea; } .tabbit-collapse.open > .tabbit-collapse-header .tabbit-collapse-arrow { transform: rotate(90deg); } .tabbit-collapse-content { padding: 0; max-height: 0; overflow: hidden; transition: max-height .3s ease, padding .3s ease; } .tabbit-collapse.open > .tabbit-collapse-content { padding: 10px 12px; max-height: 5000px; } .tabbit-collapse.open > .tabbit-collapse-header { background: linear-gradient(135deg, rgba(139, 92, 246, .12), rgba(59, 130, 246, .08)); } .tabbit-sheet-handle, .tabbit-more-btn, .tabbit-mobile-more { display: none; } /* 手机布局覆盖桌面保存的位置和尺寸,避免面板落在屏幕外。 */ @media (max-width: 600px), (max-height: 500px) and (pointer: coarse) { #${PANEL_ID} { --tabbit-sheet-visible-height: min(var(--tabbit-sheet-height, 90dvh), calc(var(--tabbit-viewport-height, 100dvh) - 16px - env(safe-area-inset-top) - env(safe-area-inset-bottom))); left: max(8px, env(safe-area-inset-left)) !important; right: max(8px, env(safe-area-inset-right)) !important; top: calc(var(--tabbit-viewport-top, 0px) + var(--tabbit-viewport-height, 100dvh) - var(--tabbit-sheet-visible-height) - 8px - env(safe-area-inset-bottom)) !important; bottom: auto !important; width: auto !important; height: calc(100vh - 16px - env(safe-area-inset-top) - env(safe-area-inset-bottom)) !important; height: var(--tabbit-sheet-visible-height) !important; min-width: 0; min-height: 0; max-width: none; max-height: none; border-radius: 14px; } .tabbit-header { display: flex; gap: 4px; padding: 4px 8px; cursor: default; } .tabbit-title { display: none; } .tabbit-header-actions { display: contents; } .tabbit-sheet-handle { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; height: 44px; padding: 0 4px; border: 0; background: transparent; color: #fff; font-family: inherit; font-size: 14px; font-weight: 600; white-space: nowrap; touch-action: none; user-select: none; -webkit-user-select: none; cursor: ns-resize; } .tabbit-sheet-handle::before { content: ''; width: 20px; height: 4px; border-radius: 4px; background: rgba(255,255,255,.6); } .tabbit-header .tabbit-icon-btn { width: 44px; height: 44px; } .tabbit-more-btn { display: block; font-size: 13px; } .tabbit-mobile-more { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; position: absolute; top: 56px; left: 8px; right: 8px; z-index: 20; max-height: calc(100% - 120px); overflow-y: auto; overscroll-behavior: contain; padding: 10px; border: 1px solid #e5e5ea; border-radius: 12px; background: #fff; box-shadow: 0 8px 28px rgba(0,0,0,.2); } .tabbit-mobile-more select { width: 100%; min-width: 0; max-width: none; height: 44px; font-size: 16px; text-overflow: ellipsis; background: #f5f5f7; } .tabbit-mobile-more .tabbit-toolbar { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; grid-column: 1 / -1; padding: 0; border: 0; } .tabbit-mobile-more #tabbit-prompt-select { grid-column: 1 / -1; } .tabbit-mobile-more button { min-height: 44px; padding: 6px; font-size: 13px; } .tabbit-mobile-more button[data-mobile-label]::after { content: ' ' attr(data-mobile-label); } .tabbit-mobile-more #tabbit-settings-btn { width: auto; background: #f5f5f7; color: #333; grid-column: 1 / -1; } .tabbit-mobile-more .tabbit-followup-presets { grid-column: 1 / -1; margin-bottom: 0; } .tabbit-input-row #tabbit-run-btn { flex-shrink: 0; height: 44px; padding: 6px 10px; font-size: 13px; } .tabbit-status:empty { display: none; } .tabbit-body { padding: 8px 10px; overscroll-behavior: contain; overflow-wrap: anywhere; } .tabbit-msg-content { padding: 10px 12px; } .tabbit-msg-role { overflow-wrap: anywhere; } .tabbit-msg-actions button { min-height: 40px; } .tabbit-input-area { padding: 6px 8px; } .tabbit-followup-presets { flex-wrap: nowrap; overflow-x: auto; } .tabbit-followup-btn { flex-shrink: 0; min-height: 40px; } #tabbit-chat-input { font-size: 16px; min-height: 44px; max-height: 96px; } .tabbit-send-btn { height: 44px; font-size: 15px; } .tabbit-input-hint, .tabbit-resize-handle { display: none; } .tabbit-panel-open #${FLOAT_BTN_ID} { visibility: hidden; } } /* 🥟 浮动按钮(Shadow DOM 内) */ #${FLOAT_BTN_ID} { pointer-events: auto; position: fixed; z-index: 2147483646; width: 44px; height: 44px; padding: 0; border-radius: 50%; background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: white; font-size: 20px; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.25); user-select: none; transition: opacity .2s, transform .2s; } #${FLOAT_BTN_ID}:hover { opacity: 1 !important; transform: scale(1.08); } #${FLOAT_BTN_ID}.dragging { transition: none !important; transform: scale(1.08); } /* 静默分析完成徽章 */ #tabbit-float-badge { position: absolute; top: -4px; right: -4px; width: 14px; height: 14px; border-radius: 7px; background: #22c55e; border: 2px solid rgba(139,92,246,1); display: none; pointer-events: none; animation: tabbitBadgePulse 2s ease-in-out infinite; } #${FLOAT_BTN_ID}.tabbit-has-badge #tabbit-float-badge { display: block; } @keyframes tabbitBadgePulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.25); opacity: 0.7; } } `; } function createExternalStyles() { // 浮动按钮样式已移至 Shadow DOM 内的 createPanelStyles() // 无需在主文档注入额外样式 } function setupShadowDOM() { shadowHost = document.createElement('div'); shadowHost.id = 'tabbit-shadow-host'; document.body.appendChild(shadowHost); shadowRoot = shadowHost.attachShadow({ mode: 'open' }); // 注入面板样式到 Shadow DOM const style = document.createElement('style'); style.textContent = createPanelStyles(); shadowRoot.appendChild(style); // 创建容器 shadowContainer = document.createElement('div'); shadowContainer.id = 'tabbit-shadow-container'; shadowRoot.appendChild(shadowContainer); } /****************************************************************** * 11. 浮动按钮 ******************************************************************/ let floatBtn = null; function createFloatButton() { if (floatBtn) return; floatBtn = document.createElement('div'); floatBtn.id = FLOAT_BTN_ID; floatBtn.title = '打开 AI 摘要(点击自动总结)'; floatBtn.textContent = '🥟'; floatBtn.style.opacity = '1'; // 静默分析完成徽章 const badge = document.createElement('div'); badge.id = 'tabbit-float-badge'; floatBtn.appendChild(badge); shadowContainer.appendChild(floatBtn); applyFloatButtonPosition(); let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0, moved = false; floatBtn.addEventListener('mousedown', e => { dragging = true; moved = false; startX = e.clientX; startY = e.clientY; const rect = floatBtn.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; floatBtn.classList.add('dragging'); e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; const dx = e.clientX - startX, dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true; floatBtn.style.left = Math.max(0, Math.min(innerWidth - 44, startLeft + dx)) + 'px'; floatBtn.style.top = Math.max(0, Math.min(innerHeight - 44, startTop + dy)) + 'px'; floatBtn.style.right = 'auto'; floatBtn.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { if (dragging) { dragging = false; floatBtn.classList.remove('dragging'); if (moved) { const rect = floatBtn.getBoundingClientRect(); config.floatButton.layoutVersion = 1; config.floatButton.side = rect.left + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right'; config.floatButton.offsetX = 16; config.floatButton.offsetY = Math.max(0, Math.round(window.innerHeight - rect.bottom)); config.floatButton.x = null; config.floatButton.y = null; saveConfig(); applyFloatButtonPosition(); const blocker = ev => { ev.stopPropagation(); ev.preventDefault(); floatBtn.removeEventListener('click', blocker, true); }; floatBtn.addEventListener('click', blocker, true); } } }); floatBtn.addEventListener('click', () => { if (moved) return; if (!panelEl || panelEl.classList.contains('tabbit-hidden')) { openPanel(true); } else { closePanel(); } }); } function applyFloatButtonPosition() { if (!floatBtn) return; if (config.floatButton.offsetY != null) { const side = config.floatButton.side === 'left' ? 'left' : 'right'; floatBtn.style[side] = '16px'; floatBtn.style[side === 'left' ? 'right' : 'left'] = 'auto'; floatBtn.style.bottom = config.floatButton.offsetY + 'px'; floatBtn.style.top = 'auto'; } else { floatBtn.style.right = '16px'; floatBtn.style.bottom = '70px'; floatBtn.style.left = 'auto'; floatBtn.style.top = 'auto'; } } window.addEventListener('resize', () => { if (!floatBtn) return; config.floatButton.offsetY = Math.max(0, Math.min(config.floatButton.offsetY ?? 70, window.innerHeight - 44)); applyFloatButtonPosition(); }); /****************************************************************** * 11.5 📋 自动复制模块 ******************************************************************/ let acLastCopyTime = 0; let acCtxEl = null; let acToastEl = null; let acToastTimer = null; let acTempTimer = null; // 🆕 临时自动复制计时器 function acShowToast(text) { if (!acToastEl) { acToastEl = document.createElement('div'); acToastEl.className = 'tabbit-copy-toast'; shadowContainer.appendChild(acToastEl); } clearTimeout(acToastTimer); acToastEl.textContent = text || '✅ 已复制'; acToastEl.classList.add('show'); acToastTimer = setTimeout(() => acToastEl.classList.remove('show'), 1500); } function acBuildCtxMenu() { if (acCtxEl) acCtxEl.remove(); const menu = document.createElement('div'); menu.className = 'tabbit-copy-ctx'; const enabled = config.autoCopy?.enabled !== false; const withSrc = !!config.autoCopy?.withSource; const tempMinutes = Number(config.acTempMinutes || 10); const tempActive = !!acTempTimer; menu.innerHTML = `
    自动复制
    附带出处信息
    ${tempActive ? '⏳ 临时开启中…' : '⏱ 临时开启 ' + tempMinutes + ' 分钟'}
    `; if (floatBtn) { const rect = floatBtn.getBoundingClientRect(); let left = rect.left - 130; let top = rect.top; if (left < 8) left = rect.right + 8; if (top + 100 > window.innerHeight) top = window.innerHeight - 100; menu.style.left = left + 'px'; menu.style.top = top + 'px'; } menu.addEventListener('click', e => { e.stopPropagation(); const row = e.target.closest('[data-ac]'); if (!row) return; if (row.dataset.ac === 'toggle') { config.autoCopy.enabled = config.autoCopy.enabled === false ? true : false; saveConfig(); acBuildCtxMenu(); acShowToast(config.autoCopy.enabled ? '✅ 自动复制已开启' : '⏸️ 自动复制已关闭'); } else if (row.dataset.ac === 'source') { config.autoCopy.withSource = !config.autoCopy.withSource; saveConfig(); acBuildCtxMenu(); acShowToast(config.autoCopy.withSource ? '📎 出处信息已开启' : '📄 出处信息已关闭'); } else if (row.dataset.ac === 'temp') { if (acTempTimer) { // 已在临时模式 → 关闭 clearTimeout(acTempTimer); acTempTimer = null; config.autoCopy.enabled = false; saveConfig(); acBuildCtxMenu(); acShowToast('⏹ 临时自动复制已关闭'); } else { // 开启临时模式 const minutes = Number(config.acTempMinutes || 10); config.autoCopy.enabled = true; saveConfig(); acTempTimer = setTimeout(() => { acTempTimer = null; config.autoCopy.enabled = false; saveConfig(); acShowToast('⏱ ' + minutes + ' 分钟已到,自动复制已关闭'); }, minutes * 60 * 1000); acBuildCtxMenu(); acShowToast('⏱ 已临时开启自动复制 ' + minutes + ' 分钟'); } } }); shadowContainer.appendChild(menu); acCtxEl = menu; } function acCloseCtxMenu() { if (acCtxEl) { acCtxEl.remove(); acCtxEl = null; } } function acIsInsideEditable(el) { if (!el) return false; if (el.isContentEditable) return true; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; } function acCopySelectedText() { if (config.autoCopy?.enabled === false) return; if (acIsInsideEditable(document.activeElement)) return; const selection = window.getSelection(); const text = selection.toString().trim(); const minLen = Number(config.acMinLen || 2); if (!text || text.length < minLen) return; const cooldown = Number(config.acCooldown || 2000); if (Date.now() - acLastCopyTime < cooldown) return; acLastCopyTime = Date.now(); let content = text; if (config.autoCopy?.withSource) { content += `\n—————\n${document.title}\n${window.location.href}`; } navigator.clipboard.writeText(content).then(() => { acShowToast('✅ 已复制'); }).catch(() => {}); } function acToggleAutoCopy() { if (!config.autoCopy) config.autoCopy = { enabled: true, withSource: false }; config.autoCopy.enabled = config.autoCopy.enabled === false ? true : false; saveConfig(); acShowToast(config.autoCopy.enabled ? '✅ 自动复制已开启' : '⏸️ 自动复制已关闭'); } function acInit() { document.addEventListener('mouseup', acCopySelectedText); let acSelTimer = null; document.addEventListener('selectionchange', () => { clearTimeout(acSelTimer); acSelTimer = setTimeout(() => { const sel = window.getSelection(); const t = sel.toString().trim(); const minLen = Number(config.acMinLen || 2); if (t.length >= minLen) acCopySelectedText(); }, 500); }); document.addEventListener('touchend', () => setTimeout(acCopySelectedText, 100)); document.addEventListener('click', acCloseCtxMenu); if (floatBtn) { floatBtn.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); if (acCtxEl) { acCloseCtxMenu(); } else { acBuildCtxMenu(); } }); } } /****************************************************************** * 12. 主面板 ******************************************************************/ let panelEl = null; function createPanel() { if (panelEl) return; panelEl = document.createElement('div'); panelEl.id = PANEL_ID; panelEl.classList.add('tabbit-hidden'); panelEl.innerHTML = `
    🥟 饺子 AI 摘要

    点击「✨ 总结」开始,或在下方输入框直接提问。

    Enter 发送 · Shift+Enter 换行
    `; shadowContainer.appendChild(panelEl); // 应用持久化的尺寸/位置 panelEl.style.width = (config.panel?.width || 420) + 'px'; if (config.panel?.height) { panelEl.style.height = config.panel.height + 'px'; } else { panelEl.style.height = ((config.panel?.heightRatio || 0.8) * 100) + 'vh'; } if (config.panel?.left != null && config.panel?.top != null) { panelEl.style.left = config.panel.left + 'px'; panelEl.style.top = config.panel.top + 'px'; panelEl.style.right = 'auto'; } panelEl.querySelector('#tabbit-close-btn').addEventListener('click', closePanel); panelEl.querySelector('#tabbit-settings-btn').addEventListener('click', openSettings); panelEl.querySelector('#tabbit-run-btn').onclick = () => runSummary(false); panelEl.querySelector('#tabbit-preview-btn').addEventListener('click', showPagePreview); panelEl.querySelector('#tabbit-addrule-btn').addEventListener('click', quickAddCurrentUrlToRules); panelEl.querySelector('#tabbit-copy-btn').addEventListener('click', copyAllConversation); panelEl.querySelector('#tabbit-image-btn').addEventListener('click', (e) => { const idx = getLatestAssistantMessageIndex(); if (idx < 0) { setStatus('先生成一段 AI 总结,再生图', 'error', 1800); return; } triggerImageGenForMessage(idx, e.currentTarget); }); panelEl.querySelector('#tabbit-flomo-btn').addEventListener('click', sendToFlomo); panelEl.querySelector('#tabbit-clear-btn').addEventListener('click', handleClearConversation); panelEl.querySelector('#tabbit-send-btn').addEventListener('click', handleSendChat); const input = panelEl.querySelector('#tabbit-chat-input'); input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !window.matchMedia('(max-width: 600px) and (pointer: coarse), (max-height: 500px) and (pointer: coarse)').matches) { e.preventDefault(); handleSendChat(); } }); input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = Math.min(140, input.scrollHeight) + 'px'; }); enableMobileControls(); enableMobileSheet(); enablePanelDrag(); enablePanelResize(); renderModelSelect(); } function bindOutsidePanelCollapse() { if (document.documentElement.dataset.tabbitAiOutsideCollapseReady === '1') return; document.documentElement.dataset.tabbitAiOutsideCollapseReady = '1'; document.addEventListener('pointerdown', event => { if (!panelEl || panelEl.classList.contains('tabbit-hidden')) return; const path = typeof event.composedPath === 'function' ? event.composedPath() : []; if (shadowHost && path.includes(shadowHost)) return; const target = event.target; if (target && target.closest && target.closest('.tabbit-image-preview-overlay')) return; closePanel(); }, true); } function enableMobileControls() { const media = window.matchMedia('(max-width: 600px), (max-height: 500px) and (pointer: coarse)'); const menu = panelEl.querySelector('#tabbit-mobile-more'); const toggle = panelEl.querySelector('#tabbit-more-btn'); const inputRow = panelEl.querySelector('.tabbit-input-row'); // 移动原控件而非复制,保留模型切换、加载状态及原事件绑定。 const ids = ['tabbit-profile-select', 'tabbit-model-select', 'tabbit-settings-btn', 'tabbit-run-btn', 'tabbit-followup-presets']; const controls = [...ids.map(id => panelEl.querySelector('#' + id)), panelEl.querySelector('.tabbit-toolbar')] .map(node => { const anchor = document.createComment('mobile-control-home'); node.before(anchor); return { node, anchor }; }); const labels = { 'tabbit-settings-btn': '设置', 'tabbit-preview-btn': '正文', 'tabbit-addrule-btn': '规则', 'tabbit-copy-btn': '复制', 'tabbit-image-btn': '配图', 'tabbit-flomo-btn': 'flomo', 'tabbit-clear-btn': '清空' }; Object.entries(labels).forEach(([id, label]) => { panelEl.querySelector('#' + id).dataset.mobileLabel = label; }); const setOpen = open => { menu.classList.toggle('tabbit-hidden', !open); toggle.setAttribute('aria-expanded', String(open)); }; const sync = () => { setOpen(false); controls.forEach(({ node, anchor }) => { if (media.matches) { if (node.id === 'tabbit-run-btn') inputRow.prepend(node); else menu.appendChild(node); } else anchor.after(node); }); }; toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true')); panelEl.addEventListener('pointerdown', event => { if (!menu.contains(event.target) && !toggle.contains(event.target)) setOpen(false); }); menu.addEventListener('click', event => { if (event.target.closest('button')) setOpen(false); }); panelEl.addEventListener('keydown', event => { if (event.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') { setOpen(false); toggle.focus(); } }); media.addEventListener('change', sync); sync(); } function enableMobileSheet() { const media = window.matchMedia('(max-width: 600px), (max-height: 500px) and (pointer: coarse)'); const stops = [0.35, 0.6, 0.95]; const handle = panelEl.querySelector('#tabbit-sheet-handle'); const input = panelEl.querySelector('#tabbit-chat-input'); let ratio = stops.includes(config.panel?.mobileHeightRatio) ? config.panel.mobileHeightRatio : 0.95; let drag = null; let suppressClick = false; const availableHeight = () => { return Math.max(0, (window.visualViewport?.height || window.innerHeight) - 16); }; const heightFor = value => Math.min(availableHeight(), Math.max(280, availableHeight() * value)); const render = height => { const viewport = window.visualViewport; panelEl.style.setProperty('--tabbit-viewport-height', (viewport?.height || window.innerHeight) + 'px'); panelEl.style.setProperty('--tabbit-viewport-top', (viewport?.offsetTop || 0) + 'px'); const nextHeight = height ?? heightFor(shadowRoot.activeElement === input ? 0.95 : ratio); panelEl.style.setProperty('--tabbit-sheet-height', nextHeight + 'px'); handle.title = (ratio === 0.35 ? '小窗' : ratio === 0.6 ? '半屏' : '全屏') + ':上下拖动调整,点击切换高度'; }; const persist = value => { ratio = value; config.panel = { ...config.panel, mobileHeightRatio: ratio }; saveConfig(); render(); }; handle.addEventListener('pointerdown', event => { if (!media.matches || !event.isPrimary || event.button !== 0) return; input.blur(); suppressClick = false; drag = { id: event.pointerId, y: event.clientY, height: panelEl.getBoundingClientRect().height, moved: false }; handle.setPointerCapture(event.pointerId); }); handle.addEventListener('pointermove', event => { if (!drag || event.pointerId !== drag.id) return; const delta = drag.y - event.clientY; if (Math.abs(delta) > 6) drag.moved = true; if (drag.moved) render(Math.min(heightFor(0.95), Math.max(heightFor(0.35), drag.height + delta))); }); const finish = event => { if (!drag || event.pointerId !== drag.id) return; const ended = drag; drag = null; suppressClick = ended.moved; if (event.type === 'pointerup' && ended.moved) { const height = panelEl.getBoundingClientRect().height; persist(stops.reduce((best, stop) => Math.abs(heightFor(stop) - height) < Math.abs(heightFor(best) - height) ? stop : best, ratio)); } else render(); if (handle.hasPointerCapture(ended.id)) handle.releasePointerCapture(ended.id); }; handle.addEventListener('pointerup', finish); handle.addEventListener('pointercancel', finish); handle.addEventListener('lostpointercapture', finish); handle.addEventListener('click', () => { if (suppressClick) { suppressClick = false; return; } persist(stops[(stops.indexOf(ratio) + 1) % stops.length]); }); const sync = () => { drag = null; render(); }; window.addEventListener('resize', sync); window.visualViewport?.addEventListener('resize', sync); window.visualViewport?.addEventListener('scroll', () => render()); media.addEventListener('change', sync); input.addEventListener('focus', () => render()); input.addEventListener('blur', () => render()); render(); } function enablePanelDrag() { const handle = panelEl.querySelector('#tabbit-drag-handle'); let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0; handle.addEventListener('mousedown', e => { if (window.matchMedia('(max-width: 600px), (max-height: 500px) and (pointer: coarse)').matches) return; if (e.target.tagName === 'SELECT' || e.target.tagName === 'BUTTON') return; dragging = true; startX = e.clientX; startY = e.clientY; const rect = panelEl.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; panelEl.style.right = 'auto'; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; panelEl.style.left = (startLeft + (e.clientX - startX)) + 'px'; panelEl.style.top = (startTop + (e.clientY - startY)) + 'px'; }); document.addEventListener('mouseup', () => { if (dragging) { dragging = false; const rect = panelEl.getBoundingClientRect(); config.panel = { ...config.panel, left: rect.left, top: rect.top }; saveConfig(); } }); } function enablePanelResize() { const handle = panelEl.querySelector('#tabbit-resize-handle'); let resizing = false, startX = 0, startY = 0, startW = 0, startH = 0; handle.addEventListener('mousedown', e => { resizing = true; startX = e.clientX; startY = e.clientY; const rect = panelEl.getBoundingClientRect(); startW = rect.width; startH = rect.height; e.preventDefault(); e.stopPropagation(); }); document.addEventListener('mousemove', e => { if (!resizing) return; const newW = Math.max(340, Math.min(window.innerWidth - 20, startW + (e.clientX - startX))); const newH = Math.max(360, Math.min(window.innerHeight - 20, startH + (e.clientY - startY))); panelEl.style.width = newW + 'px'; panelEl.style.height = newH + 'px'; }); document.addEventListener('mouseup', () => { if (resizing) { resizing = false; const rect = panelEl.getBoundingClientRect(); config.panel = { ...config.panel, width: Math.round(rect.width), height: Math.round(rect.height) }; saveConfig(); } }); } function openPanel(autoRun) { if (!panelEl) createPanel(); bindOutsidePanelCollapse(); panelEl.classList.remove('tabbit-hidden'); shadowContainer.classList.add('tabbit-panel-open'); renderModelSelect(); // 🤫 优先展示静默分析的结果 if (pendingSummary) { conversation = pendingSummary.conversation; pageContextLoaded = pendingSummary.pageContextLoaded; pendingSummary = null; hideFloatBadge(); renderConversation(); setStatus('已完成后台分析', 'ok', 2000); return; } if (autoRun) { const hasVisible = conversation.filter(m => m.role !== 'system').length > 0; if (!hasVisible) { runSummary(true); } } } function closePanel() { if (panelEl) panelEl.classList.add('tabbit-hidden'); shadowContainer?.classList.remove('tabbit-panel-open'); } function togglePanel() { if (!panelEl || panelEl.classList.contains('tabbit-hidden')) openPanel(false); else closePanel(); } function renderProfileSelect() { const root = shadowRoot; if (!root) return; const select = root.querySelector('#tabbit-profile-select'); if (!select) return; select.innerHTML = ''; config.profiles.forEach(p => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; if (p.id === config.currentProfileId) opt.selected = true; select.appendChild(opt); }); select.onchange = function () { setCurrentProfile(this.value); renderModelSelect(); setStatus(`已切换到「${getCurrentProfile().name}」`, 'ok', 1500); }; } function renderModelSelect() { const root = shadowRoot; if (!root) return; renderProfileSelect(); const select = root.querySelector('#tabbit-model-select'); if (!select) return; const profile = getCurrentProfile(); select.innerHTML = ''; normalizeModels(profile.models).forEach(model => { const option = document.createElement('option'); option.value = model.value; option.textContent = model.name || model.value; if (model.value === profile.currentModel) option.selected = true; select.appendChild(option); }); select.onchange = function () { profile.currentModel = this.value; saveConfig(); }; renderPromptSelect(); } function renderPromptSelect() { const root = shadowRoot; if (!root) return; const select = root.querySelector('#tabbit-prompt-select'); if (!select) return; const matchedTpl = getTemplateForUrl(window.location.href); select.innerHTML = ''; config.promptTemplates.forEach(t => { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = t.name; if (t.id === matchedTpl.id) opt.selected = true; select.appendChild(opt); }); select.onchange = function () { const tpl = config.promptTemplates.find(t => t.id === this.value); if (!tpl) return; setStatus(`已切换到「${tpl.name}」,正在追加总结…`, 'loading'); abortCurrentRequest(); setTimeout(() => runSummaryAppend(tpl), 100); }; } /****************************************************************** * 13. 📋 复制 / 🌱 flomo / 🗑 清空 ******************************************************************/ function buildConversationText() { const visible = conversation.filter(m => m.role !== 'system' && !m.meta?.hidden); if (!visible.length) return ''; const lines = []; lines.push(`📄 ${document.title}`); lines.push(`🔗 ${location.href}`); lines.push(''); const firstAiIdx = visible.findIndex(m => m.role === 'assistant'); if (firstAiIdx >= 0) { lines.push('===== 🤖 AI 总结 ====='); lines.push(visible[firstAiIdx].content); const rest = visible.slice(firstAiIdx + 1); if (rest.length) { lines.push(''); lines.push('===== 💬 后续对话 ====='); rest.forEach(m => { const tag = m.role === 'user' ? '【我】' : `【AI · ${m.meta?.model || ''}】`; lines.push(`${tag}\n${m.content}`); lines.push(''); }); } } else { visible.forEach(m => { const tag = m.role === 'user' ? '【我】' : `【AI · ${m.meta?.model || ''}】`; lines.push(`${tag}\n${m.content}`); lines.push(''); }); } return lines.join('\n').trim(); } function copyAllConversation() { const text = buildConversationText(); if (!text.trim()) { setStatus('没有可复制的内容', 'error', 1500); return; } if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text); setStatus('已复制全部对话到剪贴板', 'ok', 1500); } // 两个摘要脚本保持相同的检索标签和请求结果规则。 function formatFlomoNote(text, source, kind, customTags) { const tags = [...new Set(('#AI摘要 #AI摘要/' + source + ' ' + (customTags || '')) .split(/\s+/).filter(Boolean).map(tag => '#' + tag.replace(/^#+/, '')))]; return text.trim() + '\n\n类型:' + kind + '\n\n' + tags.join(' '); } const flomoPending = new Set(); function postFlomoNote(content, apiUrl) { // 阻止同一页面上自动发送与手动点击同时提交同一条笔记;不自动重试超时请求。 const key = apiUrl + '\n' + content; if (flomoPending.has(key)) return Promise.resolve(false); flomoPending.add(key); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: apiUrl, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ content }), timeout: 30000, onload(res) { try { const data = JSON.parse(res.responseText || '{}'); const ok = res.status >= 200 && res.status < 300 && (data.code === 0 || data.code === 200 || (data.code == null && (data.message === 'ok' || data.message === 'success'))); if (!ok) throw new Error(data.message || 'HTTP ' + res.status); resolve(true); } catch (err) { reject(err); } }, onerror() { reject(new Error('网络错误')); }, ontimeout() { reject(new Error('发送超时,可能已送达,请先检查 flomo')); }, onabort() { reject(new Error('发送已取消')); } }); }).finally(() => flomoPending.delete(key)); } async function sendToFlomo(options = {}) { if (!config.flomoApiUrl) { if (!options.auto) { alert('请先在设置中配置 flomo API 地址。'); openSettings(); } return; } const text = options.text === undefined ? buildConversationText() : options.text; if (!text.trim()) { setStatus('没有可发送的内容', 'error', 1500); return; } const content = formatFlomoNote(text, '网页', '网页摘要', config.flomoTags); const btn = shadowRoot?.querySelector('#tabbit-flomo-btn'); if (btn) { btn.disabled = true; btn.textContent = '⏳'; } try { if (await postFlomoNote(content, config.flomoApiUrl)) setStatus('已发送到 flomo', 'ok', 2000); } catch (err) { setStatus('发送到 flomo 失败:' + err.message, 'error', 5000); if (options.auto) acShowToast('发送到 flomo 失败:' + err.message); } finally { if (btn) { btn.textContent = '🌱'; btn.disabled = false; } } } function autoSendFlomo(text, source) { if (config.flomoAutoSend !== true || !config.flomoApiUrl || !String(text || '').trim()) return; void sendToFlomo({ auto: true, text: `📄 ${source.title}\n🔗 ${source.url}\n\n===== 🤖 AI 总结 =====\n${text}` }); } function handleClearConversation() { const visibleCount = conversation.filter(m => m.role !== 'system').length; if (!visibleCount) { setStatus('对话已是空的', '', 1500); return; } if (!confirm(`确定清空当前 ${visibleCount} 条对话吗?\n(不会影响页面正文上下文,下次提问将基于当前页面重新开始)`)) return; resetConversation(); setStatus('对话已清空', 'ok', 1500); } /****************************************************************** * 👁 预览抓取到的正文 ******************************************************************/ function showPagePreview() { const root = shadowRoot; if (!root) return; const text = getPageText(); const len = text.length; const max = Number(config.extractMaxChars || 16000); const body = root.querySelector('#tabbit-body'); if (!body) return; const previewHtml = `
    👁 正文预览(${len.toLocaleString()} / ${max.toLocaleString()} 字符)
    ${escapeAttr(text) || '(未抓取到正文)'}
    `; const old = body.querySelector('#tabbit-preview-block'); if (old) old.remove(); body.insertAdjacentHTML('beforeend', previewHtml); body.scrollTop = body.scrollHeight; body.querySelector('#tabbit-preview-copy').addEventListener('click', () => { if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text); setStatus('正文已复制', 'ok', 1500); }); body.querySelector('#tabbit-preview-close').addEventListener('click', () => { body.querySelector('#tabbit-preview-block')?.remove(); }); setStatus(`已抓取 ${len.toLocaleString()} 字符`, 'ok', 1500); } /****************************************************************** * 📌 把当前网址快捷加入规则列表 ******************************************************************/ function quickAddCurrentUrlToRules() { shadowContainer.querySelector('#tabbit-addrule-card')?.remove(); const origin = location.origin; const path = location.pathname; const dir = path.replace(/[^/]+$/, ''); const candidates = [ { label: '仅这个页面', desc: '当前精确路径', value: origin + path }, { label: '当前目录下全部', desc: '推荐 ⭐', value: origin + dir + '*' }, { label: '整站全部页面', desc: '范围最大', value: origin + '/*' } ]; const existing = new Set(config.urlRules); const card = document.createElement('div'); card.id = 'tabbit-addrule-card'; card.innerHTML = `
    📌 把当前网址加入规则
    🔗 ${escapeAttr(location.href)}
    ${candidates.map((c) => ` `).join('')}
    `; shadowContainer.appendChild(card); const close = () => card.remove(); card.querySelector('#tabbit-addrule-close').addEventListener('click', close); card.querySelector('.tabbit-addrule-mask').addEventListener('click', close); const onKey = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }; document.addEventListener('keydown', onKey); card.querySelectorAll('.tabbit-addrule-option').forEach(btn => { btn.addEventListener('click', () => { const pattern = btn.dataset.pattern; if (!pattern || existing.has(pattern)) return; config.urlRules.push(pattern); saveConfig(); renderPromptSelect(); setStatus(`✅ 已添加规则:${pattern}`, 'ok', 2500); if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) { renderSettingsUrlRules(); } close(); }); }); } /****************************************************************** * 14. 总结 + 💬 连续对话 ******************************************************************/ async function runSummary(isAuto) { const flomoSource = { title: document.title, url: location.href }; if (!panelEl) createPanel(); if (panelEl.classList.contains('tabbit-hidden')) panelEl.classList.remove('tabbit-hidden'); if (!checkApiConfig()) return; const root = shadowRoot; const tplSelect = root?.querySelector('#tabbit-prompt-select'); const tplId = tplSelect?.value; const template = config.promptTemplates.find(t => t.id === tplId) || getDefaultTemplate(); const pageText = getPageText(); if (!pageText || pageText.length < 30) { setStatus('页面正文过短', 'error', 2500); return; } conversation = []; pageContextLoaded = false; ensurePageContext(); let userPrompt = `请按以下要求总结当前页面:\n\n${template.text}`; const mdContent = getSystemPromptMd(); if (mdContent && config.systemPromptMdAsSystem === false) { userPrompt = '==== 预加载上下文 ====\n' + mdContent + '\n\n' + userPrompt; } conversation.push({ role: 'user', content: userPrompt, meta: { hidden: true } }); const runBtn = root?.querySelector('#tabbit-run-btn'); runBtn.disabled = false; runBtn.textContent = '⏹ 停止'; runBtn.onclick = abortCurrentRequest; setStatus(`使用「${template.name}」模板,模型 ${getCurrentModelDisplayName()}…`, 'loading'); const streamingMsg = { role: 'assistant', content: '', meta: { model: getCurrentModelDisplayName(), streaming: true } }; conversation.push(streamingMsg); renderConversation(); let renderTimer = null; const scheduleRender = () => { if (renderTimer) return; renderTimer = setTimeout(() => { renderTimer = null; renderConversation(); }, 60); }; try { const messagesForApi = mapMessagesForApi(); const finalText = await callChatApi(messagesForApi, (delta, full) => { streamingMsg.content = full; scheduleRender(); }); streamingMsg.content = finalText; streamingMsg.meta.streaming = false; if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } renderConversation(); setStatus('完成', 'ok', 1500); autoSendFlomo(finalText, flomoSource); if (config.enableImageGen === true) { triggerImageGenForMessage(conversation.indexOf(streamingMsg), null, { auto: true }); } } catch (err) { if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } if (streamingMsg.content) { streamingMsg.content += `\n\n❌ ${err.message || err}`; } else { streamingMsg.content = `❌ ${err.message || err}`; } streamingMsg.meta.streaming = false; renderConversation(); setStatus('生成失败', 'error', 2500); } finally { runBtn.disabled = false; runBtn.textContent = '✨ 总结'; runBtn.onclick = () => runSummary(false); } } /* ─── 🤫 静默分析:后台执行,不弹出面板 ─── */ async function runSummarySilent() { const flomoSource = { title: document.title, url: location.href }; if (!checkApiConfig()) return; const profile = getCurrentProfile(); const template = getTemplateForUrl(window.location.href); const pageText = getPageText(); if (!pageText || pageText.length < 30) { console.log('[饺子AI-静默] 页面正文过短,跳过'); return; } const silentConv = []; const mdContent = getSystemPromptMd(); if (mdContent && config.systemPromptMdAsSystem !== false) { silentConv.push({ role: 'system', content: '==== 预加载上下文 ====' + '\n' + mdContent }); } silentConv.push({ role: 'system', content: buildPageSystemPrompt() }); let userPrompt = `请按以下要求总结当前页面:\n\n${template.text}`; if (mdContent && config.systemPromptMdAsSystem === false) { userPrompt = '==== 预加载上下文 ====\n' + mdContent + '\n\n' + userPrompt; } silentConv.push({ role: 'user', content: userPrompt, meta: { hidden: true } }); console.log('[饺子AI-静默] 开始后台分析…'); try { const messagesForApi = silentConv .filter(m => !m.meta?.streaming) .map(m => ({ role: m.role, content: m.meta?.apiContent || m.content })); const finalText = await callChatApi(messagesForApi); silentConv.push({ role: 'assistant', content: finalText, meta: { model: getCurrentModelDisplayName(), streaming: false } }); pendingSummary = { conversation: silentConv, pageContextLoaded: true }; autoSendFlomo(finalText, flomoSource); showFloatBadge(); console.log('[饺子AI-静默] ✅ 分析完成,点击悬浮球查看'); } catch (err) { console.warn('[饺子AI-静默] 分析失败:', err.message || err); } } async function runSummaryAppend(template) { const flomoSource = { title: document.title, url: location.href }; if (!panelEl) createPanel(); if (panelEl.classList.contains('tabbit-hidden')) panelEl.classList.remove('tabbit-hidden'); if (!checkApiConfig()) return; const pageText = getPageText(); if (!pageText || pageText.length < 30) { setStatus('页面正文过短', 'error', 2500); return; } ensurePageContext(); let userPrompt = `请按以下要求重新总结当前页面(使用「${template.name}」风格):\n\n${template.text}`; const mdContent2 = getSystemPromptMd(); if (mdContent2 && config.systemPromptMdAsSystem === false) { userPrompt = '==== 预加载上下文 ====\n' + mdContent2 + '\n\n' + userPrompt; } conversation.push({ role: 'user', content: userPrompt, meta: { hidden: true } }); const root = shadowRoot; const runBtn = root?.querySelector('#tabbit-run-btn'); runBtn.disabled = false; runBtn.textContent = '⏹ 停止'; runBtn.onclick = abortCurrentRequest; setStatus(`使用「${template.name}」模板追加总结,模型 ${getCurrentModelDisplayName()}…`, 'loading'); const streamingMsg = { role: 'assistant', content: '', meta: { model: `${getCurrentModelDisplayName()} · ${template.name}`, streaming: true } }; conversation.push(streamingMsg); renderConversation(); let renderTimer = null; const scheduleRender = () => { if (renderTimer) return; renderTimer = setTimeout(() => { renderTimer = null; renderConversation(); }, 60); }; try { const messagesForApi = mapMessagesForApi(); const finalText = await callChatApi(messagesForApi, (delta, full) => { streamingMsg.content = full; scheduleRender(); }); streamingMsg.content = finalText; streamingMsg.meta.streaming = false; if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } renderConversation(); setStatus('完成', 'ok', 1500); autoSendFlomo(finalText, flomoSource); if (config.enableImageGen === true) { triggerImageGenForMessage(conversation.indexOf(streamingMsg), null, { auto: true }); } } catch (err) { if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } if (streamingMsg.content) { streamingMsg.content += `\n\n❌ ${err.message || err}`; } else { streamingMsg.content = `❌ ${err.message || err}`; } streamingMsg.meta.streaming = false; renderConversation(); setStatus('生成失败', 'error', 2500); } finally { runBtn.disabled = false; runBtn.textContent = '✨ 总结'; runBtn.onclick = () => runSummary(false); } } async function sendChatText(text, displayText, userMeta) { const root = shadowRoot; if (!root) return; if (!checkApiConfig()) return; const input = root.querySelector('#tabbit-chat-input'); const sendBtn = root.querySelector('#tabbit-send-btn'); const apiText = String(text || '').trim(); const visibleText = String(displayText || apiText).trim(); if (!apiText) return; ensurePageContext(); const meta = { ...(userMeta || {}) }; if (visibleText !== apiText) meta.apiContent = apiText; appendMessage('user', visibleText, meta); if (input) { input.value = ''; input.style.height = 'auto'; } if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = '...'; } setFollowupPresetsDisabled(true); setStatus(`AI 思考中(${getCurrentModelDisplayName()})…`, 'loading'); const streamingMsg = { role: 'assistant', content: '', meta: { model: getCurrentModelDisplayName(), streaming: true } }; conversation.push(streamingMsg); renderConversation(); let renderTimer = null; const scheduleRender = () => { if (renderTimer) return; renderTimer = setTimeout(() => { renderTimer = null; renderConversation(); }, 60); }; try { const messagesForApi = mapMessagesForApi(); const finalText = await callChatApi(messagesForApi, (delta, full) => { streamingMsg.content = full; scheduleRender(); }); streamingMsg.content = finalText; streamingMsg.meta.streaming = false; if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } renderConversation(); setStatus('完成', 'ok', 1200); } catch (err) { if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; } if (streamingMsg.content) { streamingMsg.content += `\n\n❌ ${err.message || err}`; } else { streamingMsg.content = `❌ ${err.message || err}`; } streamingMsg.meta.streaming = false; renderConversation(); setStatus('生成失败', 'error', 2500); } finally { if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = '发送'; } setFollowupPresetsDisabled(false); input?.focus(); } } async function handlePresetFollowup(preset) { if (preset === 'comments') { return sendChatText(buildCommentFollowupPrompt(), '总结评论区'); } if (preset === 'title') { const title = String(document.title || '').replace(/\s+/g, ' ').trim(); const shortTitle = title.length > 80 ? title.slice(0, 80) + '...' : title; const label = isPageTitleQuestion() ? '回答标题问题' : '按标题追问'; return sendChatText(buildTitleFollowupPrompt(), `${label}:${shortTitle || '当前页面标题'}`); } } async function handleSendChat() { const input = shadowRoot?.querySelector('#tabbit-chat-input'); return sendChatText(input?.value || ''); } /****************************************************************** * 15. 设置弹窗 ******************************************************************/ let settingsEl = null; function createSettingsModal() { if (settingsEl) return; settingsEl = document.createElement('div'); settingsEl.id = SETTINGS_ID; settingsEl.classList.add('tabbit-hidden'); settingsEl.innerHTML = `
    ⚙️ 饺子 AI 设置
    连接与模型
    🔌 连接与模型 ▶
    不同 API 服务商可建多个预设,一键切换。提示词模板和网址规则跨预设共用。
    默认生成参数(可选)
    🤖 模型预设
    模型属于当前连接预设;常用操作保持简洁,高级参数按需展开。
    自动化与显示
    🌐 网址规则 ▶
    支持通配符 *。匹配的页面打开时自动弹出面板(需勾选自动弹出)。每条规则可绑定一个提示词模板。
    ⚙️ 自动化与显示 ▶
    📋 自动复制
    选中文本后自动复制到剪贴板,可选择是否附带页面标题和链接作为出处。也可右键点击浮动按钮快速切换。
    内容与集成
    📝 提示词模板 ▶
    🌱 Flomo ▶
    🖼️ 生图 ▶
    默认复用当前 API 预设的 URL 和 Key;如果生图服务不同,再单独填写下面两项。摘要完成后也可以在结果区手动生成配图。
    📚 预加载上下文 ▶
    这里的 Markdown 会插入到提示词最前面。云端方式:先在浏览记录脚本生成用户画像,再点“从坚果云读取用户画像”。本地方式:导出画像 MD 后点“导入 .md 文件”。
    字数:0
    高级与维护
    🛠️ 坚果云 WebDAV ▶
    • tabbit-shared/ai-profiles.json — API 预设(多脚本共享)
    • tabbit-shared/user-profile.json — 用户画像(浏览记录脚本生成)
    • tabbit-ai-summary/config.json — 模板 + 网址规则(本脚本专属)
    ⚠️ API Key 会上传到云端,请确保账号安全。
    同步范围:
    `; shadowContainer.appendChild(settingsEl); settingsEl.querySelector('#tabbit-set-close').addEventListener('click', closeSettings); settingsEl.querySelector('#tabbit-set-cancel').addEventListener('click', closeSettings); settingsEl.querySelector('#tabbit-set-save').addEventListener('click', saveSettingsFromForm); // MD 文件导入/清空/字数统计 const mdUploadBtn = settingsEl.querySelector('#tabbit-md-upload'); const mdFileInput = settingsEl.querySelector('#tabbit-md-file'); const mdClearBtn = settingsEl.querySelector('#tabbit-md-clear'); const mdLoadProfileBtn = settingsEl.querySelector('#tabbit-md-load-profile'); const mdTextarea = settingsEl.querySelector('#tabbit-set-system-md'); const mdCharsEl = settingsEl.querySelector('#tabbit-md-chars'); if (mdLoadProfileBtn) { mdLoadProfileBtn.addEventListener('click', handleLoadUserProfileMd); } if (mdUploadBtn && mdFileInput) { mdUploadBtn.addEventListener('click', () => mdFileInput.click()); mdFileInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { mdTextarea.value = ev.target.result; if (mdCharsEl) mdCharsEl.textContent = '字数:' + ev.target.result.length; }; reader.readAsText(file); mdFileInput.value = ''; }); } if (mdClearBtn && mdTextarea) { mdClearBtn.addEventListener('click', () => { mdTextarea.value = ''; if (mdCharsEl) mdCharsEl.textContent = '字数:0'; }); } if (mdTextarea && mdCharsEl) { mdTextarea.addEventListener('input', () => { mdCharsEl.textContent = '字数:' + mdTextarea.value.length; }); } settingsEl.querySelector('#tabbit-set-profile-select').addEventListener('change', handleProfileSwitch); settingsEl.querySelector('#tabbit-profile-add').addEventListener('click', handleProfileAdd); settingsEl.querySelector('#tabbit-profile-clone').addEventListener('click', handleProfileClone); settingsEl.querySelector('#tabbit-profile-rename').addEventListener('click', handleProfileRename); settingsEl.querySelector('#tabbit-profile-delete').addEventListener('click', handleProfileDelete); settingsEl.querySelector('#tabbit-test-api').addEventListener('click', testApiConnection); settingsEl.querySelector('#tabbit-fetch-models').addEventListener('click', fetchModelsFromApi); settingsEl.querySelector('#tabbit-add-model').addEventListener('click', () => { syncModelsFromSettings(); getCurrentProfile().models.push({ name: '', value: '', temperature: '', maxTokens: '' }); renderSettingsModels(); }); settingsEl.querySelector('#tabbit-add-tpl').addEventListener('click', () => { syncTemplatesFromSettings(); config.promptTemplates.push({ id: makeId('tpl'), name: '新模板', text: '' }); renderSettingsTemplates(); }); settingsEl.querySelector('#tabbit-add-rule').addEventListener('click', () => { syncUrlRulesFromSettings(); config.urlRules.push('https://example.com/*'); renderSettingsUrlRules(); }); settingsEl.querySelector('#tabbit-add-current-url').addEventListener('click', () => { syncUrlRulesFromSettings(); const cur = location.origin + location.pathname.replace(/[^/]+$/, '*'); if (!config.urlRules.includes(cur)) config.urlRules.push(cur); renderSettingsUrlRules(); }); settingsEl.querySelector('#tabbit-cloud-test').addEventListener('click', handleCloudTest); settingsEl.querySelector('#tabbit-cloud-pull').addEventListener('click', handleCloudPull); settingsEl.querySelector('#tabbit-cloud-push').addEventListener('click', handleCloudPush); settingsEl.querySelector('#tabbit-cloud-force-push').addEventListener('click', handleCloudForcePush); settingsEl.querySelectorAll('.tabbit-collapse-header[data-collapse="toggle"]').forEach(header => { header.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; const collapse = header.closest('.tabbit-collapse'); if (collapse) collapse.classList.toggle('open'); }); }); } function openSettings() { if (!settingsEl) createSettingsModal(); settingsEl.classList.remove('tabbit-hidden'); fillSettingsForm(); } function closeSettings() { if (settingsEl) settingsEl.classList.add('tabbit-hidden'); } function syncCurrentProfileFromForm() { if (!settingsEl) return; const profile = getCurrentProfile(); if (!profile) return; profile.name = settingsEl.querySelector('#tabbit-set-profile-name').value.trim() || profile.name; profile.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim(); profile.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim(); profile.temperature = Number(settingsEl.querySelector('#tabbit-set-temperature').value || 0.7); profile.maxTokens = Number(settingsEl.querySelector('#tabbit-set-max-tokens').value || 2000); syncModelsFromSettings(); } function handleProfileSwitch(e) { syncCurrentProfileFromForm(); setCurrentProfile(e.target.value); fillSettingsForm(); } function handleProfileAdd() { const name = prompt('新预设名称:', '新配置'); if (!name) return; syncCurrentProfileFromForm(); addProfile(name, false); fillSettingsForm(); } function handleProfileClone() { const cur = getCurrentProfile(); const name = prompt('复制为新预设:', cur.name + '(副本)'); if (!name) return; syncCurrentProfileFromForm(); addProfile(name, true); fillSettingsForm(); } function handleProfileRename() { const cur = getCurrentProfile(); const name = prompt('重命名当前预设:', cur.name); if (!name) return; renameProfile(cur.id, name); fillSettingsForm(); } function handleProfileDelete() { const cur = getCurrentProfile(); if (config.profiles.length <= 1) { alert('至少保留一个预设。'); return; } if (!confirm(`确定删除预设「${cur.name}」?`)) return; deleteProfile(cur.id); fillSettingsForm(); } function fillSettingsForm() { if (!settingsEl) return; const ps = settingsEl.querySelector('#tabbit-set-profile-select'); ps.innerHTML = ''; config.profiles.forEach(p => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; if (p.id === config.currentProfileId) opt.selected = true; ps.appendChild(opt); }); const profile = getCurrentProfile(); settingsEl.querySelector('#tabbit-set-profile-name').value = profile.name || ''; settingsEl.querySelector('#tabbit-set-api-url').value = profile.apiUrl || ''; settingsEl.querySelector('#tabbit-set-api-key').value = profile.apiKey || ''; settingsEl.querySelector('#tabbit-set-temperature').value = profile.temperature ?? 0.7; settingsEl.querySelector('#tabbit-set-max-tokens').value = profile.maxTokens ?? 2000; settingsEl.querySelector('#tabbit-set-panel-width').value = config.panel?.width || 460; settingsEl.querySelector('#tabbit-set-height-ratio').value = config.panel?.heightRatio || 0.8; settingsEl.querySelector('#tabbit-set-extract-max').value = config.extractMaxChars || 16000; settingsEl.querySelector('#tabbit-set-api-timeout').value = config.apiTimeout || 120000; settingsEl.querySelector('#tabbit-set-ac-min-len').value = config.acMinLen || 2; settingsEl.querySelector('#tabbit-set-ac-cooldown').value = config.acCooldown || 2000; settingsEl.querySelector('#tabbit-set-ac-temp-minutes').value = config.acTempMinutes || 10; settingsEl.querySelector('#tabbit-set-auto-run').checked = !!config.autoRun; settingsEl.querySelector('#tabbit-set-silent-auto-run').checked = !!config.silentAutoRun; settingsEl.querySelector("#tabbit-set-enable-thinking").checked = !!config.enableThinking; settingsEl.querySelector('#tabbit-set-flomo-auto').checked = config.flomoAutoSend === true; settingsEl.querySelector('#tabbit-set-flomo-api').value = config.flomoApiUrl || ''; settingsEl.querySelector('#tabbit-set-flomo-tags').value = config.flomoTags ?? '#饺子AI摘要'; settingsEl.querySelector('#tabbit-set-auto-copy').checked = config.autoCopy?.enabled !== false; settingsEl.querySelector('#tabbit-set-auto-copy-source').checked = !!config.autoCopy?.withSource; settingsEl.querySelector('#tabbit-set-enable-image-gen').checked = config.enableImageGen === true; settingsEl.querySelector('#tabbit-set-image-api-url').value = config.imageGenApiUrl || ''; settingsEl.querySelector('#tabbit-set-image-api-key').value = config.imageGenApiKey || ''; settingsEl.querySelector('#tabbit-set-image-model').value = config.imageGenModel || DEFAULT_CONFIG.imageGenModel; settingsEl.querySelector('#tabbit-set-image-size').value = config.imageGenSize || DEFAULT_CONFIG.imageGenSize; settingsEl.querySelector('#tabbit-set-image-auto-download').checked = config.enableImageAutoDownload !== false; settingsEl.querySelector('#tabbit-set-image-prompt').value = config.imageGenPromptText || IMAGE_GEN_PROMPT_TEXT; settingsEl.querySelector('#tabbit-set-jgy-account').value = config.cloudSync?.account || ''; settingsEl.querySelector('#tabbit-set-jgy-password').value = config.cloudSync?.appPassword || ''; const mdTa = settingsEl.querySelector('#tabbit-set-system-md'); if (mdTa) { mdTa.value = config.systemPromptMd || ''; const mdChars = settingsEl.querySelector('#tabbit-md-chars'); if (mdChars) mdChars.textContent = '字数:' + (config.systemPromptMd || '').length; } const mdEnabled = settingsEl.querySelector('#tabbit-set-md-enabled'); if (mdEnabled) mdEnabled.checked = !!config.systemPromptMdEnabled; const mdAsSystem = settingsEl.querySelector('#tabbit-set-md-as-system'); if (mdAsSystem) mdAsSystem.checked = config.systemPromptMdAsSystem !== false; const cs = settingsEl.querySelector('#tabbit-cloud-status'); if (cs) { const t = config.cloudSync?.lastSyncAt; if (t) { const d = new Date(t); const pad = n => String(n).padStart(2, '0'); const dirMap = { 'pull': '从云端拉取', 'push': '增量上传', 'force-push': '强制覆盖上传' }; const dir = dirMap[config.cloudSync.lastSyncDirection] || ''; cs.textContent = `最近同步:${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}(${dir})`; } else cs.textContent = '尚未进行过云同步'; } renderSettingsModels(); renderSettingsTemplates(); renderSettingsUrlRules(); } function renderSettingsModels() { if (!settingsEl) return; const profile = getCurrentProfile(); const box = settingsEl.querySelector('#tabbit-model-list'); box.innerHTML = ''; profile.models.forEach((model, index) => { const row = document.createElement('div'); row.className = 'tabbit-model-row'; row.innerHTML = `
    高级参数(可选)
    `; row.querySelector('.tabbit-remove-model').addEventListener('click', () => { syncModelsFromSettings(); profile.models.splice(index, 1); if (!profile.models.length) profile.models.push({ name: '', value: '', temperature: '', maxTokens: '' }); if (!profile.models.some(m => m.value === profile.currentModel)) profile.currentModel = profile.models[0].value; renderSettingsModels(); }); box.appendChild(row); }); } function syncModelsFromSettings() { if (!settingsEl) return; const profile = getCurrentProfile(); const rows = [...settingsEl.querySelectorAll('.tabbit-model-row')]; let nextCurrent = profile.currentModel; const models = rows.map(row => { 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: value, value, temperature, maxTokens }; }).filter(m => m.value); profile.models = normalizeModels(models); if (!profile.models.some(m => m.value === nextCurrent)) nextCurrent = profile.models[0]?.value || ''; profile.currentModel = nextCurrent; } function renderSettingsTemplates() { if (!settingsEl) return; const box = settingsEl.querySelector('#tabbit-tpl-list'); box.innerHTML = ''; config.promptTemplates.forEach((tpl, index) => { const row = document.createElement('div'); row.className = 'tabbit-tpl-row'; row.innerHTML = ` `; row.dataset.id = tpl.id; row.querySelector('.tabbit-remove-model').addEventListener('click', () => { syncTemplatesFromSettings(); config.promptTemplates.splice(index, 1); if (!config.promptTemplates.length) config.promptTemplates.push({ id: makeId('tpl'), name: '默认总结', text: DEFAULT_PROMPT_TEXT }); if (!config.promptTemplates.some(t => t.id === config.defaultPromptTemplateId)) config.defaultPromptTemplateId = config.promptTemplates[0].id; renderSettingsTemplates(); }); box.appendChild(row); }); } function syncTemplatesFromSettings() { if (!settingsEl) return; const rows = [...settingsEl.querySelectorAll('.tabbit-tpl-row')]; let nextDefault = config.defaultPromptTemplateId; const tpls = rows.map(row => { const id = row.dataset.id || makeId('tpl'); const name = row.querySelector('.tabbit-tpl-name').value.trim(); const text = row.querySelector('.tabbit-tpl-text').value.trim(); const checked = row.querySelector('input[type="radio"]').checked; if (checked) nextDefault = id; return { id, name, text }; }).filter(t => t.name && t.text); config.promptTemplates = normalizePromptTemplates(tpls); if (!config.promptTemplates.some(t => t.id === nextDefault)) nextDefault = config.promptTemplates[0]?.id || 'default'; config.defaultPromptTemplateId = nextDefault; } function renderSettingsUrlRules() { if (!settingsEl) return; const box = settingsEl.querySelector('#tabbit-rule-list'); box.innerHTML = ''; config.urlRules.forEach((rule, index) => { const binding = config.rulePromptBindings.find(b => b.rule === rule); const row = document.createElement('div'); row.className = 'tabbit-rule-row'; const tplOptions = config.promptTemplates.map(t => `` ).join(''); row.innerHTML = ` `; row.querySelector('.tabbit-remove-model').addEventListener('click', () => { syncUrlRulesFromSettings(); config.urlRules.splice(index, 1); renderSettingsUrlRules(); }); box.appendChild(row); }); } function syncUrlRulesFromSettings() { if (!settingsEl) return; const rows = [...settingsEl.querySelectorAll('.tabbit-rule-row')]; const rules = [], bindings = []; rows.forEach(row => { const pattern = row.querySelector('.tabbit-rule-pattern').value.trim(); const tplId = row.querySelector('.tabbit-rule-tpl').value; if (!pattern) return; rules.push(pattern); if (tplId) bindings.push({ rule: pattern, templateId: tplId }); }); config.urlRules = normalizeUrlRules(rules); config.rulePromptBindings = normalizeRulePromptBindings(bindings); } function saveSettingsFromForm() { syncCurrentProfileFromForm(); syncTemplatesFromSettings(); syncUrlRulesFromSettings(); config.autoRun = settingsEl.querySelector('#tabbit-set-auto-run').checked; config.silentAutoRun = settingsEl.querySelector('#tabbit-set-silent-auto-run').checked; config.enableThinking = settingsEl.querySelector("#tabbit-set-enable-thinking").checked; config.extractMaxChars = Number(settingsEl.querySelector('#tabbit-set-extract-max').value || 16000); config.apiTimeout = Number(settingsEl.querySelector('#tabbit-set-api-timeout').value || 120000); config.acMinLen = Number(settingsEl.querySelector('#tabbit-set-ac-min-len').value || 2); config.acCooldown = Number(settingsEl.querySelector('#tabbit-set-ac-cooldown').value || 2000); config.acTempMinutes = Number(settingsEl.querySelector('#tabbit-set-ac-temp-minutes').value || 10); const mdTa2 = settingsEl.querySelector('#tabbit-set-system-md'); if (mdTa2) config.systemPromptMd = mdTa2.value; const mdEn2 = settingsEl.querySelector('#tabbit-set-md-enabled'); if (mdEn2) config.systemPromptMdEnabled = mdEn2.checked; const mdSys2 = settingsEl.querySelector('#tabbit-set-md-as-system'); if (mdSys2) config.systemPromptMdAsSystem = mdSys2.checked; config.flomoAutoSend = settingsEl.querySelector('#tabbit-set-flomo-auto').checked; config.flomoApiUrl = settingsEl.querySelector('#tabbit-set-flomo-api').value.trim(); config.flomoTags = settingsEl.querySelector('#tabbit-set-flomo-tags').value.trim(); config.autoCopy = { enabled: settingsEl.querySelector('#tabbit-set-auto-copy').checked, withSource: settingsEl.querySelector('#tabbit-set-auto-copy-source').checked }; config.enableImageGen = settingsEl.querySelector('#tabbit-set-enable-image-gen').checked; config.imageGenApiUrl = settingsEl.querySelector('#tabbit-set-image-api-url').value.trim(); config.imageGenApiKey = settingsEl.querySelector('#tabbit-set-image-api-key').value.trim(); config.imageGenModel = settingsEl.querySelector('#tabbit-set-image-model').value.trim() || DEFAULT_CONFIG.imageGenModel; const normalizedImageSize = normalizeImageSizeInput(settingsEl.querySelector('#tabbit-set-image-size').value); if (!normalizedImageSize) { alert('生图尺寸格式不正确,请填写类似 1024x1024、1280x720,或 auto'); return; } config.imageGenSize = normalizedImageSize; config.enableImageAutoDownload = settingsEl.querySelector('#tabbit-set-image-auto-download').checked; config.imageGenPromptText = settingsEl.querySelector('#tabbit-set-image-prompt').value.trim() || IMAGE_GEN_PROMPT_TEXT; config.cloudSync = { ...(config.cloudSync || {}), account: settingsEl.querySelector('#tabbit-set-jgy-account').value.trim(), appPassword: settingsEl.querySelector('#tabbit-set-jgy-password').value.trim() }; const newHeightRatio = Number(settingsEl.querySelector('#tabbit-set-height-ratio').value || 0.8); config.panel = { ...config.panel, width: Math.max(320, Number(settingsEl.querySelector('#tabbit-set-panel-width').value || 460)), heightRatio: Math.max(0.3, Math.min(1, newHeightRatio)) }; saveConfig(); renderModelSelect(); applyFloatButtonPosition(); if (panelEl) { panelEl.style.width = config.panel.width + 'px'; if (!config.panel.height) { panelEl.style.height = (config.panel.heightRatio * 100) + 'vh'; } } closeSettings(); setStatus('设置已保存', 'ok', 1200); } /****************************************************************** * 16. 测试 API / 获取模型 ******************************************************************/ async function testApiConnection() { syncCurrentProfileFromForm(); const profile = getCurrentProfile(); if (!profile.apiUrl || !profile.apiKey || !profile.currentModel) { alert('请先填写 API 地址、API Key,并选择当前模型。'); return; } const btn = settingsEl.querySelector('#tabbit-test-api'); btn.disabled = true; btn.textContent = '测试中…'; try { await callChatApi([{ role: 'user', content: '请只回复 OK' }]); alert(`✅ 预设「${profile.name}」API 测试成功。`); } catch (err) { alert('❌ API 测试失败:\n\n' + (err.message || String(err))); } finally { btn.disabled = false; btn.textContent = '⚡ 测试 API'; } } function extractModelIds(payload) { const list = Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload?.models) ? payload.models : (Array.isArray(payload) ? payload : [])); return Array.from(new Set(list.map(item => typeof item === 'string' ? item : (item?.id || item?.model || item?.name)).filter(Boolean).map(String))); } function fetchModelsFromApi() { syncCurrentProfileFromForm(); const profile = getCurrentProfile(); if (!profile.apiUrl || !profile.apiKey) { alert('请先填写 API 地址和 Key。'); return; } let modelsUrl = ''; try { modelsUrl = buildModelsUrl(profile.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 ${profile.apiKey}` }, timeout: 60000, onload(res) { btn.disabled = false; btn.textContent = '🔄 获取模型列表'; try { if (res.status < 200 || res.status >= 300) { alert(`获取失败:${res.status}`); return; } const data = JSON.parse(res.responseText); const ids = extractModelIds(data); if (!ids.length) { alert('没有从响应中识别到模型列表。'); return; } syncModelsFromSettings(); ids.forEach(id => { if (!profile.models.some(m => m.value === id)) { profile.models.push({ name: id, value: id, temperature: '', maxTokens: '' }); } }); if (!profile.currentModel) profile.currentModel = profile.models[0]?.value || ''; renderSettingsModels(); alert(`✅ 已为预设「${profile.name}」获取 ${ids.length} 个模型。`); } catch (err) { alert('解析失败:' + err.message); } }, onerror() { btn.disabled = false; btn.textContent = '🔄 获取模型列表'; alert('获取失败'); }, ontimeout() { btn.disabled = false; btn.textContent = '🔄 获取模型列表'; alert('超时'); } }); } /****************************************************************** * 17. 导入 / 导出 / 重置 ******************************************************************/ function exportConfigToFile() { const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `tabbit-ai-config-${Date.now()}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function importConfigFromFile() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json,application/json'; input.onchange = e => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const saved = JSON.parse(reader.result); if (!confirm('确认导入?这将覆盖现有配置。')) return; config = mergeConfig(clone(DEFAULT_CONFIG), saved); saveConfig(); if (panelEl) renderModelSelect(); if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) fillSettingsForm(); alert('✅ 导入成功'); } catch (err) { alert('导入失败:' + err.message); } }; reader.readAsText(file); }; input.click(); } function resetConfig() { if (!confirm('确认重置所有配置?')) return; config = clone(DEFAULT_CONFIG); saveConfig(); if (panelEl) renderModelSelect(); if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) fillSettingsForm(); alert('已重置。'); } function openAddUrlRuleModal() { openSettings(); setTimeout(() => { syncUrlRulesFromSettings(); const cur = location.origin + location.pathname.replace(/[^/]+$/, '*'); if (!config.urlRules.includes(cur)) config.urlRules.push(cur); renderSettingsUrlRules(); }, 100); } /****************************************************************** * 18. 油猴菜单 ******************************************************************/ function registerMenus() { if (typeof GM_registerMenuCommand !== 'function') return; GM_registerMenuCommand('打开面板', () => openPanel(false)); GM_registerMenuCommand('设置', openSettings); } function switchProfileQuick() { const list = config.profiles.map((p, i) => `${i + 1}. ${p.name}${p.id === config.currentProfileId ? ' ✓' : ''}`).join('\n'); const input = prompt(`输入要切换到的预设序号:\n\n${list}`, '1'); if (!input) return; const idx = parseInt(input, 10) - 1; if (isNaN(idx) || idx < 0 || idx >= config.profiles.length) { alert('无效的序号'); return; } setCurrentProfile(config.profiles[idx].id); if (panelEl) renderModelSelect(); alert(`✅ 已切换到「${config.profiles[idx].name}」`); } async function quickCloudPull() { if (!config.cloudSync?.account || !config.cloudSync?.appPassword) { alert('请先在设置中填写坚果云账号'); openSettings(); return; } if (!confirm('从云端拉取所有配置?')) return; try { const r = await cloudPull({ profiles: true, app: true }); alert(`拉取完成:\nAPI预设 ${r.profilesCount} 个\n模板 ${r.tplsCount} 条\n规则 ${r.rulesCount} 条`); if (panelEl) renderModelSelect(); } catch (err) { alert('❌ ' + (err.message || err)); } } async function quickCloudPush() { if (!config.cloudSync?.account || !config.cloudSync?.appPassword) { alert('请先在设置中填写坚果云账号'); openSettings(); return; } if (!confirm('上传所有配置到云端?')) return; try { await cloudPush({ profiles: true, app: true }); alert('✅ 已上传'); } catch (err) { alert('❌ ' + (err.message || err)); } } /****************************************************************** * 19. 🔁 SPA 路由切换监听(仅路径级变动才重置) ******************************************************************/ let __lastPathname = location.origin + location.pathname; function handleUrlChanged() { const currentPath = location.origin + location.pathname; if (currentPath === __lastPathname) return; // Query/Hash 变了但路径没变 → 不重置 __lastPathname = currentPath; resetConversation('🔁 页面已切换,对话已重置。\n\n点击「✨ 总结」开始,或在下方直接提问。'); renderPromptSelect(); if (config.autoRun && matchUrl(location.href, config.urlRules)) { if (config.silentAutoRun) { setTimeout(() => runSummarySilent(), 600); } else { setTimeout(() => openPanel(true), 600); } } } function watchUrlChange() { setInterval(handleUrlChanged, 1000); const origPush = history.pushState; const origReplace = history.replaceState; history.pushState = function (...args) { const r = origPush.apply(this, args); setTimeout(handleUrlChanged, 50); return r; }; history.replaceState = function (...args) { const r = origReplace.apply(this, args); setTimeout(handleUrlChanged, 50); return r; }; window.addEventListener('popstate', () => setTimeout(handleUrlChanged, 50)); window.addEventListener('hashchange', () => setTimeout(handleUrlChanged, 50)); } /****************************************************************** * 20. 入口 ******************************************************************/ function bootstrap() { // 1. 初始化 Shadow DOM(必须最先执行) setupShadowDOM(); // 2. 浮动按钮样式注入主文档 createExternalStyles(); // 3. 创建浮动按钮(Shadow DOM 内) createFloatButton(); // 4. 自动复制 acInit(); // 5. 油猴菜单 registerMenus(); // 6. 规则自动总结:首次加载即匹配 if (config.autoRun && matchUrl(location.href, config.urlRules)) { if (config.silentAutoRun) { setTimeout(() => runSummarySilent(), 800); } else { setTimeout(() => openPanel(true), 800); } } // 7. SPA 路由切换监听 watchUrlChange(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap); } else { bootstrap(); } })();