// ==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('
')
: 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 += '' + listStack.pop().type + '>'; }
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 += '' + listStack.pop().type + '>';
if (listStack.length && listStack[listStack.length - 1].indent === indent && listStack[listStack.length - 1].type !== type) html += '' + listStack.pop().type + '>';
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 ``;
} 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 = `
`;
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 = `
自动化与显示
支持通配符 *。匹配的页面打开时自动弹出面板(需勾选自动弹出)。每条规则可绑定一个提示词模板。
`;
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();
}
})();