Greasy Fork is available in English.
指定网站自动弹出 AI 网页摘要,支持全局配置、网址规则管理、提示词模板、停止/重试、正文预览、模型拉取。
当前为
// ==UserScript==
// @name 饺子 AI 网页摘要 + 连续对话
// @namespace https://space.bilibili.com/38389107
// @version 2.0.0
// @description 指定网站自动弹出 AI 网页摘要,支持全局配置、网址规则管理、提示词模板、停止/重试、正文预览、模型拉取。
// @author 次元饺子
// @icon https://img.icons8.com/?size=100&id=90385&format=png&color=000000
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/******************************************************************
* 固定配置 Key
******************************************************************/
const STORAGE_KEY = 'tabbit_ai_summary_config';
const LEGACY_STORAGE_KEYS = [
'tabbit_ai_summary_config_v24',
'tabbit_ai_summary_config_v23',
'tabbit_ai_summary_config_v22',
'tabbit_ai_summary_config_v21',
'tabbit_ai_summary_config_v2'
];
const DEFAULT_PROMPT_TEXT =
'我是一个有轻微理解障碍的人,没有耐心,不想动脑子。请你用很短、很直白的话解释这个网页到底在说什么。' +
'\n\n请输出:' +
'\n1. 这个网页一句话总结' +
'\n2. 关键点' +
'\n3. 对我有什么用' +
'\n4. 原始链接';
const DEFAULT_CONFIG = {
apiUrl: 'https://api.xiaomimimo.com/v1/chat/completions',
apiKey: '',
currentModel: 'mimo-v2-flash',
temperature: 0.7,
maxTokens: 2000,
models: [
{
name: 'mimo-v2-flash',
value: 'mimo-v2-flash',
temperature: '',
maxTokens: ''
}
],
promptTemplates: [
{
id: 'default',
name: '默认总结',
text: DEFAULT_PROMPT_TEXT
},
{
id: 'plain',
name: '大白话解释',
text:
'请用非常简单、直白、短句的方式解释这个网页。不要绕弯子,不要讲废话。' +
'\n\n请输出:' +
'\n1. 一句话说明它在说什么' +
'\n2. 三个最重要的点' +
'\n3. 普通人应该怎么理解'
},
{
id: 'forum',
name: '论坛讨论总结',
text:
'请总结这个帖子或讨论页面。重点提炼楼主观点、主要争议、支持方观点、反对方观点,以及最后值得关注的结论。'
},
{
id: 'investment',
name: '投资视角',
text:
'请从投资和商业角度总结这个网页。重点关注公司、行业、数据、增长、风险、市场预期,以及对普通投资者有什么参考价值。'
}
],
defaultPromptTemplateId: 'default',
urlRules: [
'https://mp.weixin.qq.com/*',
'https://nga.178.com/read.php*',
'https://www.jisilu.cn/*',
'https://www.gelonghui.com/*',
'https://bbs.nga.cn/read.php*',
'https://www.youxituoluo.com/*',
'https://www.vrtuoluo.cn/*',
'https://sspai.com/post/*',
'https://www.ifanr.com/*',
'http://www.gamelook.com.cn/*'
],
rulePromptBindings: [],
autoRun: true,
floatButton: {
side: 'right',
y: null,
opacity: 0.55
},
panel: {
width: 460,
heightRatio: 0.82
},
extractMaxChars: 16000
};
let config = loadConfig();
let panelEl = null;
let floatBtnEl = null;
let settingsEl = null;
let addRuleModalEl = null;
let previewModalEl = null;
let chatMessages = [];
let summaryStarted = false;
let currentPageUrl = location.href;
let lastUrl = location.href;
let isRequesting = false;
let currentRequest = null;
let currentReject = null;
let lastRequestPayload = null;
let lastExtractedText = '';
init();
/******************************************************************
* 初始化
******************************************************************/
function init() {
createStyles();
createFloatButton();
registerMenus();
if (config.autoRun && isUrlMatched(location.href, config.urlRules)) {
openPanel(true);
}
watchUrlChange();
}
function registerMenus() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('饺子 AI:打开面板', () => openPanel(false));
GM_registerMenuCommand('饺子 AI:设置', openSettings);
GM_registerMenuCommand('饺子 AI:加入当前网址', () => openAddUrlRuleModal());
GM_registerMenuCommand('饺子 AI:导出配置文件', exportConfigToFile);
GM_registerMenuCommand('饺子 AI:导入配置文件', importConfigFromFile);
GM_registerMenuCommand('饺子 AI:重置配置', resetConfig);
}
function watchUrlChange() {
setInterval(() => {
if (location.href === lastUrl) return;
lastUrl = location.href;
currentPageUrl = location.href;
summaryStarted = false;
chatMessages = [];
lastRequestPayload = null;
lastExtractedText = '';
if (panelEl) {
const list = panelEl.querySelector('#tabbit-chat-list');
if (list) list.innerHTML = '';
setStatus('');
}
if (config.autoRun && isUrlMatched(location.href, config.urlRules)) {
openPanel(true);
}
}, 1000);
}
/******************************************************************
* 配置读写:只使用 GM_getValue / GM_setValue
******************************************************************/
function loadConfig() {
try {
if (typeof GM_getValue !== 'function') {
console.warn('[饺子 AI] 当前环境不支持 GM_getValue。');
return clone(DEFAULT_CONFIG);
}
let raw = GM_getValue(STORAGE_KEY, '');
if (!raw) {
for (const key of LEGACY_STORAGE_KEYS) {
const legacyRaw = GM_getValue(key, '');
if (legacyRaw) {
raw = legacyRaw;
GM_setValue(STORAGE_KEY, legacyRaw);
console.log('[饺子 AI] 已迁移旧配置:', key);
break;
}
}
}
if (!raw) return clone(DEFAULT_CONFIG);
const saved = JSON.parse(raw);
return mergeConfig(clone(DEFAULT_CONFIG), saved);
} catch (err) {
console.warn('[饺子 AI] 配置读取失败:', err);
return clone(DEFAULT_CONFIG);
}
}
function saveConfig() {
try {
if (typeof GM_setValue !== 'function') {
alert('当前环境不支持 GM_setValue,配置无法保存。');
return;
}
config.urlRules = normalizeUrlRules(config.urlRules);
config.rulePromptBindings = normalizeRulePromptBindings(config.rulePromptBindings);
config.promptTemplates = normalizePromptTemplates(config.promptTemplates);
config.models = normalizeModels(config.models);
GM_setValue(STORAGE_KEY, JSON.stringify(config));
} catch (err) {
console.warn('[饺子 AI] 配置保存失败:', err);
alert('配置保存失败:' + err.message);
}
}
function resetConfig() {
if (!confirm('确定要重置 饺子 AI 的所有配置吗?')) return;
try {
if (typeof GM_deleteValue === 'function') {
GM_deleteValue(STORAGE_KEY);
}
config = clone(DEFAULT_CONFIG);
saveConfig();
alert('配置已重置。');
location.reload();
} catch (err) {
alert('重置失败:' + err.message);
}
}
function mergeConfig(base, saved) {
const result = {
...base,
...saved
};
if (!Array.isArray(result.models)) result.models = base.models;
if (!Array.isArray(result.urlRules)) result.urlRules = base.urlRules;
if (!Array.isArray(result.promptTemplates)) result.promptTemplates = base.promptTemplates;
if (!Array.isArray(result.rulePromptBindings)) result.rulePromptBindings = [];
// 兼容旧 promptText。
if (saved.promptText && !saved.promptTemplates) {
result.promptTemplates = [
{
id: 'default',
name: '默认总结',
text: saved.promptText
},
...base.promptTemplates.filter(t => t.id !== 'default')
];
}
result.models = normalizeModels(result.models);
result.urlRules = normalizeUrlRules(result.urlRules);
result.promptTemplates = normalizePromptTemplates(result.promptTemplates);
result.rulePromptBindings = normalizeRulePromptBindings(result.rulePromptBindings);
result.floatButton = {
...base.floatButton,
...(saved.floatButton || {})
};
result.panel = {
...base.panel,
...(saved.panel || {})
};
if (!result.defaultPromptTemplateId) {
result.defaultPromptTemplateId = result.promptTemplates[0]?.id || 'default';
}
if (!result.promptTemplates.some(t => t.id === result.defaultPromptTemplateId)) {
result.defaultPromptTemplateId = result.promptTemplates[0]?.id || 'default';
}
if (!result.currentModel && result.models.length) {
result.currentModel = result.models[0].value;
}
if (!result.models.some(m => m.value === result.currentModel)) {
result.currentModel = result.models[0]?.value || '';
}
result.extractMaxChars = Number(result.extractMaxChars || 16000);
return result;
}
function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
/******************************************************************
* URL 规则
******************************************************************/
function normalizeUrlRules(rules) {
if (!Array.isArray(rules)) return [];
const result = [];
rules
.map(rule => String(rule || '').trim())
.filter(Boolean)
.forEach(rule => {
if (!result.includes(rule)) result.push(rule);
});
return result;
}
function normalizeRulePromptBindings(bindings) {
if (!Array.isArray(bindings)) return [];
const result = [];
bindings.forEach(item => {
const rule = String(item?.rule || '').trim();
const templateId = String(item?.templateId || '').trim();
if (!rule || !templateId) return;
const old = result.find(x => x.rule === rule);
if (old) {
old.templateId = templateId;
} else {
result.push({ rule, templateId });
}
});
return result;
}
function isUrlMatched(url, rules) {
return findMatchedUrlRules(url, rules).length > 0;
}
function findMatchedUrlRules(url, rules = config.urlRules) {
return normalizeUrlRules(rules).filter(rule => testUrlRule(url, rule));
}
function testUrlRule(url, rule) {
rule = String(rule || '').trim();
if (!rule) return false;
if (rule.includes('*')) {
const escaped = rule
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
return new RegExp('^' + escaped).test(url);
}
return url.startsWith(rule);
}
function getMatchedRuleForCurrentPage() {
return findMatchedUrlRules(location.href)[0] || '';
}
function buildUrlRuleCandidates(rawUrl) {
let url;
try {
url = new URL(rawUrl);
} catch (err) {
return [rawUrl.split('#')[0].split('?')[0]];
}
const origin = url.origin;
const fullNoHash = rawUrl.split('#')[0];
const path = url.pathname || '/';
const pathNoSlash = path.replace(/\/+$/, '') || '/';
const segments = pathNoSlash.split('/').filter(Boolean);
const last = segments[segments.length - 1] || '';
const candidates = [];
const recommended = buildSmartUrlRule(rawUrl);
pushUnique(candidates, recommended);
pushUnique(candidates, fullNoHash);
if (path !== '/') {
pushUnique(candidates, origin + pathNoSlash + '*');
}
if (segments.length > 1) {
pushUnique(candidates, origin + '/' + segments.slice(0, -1).join('/') + '/*');
}
pushUnique(candidates, origin + '/*');
pushUnique(candidates, origin + '/');
if (/\.(php|asp|aspx|jsp|html|htm)$/i.test(last)) {
pushUnique(candidates, origin + pathNoSlash + '*');
}
return candidates;
}
function buildSmartUrlRule(rawUrl) {
let url;
try {
url = new URL(rawUrl);
} catch (err) {
return rawUrl.split('#')[0].split('?')[0];
}
const origin = url.origin;
let pathname = url.pathname || '/';
pathname = pathname.replace(/\/{2,}/g, '/');
if (!pathname || pathname === '/') {
return origin + '/';
}
const pathWithoutTrailingSlash = pathname.replace(/\/+$/, '');
const segments = pathWithoutTrailingSlash.split('/').filter(Boolean);
const last = segments[segments.length - 1] || '';
const hasQuery = !!url.search;
const isFileLike = /\.[a-z0-9]{2,8}$/i.test(last);
const isPhpLike = /\.(php|asp|aspx|jsp|html|htm)$/i.test(last);
const isNumericId = /^\d+$/.test(last);
const isHexId = /^[a-f0-9]{8,}$/i.test(last);
const isSlugWithId = /(^|[-_])\d{3,}($|[-_])/i.test(last);
const isLongToken = last.length >= 24 && /^[a-z0-9_-]+$/i.test(last);
const isIdLike = isNumericId || isHexId || isSlugWithId || isLongToken;
if (isPhpLike || isFileLike) {
return origin + pathWithoutTrailingSlash + '*';
}
if (isIdLike) {
if (segments.length <= 1) return origin + '/*';
return origin + '/' + segments.slice(0, -1).join('/') + '/*';
}
if (hasQuery) {
return origin + pathWithoutTrailingSlash + '*';
}
if (pathname.endsWith('/')) {
return origin + pathname;
}
return origin + pathWithoutTrailingSlash + '*';
}
function pushUnique(arr, value) {
value = String(value || '').trim();
if (value && !arr.includes(value)) arr.push(value);
}
function addUrlRule(rule, templateId = '') {
rule = String(rule || '').trim();
if (!rule) return false;
config.urlRules = normalizeUrlRules([...config.urlRules, rule]);
if (templateId) {
setRuleTemplateBinding(rule, templateId);
}
saveConfig();
if (settingsEl && !settingsEl.classList.contains('tabbit-hidden')) {
renderSettingsUrlRules();
}
setStatus('已加入网址规则', 'ok', 1800);
return true;
}
function setRuleTemplateBinding(rule, templateId) {
rule = String(rule || '').trim();
templateId = String(templateId || '').trim();
config.rulePromptBindings = normalizeRulePromptBindings(config.rulePromptBindings);
config.rulePromptBindings = config.rulePromptBindings.filter(x => x.rule !== rule);
if (templateId) {
config.rulePromptBindings.push({
rule,
templateId
});
}
}
function getTemplateIdForRule(rule) {
const binding = config.rulePromptBindings.find(x => x.rule === rule);
return binding?.templateId || '';
}
/******************************************************************
* 提示词模板
******************************************************************/
function normalizePromptTemplates(templates) {
if (!Array.isArray(templates)) templates = [];
const result = [];
templates.forEach(item => {
const id = String(item?.id || '').trim() || makeId('tpl');
const name = String(item?.name || '').trim() || '未命名模板';
const text = String(item?.text || '').trim();
if (!text) return;
if (!result.some(t => t.id === id)) {
result.push({ id, name, text });
}
});
if (!result.length) {
result.push({
id: 'default',
name: '默认总结',
text: DEFAULT_PROMPT_TEXT
});
}
return result;
}
function getPromptTemplateById(id) {
return config.promptTemplates.find(t => t.id === id) || config.promptTemplates[0];
}
function getPromptForCurrentPage() {
const matchedRule = getMatchedRuleForCurrentPage();
const boundTemplateId = matchedRule ? getTemplateIdForRule(matchedRule) : '';
const templateId = boundTemplateId || config.defaultPromptTemplateId;
return getPromptTemplateById(templateId)?.text || DEFAULT_PROMPT_TEXT;
}
function makeId(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
/******************************************************************
* 模型
******************************************************************/
function normalizeModels(models) {
if (!Array.isArray(models)) models = [];
const result = [];
models.forEach(model => {
const value = String(model?.value || '').trim();
if (!value) return;
const item = {
name: String(model?.name || value).trim(),
value,
temperature: model?.temperature ?? '',
maxTokens: model?.maxTokens ?? ''
};
if (!result.some(m => m.value === value)) {
result.push(item);
}
});
if (!result.length) {
result.push({
name: 'mimo-v2-flash',
value: 'mimo-v2-flash',
temperature: '',
maxTokens: ''
});
}
return result;
}
function getCurrentModelConfig() {
return config.models.find(m => m.value === config.currentModel) || config.models[0] || {};
}
function getCurrentModelDisplayName() {
const model = getCurrentModelConfig();
return model?.name || model?.value || config.currentModel || '未知模型';
}
function getCurrentTemperature() {
const model = getCurrentModelConfig();
const value = model?.temperature !== '' && model?.temperature !== undefined
? model.temperature
: config.temperature;
return Number(value || 0.7);
}
function getCurrentMaxTokens() {
const model = getCurrentModelConfig();
const value = model?.maxTokens !== '' && model?.maxTokens !== undefined
? model.maxTokens
: config.maxTokens;
return Number(value || 2000);
}
/******************************************************************
* 悬浮按钮
******************************************************************/
function createFloatButton() {
const old = document.querySelector('#tabbit-ai-float-btn');
if (old) old.remove();
floatBtnEl = document.createElement('button');
floatBtnEl.id = 'tabbit-ai-float-btn';
floatBtnEl.innerHTML = '<span>AI</span>';
floatBtnEl.title = '打开 饺子 AI。可拖拽,右键恢复默认位置。';
document.body.appendChild(floatBtnEl);
applyFloatButtonPosition();
let dragging = false;
let moved = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
floatBtnEl.addEventListener('mousedown', e => {
if (e.button !== 0) return;
dragging = true;
moved = false;
const rect = floatBtnEl.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
document.body.classList.add('tabbit-dragging');
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
let left = startLeft + dx;
let top = startTop + dy;
const rect = floatBtnEl.getBoundingClientRect();
const maxLeft = window.innerWidth - rect.width;
const maxTop = window.innerHeight - rect.height;
left = Math.max(0, Math.min(maxLeft, left));
top = Math.max(0, Math.min(maxTop, top));
floatBtnEl.style.left = left + 'px';
floatBtnEl.style.top = top + 'px';
floatBtnEl.style.right = 'auto';
floatBtnEl.style.bottom = 'auto';
floatBtnEl.style.transform = 'none';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
document.body.classList.remove('tabbit-dragging');
const rect = floatBtnEl.getBoundingClientRect();
const stickToRight = rect.left + rect.width / 2 > window.innerWidth / 2;
config.floatButton.side = stickToRight ? 'right' : 'left';
config.floatButton.y = Math.round(rect.top);
saveConfig();
applyFloatButtonPosition();
});
floatBtnEl.addEventListener('click', e => {
if (moved) {
e.preventDefault();
e.stopPropagation();
return;
}
openPanel(false);
});
floatBtnEl.addEventListener('contextmenu', e => {
e.preventDefault();
config.floatButton = {
side: 'right',
y: null,
opacity: 0.55
};
saveConfig();
applyFloatButtonPosition();
});
window.addEventListener('resize', applyFloatButtonPosition);
}
function applyFloatButtonPosition() {
if (!floatBtnEl) return;
const fb = config.floatButton || {};
floatBtnEl.style.left = 'auto';
floatBtnEl.style.right = 'auto';
floatBtnEl.style.top = 'auto';
floatBtnEl.style.bottom = 'auto';
floatBtnEl.style.transform = 'none';
if (typeof fb.y === 'number') {
const btnHeight = 72;
const safeY = Math.max(10, Math.min(window.innerHeight - btnHeight - 10, fb.y));
floatBtnEl.style.top = safeY + 'px';
if (fb.side === 'left') {
floatBtnEl.style.left = '0px';
floatBtnEl.classList.add('tabbit-float-left');
floatBtnEl.classList.remove('tabbit-float-right');
} else {
floatBtnEl.style.right = '0px';
floatBtnEl.classList.add('tabbit-float-right');
floatBtnEl.classList.remove('tabbit-float-left');
}
} else {
floatBtnEl.style.top = '50%';
floatBtnEl.style.right = '0px';
floatBtnEl.style.transform = 'translateY(-50%)';
floatBtnEl.classList.add('tabbit-float-right');
floatBtnEl.classList.remove('tabbit-float-left');
}
floatBtnEl.style.opacity = String(fb.opacity ?? 0.55);
}
/******************************************************************
* 主面板
******************************************************************/
function openPanel(autoRun) {
if (!panelEl) {
panelEl = createPanel();
document.body.appendChild(panelEl);
}
panelEl.classList.remove('tabbit-hidden');
renderModelSelect();
if (autoRun && !summaryStarted) {
runSummary();
}
}
function closePanel() {
if (panelEl) {
panelEl.classList.add('tabbit-hidden');
}
}
function createPanel() {
const panel = document.createElement('div');
panel.id = 'tabbit-ai-panel';
const width = Number(config.panel?.width || 460);
const heightRatio = Number(config.panel?.heightRatio || 0.82);
panel.style.width = width + 'px';
panel.style.height = Math.round(window.innerHeight * heightRatio) + 'px';
panel.innerHTML = `
<div class="tabbit-header">
<div class="tabbit-title">📖 饺子 AI</div>
<div class="tabbit-header-actions">
<select id="tabbit-model-select" class="tabbit-model-select"></select>
<button id="tabbit-settings-btn" class="tabbit-icon-btn" title="设置">⚙️</button>
<button id="tabbit-close-btn" class="tabbit-icon-btn" title="关闭">×</button>
</div>
</div>
<div class="tabbit-toolbar">
<button id="tabbit-summary-btn" class="tabbit-primary-btn">总结</button>
<button id="tabbit-stop-btn" class="tabbit-secondary-btn" disabled>停止</button>
<button id="tabbit-retry-btn" class="tabbit-secondary-btn" disabled>重试</button>
<button id="tabbit-preview-btn" class="tabbit-secondary-btn">正文</button>
<button id="tabbit-add-url-rule-btn" class="tabbit-secondary-btn">加入网址</button>
<button id="tabbit-clear-btn" class="tabbit-secondary-btn">清空</button>
<button id="tabbit-copy-btn" class="tabbit-secondary-btn">复制</button>
</div>
<div id="tabbit-status-bar" class="tabbit-status-bar tabbit-hidden"></div>
<div id="tabbit-chat-list" class="tabbit-chat-list"></div>
<div class="tabbit-input-area">
<textarea id="tabbit-user-input" placeholder="继续追问。Enter 发送,Shift + Enter 换行"></textarea>
<button id="tabbit-send-btn">发送</button>
</div>
`;
panel.querySelector('#tabbit-close-btn').addEventListener('click', closePanel);
panel.querySelector('#tabbit-settings-btn').addEventListener('click', openSettings);
panel.querySelector('#tabbit-summary-btn').addEventListener('click', runSummary);
panel.querySelector('#tabbit-stop-btn').addEventListener('click', stopCurrentRequest);
panel.querySelector('#tabbit-retry-btn').addEventListener('click', retryLastRequest);
panel.querySelector('#tabbit-preview-btn').addEventListener('click', openPreviewModal);
panel.querySelector('#tabbit-add-url-rule-btn').addEventListener('click', () => openAddUrlRuleModal());
panel.querySelector('#tabbit-clear-btn').addEventListener('click', clearChat);
panel.querySelector('#tabbit-copy-btn').addEventListener('click', copyChat);
panel.querySelector('#tabbit-send-btn').addEventListener('click', sendUserMessage);
const input = panel.querySelector('#tabbit-user-input');
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendUserMessage();
}
});
return panel;
}
function renderModelSelect() {
if (!panelEl) return;
const select = panelEl.querySelector('#tabbit-model-select');
if (!select) return;
select.innerHTML = '';
normalizeModels(config.models).forEach(model => {
const option = document.createElement('option');
option.value = model.value;
option.textContent = model.name || model.value;
if (model.value === config.currentModel) {
option.selected = true;
}
select.appendChild(option);
});
select.onchange = function () {
config.currentModel = this.value;
saveConfig();
};
}
function setStatus(text, type = '', autoHideMs = 0) {
if (!panelEl) return;
const bar = panelEl.querySelector('#tabbit-status-bar');
if (!bar) return;
if (!text) {
bar.textContent = '';
bar.className = 'tabbit-status-bar tabbit-hidden';
return;
}
bar.textContent = text;
bar.className = `tabbit-status-bar tabbit-status-${type || 'normal'}`;
if (autoHideMs) {
setTimeout(() => {
if (bar.textContent === text) setStatus('');
}, autoHideMs);
}
}
/******************************************************************
* 总结与对话
******************************************************************/
async function runSummary() {
if (isRequesting) return;
summaryStarted = true;
currentPageUrl = location.href;
if (!checkApiConfig()) return;
const pageContent = extractPageContent();
lastExtractedText = pageContent;
if (!pageContent || pageContent.length < 80) {
appendErrorMessage('没有提取到足够的网页正文。');
setStatus('正文过短', 'error', 2500);
return;
}
const modelLabel = getCurrentModelDisplayName();
const fullPrompt = `${getPromptForCurrentPage()}
网页标题:
${document.title || ''}
网页 URL:
${currentPageUrl}
网页正文:
${pageContent}`;
const userMsg = {
role: 'user',
content: fullPrompt
};
const payloadMessages = [...chatMessages, userMsg];
lastRequestPayload = {
messages: payloadMessages,
modelLabel
};
try {
setInputLoading(true);
setStatus(`分析中 · ${pageContent.length} 字 · ${modelLabel}`, 'loading');
const answer = await callChatApi(payloadMessages);
chatMessages.push(userMsg);
chatMessages.push({
role: 'assistant',
content: answer
});
appendAssistantMessage(answer, modelLabel);
setStatus('完成', 'ok', 1200);
} catch (err) {
if (String(err?.message || '').includes('请求已取消')) {
setStatus('已停止', 'normal', 1600);
} else {
appendErrorMessage(err.message || String(err));
setStatus('请求失败,可重试', 'error');
}
} finally {
setInputLoading(false);
}
}
async function sendUserMessage() {
if (isRequesting) return;
if (!checkApiConfig()) return;
if (!panelEl) return;
const input = panelEl.querySelector('#tabbit-user-input');
const text = input.value.trim();
if (!text) return;
input.value = '';
appendUserMessage(text);
const userMsg = {
role: 'user',
content: text
};
const modelLabel = getCurrentModelDisplayName();
const payloadMessages = [...chatMessages, userMsg];
lastRequestPayload = {
messages: payloadMessages,
modelLabel
};
try {
setInputLoading(true);
setStatus(`请求中 · ${modelLabel}`, 'loading');
const answer = await callChatApi(payloadMessages);
chatMessages.push(userMsg);
chatMessages.push({
role: 'assistant',
content: answer
});
appendAssistantMessage(answer, modelLabel);
setStatus('完成', 'ok', 1200);
} catch (err) {
if (String(err?.message || '').includes('请求已取消')) {
setStatus('已停止', 'normal', 1600);
} else {
appendErrorMessage(err.message || String(err));
setStatus('请求失败,可重试', 'error');
}
} finally {
setInputLoading(false);
}
}
async function retryLastRequest() {
if (isRequesting) return;
if (!lastRequestPayload) return;
if (!checkApiConfig()) return;
try {
setInputLoading(true);
setStatus(`重试中 · ${lastRequestPayload.modelLabel || getCurrentModelDisplayName()}`, 'loading');
const answer = await callChatApi(lastRequestPayload.messages);
const lastUser = [...lastRequestPayload.messages].reverse().find(m => m.role === 'user');
if (lastUser && !chatMessages.includes(lastUser)) {
chatMessages.push(lastUser);
}
chatMessages.push({
role: 'assistant',
content: answer
});
appendAssistantMessage(answer, lastRequestPayload.modelLabel || getCurrentModelDisplayName());
setStatus('重试完成', 'ok', 1200);
} catch (err) {
if (String(err?.message || '').includes('请求已取消')) {
setStatus('已停止', 'normal', 1600);
} else {
appendErrorMessage(err.message || String(err));
setStatus('重试失败', 'error');
}
} finally {
setInputLoading(false);
}
}
function stopCurrentRequest() {
if (!isRequesting) return;
try {
if (currentRequest && typeof currentRequest.abort === 'function') {
currentRequest.abort();
}
if (typeof currentReject === 'function') {
currentReject(new Error('请求已取消'));
}
} catch (err) {
console.warn('[饺子 AI] 停止请求失败:', err);
} finally {
currentRequest = null;
currentReject = null;
setInputLoading(false);
setStatus('已停止', 'normal', 1600);
}
}
function checkApiConfig() {
if (!config.apiUrl || !config.apiKey || !config.currentModel) {
openSettings();
setStatus('请先配置 API', 'error', 2500);
return false;
}
return true;
}
function callChatApi(messages) {
const body = {
model: config.currentModel,
messages,
temperature: getCurrentTemperature(),
max_tokens: getCurrentMaxTokens()
};
return new Promise((resolve, reject) => {
currentReject = reject;
currentRequest = GM_xmlhttpRequest({
method: 'POST',
url: config.apiUrl,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`
},
data: JSON.stringify(body),
timeout: 120000,
onload(res) {
currentRequest = null;
currentReject = null;
try {
if (res.status < 200 || res.status >= 300) {
reject(new Error(formatApiError(res.status, res.responseText)));
return;
}
const data = JSON.parse(res.responseText);
const content = data?.choices?.[0]?.message?.content;
if (!content) {
reject(new Error('API 响应格式异常:没有找到 choices[0].message.content。'));
return;
}
resolve(content);
} catch (err) {
reject(err);
}
},
onerror(err) {
currentRequest = null;
currentReject = null;
reject(new Error('网络请求失败:' + JSON.stringify(err)));
},
ontimeout() {
currentRequest = null;
currentReject = null;
reject(new Error('API 请求超时。'));
}
});
});
}
function formatApiError(status, text) {
const map = {
400: '400:请求参数可能有误。',
401: '401:API Key 可能不正确。',
403: '403:当前 API Key 可能没有权限。',
404: '404:API 地址或模型名可能错误。',
429: '429:请求过于频繁或额度不足。',
500: '500:服务商内部错误。',
502: '502:服务商网关错误。',
503: '503:服务暂不可用。'
};
return `${map[status] || `API 请求失败:${status}`}\n${text || ''}`;
}
function extractPageContent() {
const clonedBody = document.body.cloneNode(true);
const removeSelectors = [
'script',
'style',
'noscript',
'iframe',
'svg',
'canvas',
'video',
'audio',
'nav',
'header',
'footer',
'form',
'button',
'input',
'textarea',
'select',
'#tabbit-ai-panel',
'#tabbit-ai-float-btn',
'#tabbit-settings-modal',
'#tabbit-add-rule-modal',
'#tabbit-preview-modal'
];
removeSelectors.forEach(selector => {
clonedBody.querySelectorAll(selector).forEach(el => el.remove());
});
const candidates = [
'article',
'main',
'.rich_media_content',
'#js_content',
'.post-content',
'.article-content',
'.entry-content',
'.content',
'#content',
'.post',
'.article',
'.articleBody',
'.article-body'
];
let bestText = '';
for (const selector of candidates) {
const nodes = clonedBody.querySelectorAll(selector);
nodes.forEach(node => {
const text = cleanText(node.innerText || node.textContent || '');
if (text.length > bestText.length) bestText = text;
});
}
if (!bestText || bestText.length < 200) {
bestText = cleanText(clonedBody.innerText || clonedBody.textContent || '');
}
return bestText.substring(0, Number(config.extractMaxChars || 16000));
}
function cleanText(text) {
return String(text || '')
.replace(/\r/g, '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/******************************************************************
* 消息展示
******************************************************************/
function appendUserMessage(text) {
appendMessage({
type: 'user',
label: '我',
text
});
}
function appendAssistantMessage(text, modelLabel) {
appendMessage({
type: 'assistant',
label: modelLabel || getCurrentModelDisplayName(),
text
});
}
function appendErrorMessage(text) {
appendMessage({
type: 'error',
label: '错误',
text: `⚠️ ${text}`
});
}
function appendMessage({ type, label, text }) {
if (!panelEl) openPanel(false);
const list = panelEl.querySelector('#tabbit-chat-list');
if (!list) return;
const item = document.createElement('div');
item.className = `tabbit-msg tabbit-msg-${type}`;
item.dataset.msgType = type;
item.dataset.msgLabel = label || '';
item.innerHTML = `
<div class="tabbit-msg-label">${escapeHtml(label || '')}</div>
<div class="tabbit-msg-body">${renderMarkdown(text)}</div>
`;
list.appendChild(item);
list.scrollTop = list.scrollHeight;
}
function clearChat() {
chatMessages = [];
summaryStarted = false;
lastRequestPayload = null;
const list = panelEl?.querySelector('#tabbit-chat-list');
if (list) list.innerHTML = '';
setStatus('');
updateRetryButton();
}
function copyChat() {
if (!panelEl) return;
const nodes = [
...panelEl.querySelectorAll('.tabbit-msg-user, .tabbit-msg-assistant')
];
const text = nodes
.map(el => {
const label = el.dataset.msgLabel || '';
const body = el.querySelector('.tabbit-msg-body')?.innerText?.trim() || '';
return `${label}:\n${body}`;
})
.filter(Boolean)
.join('\n\n');
copyText(text || '');
setStatus('已复制', 'ok', 1200);
}
function setInputLoading(loading) {
isRequesting = loading;
if (!panelEl) return;
const sendBtn = panelEl.querySelector('#tabbit-send-btn');
const summaryBtn = panelEl.querySelector('#tabbit-summary-btn');
const stopBtn = panelEl.querySelector('#tabbit-stop-btn');
const retryBtn = panelEl.querySelector('#tabbit-retry-btn');
const input = panelEl.querySelector('#tabbit-user-input');
if (sendBtn) {
sendBtn.disabled = loading;
sendBtn.textContent = loading ? '等待' : '发送';
}
if (summaryBtn) summaryBtn.disabled = loading;
if (stopBtn) stopBtn.disabled = !loading;
if (retryBtn) retryBtn.disabled = loading || !lastRequestPayload;
if (input) input.disabled = loading;
}
function updateRetryButton() {
if (!panelEl) return;
const retryBtn = panelEl.querySelector('#tabbit-retry-btn');
if (retryBtn) retryBtn.disabled = isRequesting || !lastRequestPayload;
}
/******************************************************************
* 加入网址规则弹窗
******************************************************************/
function openAddUrlRuleModal() {
if (!addRuleModalEl) {
addRuleModalEl = createAddRuleModal();
document.body.appendChild(addRuleModalEl);
}
renderAddRuleModal();
addRuleModalEl.classList.remove('tabbit-hidden');
}
function closeAddRuleModal() {
if (addRuleModalEl) addRuleModalEl.classList.add('tabbit-hidden');
}
function createAddRuleModal() {
const modal = document.createElement('div');
modal.id = 'tabbit-add-rule-modal';
modal.innerHTML = `
<div class="tabbit-small-card">
<div class="tabbit-settings-header">
<div class="tabbit-settings-title">加入当前网址</div>
<button id="tabbit-add-rule-close" class="tabbit-icon-btn">×</button>
</div>
<div class="tabbit-settings-body">
<div class="tabbit-field">
<span>选择规则</span>
<div id="tabbit-rule-candidates" class="tabbit-rule-candidates"></div>
</div>
<label class="tabbit-field">
<span>自定义规则</span>
<input id="tabbit-custom-rule-input" type="text">
<small>不带 * 时按前缀匹配;带 * 时按通配符匹配。</small>
</label>
<label class="tabbit-field">
<span>绑定提示词模板,可选</span>
<select id="tabbit-add-rule-template"></select>
</label>
</div>
<div class="tabbit-settings-footer">
<button id="tabbit-confirm-add-rule" class="tabbit-primary-btn">添加</button>
<button id="tabbit-cancel-add-rule" class="tabbit-secondary-btn">取消</button>
</div>
</div>
`;
modal.querySelector('#tabbit-add-rule-close').addEventListener('click', closeAddRuleModal);
modal.querySelector('#tabbit-cancel-add-rule').addEventListener('click', closeAddRuleModal);
modal.querySelector('#tabbit-confirm-add-rule').addEventListener('click', () => {
const input = modal.querySelector('#tabbit-custom-rule-input');
const templateSelect = modal.querySelector('#tabbit-add-rule-template');
const rule = input.value.trim();
const templateId = templateSelect.value;
if (!rule) return;
addUrlRule(rule, templateId);
closeAddRuleModal();
});
modal.addEventListener('click', e => {
if (e.target === modal) closeAddRuleModal();
});
return modal;
}
function renderAddRuleModal() {
const candidates = buildUrlRuleCandidates(location.href);
const box = addRuleModalEl.querySelector('#tabbit-rule-candidates');
const input = addRuleModalEl.querySelector('#tabbit-custom-rule-input');
const templateSelect = addRuleModalEl.querySelector('#tabbit-add-rule-template');
box.innerHTML = '';
candidates.forEach((rule, index) => {
const item = document.createElement('label');
item.className = 'tabbit-rule-candidate';
item.innerHTML = `
<input type="radio" name="tabbit-rule-candidate" ${index === 0 ? 'checked' : ''}>
<span>${escapeHtml(rule)}</span>
`;
item.querySelector('input').addEventListener('change', () => {
input.value = rule;
});
box.appendChild(item);
});
input.value = candidates[0] || '';
templateSelect.innerHTML = `<option value="">使用默认提示词</option>`;
config.promptTemplates.forEach(t => {
const option = document.createElement('option');
option.value = t.id;
option.textContent = t.name;
templateSelect.appendChild(option);
});
}
/******************************************************************
* 正文预览
******************************************************************/
function openPreviewModal() {
if (!previewModalEl) {
previewModalEl = createPreviewModal();
document.body.appendChild(previewModalEl);
}
const text = lastExtractedText || extractPageContent();
lastExtractedText = text;
previewModalEl.querySelector('#tabbit-preview-count').textContent = `${text.length} 字符`;
previewModalEl.querySelector('#tabbit-preview-text').value = text;
previewModalEl.classList.remove('tabbit-hidden');
}
function closePreviewModal() {
if (previewModalEl) previewModalEl.classList.add('tabbit-hidden');
}
function createPreviewModal() {
const modal = document.createElement('div');
modal.id = 'tabbit-preview-modal';
modal.innerHTML = `
<div class="tabbit-preview-card">
<div class="tabbit-settings-header">
<div class="tabbit-settings-title">正文预览 <span id="tabbit-preview-count"></span></div>
<button id="tabbit-preview-close" class="tabbit-icon-btn">×</button>
</div>
<div class="tabbit-settings-body">
<textarea id="tabbit-preview-text" readonly></textarea>
</div>
<div class="tabbit-settings-footer">
<button id="tabbit-copy-preview" class="tabbit-secondary-btn">复制正文</button>
<button id="tabbit-close-preview" class="tabbit-primary-btn">关闭</button>
</div>
</div>
`;
modal.querySelector('#tabbit-preview-close').addEventListener('click', closePreviewModal);
modal.querySelector('#tabbit-close-preview').addEventListener('click', closePreviewModal);
modal.querySelector('#tabbit-copy-preview').addEventListener('click', () => {
copyText(modal.querySelector('#tabbit-preview-text').value || '');
setStatus('正文已复制', 'ok', 1200);
});
modal.addEventListener('click', e => {
if (e.target === modal) closePreviewModal();
});
return modal;
}
/******************************************************************
* 设置页面
******************************************************************/
function openSettings() {
if (!settingsEl) {
settingsEl = createSettingsModal();
document.body.appendChild(settingsEl);
}
fillSettingsForm();
settingsEl.classList.remove('tabbit-hidden');
}
function closeSettings() {
if (settingsEl) settingsEl.classList.add('tabbit-hidden');
}
function createSettingsModal() {
const modal = document.createElement('div');
modal.id = 'tabbit-settings-modal';
modal.innerHTML = `
<div class="tabbit-settings-card">
<div class="tabbit-settings-header">
<div class="tabbit-settings-title">⚙️ 饺子 AI 设置</div>
<button id="tabbit-settings-close" class="tabbit-icon-btn">×</button>
</div>
<div class="tabbit-settings-body">
<label class="tabbit-field">
<span>API 地址</span>
<input id="tabbit-set-api-url" type="text" placeholder="https://api.openai.com/v1/chat/completions">
</label>
<label class="tabbit-field">
<span>API Key</span>
<input id="tabbit-set-api-key" type="password" placeholder="sk-xxxx">
<small>配置只保存到油猴全局存储 GM_getValue / GM_setValue。</small>
</label>
<div class="tabbit-settings-actions">
<button id="tabbit-test-api" class="tabbit-secondary-btn" type="button">测试 API</button>
<button id="tabbit-fetch-models" class="tabbit-secondary-btn" type="button">获取模型列表</button>
</div>
<div class="tabbit-row-2">
<label class="tabbit-field">
<span>默认 temperature</span>
<input id="tabbit-set-temperature" type="number" step="0.1" min="0" max="2">
</label>
<label class="tabbit-field">
<span>默认 max_tokens</span>
<input id="tabbit-set-max-tokens" type="number" min="100">
</label>
</div>
<div class="tabbit-row-2">
<label class="tabbit-field">
<span>面板宽度 px</span>
<input id="tabbit-set-panel-width" type="number" min="320" max="900">
</label>
<label class="tabbit-field">
<span>发送正文最大字符数</span>
<input id="tabbit-set-extract-max" type="number" min="1000" max="80000">
</label>
</div>
<label class="tabbit-field tabbit-checkbox-field">
<input id="tabbit-set-auto-run" type="checkbox">
<span>打开匹配网址时自动弹出并总结</span>
</label>
<div class="tabbit-section-title">模型预设</div>
<div id="tabbit-model-list" class="tabbit-model-list"></div>
<div class="tabbit-settings-actions">
<button id="tabbit-add-model" class="tabbit-secondary-btn" type="button">+ 添加模型</button>
</div>
<div class="tabbit-section-title">提示词模板</div>
<label class="tabbit-field">
<span>默认模板</span>
<select id="tabbit-default-template"></select>
</label>
<div id="tabbit-template-list" class="tabbit-template-list"></div>
<div class="tabbit-settings-actions">
<button id="tabbit-add-template" class="tabbit-secondary-btn" type="button">+ 添加模板</button>
</div>
<div class="tabbit-section-title">指定网址</div>
<small class="tabbit-help">一行一条规则。不带 * 时按前缀匹配;带 * 时按通配符匹配。每条规则可绑定提示词模板。</small>
<div id="tabbit-url-rule-list" class="tabbit-url-rule-list"></div>
<div class="tabbit-settings-actions">
<button id="tabbit-settings-add-current-url" class="tabbit-secondary-btn" type="button">加入当前网址</button>
<button id="tabbit-settings-add-empty-url" class="tabbit-secondary-btn" type="button">+ 添加空规则</button>
<button id="tabbit-settings-dedupe-url" class="tabbit-secondary-btn" type="button">去重整理</button>
</div>
<div class="tabbit-section-title">配置文件</div>
<div class="tabbit-settings-actions">
<button id="tabbit-export-file" class="tabbit-secondary-btn" type="button">导出配置文件</button>
<button id="tabbit-import-file" class="tabbit-secondary-btn" type="button">导入配置文件</button>
<button id="tabbit-reset-config" class="tabbit-danger-btn" type="button">重置配置</button>
</div>
</div>
<div class="tabbit-settings-footer">
<button id="tabbit-save-settings" class="tabbit-primary-btn">保存设置</button>
<button id="tabbit-cancel-settings" class="tabbit-secondary-btn">取消</button>
</div>
</div>
`;
modal.querySelector('#tabbit-settings-close').addEventListener('click', closeSettings);
modal.querySelector('#tabbit-cancel-settings').addEventListener('click', closeSettings);
modal.querySelector('#tabbit-save-settings').addEventListener('click', saveSettingsFromForm);
modal.querySelector('#tabbit-add-model').addEventListener('click', () => {
syncModelsFromSettings();
config.models.push({
name: '新模型',
value: '',
temperature: '',
maxTokens: ''
});
renderSettingsModels();
});
modal.querySelector('#tabbit-add-template').addEventListener('click', () => {
syncTemplatesFromSettings();
config.promptTemplates.push({
id: makeId('tpl'),
name: '新模板',
text: '请总结这个网页的核心内容。'
});
renderSettingsTemplates();
renderSettingsUrlRules();
});
modal.querySelector('#tabbit-settings-add-current-url').addEventListener('click', () => {
openAddUrlRuleModal();
});
modal.querySelector('#tabbit-settings-add-empty-url').addEventListener('click', () => {
syncUrlRulesFromSettings();
config.urlRules.push('');
renderSettingsUrlRules();
});
modal.querySelector('#tabbit-settings-dedupe-url').addEventListener('click', () => {
syncUrlRulesFromSettings();
config.urlRules = normalizeUrlRules(config.urlRules);
config.rulePromptBindings = config.rulePromptBindings.filter(b => config.urlRules.includes(b.rule));
renderSettingsUrlRules();
});
modal.querySelector('#tabbit-test-api').addEventListener('click', testApiConnection);
modal.querySelector('#tabbit-fetch-models').addEventListener('click', fetchModelsFromApi);
modal.querySelector('#tabbit-export-file').addEventListener('click', exportConfigToFile);
modal.querySelector('#tabbit-import-file').addEventListener('click', importConfigFromFile);
modal.querySelector('#tabbit-reset-config').addEventListener('click', resetConfig);
modal.addEventListener('click', e => {
if (e.target === modal) closeSettings();
});
return modal;
}
function fillSettingsForm() {
if (!settingsEl) return;
settingsEl.querySelector('#tabbit-set-api-url').value = config.apiUrl || '';
settingsEl.querySelector('#tabbit-set-api-key').value = config.apiKey || '';
settingsEl.querySelector('#tabbit-set-temperature').value = config.temperature ?? 0.7;
settingsEl.querySelector('#tabbit-set-max-tokens').value = config.maxTokens ?? 2000;
settingsEl.querySelector('#tabbit-set-panel-width').value = config.panel?.width || 460;
settingsEl.querySelector('#tabbit-set-extract-max').value = config.extractMaxChars || 16000;
settingsEl.querySelector('#tabbit-set-auto-run').checked = !!config.autoRun;
renderSettingsModels();
renderSettingsTemplates();
renderSettingsUrlRules();
}
function renderSettingsModels() {
if (!settingsEl) return;
const box = settingsEl.querySelector('#tabbit-model-list');
box.innerHTML = '';
config.models.forEach((model, index) => {
const row = document.createElement('div');
row.className = 'tabbit-model-row';
row.innerHTML = `
<input class="tabbit-model-name" type="text" placeholder="显示名称" value="${escapeAttr(model.name || '')}">
<input class="tabbit-model-value" type="text" placeholder="模型名" value="${escapeAttr(model.value || '')}">
<input class="tabbit-model-temp" type="number" step="0.1" placeholder="temp" value="${escapeAttr(model.temperature ?? '')}">
<input class="tabbit-model-tokens" type="number" placeholder="tokens" value="${escapeAttr(model.maxTokens ?? '')}">
<label class="tabbit-current-model">
<input type="radio" name="tabbit-current-model" ${model.value === config.currentModel ? 'checked' : ''}>
当前
</label>
<button class="tabbit-remove-model" type="button">×</button>
`;
row.querySelector('.tabbit-remove-model').addEventListener('click', () => {
syncModelsFromSettings();
config.models.splice(index, 1);
if (!config.models.length) {
config.models.push({
name: 'mimo-v2-flash',
value: 'mimo-v2-flash',
temperature: '',
maxTokens: ''
});
}
if (!config.models.some(m => m.value === config.currentModel)) {
config.currentModel = config.models[0].value;
}
renderSettingsModels();
});
box.appendChild(row);
});
}
function syncModelsFromSettings() {
if (!settingsEl) return;
const rows = [...settingsEl.querySelectorAll('.tabbit-model-row')];
let nextCurrent = config.currentModel;
const models = rows
.map(row => {
const name = row.querySelector('.tabbit-model-name').value.trim();
const value = row.querySelector('.tabbit-model-value').value.trim();
const temperature = row.querySelector('.tabbit-model-temp').value.trim();
const maxTokens = row.querySelector('.tabbit-model-tokens').value.trim();
const checked = row.querySelector('input[type="radio"]').checked;
if (checked && value) nextCurrent = value;
return {
name: name || value,
value,
temperature,
maxTokens
};
})
.filter(model => model.value);
config.models = normalizeModels(models);
if (!config.models.some(m => m.value === nextCurrent)) {
nextCurrent = config.models[0]?.value || '';
}
config.currentModel = nextCurrent;
}
function renderSettingsTemplates() {
if (!settingsEl) return;
const select = settingsEl.querySelector('#tabbit-default-template');
const box = settingsEl.querySelector('#tabbit-template-list');
select.innerHTML = '';
box.innerHTML = '';
config.promptTemplates.forEach(t => {
const option = document.createElement('option');
option.value = t.id;
option.textContent = t.name;
option.selected = t.id === config.defaultPromptTemplateId;
select.appendChild(option);
});
config.promptTemplates.forEach((tpl, index) => {
const row = document.createElement('div');
row.className = 'tabbit-template-row';
row.dataset.templateId = tpl.id;
row.innerHTML = `
<div class="tabbit-template-head">
<input class="tabbit-template-name" type="text" placeholder="模板名称" value="${escapeAttr(tpl.name)}">
<button class="tabbit-remove-template" type="button">×</button>
</div>
<textarea class="tabbit-template-text" rows="5">${escapeHtml(tpl.text)}</textarea>
`;
row.querySelector('.tabbit-remove-template').addEventListener('click', () => {
syncTemplatesFromSettings();
if (config.promptTemplates.length <= 1) {
alert('至少保留一个提示词模板。');
return;
}
const removed = config.promptTemplates[index];
config.promptTemplates.splice(index, 1);
if (removed?.id === config.defaultPromptTemplateId) {
config.defaultPromptTemplateId = config.promptTemplates[0]?.id || 'default';
}
config.rulePromptBindings = config.rulePromptBindings.filter(b => b.templateId !== removed?.id);
renderSettingsTemplates();
renderSettingsUrlRules();
});
box.appendChild(row);
});
select.onchange = () => {
config.defaultPromptTemplateId = select.value;
};
}
function syncTemplatesFromSettings() {
if (!settingsEl) return;
const rows = [...settingsEl.querySelectorAll('.tabbit-template-row')];
config.promptTemplates = normalizePromptTemplates(
rows.map(row => ({
id: row.dataset.templateId || makeId('tpl'),
name: row.querySelector('.tabbit-template-name').value.trim(),
text: row.querySelector('.tabbit-template-text').value.trim()
}))
);
const defaultSelect = settingsEl.querySelector('#tabbit-default-template');
if (defaultSelect?.value) {
config.defaultPromptTemplateId = defaultSelect.value;
}
if (!config.promptTemplates.some(t => t.id === config.defaultPromptTemplateId)) {
config.defaultPromptTemplateId = config.promptTemplates[0]?.id || 'default';
}
}
function renderSettingsUrlRules() {
if (!settingsEl) return;
const box = settingsEl.querySelector('#tabbit-url-rule-list');
box.innerHTML = '';
config.urlRules.forEach((rule, index) => {
const row = document.createElement('div');
row.className = 'tabbit-url-rule-row';
const matched = rule && testUrlRule(location.href, rule);
row.innerHTML = `
<input class="tabbit-url-rule-input" type="text" placeholder="https://example.com/path/*" value="${escapeAttr(rule)}">
<select class="tabbit-url-rule-template">
<option value="">默认模板</option>
</select>
<span class="tabbit-rule-match ${matched ? 'matched' : ''}">${matched ? '匹配当前页' : ''}</span>
<button class="tabbit-remove-url-rule" type="button">×</button>
`;
const select = row.querySelector('.tabbit-url-rule-template');
const currentTemplateId = getTemplateIdForRule(rule);
config.promptTemplates.forEach(t => {
const option = document.createElement('option');
option.value = t.id;
option.textContent = t.name;
option.selected = t.id === currentTemplateId;
select.appendChild(option);
});
row.querySelector('.tabbit-remove-url-rule').addEventListener('click', () => {
syncUrlRulesFromSettings();
const removedRule = config.urlRules[index];
config.urlRules.splice(index, 1);
config.rulePromptBindings = config.rulePromptBindings.filter(b => b.rule !== removedRule);
renderSettingsUrlRules();
});
box.appendChild(row);
});
}
function syncUrlRulesFromSettings() {
if (!settingsEl) return;
const rows = [...settingsEl.querySelectorAll('.tabbit-url-rule-row')];
const rules = [];
const bindings = [];
rows.forEach(row => {
const rule = row.querySelector('.tabbit-url-rule-input').value.trim();
const templateId = row.querySelector('.tabbit-url-rule-template').value;
if (!rule) return;
rules.push(rule);
if (templateId) {
bindings.push({
rule,
templateId
});
}
});
config.urlRules = normalizeUrlRules(rules);
config.rulePromptBindings = normalizeRulePromptBindings(bindings);
}
function saveSettingsFromForm() {
syncModelsFromSettings();
syncTemplatesFromSettings();
syncUrlRulesFromSettings();
config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim();
config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim();
config.temperature = Number(settingsEl.querySelector('#tabbit-set-temperature').value || 0.7);
config.maxTokens = Number(settingsEl.querySelector('#tabbit-set-max-tokens').value || 2000);
config.autoRun = settingsEl.querySelector('#tabbit-set-auto-run').checked;
config.extractMaxChars = Number(settingsEl.querySelector('#tabbit-set-extract-max').value || 16000);
config.panel = {
...config.panel,
width: Math.max(320, Number(settingsEl.querySelector('#tabbit-set-panel-width').value || 460))
};
if (!config.currentModel && config.models.length) {
config.currentModel = config.models[0].value;
}
saveConfig();
renderModelSelect();
applyFloatButtonPosition();
if (panelEl) {
panelEl.style.width = config.panel.width + 'px';
}
closeSettings();
setStatus('设置已保存', 'ok', 1200);
}
/******************************************************************
* API 测试与模型拉取
******************************************************************/
async function testApiConnection() {
syncModelsFromSettings();
config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim();
config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim();
config.temperature = Number(settingsEl.querySelector('#tabbit-set-temperature').value || 0.7);
config.maxTokens = Number(settingsEl.querySelector('#tabbit-set-max-tokens').value || 2000);
if (!config.apiUrl || !config.apiKey || !config.currentModel) {
alert('请先填写 API 地址、API Key,并设置当前模型。');
return;
}
try {
const btn = settingsEl.querySelector('#tabbit-test-api');
btn.disabled = true;
btn.textContent = '测试中…';
await callChatApi([
{
role: 'user',
content: '请只回复 OK'
}
]);
alert('API 测试成功。');
} catch (err) {
alert('API 测试失败:\n\n' + (err.message || String(err)));
} finally {
const btn = settingsEl.querySelector('#tabbit-test-api');
btn.disabled = false;
btn.textContent = '测试 API';
setInputLoading(false);
}
}
function buildModelsUrl(apiUrl) {
const url = new URL(apiUrl);
if (/\/chat\/completions\/?$/i.test(url.pathname)) {
url.pathname = url.pathname.replace(/\/chat\/completions\/?$/i, '/models');
return url.toString();
}
if (/\/completions\/?$/i.test(url.pathname)) {
url.pathname = url.pathname.replace(/\/completions\/?$/i, '/models');
return url.toString();
}
url.pathname = url.pathname.replace(/\/+$/, '') + '/models';
return url.toString();
}
function fetchModelsFromApi() {
config.apiUrl = settingsEl.querySelector('#tabbit-set-api-url').value.trim();
config.apiKey = settingsEl.querySelector('#tabbit-set-api-key').value.trim();
if (!config.apiUrl || !config.apiKey) {
alert('请先填写 API 地址和 API Key。');
return;
}
let modelsUrl = '';
try {
modelsUrl = buildModelsUrl(config.apiUrl);
} catch (err) {
alert('API 地址格式不正确。');
return;
}
const btn = settingsEl.querySelector('#tabbit-fetch-models');
btn.disabled = true;
btn.textContent = '获取中…';
GM_xmlhttpRequest({
method: 'GET',
url: modelsUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`
},
timeout: 60000,
onload(res) {
btn.disabled = false;
btn.textContent = '获取模型列表';
try {
if (res.status < 200 || res.status >= 300) {
alert(`获取失败:${res.status}\n${res.responseText || ''}`);
return;
}
const data = JSON.parse(res.responseText);
const ids = Array.isArray(data?.data)
? data.data.map(x => x.id || x.name || x.model).filter(Boolean)
: [];
if (!ids.length) {
alert('没有从响应中识别到模型列表。');
return;
}
syncModelsFromSettings();
ids.forEach(id => {
if (!config.models.some(m => m.value === id)) {
config.models.push({
name: id,
value: id,
temperature: '',
maxTokens: ''
});
}
});
if (!config.currentModel) {
config.currentModel = config.models[0]?.value || '';
}
renderSettingsModels();
alert(`已获取 ${ids.length} 个模型。`);
} catch (err) {
alert('解析模型列表失败:' + err.message);
}
},
onerror(err) {
btn.disabled = false;
btn.textContent = '获取模型列表';
alert('获取模型列表失败:' + JSON.stringify(err));
},
ontimeout() {
btn.disabled = false;
btn.textContent = '获取模型列表';
alert('获取模型列表超时。');
}
});
}
/******************************************************************
* 文件导入导出
******************************************************************/
function exportConfigToFile() {
try {
saveConfig();
const data = JSON.stringify(config, null, 2);
const blob = new Blob([data], {
type: 'application/json;charset=utf-8'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const date = new Date();
const pad = n => String(n).padStart(2, '0');
const fileName =
`tabbit-ai-config-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}.json`;
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert('导出失败:' + err.message);
}
}
function importConfigFromFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.style.cssText = 'position:fixed;left:-9999px;top:-9999px;';
document.body.appendChild(input);
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) {
input.remove();
return;
}
const reader = new FileReader();
reader.onload = e => {
try {
const imported = JSON.parse(e.target.result);
config = mergeConfig(clone(DEFAULT_CONFIG), imported);
saveConfig();
if (settingsEl) fillSettingsForm();
if (panelEl) renderModelSelect();
applyFloatButtonPosition();
alert('配置导入成功。');
} catch (err) {
alert('导入失败:JSON 格式错误。\n\n' + err.message);
} finally {
input.remove();
}
};
reader.onerror = () => {
alert('读取文件失败。');
input.remove();
};
reader.readAsText(file, 'utf-8');
});
input.click();
}
/******************************************************************
* 工具函数
******************************************************************/
function copyText(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text);
return;
}
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
return;
}
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
function renderMarkdown(text) {
let html = escapeHtml(text || '');
html = html.replace(/^###### (.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^##### (.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
);
html = html
.split('\n')
.map(line => {
if (/^<h\d/.test(line)) return line;
if (/^\s*[-*]\s+/.test(line)) {
return `<div class="tabbit-list-item">• ${line.replace(/^\s*[-*]\s+/, '')}</div>`;
}
if (!line.trim()) return '<br>';
return `<p>${line}</p>`;
})
.join('');
return html;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function escapeAttr(str) {
return escapeHtml(str).replace(/`/g, '`');
}
/******************************************************************
* 样式
******************************************************************/
function createStyles() {
if (document.querySelector('#tabbit-ai-style')) return;
const style = document.createElement('style');
style.id = 'tabbit-ai-style';
style.textContent = `
#tabbit-ai-float-btn {
position: fixed;
z-index: 2147483645;
width: 28px;
height: 72px;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
background: linear-gradient(160deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 13px;
font-weight: 700;
box-shadow: 0 4px 16px rgba(0, 0, 0, .18);
transition: opacity .2s ease, width .2s ease, filter .2s ease, box-shadow .2s ease;
user-select: none;
writing-mode: vertical-rl;
letter-spacing: 2px;
}
#tabbit-ai-float-btn.tabbit-float-right {
border-radius: 10px 0 0 10px;
}
#tabbit-ai-float-btn.tabbit-float-left {
border-radius: 0 10px 10px 0;
}
#tabbit-ai-float-btn:hover {
opacity: 1 !important;
width: 38px;
filter: brightness(1.05);
box-shadow: 0 6px 22px rgba(0, 0, 0, .25);
}
#tabbit-ai-float-btn span {
pointer-events: none;
}
body.tabbit-dragging,
body.tabbit-dragging * {
cursor: grabbing !important;
}
#tabbit-ai-panel {
position: fixed;
top: 20px;
right: 20px;
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
background: #fff;
color: #222;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,.18);
z-index: 2147483646;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.tabbit-hidden {
display: none !important;
}
.tabbit-header,
.tabbit-settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.tabbit-title,
.tabbit-settings-title {
font-weight: 700;
font-size: 16px;
}
.tabbit-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tabbit-model-select {
max-width: 150px;
border: none;
border-radius: 8px;
padding: 5px 8px;
font-size: 12px;
}
.tabbit-icon-btn {
border: none;
border-radius: 8px;
background: rgba(255,255,255,.18);
color: inherit;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 6px 9px;
}
.tabbit-icon-btn:hover {
background: rgba(255,255,255,.28);
}
.tabbit-toolbar {
display: flex;
flex-wrap: wrap;
gap: 7px;
padding: 9px 10px;
border-bottom: 1px solid #eee;
background: #fafafa;
}
.tabbit-primary-btn,
.tabbit-secondary-btn,
.tabbit-danger-btn {
border: none;
border-radius: 8px;
padding: 7px 10px;
cursor: pointer;
font-size: 13px;
}
.tabbit-primary-btn {
background: #667eea;
color: #fff;
}
.tabbit-primary-btn:hover {
background: #5a6fd6;
}
.tabbit-secondary-btn {
background: #f0f0f5;
color: #444;
}
.tabbit-secondary-btn:hover {
background: #e6e6ef;
}
.tabbit-danger-btn {
background: #fff1f1;
color: #c00000;
border: 1px solid #ffcaca;
}
.tabbit-danger-btn:hover {
background: #ffe1e1;
}
.tabbit-primary-btn:disabled,
.tabbit-secondary-btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.tabbit-status-bar {
padding: 6px 12px;
font-size: 12px;
border-bottom: 1px solid #eee;
background: #f8f8fc;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tabbit-status-loading {
color: #5a43c8;
background: #f4f1ff;
}
.tabbit-status-ok {
color: #0d7a3a;
background: #effaf3;
}
.tabbit-status-error {
color: #b00000;
background: #fff1f1;
}
.tabbit-chat-list {
flex: 1;
overflow-y: auto;
padding: 14px;
background: #f7f8fb;
}
.tabbit-msg {
margin-bottom: 12px;
}
.tabbit-msg-label {
font-size: 12px;
color: #777;
margin-bottom: 4px;
}
.tabbit-msg-body {
border-radius: 12px;
padding: 10px 12px;
font-size: 14px;
line-height: 1.65;
word-break: break-word;
}
.tabbit-msg-body p {
margin: 6px 0;
}
.tabbit-msg-body h1,
.tabbit-msg-body h2,
.tabbit-msg-body h3,
.tabbit-msg-body h4 {
margin: 10px 0 6px;
line-height: 1.4;
}
.tabbit-msg-body code {
background: #ececf4;
padding: 2px 5px;
border-radius: 4px;
color: #c7254e;
}
.tabbit-msg-body a {
color: #667eea;
}
.tabbit-list-item {
margin: 4px 0;
}
.tabbit-msg-user .tabbit-msg-body {
background: #e8f0ff;
border: 1px solid #d8e4ff;
}
.tabbit-msg-assistant .tabbit-msg-body {
background: #fff;
border: 1px solid #e9e9ef;
}
.tabbit-msg-error .tabbit-msg-body {
background: #fff1f1;
border: 1px solid #ffc9c9;
color: #b00000;
}
.tabbit-input-area {
display: flex;
gap: 8px;
padding: 12px;
background: #fff;
border-top: 1px solid #eee;
}
#tabbit-user-input {
flex: 1;
resize: none;
min-height: 42px;
max-height: 120px;
border: 1px solid #ddd;
border-radius: 10px;
padding: 9px 10px;
font-size: 14px;
line-height: 1.5;
outline: none;
}
#tabbit-user-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102,126,234,.12);
}
#tabbit-send-btn {
width: 72px;
border: none;
border-radius: 10px;
background: #667eea;
color: #fff;
cursor: pointer;
}
#tabbit-send-btn:disabled {
opacity: .65;
cursor: not-allowed;
}
#tabbit-settings-modal,
#tabbit-add-rule-modal,
#tabbit-preview-modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,.35);
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.tabbit-settings-card {
width: 860px;
max-width: calc(100vw - 36px);
max-height: 88vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,.28);
}
.tabbit-small-card {
width: 620px;
max-width: calc(100vw - 36px);
max-height: 86vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,.28);
}
.tabbit-preview-card {
width: 820px;
max-width: calc(100vw - 36px);
height: 80vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,.28);
}
.tabbit-settings-body {
padding: 16px 18px;
overflow-y: auto;
}
.tabbit-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
font-size: 13px;
color: #444;
}
.tabbit-field small,
.tabbit-help {
color: #888;
line-height: 1.5;
font-size: 12px;
}
.tabbit-checkbox-field {
flex-direction: row;
align-items: center;
}
.tabbit-checkbox-field input {
width: auto !important;
}
.tabbit-field input,
.tabbit-field textarea,
.tabbit-field select,
.tabbit-url-rule-row input,
.tabbit-url-rule-row select,
.tabbit-model-row input,
.tabbit-template-row input,
.tabbit-template-row textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid #ddd;
border-radius: 10px;
padding: 8px 9px;
font-size: 13px;
outline: none;
font-family: inherit;
}
.tabbit-field textarea,
.tabbit-template-row textarea {
resize: vertical;
}
.tabbit-field input:focus,
.tabbit-field textarea:focus,
.tabbit-field select:focus,
.tabbit-url-rule-row input:focus,
.tabbit-url-rule-row select:focus,
.tabbit-model-row input:focus,
.tabbit-template-row input:focus,
.tabbit-template-row textarea:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102,126,234,.12);
}
.tabbit-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.tabbit-section-title {
margin: 18px 0 10px;
font-weight: 700;
font-size: 14px;
color: #333;
}
.tabbit-model-list,
.tabbit-template-list,
.tabbit-url-rule-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.tabbit-model-row {
display: grid;
grid-template-columns: 1fr 1.3fr 90px 100px auto 34px;
gap: 8px;
align-items: center;
}
.tabbit-current-model {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
font-size: 12px;
}
.tabbit-remove-model,
.tabbit-remove-url-rule,
.tabbit-remove-template {
width: 34px;
height: 34px;
border: none;
border-radius: 8px;
background: #f2f2f2;
cursor: pointer;
font-size: 18px;
color: #666;
}
.tabbit-remove-model:hover,
.tabbit-remove-url-rule:hover,
.tabbit-remove-template:hover {
background: #ffe8e8;
color: #c00;
}
.tabbit-template-row {
padding: 10px;
border: 1px solid #eee;
border-radius: 12px;
background: #fafafa;
}
.tabbit-template-head {
display: grid;
grid-template-columns: 1fr 34px;
gap: 8px;
margin-bottom: 8px;
}
.tabbit-url-rule-row {
display: grid;
grid-template-columns: 1.6fr 150px 84px 34px;
gap: 8px;
align-items: center;
padding: 8px;
border: 1px solid #eee;
border-radius: 12px;
background: #fafafa;
}
.tabbit-rule-match {
font-size: 12px;
color: #aaa;
white-space: nowrap;
}
.tabbit-rule-match.matched {
color: #0d7a3a;
}
.tabbit-settings-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 8px 0 14px;
}
.tabbit-settings-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px 18px;
border-top: 1px solid #eee;
background: #fafafa;
}
.tabbit-rule-candidates {
display: flex;
flex-direction: column;
gap: 8px;
}
.tabbit-rule-candidate {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px 10px;
border: 1px solid #eee;
border-radius: 10px;
background: #fafafa;
font-size: 13px;
word-break: break-all;
cursor: pointer;
}
#tabbit-preview-text {
width: 100%;
height: calc(80vh - 150px);
box-sizing: border-box;
resize: none;
border: 1px solid #ddd;
border-radius: 10px;
padding: 12px;
font-size: 13px;
line-height: 1.6;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
#tabbit-preview-count {
font-size: 12px;
opacity: .85;
margin-left: 8px;
}
@media (max-width: 640px) {
#tabbit-ai-panel {
top: 10px;
right: 10px;
left: 10px;
width: auto !important;
max-width: none;
height: 82vh !important;
}
.tabbit-row-2,
.tabbit-model-row,
.tabbit-url-rule-row {
grid-template-columns: 1fr;
}
.tabbit-settings-card,
.tabbit-small-card,
.tabbit-preview-card {
max-width: calc(100vw - 20px);
max-height: 92vh;
}
}
`;
document.head.appendChild(style);
}
})();