// ==UserScript== // @name 饺子 AI 网页摘要助手 // @namespace https://space.bilibili.com/38389107 // @version 2.6.1 // @description 指定网站自动弹出 AI 网页摘要,支持连续对话、多预设、多模板、SPA路由,flomo、坚果云双文件云同步。 // @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.icu/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.icu/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. 常量 ******************************************************************/ 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'; const DEFAULT_PROMPT_TEXT = '请阅读这个网页,并为我提供一份结构化的中文摘要。' + '\n\n请按以下格式输出:' + '\n\n## 一句话总结\n用一句话说明这个网页讲了什么。' + '\n\n## 核心要点\n用 3-5 个要点列出这个网页的核心信息。' + '\n\n## 值得关注的细节\n如果有数据、案例、引用、关键人物,请单独列出。' + '\n\n## 我的解读建议\n如果可能,给出一段独立思考或建议(可选)。'; 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: '', 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: { side: 'right', y: null, opacity: 0.55 }, panel: { width: 460, height: null, heightRatio: 0.82, left: null, top: null }, extractMaxChars: 16000, cloudSync: { account: '', appPassword: '', lastSyncAt: 0, lastSyncDirection: '' } }; function clone(obj) { return JSON.parse(JSON.stringify(obj)); } /****************************************************************** * 1. 内联 Markdown 渲染器 ******************************************************************/ const _md = (function () { function escapeHtml(str) { return String(str) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function renderInline(text) { let s = escapeHtml(text); s = s.replace(/`([^`]+?)`/g, '$1'); s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, '$1'); s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, '$1'); 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'); return s; } return function parse(md) { if (!md) return ''; md = String(md).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = md.split('\n'); let html = '', i = 0, inCode = false, codeLang = '', codeBuf = []; let listStack = []; function closeAllLists() { while (listStack.length) html += ''; } while (i < lines.length) { const line = lines[i]; const fence = line.match(/^```(\w*)\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)) { closeAllLists(); i++; 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 = m[2]; while (listStack.length && listStack[listStack.length - 1].indent > indent) html += ''; if (listStack.length && listStack[listStack.length - 1].indent === indent && listStack[listStack.length - 1].type !== type) html += ''; if (!listStack.length || listStack[listStack.length - 1].indent < indent) { html += '<' + type + '>
  • '; listStack.push({ type: type, indent: indent }); } else html += '
  • '; html += renderInline(content); 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])) { 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) { return String(v ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } 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 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; } }); } /****************************************************************** * 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); 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); return mergeConfig(clone(DEFAULT_CONFIG), JSON.parse(raw)); } 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); 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(); /****************************************************************** * 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); if (text.length > max) text = text.substring(0, max) + `\n\n(已截断到 ${max} 字符)`; return text; } catch (err) { return document.body?.innerText || ''; } } /****************************************************************** * 6. 调用 Chat API ******************************************************************/ let currentRequest = null; let currentReject = null; function callChatApi(messages) { const profile = getCurrentProfile(); const body = { model: profile.currentModel, messages, temperature: getCurrentTemperature(), max_tokens: getCurrentMaxTokens() }; return new Promise((resolve, reject) => { currentReject = reject; currentRequest = GM_xmlhttpRequest({ method: 'POST', url: profile.apiUrl, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${profile.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 响应格式异常')); 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 abortCurrentRequest() { try { currentRequest?.abort?.(); } catch (e) {} if (currentReject) { try { currentReject(new Error('已取消')); } catch (e) {} } currentRequest = null; currentReject = null; } /****************************************************************** * 7. 💬 对话状态管理(新核心) ******************************************************************/ // 对话历史:[{role:'system'|'user'|'assistant', content:'...', meta?}] let conversation = []; let pageContextLoaded = false; // 是否已经把页面正文塞到 system 中 function resetConversation(reason) { conversation = []; pageContextLoaded = false; if (panelEl) { const body = panelEl.querySelector('#tabbit-body'); if (body) { body.innerHTML = `

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

    `; } const input = panelEl.querySelector('#tabbit-chat-input'); if (input) input.value = ''; } } 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 ensurePageContext() { if (pageContextLoaded) return; conversation.unshift({ role: 'system', content: buildPageSystemPrompt() }); pageContextLoaded = true; } function renderConversation() { if (!panelEl) return; const body = panelEl.querySelector('#tabbit-body'); if (!body) return; const visibleMsgs = conversation.filter(m => m.role !== 'system' && !m.meta?.hidden); if (!visibleMsgs.length) { body.innerHTML = `

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

    `; return; } body.innerHTML = visibleMsgs.map(m => { if (m.role === 'user') { return `
    🙋 我
    ${_md(m.content)}
    `; } else { return `
    🤖 ${escapeAttr(m.meta?.model || 'AI')}
    ${_md(m.content)}
    `; } }).join(''); body.scrollTop = body.scrollHeight; } function appendMessage(role, content, meta) { conversation.push({ role, content, meta: meta || {} }); renderConversation(); } /****************************************************************** * 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_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 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() { if (!settingsEl) return; config.cloudSync = { ...(config.cloudSync || {}), account: settingsEl.querySelector('#tabbit-set-jgy-account').value.trim(), appPassword: settingsEl.querySelector('#tabbit-set-jgy-password').value.trim() }; } function readSyncScopeFromForm() { if (!settingsEl) return { profiles: true, app: true }; return { profiles: settingsEl.querySelector('#tabbit-sync-profiles')?.checked !== false, app: settingsEl.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 = '⚠️ 强制覆盖上传'; } } } /****************************************************************** * 9. 状态条 ******************************************************************/ let statusTimer = null; function setStatus(msg, level, duration) { if (!panelEl) return; const el = panelEl.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. 样式 ******************************************************************/ function createStyles() { if (document.getElementById(STYLE_ID)) return; const css = ` #${FLOAT_BTN_ID} { position: fixed; z-index: 2147483646; width: 44px; height: 44px; 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); } #${PANEL_ID} { position: fixed; top: 5vh; right: 16px; width: 460px; height: 82vh; min-width: 340px; min-height: 360px; max-width: 96vw; max-height: 96vh; 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; background: linear-gradient(135deg, #8b5cf6, #3b82f6); color: white; cursor: move; flex-shrink: 0; } .tabbit-title { font-weight: 700; font-size: 14px; } .tabbit-header-actions { display: flex; gap: 6px; align-items: center; } .tabbit-icon-btn { background: rgba(255,255,255,.18); color: #fff; border: none; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; font-size: 16px; } .tabbit-icon-btn:hover { background: rgba(255,255,255,.32); } .tabbit-model-select, .tabbit-prompt-select, .tabbit-profile-select { max-width: 130px; border: none; border-radius: 8px; padding: 5px 8px; font-size: 12px; background: rgba(255,255,255,.92); color: #333; cursor: pointer; } .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; } .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; } .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; } .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; } .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; } .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-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; } /* 💬 输入区 */ .tabbit-input-area { flex-shrink: 0; border-top: 1px solid #eee; padding: 8px 10px 10px; background: #fafafa; } .tabbit-input-row { display: flex; gap: 6px; align-items: flex-end; } #tabbit-chat-input { flex: 1; 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; } #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; } .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; } .tabbit-resize-handle:hover { background: linear-gradient(135deg, transparent 50%, rgba(139,92,246,0.7) 50%); } /* 设置弹窗 */ #${SETTINGS_ID} { 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: 600px; max-width: 96vw; max-height: 90vh; overflow-y: auto; border-radius: 14px; padding: 18px 20px; } .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-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: 10px; 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-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-model-row { display: grid; grid-template-columns: 1.6fr .7fr .8fr auto auto; gap: 6px; margin-bottom: 6px; align-items: center; } .tabbit-model-row input { padding: 5px 7px; font-size: 12px; border: 1px solid #ddd; border-radius: 6px; } .tabbit-current-model { font-size: 11px; display: flex; align-items: center; gap: 3px; } .tabbit-remove-model { background: #fee2e2; color: #b91c1c; border: none; border-radius: 6px; cursor: pointer; padding: 4px 8px; } .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-addrule-card { 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; } `; if (typeof GM_addStyle === 'function') GM_addStyle(css); else { const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = css; document.head.appendChild(style); } } /****************************************************************** * 11. 浮动按钮(任何网站点击都能打开 + 自动总结) ******************************************************************/ let floatBtn = null; function createFloatButton() { if (document.getElementById(FLOAT_BTN_ID)) return; floatBtn = document.createElement('div'); floatBtn.id = FLOAT_BTN_ID; floatBtn.title = '打开 AI 摘要(点击自动总结)'; floatBtn.textContent = '🥟'; floatBtn.style.opacity = config.floatButton.opacity; document.body.appendChild(floatBtn); applyFloatButtonPosition(); let dragging = false, startY = 0, startTop = 0, moved = false; floatBtn.addEventListener('mousedown', e => { dragging = true; moved = false; startY = e.clientY; startTop = floatBtn.getBoundingClientRect().top; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; const dy = e.clientY - startY; if (Math.abs(dy) > 5) moved = true; const newTop = Math.max(0, Math.min(window.innerHeight - 44, startTop + dy)); floatBtn.style.top = newTop + 'px'; floatBtn.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { if (dragging) { dragging = false; if (moved) { config.floatButton.y = floatBtn.getBoundingClientRect().top; saveConfig(); } } }); floatBtn.addEventListener('click', () => { if (moved) return; // 🚀 点击浮窗:如果面板关闭则打开+自动总结,已开则关闭 if (!panelEl || panelEl.classList.contains('tabbit-hidden')) { openPanel(true); // 自动总结 } else { closePanel(); } }); } function applyFloatButtonPosition() { if (!floatBtn) return; floatBtn.style.right = '12px'; if (config.floatButton.y != null) { floatBtn.style.top = config.floatButton.y + 'px'; floatBtn.style.bottom = 'auto'; } else { floatBtn.style.bottom = '80px'; floatBtn.style.top = 'auto'; } } /****************************************************************** * 12. 主面板 ******************************************************************/ let panelEl = null; function createPanel() { if (document.getElementById(PANEL_ID)) return; panelEl = document.createElement('div'); panelEl.id = PANEL_ID; panelEl.classList.add('tabbit-hidden'); panelEl.innerHTML = `
    🥟 饺子 AI 摘要

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

    Enter 发送 · Shift+Enter 换行
    `; document.body.appendChild(panelEl); // 应用持久化的尺寸/位置 panelEl.style.width = (config.panel?.width || 460) + 'px'; if (config.panel?.height) { panelEl.style.height = config.panel.height + 'px'; } else { panelEl.style.height = ((config.panel?.heightRatio || 0.82) * 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').addEventListener('click', () => 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-flomo-btn').addEventListener('click', sendToFlomo); panelEl.querySelector('#tabbit-clear-btn').addEventListener('click', handleClearConversation); panelEl.querySelector('#tabbit-send-btn').addEventListener('click', handleSendChat); // 💬 输入框:Enter 发送,Shift+Enter 换行,自动伸高 const input = panelEl.querySelector('#tabbit-chat-input'); input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); handleSendChat(); } }); input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = Math.min(140, input.scrollHeight) + 'px'; }); enablePanelDrag(); enablePanelResize(); renderModelSelect(); } 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 (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(); panelEl.classList.remove('tabbit-hidden'); renderModelSelect(); if (autoRun) runSummary(true); } function closePanel() { if (panelEl) panelEl.classList.add('tabbit-hidden'); } function togglePanel() { if (!panelEl || panelEl.classList.contains('tabbit-hidden')) openPanel(false); else closePanel(); } function renderProfileSelect() { if (!panelEl) return; const select = panelEl.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() { if (!panelEl) return; renderProfileSelect(); const select = panelEl.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() { if (!panelEl) return; const select = panelEl.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); }); } /****************************************************************** * 13. 📋 复制 / 🌱 flomo / 🗑 清空 ******************************************************************/ function buildConversationText() { return conversation .filter(m => m.role !== 'system' && !m.meta?.hidden) // ← 加上 && !m.meta?.hidden .map(m => { const tag = m.role === 'user' ? '【我】' : `【AI · ${m.meta?.model || ''}】`; return `${tag}\n${m.content}`; }) .join('\n\n---\n\n'); } 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 sendToFlomo() { if (!config.flomoApiUrl) { alert('请先在设置中配置 flomo API 地址。'); openSettings(); return; } const text = buildConversationText(); if (!text.trim()) { setStatus('没有可发送的内容', 'error', 1500); return; } const content = `${text}\n\n---\n📄 ${document.title}\n🔗 ${location.href}\n#饺子AI摘要`; const btn = panelEl.querySelector('#tabbit-flomo-btn'); if (btn) { btn.disabled = true; btn.textContent = '⏳'; } setStatus('正在发送到 flomo…', 'loading'); GM_xmlhttpRequest({ method: 'POST', url: config.flomoApiUrl, 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.message === 'ok' || data.message === 'success'); if (ok) { setStatus('已发送到 flomo', 'ok', 2000); if (btn) { btn.textContent = '✅'; setTimeout(() => { btn.textContent = '🌱'; btn.disabled = false; }, 2000); } } else throw new Error(data.message || `HTTP ${res.status}`); } catch (err) { setStatus('发送失败:' + err.message, 'error', 3000); if (btn) { btn.textContent = '🌱'; btn.disabled = false; } } }, onerror() { setStatus('发送失败:网络错误', 'error', 3000); if (btn) { btn.textContent = '🌱'; btn.disabled = false; } }, ontimeout() { setStatus('发送超时', 'error', 3000); if (btn) { btn.textContent = '🌱'; btn.disabled = false; } } }); } 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() { if (!panelEl) return; const text = getPageText(); const len = text.length; const max = Number(config.extractMaxChars || 16000); // 把预览作为一条"系统提示"消息塞进对话区显示,但不进 conversation 上下文 const body = panelEl.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() { // 已存在的卡片先关掉,避免叠加 document.getElementById('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, i) => ` `).join('')}
    `; document.body.appendChild(card); const close = () => card.remove(); card.querySelector('#tabbit-addrule-close').addEventListener('click', close); card.querySelector('.tabbit-addrule-mask').addEventListener('click', close); // ESC 关闭 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) { if (!panelEl) createPanel(); if (panelEl.classList.contains('tabbit-hidden')) panelEl.classList.remove('tabbit-hidden'); if (!checkApiConfig()) return; const tplSelect = panelEl.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; } // 重新开始一次"总结"会话:清空旧对话 + 注入页面 system + 投放第一条 user conversation = []; pageContextLoaded = false; ensurePageContext(); const userPrompt = `请按以下要求总结当前页面:\n\n${template.text}`; // 总结指令只进对话历史(供 AI 上下文使用),不在界面显示 conversation.push({ role: 'user', content: userPrompt, meta: { hidden: true } }); const runBtn = panelEl.querySelector('#tabbit-run-btn'); runBtn.disabled = false; runBtn.textContent = '⏹ 停止'; runBtn.onclick = abortCurrentRequest; setStatus(`使用「${template.name}」模板,模型 ${getCurrentModelDisplayName()}…`, 'loading'); // 占位 const placeholder = { role: 'assistant', content: '🤖 正在思考…', meta: { model: getCurrentModelDisplayName(), placeholder: true } }; conversation.push(placeholder); renderConversation(); try { const content = await callChatApi(conversation.filter(m => !m.meta?.placeholder).map(m => ({ role: m.role, content: m.content }))); // 替换占位 conversation.pop(); appendMessage('assistant', content, { model: getCurrentModelDisplayName() }); setStatus('完成', 'ok', 1500); } catch (err) { conversation.pop(); appendMessage('assistant', `❌ ${err.message || err}`, { model: getCurrentModelDisplayName() }); setStatus('生成失败', 'error', 2500); } finally { runBtn.disabled = false; runBtn.textContent = '✨ 总结'; runBtn.onclick = () => runSummary(false); } } async function handleSendChat() { if (!panelEl) return; if (!checkApiConfig()) return; const input = panelEl.querySelector('#tabbit-chat-input'); const sendBtn = panelEl.querySelector('#tabbit-send-btn'); const text = (input.value || '').trim(); if (!text) return; // 第一次提问也注入页面上下文 ensurePageContext(); appendMessage('user', text); input.value = ''; input.style.height = 'auto'; sendBtn.disabled = true; sendBtn.textContent = '...'; setStatus(`AI 思考中(${getCurrentModelDisplayName()})…`, 'loading'); const placeholder = { role: 'assistant', content: '🤖 正在思考…', meta: { model: getCurrentModelDisplayName(), placeholder: true } }; conversation.push(placeholder); renderConversation(); try { const content = await callChatApi( conversation.filter(m => !m.meta?.placeholder).map(m => ({ role: m.role, content: m.content })) ); conversation.pop(); appendMessage('assistant', content, { model: getCurrentModelDisplayName() }); setStatus('完成', 'ok', 1200); } catch (err) { conversation.pop(); appendMessage('assistant', `❌ ${err.message || err}`, { model: getCurrentModelDisplayName() }); setStatus('生成失败', 'error', 2500); } finally { sendBtn.disabled = false; sendBtn.textContent = '发送'; input.focus(); } } /****************************************************************** * 15. 设置弹窗 ******************************************************************/ let settingsEl = null; function createSettingsModal() { if (document.getElementById(SETTINGS_ID)) return; settingsEl = document.createElement('div'); settingsEl.id = SETTINGS_ID; settingsEl.classList.add('tabbit-hidden'); settingsEl.innerHTML = `
    ⚙️ 饺子 AI 设置
    📦 API 配置预设
    不同 API 服务商可建多个预设,一键切换。提示词模板和网址规则跨预设共用。
    🤖 模型预设
    📝 提示词模板(全局共用)
    🌐 网址规则(自动弹出 + 模板绑定)
    支持通配符 *。匹配的页面打开时自动弹出面板(需勾选自动弹出)。每条规则可绑定一个提示词模板。
    🔧 通用
    ☁️ 坚果云 WebDAV 云同步
    tabbit-shared/ai-profiles.json — API 预设(多脚本共享)
    tabbit-ai-summary/config.json — 模板 + 网址规则(本脚本专属)
    ⚠️ API Key 会上传到云端,请确保账号安全。
    同步范围:
    `; document.body.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); 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); } 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-extract-max').value = config.extractMaxChars || 16000; settingsEl.querySelector('#tabbit-set-auto-run').checked = !!config.autoRun; settingsEl.querySelector('#tabbit-set-flomo-api').value = config.flomoApiUrl || ''; settingsEl.querySelector('#tabbit-set-jgy-account').value = config.cloudSync?.account || ''; settingsEl.querySelector('#tabbit-set-jgy-password').value = config.cloudSync?.appPassword || ''; 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.extractMaxChars = Number(settingsEl.querySelector('#tabbit-set-extract-max').value || 16000); config.flomoApiUrl = settingsEl.querySelector('#tabbit-set-flomo-api').value.trim(); config.cloudSync = { ...(config.cloudSync || {}), account: settingsEl.querySelector('#tabbit-set-jgy-account').value.trim(), appPassword: settingsEl.querySelector('#tabbit-set-jgy-password').value.trim() }; config.panel = { ...config.panel, width: Math.max(320, Number(settingsEl.querySelector('#tabbit-set-panel-width').value || 460)) }; saveConfig(); renderModelSelect(); applyFloatButtonPosition(); if (panelEl) panelEl.style.width = config.panel.width + 'px'; 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 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 = 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 (!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('立即总结当前页', () => openPanel(true)); GM_registerMenuCommand('设置', openSettings); GM_registerMenuCommand(`🔀 切换预设(当前:${getCurrentProfile().name})`, switchProfileQuick); GM_registerMenuCommand('☁️ 一键拉取云端配置', quickCloudPull); GM_registerMenuCommand('☁️ 一键上传到云端', quickCloudPush); GM_registerMenuCommand('加入当前网址', () => openAddUrlRuleModal()); GM_registerMenuCommand('导出配置文件', exportConfigToFile); GM_registerMenuCommand('导入配置文件', importConfigFromFile); GM_registerMenuCommand('重置配置', resetConfig); } 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 __lastUrl = location.href; function handleUrlChanged() { const newUrl = location.href; if (newUrl === __lastUrl) return; __lastUrl = newUrl; // 🔁 SPA 路由切换:自动重置对话 + 重新检查规则 resetConversation('🔁 页面已切换,对话已重置。\n\n点击「✨ 总结」开始,或在下方直接提问。'); renderPromptSelect(); if (config.autoRun && matchUrl(newUrl, config.urlRules)) { setTimeout(() => openPanel(true), 600); } } function watchUrlChange() { // 1. 轮询兜底 setInterval(handleUrlChanged, 1000); // 2. 拦截 history API(更即时) 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() { createStyles(); createFloatButton(); registerMenus(); // 📜 规则自动总结:首次加载即匹配 if (config.autoRun && matchUrl(location.href, config.urlRules)) { setTimeout(() => openPanel(true), 800); } // 🔁 SPA 路由切换监听 watchUrlChange(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap); } else { bootstrap(); } })();