// ==UserScript== // @name QiReader AI 总结 // @name:en QiReader AI Summary // @namespace https://github.com/qireader-ai-summary // @version 1.4.9 // @description 在 QiReader 阅读页的「稍后阅读」与「加载网页全文」之间加入 AI 总结按钮,点击后在按钮旁浮出逐段总结面板,点击任一段可跳转到原文对应段落。支持 OpenAI 与 Anthropic 两种开放接口格式。 // @description:en Add an AI summary button between "Read Later" and "Load full content" in QiReader. A popover shows a paragraph-by-paragraph summary; click an item to jump to that paragraph. Supports OpenAI- and Anthropic-compatible APIs. // @author yuwanyuan // @homepageURL https://github.com/yuwanyuan // @license MIT // @icon https://www.qireader.com/static/icon-full.svg // @match https://www.qireader.com/* // @match https://www.qireader.com.cn/* // @match https://qireader.com/* // @match https://qireader.com.cn/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_addStyle // @connect * // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; /* ------------------------------------------------------------------ * * 常量与默认配置 * ------------------------------------------------------------------ */ const KEY_SETTINGS = 'qai.settings.v1'; const KEY_CACHE = 'qai.cache.v1'; const KEY_SALT = 'qai.salt.v1'; const MAX_INPUT = 16000; const MAX_BLOCKS = 90; // 总结策略版本:提示词风格变化时更新它,让旧缓存自动失效 const SUMMARY_STYLE = 'sections-dom-aligned-v4'; const CACHE_LIMIT = 300; const ACCENT = '#f79b00'; const DEFAULT_SETTINGS = { activeProviderId: 'openai', providers: [ { id: 'openai', label: 'OpenAI', format: 'openai', baseUrl: 'https://api.openai.com/v1', apiKey: '', model: 'gpt-4o-mini' }, { id: 'anthropic', label: 'Anthropic Claude', format: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', apiKey: '', model: 'claude-sonnet-4-5' } ], maxTokens: 1600, temperature: 0.3, stream: true, language: 'zh', // zh | en | auto auto: false, cache: true, extra: '' }; const PRESETS = [ { id: 'openai', label: 'OpenAI', format: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' }, { id: 'anthropic', label: 'Anthropic Claude', format: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5' }, { id: 'deepseek', label: 'DeepSeek', format: 'openai', baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' }, { id: 'moonshot', label: 'Moonshot / Kimi', format: 'openai', baseUrl: 'https://api.moonshot.cn/v1', model: 'kimi-k2-0711-preview' }, { id: 'zhipu', label: '智谱 GLM', format: 'openai', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' }, { id: 'siliconflow', label: '硅基流动', format: 'openai', baseUrl: 'https://api.siliconflow.cn/v1', model: 'Qwen/Qwen3-8B' }, { id: 'openrouter', label: 'OpenRouter', format: 'openai', baseUrl: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-sonnet-4.5' }, { id: 'ollama', label: 'Ollama(本地)', format: 'openai', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b' }, { id: 'custom', label: '自定义…', format: null } ]; /* ------------------------------------------------------------------ * * 存储:密钥与配置都存在脚本管理器(Tampermonkey)的本地存储 * ------------------------------------------------------------------ */ function gmGet(key, fallback) { try { if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); } catch (e) { /* ignore */ } try { const raw = localStorage.getItem(key); return raw == null ? fallback : JSON.parse(raw); } catch (e) { return fallback; } } function gmSet(key, value) { try { if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; } } catch (e) { /* ignore */ } try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* ignore */ } } /* ------------------------------------------------------------------ * * API Key 混淆存储 * * 说明:油猴存储本身没有加密能力,这里做的是混淆(不可见的设备绑定盐 + * XOR 流 + Base64)。足以挡住随手打开 Tampermonkey 面板 / localStorage * 翻看的人,但拿到同一浏览器配置目录的人仍可能还原。这是扩展存储能做到 * 的上限;仍然建议给 API Key 单独开一个、可随时吊销。 * ------------------------------------------------------------------ */ const OBF_PREFIX = 'enc1:'; // v1:绑定 UA 的旧方案,仅用于解码旧数据 const OBF_PREFIX_V2 = 'enc2:'; // v2:只绑定随机盐,浏览器升级不会导致 Key 解不开 function getSalt() { let salt = gmGet(KEY_SALT, ''); if (!salt) { const bytes = new Uint8Array(24); try { crypto.getRandomValues(bytes); } catch (e) { for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); } salt = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); gmSet(KEY_SALT, salt); } return salt; } // 由盐 + 种子串派生 XOR 流;v2 不再绑定 UA(UA 随浏览器升级变化,会让旧 Key 解码成乱码) function keystream(length, legacy) { const salt = getSalt(); const seed = legacy ? navigator.userAgent + '|' + (navigator.language || '') + '|qai' : 'qai|v2'; const out = new Uint8Array(length); let state = 2166136261; for (let i = 0; i < seed.length; i++) { state ^= seed.charCodeAt(i); state = Math.imul(state, 16777619) >>> 0; } for (let i = 0; i < length; i++) { const mixed = salt.charCodeAt(i % salt.length) ^ seed.charCodeAt(i % seed.length); state = (Math.imul(state ^ mixed, 2654435761) >>> 0) ^ (state >>> 13); out[i] = (state >>> 16) & 0xff; } return out; } function xorCodec(text, legacy) { const bytes = typeof text === 'string' ? new TextEncoder().encode(text) : text; const ks = keystream(bytes.length, legacy); const out = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] ^ ks[i]; if (typeof text !== 'string') return new TextDecoder().decode(out); let bin = ''; for (const b of out) bin += String.fromCharCode(b); return btoa(bin).replace(/=+$/, ''); } function obfuscate(text) { if (!text) return ''; try { return OBF_PREFIX_V2 + xorCodec(text, false); } catch (e) { return text; } } function deobfuscate(stored) { if (!stored || typeof stored !== 'string') return ''; if (stored.startsWith(OBF_PREFIX_V2)) { try { const b64 = stored.slice(OBF_PREFIX_V2.length) + '='.repeat((4 - (stored.length - OBF_PREFIX_V2.length) % 4) % 4); const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return xorCodec(bytes, false); } catch (e) { return ''; } } if (stored.startsWith(OBF_PREFIX)) { try { const b64 = stored.slice(OBF_PREFIX.length) + '='.repeat((4 - (stored.length - OBF_PREFIX.length) % 4) % 4); const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return xorCodec(bytes, true); } catch (e) { return ''; } } return stored; // 旧版明文或外部导入,原样返回,下次保存时升级为 enc2 } function normalizeProvider(p, fallbackId) { return { id: typeof p.id === 'string' && p.id ? p.id : (fallbackId || 'p' + Date.now().toString(36)), label: typeof p.label === 'string' && p.label.trim() ? p.label.trim() : '未命名服务', format: p.format === 'anthropic' ? 'anthropic' : 'openai', baseUrl: typeof p.baseUrl === 'string' ? p.baseUrl.trim() : '', apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', model: typeof p.model === 'string' ? p.model.trim() : '' }; } // 兼容旧版本两份 profiles 的迁移 function migrateLegacy(saved) { if (!saved || !saved.profiles) return null; const providers = []; for (const [fmt, p] of Object.entries(saved.profiles || {})) { const preset = PRESETS.find((x) => x.format === fmt) || null; providers.push(normalizeProvider({ id: fmt, label: preset ? preset.label : fmt, format: fmt, baseUrl: p.baseUrl, apiKey: p.apiKey, model: p.model }, fmt)); } if (!providers.length) return null; const migrated = { ...saved, providers, activeProviderId: providers[0].id }; delete migrated.profiles; delete migrated.format; if (saved.format === 'anthropic' && providers.some((p) => p.id === 'anthropic')) { migrated.activeProviderId = 'anthropic'; } // 迁移落盘前先混淆,存储里不出现明文 Key migrated.providers = migrated.providers.map((p) => ({ ...p, apiKey: obfuscate(p.apiKey) })); return migrated; } function loadSettings() { let saved = gmGet(KEY_SETTINGS, null) || {}; if (saved.profiles) { saved = migrateLegacy(saved) || {}; // 迁移一次性完成;接下来保存时以新结构写回 gmSet(KEY_SETTINGS, saved); } const base = { ...DEFAULT_SETTINGS, ...saved, providers: [] }; const rawProviders = Array.isArray(saved.providers) && saved.providers.length ? saved.providers : DEFAULT_SETTINGS.providers; base.providers = rawProviders.map((p, i) => { const n = normalizeProvider(p, 'p' + i); n.apiKey = deobfuscate(n.apiKey); return n; }); if (!base.providers.length) { base.providers = DEFAULT_SETTINGS.providers.map((p) => ({ ...p })); } if (!base.providers.some((p) => p.id === base.activeProviderId)) { base.activeProviderId = base.providers[0].id; } return base; } let settings = loadSettings(); function saveSettings(next) { settings = { ...next, providers: (next.providers || []).map((p, i) => normalizeProvider(p, 'p' + i)) }; if (!settings.providers.some((p) => p.id === settings.activeProviderId)) { settings.activeProviderId = settings.providers[0] ? settings.providers[0].id : ''; } const toStore = { ...settings, providers: settings.providers.map((p) => ({ ...p, apiKey: obfuscate(p.apiKey) })) }; gmSet(KEY_SETTINGS, toStore); } function activeProfile(s = settings) { const list = s.providers || []; return list.find((p) => p.id === s.activeProviderId) || list[0] || { format: 'openai', baseUrl: '', apiKey: '', model: '' }; } /* ------------------------------------------------------------------ * * 缓存 * ------------------------------------------------------------------ */ function hashText(text) { let h = 5381; for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0; return (h >>> 0).toString(36); } function readCache(key) { const all = gmGet(KEY_CACHE, {}) || {}; return all[key] || null; } function writeCache(key, payload) { const all = gmGet(KEY_CACHE, {}) || {}; all[key] = payload; const keys = Object.keys(all); if (keys.length > CACHE_LIMIT) { keys.sort((a, b) => (all[a].ts || 0) - (all[b].ts || 0)); keys.slice(0, keys.length - CACHE_LIMIT).forEach((k) => { delete all[k]; }); } gmSet(KEY_CACHE, all); } /* ------------------------------------------------------------------ * * 图标(lucide,与站点工具栏同一套) * ------------------------------------------------------------------ */ const ICONS = { sparkles: '', refresh: '', copy: '', check: '', close: '', settings: '', alert: '', zap: '', text: '' }; function icon(name, size = 16, extraClass = '') { return ''; } /* ------------------------------------------------------------------ * * 样式 * ------------------------------------------------------------------ */ const CSS = [ '.qai-nav-btn{position:relative}', '.qai-nav-btn[data-qai-active="1"],.qai-nav-btn[data-qai-ready="1"]{color:' + ACCENT + ' !important}', '.qai-nav-btn.qai-busy svg{animation:qai-spin 1.1s linear infinite}', '@keyframes qai-spin{to{transform:rotate(360deg)}}', /* 悬浮面板:照抄 QiReader 的毛玻璃配方 surface@85% + saturate(180%) blur(12px) */ '.qai-pop{position:fixed;z-index:2147483400;display:flex;flex-direction:column;', ' width:min(330px,calc(100vw - 20px));max-height:min(84vh,760px);', ' border-radius:14px;overflow:hidden;box-sizing:border-box;', ' background:color-mix(in srgb,var(--qai-surface,#fff) 85%,transparent);', ' backdrop-filter:saturate(180%) blur(12px);-webkit-backdrop-filter:saturate(180%) blur(12px);', ' color:var(--qai-on-surface,#18181b);', ' border:1px solid color-mix(in srgb,var(--qai-on-surface) 16%,transparent);', ' box-shadow:0 18px 48px rgba(0,0,0,.3),0 2px 8px rgba(0,0,0,.14);', ' font-size:14px;line-height:1.6;font-family:inherit;', ' animation:qai-pop-in .15s ease-out}', // display:flex 会压过 hidden 属性的 UA 默认样式,必须显式声明,否则关闭按钮失效 '.qai-pop[hidden]{display:none !important}', '.qai-overlay[hidden]{display:none !important}', '@keyframes qai-pop-in{from{opacity:0;transform:translateY(-4px) scale(.985)}to{opacity:1;transform:none}}', '.qai-pop *{box-sizing:border-box}', '.qai-pop-head{display:flex;align-items:center;gap:8px;padding:8px 8px 8px 12px;', ' border-bottom:1px solid color-mix(in srgb,var(--qai-on-surface) 10%,transparent);', ' background:color-mix(in srgb,var(--qai-on-surface) 3%,transparent);flex:none}', '.qai-brand{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;', ' letter-spacing:.04em;text-transform:uppercase;', ' color:color-mix(in srgb,var(--qai-on-surface) 62%,transparent)}', '.qai-brand svg{color:' + ACCENT + '}', '.qai-switcher{margin-left:6px;max-width:150px;padding:2px 4px;border-radius:7px;font:inherit;', ' font-size:11.5px;color:color-mix(in srgb,var(--qai-on-surface) 72%,transparent);background:transparent;', ' border:1px solid transparent;cursor:pointer;text-overflow:ellipsis}', '.qai-switcher:hover{border-color:color-mix(in srgb,var(--qai-on-surface) 18%,transparent)}', '.qai-switcher:focus-visible{outline:2px solid ' + ACCENT + ';outline-offset:1px}', '.qai-provider-row{display:flex;gap:4px;align-items:center}', '.qai-provider-row .qai-provider-select{flex:1;min-width:0}', '.qai-provider-current{margin-top:5px;font-size:11.5px;line-height:1.5;', ' color:color-mix(in srgb,var(--qai-on-surface) 52%,transparent)}', '.qai-tools{margin-left:auto;display:flex;align-items:center;gap:2px}', '.qai-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:27px;height:27px;', ' padding:0;border:0;border-radius:7px;background:transparent;color:inherit;cursor:pointer;', ' opacity:.62;transition:opacity .15s ease,background-color .15s ease}', '.qai-icon-btn:hover{opacity:1;background:color-mix(in srgb,var(--qai-on-surface) 10%,transparent)}', '.qai-icon-btn:focus-visible{outline:2px solid ' + ACCENT + ';outline-offset:1px}', '.qai-pop-body{padding:10px 10px 12px;overflow-y:auto;overscroll-behavior:contain;flex:1 1 auto}', '.qai-overview{font-size:13.5px;line-height:1.7;padding:2px 0 10px 10px;margin:0 0 4px;', ' border-left:2px solid ' + ACCENT + ';color:color-mix(in srgb,var(--qai-on-surface) 88%,transparent)}', '.qai-plain-hint{font-size:11.5px;margin:0 0 8px;color:color-mix(in srgb,var(--qai-on-surface) 48%,transparent)}', '.qai-plain-p{font-size:13.5px;line-height:1.7;margin:.4em 0;padding-left:10px;', ' border-left:2px solid color-mix(in srgb,var(--qai-on-surface) 16%,transparent)}', '.qai-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px}', '.qai-item{display:flex;align-items:flex-start;gap:9px;width:100%;padding:7px 8px;', ' border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;', ' font-size:13.5px;line-height:1.65;text-align:left;cursor:pointer;', ' transition:background-color .12s ease}', '.qai-item:hover,.qai-item:focus-visible{background:color-mix(in srgb,var(--qai-on-surface) 7%,transparent);outline:none}', '.qai-item:active{background:color-mix(in srgb,var(--qai-on-surface) 11%,transparent)}', '.qai-num{flex:none;min-width:1.35em;font-size:11.5px;font-weight:600;line-height:1.85;', ' color:' + ACCENT + ';font-variant-numeric:tabular-nums;text-align:right;opacity:.9}', '.qai-text{flex:1;min-width:0;overflow-wrap:break-word}', '.qai-placeholder{color:color-mix(in srgb,var(--qai-on-surface) 55%,transparent);', ' font-size:13.5px;margin:.2em 0}', '.qai-error{display:flex;gap:8px;align-items:flex-start;color:#e5484d;font-size:13.5px;margin:.2em 0}', '.qai-error svg{flex:none;margin-top:.28em}', '.qai-error-text{white-space:pre-wrap;overflow-wrap:break-word}', '.qai-inline-btn{margin-top:8px;padding:5px 10px;border-radius:8px;cursor:pointer;', ' border:1px solid color-mix(in srgb,var(--qai-on-surface) 18%,transparent);background:transparent;', ' color:inherit;font:inherit;font-size:12.5px}', '.qai-inline-btn:hover{background:color-mix(in srgb,var(--qai-on-surface) 8%,transparent)}', '.qai-skeleton{display:flex;flex-direction:column;gap:7px;padding:4px 2px}', '.qai-skeleton i{display:block;height:11px;border-radius:6px;', ' background:linear-gradient(90deg,color-mix(in srgb,var(--qai-on-surface) 7%,transparent) 25%,', ' color-mix(in srgb,var(--qai-on-surface) 15%,transparent) 37%,', ' color-mix(in srgb,var(--qai-on-surface) 7%,transparent) 63%);background-size:400% 100%;', ' animation:qai-shimmer 1.4s ease infinite}', '.qai-skeleton i:nth-child(2){width:92%}', '.qai-skeleton i:nth-child(3){width:76%}', '.qai-skeleton i:nth-child(4){width:88%}', '.qai-skeleton i:nth-child(5){width:70%}', '@keyframes qai-shimmer{0%{background-position:100% 0}100%{background-position:0 0}}', '.qai-loading-text{font-size:12px;color:color-mix(in srgb,var(--qai-on-surface) 55%,transparent);margin-top:9px}', '.qai-pop-foot{flex:none;padding:6px 12px 8px;font-size:11.5px;display:flex;gap:5px;flex-wrap:wrap;', ' color:color-mix(in srgb,var(--qai-on-surface) 48%,transparent)}', '.qai-pop-foot:empty{display:none}', '.qai-dot{opacity:.4}', /* 正文段落高亮 */ '.qai-block-soft{outline:2px solid rgba(247,155,0,.32);outline-offset:3px;border-radius:4px}', '.qai-block-flash{animation:qai-flash 1.5s ease-out}', '@keyframes qai-flash{0%{background-color:rgba(247,155,0,.3)}100%{background-color:transparent}}', '.qai-toast{position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:2147483600;', ' padding:8px 16px;border-radius:10px;font-size:13px;line-height:1.4;pointer-events:none;', ' background:rgba(24,24,27,.85);backdrop-filter:saturate(180%) blur(12px);', ' -webkit-backdrop-filter:saturate(180%) blur(12px);color:#f4f4f5;', ' box-shadow:0 8px 28px rgba(0,0,0,.32);', ' opacity:0;transition:opacity .18s ease,transform .18s ease}', '.qai-toast.qai-show{opacity:1;transform:translateX(-50%) translateY(-2px)}', /* 设置面板 */ '.qai-overlay{position:fixed;inset:0;z-index:2147483500;display:flex;align-items:center;justify-content:center;', ' padding:16px;background:rgba(0,0,0,.46)}', '.qai-modal{width:min(580px,100%);max-height:88vh;overflow:auto;border-radius:16px;padding:20px 22px 16px;', ' background:color-mix(in srgb,var(--qai-surface,#fff) 85%,transparent);', ' backdrop-filter:saturate(180%) blur(12px);-webkit-backdrop-filter:saturate(180%) blur(12px);', ' color:var(--qai-on-surface,#18181b);', ' box-shadow:0 24px 64px rgba(0,0,0,.36);font-size:14px;line-height:1.6;font-family:inherit}', '.qai-modal h2{margin:0 0 2px;font-size:17px;font-weight:650;display:flex;align-items:center;gap:7px}', '.qai-modal h2 svg{color:' + ACCENT + '}', '.qai-sub{margin:0 0 16px;font-size:12.5px;color:color-mix(in srgb,var(--qai-on-surface) 55%,transparent)}', '.qai-field{margin-bottom:12px}', '.qai-field>label{display:block;font-size:12.5px;font-weight:600;margin-bottom:5px;', ' color:color-mix(in srgb,var(--qai-on-surface) 78%,transparent)}', '.qai-field .qai-hint{font-weight:400;color:color-mix(in srgb,var(--qai-on-surface) 50%,transparent)}', '.qai-input,.qai-select,.qai-area{width:100%;padding:8px 10px;font:inherit;font-size:13px;', ' border-radius:9px;color:inherit;background:color-mix(in srgb,var(--qai-on-surface) 5%,transparent);', ' border:1px solid color-mix(in srgb,var(--qai-on-surface) 18%,transparent);outline:none}', '.qai-input:focus,.qai-select:focus,.qai-area:focus{border-color:' + ACCENT + ';', ' box-shadow:0 0 0 3px rgba(247,155,0,.18)}', '.qai-area{resize:vertical;min-height:58px}', '.qai-row{display:flex;gap:10px}', '.qai-row>*{flex:1;min-width:0}', '.qai-check{display:flex;align-items:center;gap:8px;font-size:13px;margin-bottom:8px;cursor:pointer}', '.qai-check input{accent-color:' + ACCENT + ';width:15px;height:15px;margin:0}', '.qai-modal-foot{display:flex;align-items:center;gap:8px;margin-top:16px;padding-top:12px;', ' border-top:1px solid color-mix(in srgb,var(--qai-on-surface) 12%,transparent)}', '.qai-btn{display:inline-flex;align-items:center;gap:5px;padding:7px 14px;border-radius:9px;', ' font:inherit;font-size:13px;cursor:pointer;border:1px solid transparent;background:transparent;color:inherit}', '.qai-btn:hover{background:color-mix(in srgb,var(--qai-on-surface) 8%,transparent)}', '.qai-btn-primary{background:' + ACCENT + ';color:#161616;font-weight:600}', '.qai-btn-primary:hover{background:color-mix(in srgb,' + ACCENT + ' 86%,#000)}', '.qai-spacer{flex:1}', '.qai-test-status{font-size:12.5px;white-space:pre-wrap;max-width:220px;overflow-wrap:anywhere}', '.qai-key-row{position:relative}', '.qai-key-row .qai-input{padding-right:64px}', '.qai-key-toggle{position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:11.5px;', ' padding:3px 7px;border:0;border-radius:6px;background:transparent;color:inherit;opacity:.6;cursor:pointer}', '.qai-key-toggle:hover{opacity:1;background:color-mix(in srgb,var(--qai-on-surface) 8%,transparent)}', '.qai-note{margin:10px 0 0;padding:8px 10px;border-radius:8px;font-size:12px;line-height:1.6;', ' background:color-mix(in srgb,var(--qai-on-surface) 5%,transparent);', ' color:color-mix(in srgb,var(--qai-on-surface) 60%,transparent)}', '@media (max-width:560px){.qai-row{flex-direction:column;gap:0}.qai-pop{max-height:76vh}}' ].join('\n'); function injectStyle() { try { if (typeof GM_addStyle === 'function') { GM_addStyle(CSS); return; } } catch (e) { /* ignore */ } const el = document.createElement('style'); el.textContent = CSS; (document.head || document.documentElement).appendChild(el); } /* ------------------------------------------------------------------ * * 通用小工具 * ------------------------------------------------------------------ */ function toast(msg) { let el = document.querySelector('.qai-toast'); if (!el) { el = document.createElement('div'); el.className = 'qai-toast'; document.body.appendChild(el); } el.textContent = msg; el.classList.add('qai-show'); clearTimeout(toast._t); toast._t = setTimeout(() => { el.classList.remove('qai-show'); }, 1900); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); } function norm(s) { return String(s || '').replace(/\s+/g, ' ').trim(); } function themeVars(el) { const probe = document.querySelector('.article-page .surface') || document.querySelector('.surface') || document.body; const cs = getComputedStyle(probe); let bg = cs.backgroundColor; if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') { bg = getComputedStyle(document.body).backgroundColor || '#ffffff'; if (bg === 'rgba(0, 0, 0, 0)') bg = '#ffffff'; } el.style.setProperty('--qai-surface', bg); el.style.setProperty('--qai-on-surface', cs.color || '#18181b'); } // 站点在浅色/深色主题间切换时,实时把新配色刷到所有已创建的面板上 let lastThemeSig = ''; function refreshTheme() { const probe = document.querySelector('.article-page .surface') || document.querySelector('.surface') || document.body; const cs = getComputedStyle(probe); const sig = cs.backgroundColor + '|' + cs.color; if (sig === lastThemeSig) return; lastThemeSig = sig; for (const nav of document.querySelectorAll('nav')) { const slot = slots.get(nav); if (slot && slot.pop) themeVars(slot.pop); } if (settingsOverlay) themeVars(settingsOverlay); } /* ------------------------------------------------------------------ * * 正文段落抽取 * ------------------------------------------------------------------ */ const BLOCK_SEL = 'p, li, blockquote, pre, figcaption, dd'; const SKIP_SEL = 'nav, aside, footer, header, form, button, [data-qai-pop], [data-qai-settings]'; function findContentContainer(root) { const nodes = root.querySelectorAll('div, article, section, main'); let best = null; let bestScore = 0; for (const el of nodes) { if (el.closest(SKIP_SEL)) continue; let score = 0; for (const child of el.children) { const tag = child.tagName; if (/^(P|LI|BLOCKQUOTE|PRE|H[1-6]|UL|OL|FIGURE|TABLE|DIV|SECTION|ARTICLE)$/.test(tag)) { score += norm(child.textContent).length; } } if (score > bestScore && score > 80) { bestScore = score; best = el; } } return best || root; } function fallbackBlocks(container) { const out = []; const walk = (el) => { if (out.length >= MAX_BLOCKS) return; if (el.matches && el.matches(SKIP_SEL)) return; if (/^(SCRIPT|STYLE|SVG|NAV|BUTTON|FORM|IFRAME|SELECT|TEXTAREA)$/.test(el.tagName)) return; let direct = ''; for (const n of el.childNodes) { if (n.nodeType === 3) direct += n.nodeValue; } direct = norm(direct); const heavyChildren = Array.from(el.children).filter((c) => norm(c.textContent).length >= 30 && !c.matches(SKIP_SEL)); if (direct.length >= 30 && heavyChildren.length === 0) { out.push(el); return; } for (const c of heavyChildren) walk(c); }; walk(container); return out; } // QiReader 的正文渲染在
里(站点自定义 CSS 也锚定 .article) function contentContainer(root) { return root.querySelector('article.article') || root.querySelector('article') || findContentContainer(root); } // 页脚/署名类段落:保留占位参与编号(保证编号与页面段落一致),但提示词里标记为可忽略 const FOOTER_RE = /^(查看评论|评论|分享|广告|相关阅读|阅读原文|责任编辑|投稿|来源[::]|联系方式|关注我们|订阅|版权|打赏|作者[::])/; // 周刊/通讯类文章的固定开场白:投稿方式、社群链接、邮箱等模板文字 const FOOTER_HINT_RE = /(投稿方式|投稿邮箱|联系方式|加入我们|电报|telegram|微信群|qq群|@[a-z0-9.-]+\.(com|net|org|io|me)|收稿|稿酬|社群|转载须知|点击阅读原文|扫码关注)/i; function blockPromptText(el) { const t = norm(el.innerText || el.textContent); if (!t) return '(图片)'; if (FOOTER_RE.test(t)) return '(页脚/署名信息,可忽略)'; if (t.length < 160 && FOOTER_HINT_RE.test(t)) return '(页脚/署名信息,可忽略)'; return t; } function collectBlocks(root) { const container = contentContainer(root); let nodes = Array.from(container.querySelectorAll(BLOCK_SEL)); const nested = (el) => { let p = el.parentElement; while (p && p !== container) { if (p.matches(BLOCK_SEL)) return true; p = p.parentElement; } return false; }; nodes = nodes.filter((el) => { if (el.closest(SKIP_SEL)) return false; if (nested(el)) return false; const t = norm(el.innerText || el.textContent); if (!t) { // 无文字但含图片的段落保留为占位,编号才能与页面可见段落一一对应 return !!el.querySelector('img'); } return true; }); if (nodes.length < 2) nodes = fallbackBlocks(container); return nodes.slice(0, MAX_BLOCKS); } function articleText(root) { const container = contentContainer(root); return norm(container.innerText || container.textContent).slice(0, 40000); } /* ------------------------------------------------------------------ * * 请求与模型调用 * ------------------------------------------------------------------ */ function getXhr() { if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest; if (typeof GM !== 'undefined' && GM && typeof GM.xmlHttpRequest === 'function') { return GM.xmlHttpRequest.bind(GM); } return null; } function maskKey(key) { if (!key) return '(空)'; if (key.length <= 10) return key.slice(0, 2) + '***'; return key.slice(0, 6) + '…' + key.slice(-4); } function apiErrorText(text, status, url, profile) { let detail = ''; try { const json = JSON.parse(text); detail = (json.error && (json.error.message || json.error.type)) || json.message || ''; } catch (e) { detail = String(text || '').slice(0, 300); } const code = status ? 'HTTP ' + status : ''; if (status === 401) { let hint = '\n提示:401 = Key 无效或没被接受。检查是否复制完整、是否属于这个平台。'; if (profile && profile.apiKey) { const isAntKey = /^sk-ant-/i.test(profile.apiKey); if (profile.format !== 'anthropic' && isAntKey) { hint = '\n提示:这把是 Anthropic 的 Key(sk-ant- 开头),但当前按 OpenAI 格式发送(Authorization: Bearer)。请把接口格式切到「Anthropic 兼容」。'; } else if (profile.format === 'anthropic' && !isAntKey && /^sk-/.test(profile.apiKey)) { hint = '\n提示:当前是 Anthropic 格式(x-api-key 头),但这把 Key 更像 OpenAI/中转平台的 Key(sk- 开头且非 sk-ant-)。若确是中转 Key,请把接口格式切到「OpenAI 兼容」。'; } } return (code ? code + ':' : '') + '鉴权失败,Key 未被接受' + (detail ? '\n' + detail : '') + hint; } if (status === 403) { let hint = '\n提示:403 一般是 Key 无权访问该模型,或服务方风控拦截(地区/IP 限制)。'; if (/api\.openai\.com/.test(url || '')) { hint = '\n提示:OpenAI 官方接口对部分地区/IP 返回 403(Cloudflare 地区限制)。需走代理网络,或改用中转及其他开放格式服务。'; } else if (/api\.anthropic\.com/.test(url || '')) { hint = '\n提示:Anthropic 官方可能因账号地区或 Key 权限返回 403;可改用支持 Claude 的中转(OpenAI 格式)。'; } return (code ? code + ':' : '') + '鉴权被拒绝' + (detail ? '\n' + detail : '') + hint; } if (status === 404) { return (code ? code + ':' : '') + (detail || '接口地址不存在') + '\n提示:多为地址拼接不匹配。可在「接口地址」直接粘贴完整 endpoint(…/chat/completions 或 …/messages)。'; } return (code ? code + ':' : '') + (detail || '接口返回了异常响应'); } function endpoint(base, suffix) { // suffix: 'v1/chat/completions' | 'v1/messages' const b = String(base || '').trim().replace(/\/+$/, ''); if (!b) return suffix; const tail = suffix.indexOf('chat/completions') !== -1 ? 'chat/completions' : 'messages'; if (b.endsWith('/' + suffix)) return b; if (new RegExp('/' + tail + '$').test(b)) return b; // 地址末段已是版本号(/v1、/v4、/v1beta…)时只拼尾段, // 否则智谱 /api/paas/v4 这类会被拼成 /v4/v1/chat/completions 导致 404 if (/\/v\d+[a-z]*(-\w+)?$/i.test(b)) return b + '/' + tail; return b + '/' + suffix; } function extractOpenAIText(json) { const choice = json && json.choices && json.choices[0]; if (!choice) return ''; const msg = choice.message || choice; const content = msg.content; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content.map((p) => (typeof p === 'string' ? p : (p && p.text) || '')).join(''); } return choice.text || ''; } function extractAnthropicText(json) { if (!json || !Array.isArray(json.content)) return ''; return json.content.map((b) => (b && b.type === 'text' ? b.text : '')).join(''); } function streamDelta(format, obj) { if (format === 'anthropic') { if (obj.type === 'content_block_delta' && obj.delta) return obj.delta.text || ''; return ''; } const choice = obj.choices && obj.choices[0]; if (!choice) return ''; const delta = choice.delta || {}; if (typeof delta.content === 'string') return delta.content; if (Array.isArray(delta.content)) return delta.content.map((p) => (p && p.text) || '').join(''); return ''; } function parseUsage(format, json) { if (!json || !json.usage) return null; const u = json.usage; if (format === 'anthropic') { const input = u.input_tokens || 0; const output = u.output_tokens || 0; return input || output ? { input, output, total: input + output } : null; } const input = u.prompt_tokens || 0; const output = u.completion_tokens || 0; const total = u.total_tokens || input + output; return total ? { input, output, total } : null; } function sseConsume(buffer, cursor, format, onDelta, usageSink) { let idx; let pos = cursor; while ((idx = buffer.indexOf('\n', pos)) !== -1) { const line = buffer.slice(pos, idx).trim(); pos = idx + 1; if (!line.startsWith('data:')) continue; const payload = line.slice(5).trim(); if (!payload || payload === '[DONE]') continue; let obj; try { obj = JSON.parse(payload); } catch (e) { continue; } if (obj.usage && usageSink) usageSink(obj.usage); if (format === 'anthropic' && obj.message && obj.message.usage && usageSink) { usageSink(obj.message.usage); } const delta = streamDelta(format, obj); if (delta) onDelta(delta); } return pos; } function looksLikeSSE(text) { return /^\s*data\s*:/m.test(String(text || '')); } async function callModel(cfg, system, user, onDelta) { const format = cfg.format; const profile = cfg.profile; const stream = !!cfg.stream; const isAnthropic = format === 'anthropic'; const url = isAnthropic ? endpoint(profile.baseUrl, 'v1/messages') : endpoint(profile.baseUrl, 'v1/chat/completions'); const headers = { 'Content-Type': 'application/json' }; let body; if (isAnthropic) { headers['x-api-key'] = profile.apiKey; headers['anthropic-version'] = '2023-06-01'; // 浏览器直连 Anthropic 官方接口需要该声明头 headers['anthropic-dangerous-direct-browser-access'] = 'true'; body = { model: profile.model, max_tokens: Number(cfg.maxTokens) || 1600, temperature: Number(cfg.temperature), system, messages: [{ role: 'user', content: user }] }; if (stream) body.stream = true; } else { if (profile.apiKey) headers.Authorization = 'Bearer ' + profile.apiKey; body = { model: profile.model, messages: [ { role: 'system', content: system }, { role: 'user', content: user } ], temperature: Number(cfg.temperature), max_tokens: Number(cfg.maxTokens) || 1600 }; if (stream) body.stream = true; } if (!stream) { return new Promise((resolve, reject) => { const xhr = getXhr(); const handle = (status, text) => { if (status < 200 || status >= 300) { reject(new Error(apiErrorText(text, status, url, profile))); return; } let json; try { json = JSON.parse(text); } catch (e) { reject(new Error('无法解析接口返回的数据')); return; } const out = isAnthropic ? extractAnthropicText(json) : extractOpenAIText(json); if (!out) { reject(new Error('接口没有返回任何文本内容')); return; } resolve({ text: out, usage: parseUsage(format, json) }); }; if (!xhr) { fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }) .then(async (res) => handle(res.status, await res.text())) .catch((e) => reject(new Error('网络请求失败:' + (e && e.message ? e.message : e)))); return; } xhr({ method: 'POST', url, headers, data: JSON.stringify(body), timeout: 180000, responseType: 'text', onload: (res) => handle(res.status, res.responseText || ''), onerror: () => reject(new Error('网络请求失败,请检查网络、接口地址或代理设置')), ontimeout: () => reject(new Error('请求超时,请稍后重试')) }); }); } return new Promise((resolve, reject) => { let cursor = 0; let full = ''; let sawSSE = false; let usage = null; let settled = false; const sink = (u) => { const parsed = parseUsage(format, { usage: u }); if (parsed) usage = parsed; }; const onprogress = (res) => { const text = res.responseText || ''; if (!text) return; if (!sawSSE && text.trimStart().startsWith('{')) return; // 服务端忽略了 stream sawSSE = true; try { cursor = sseConsume(text, cursor, format, (d) => { full += d; onDelta(d); }, sink); } catch (e) { /* ignore */ } }; // 优先尝试浏览器 fetch 流式读取:部分网关/管理器组合下 GM 通道会整段缓冲, // fetch 的 ReadableStream 能真正逐块拿到 SSE const tryFetchStream = async () => { const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }); if (!res.ok) { const text = await res.text(); throw Object.assign(new Error(apiErrorText(text, res.status, url, profile)), { qaiApiError: true }); } const contentError = (msg) => Object.assign(new Error(msg), { qaiApiError: true }); if (!res.body || !res.body.getReader) { const text = await res.text(); if (looksLikeSSE(text)) { let out = ''; let usageF = null; sseConsume(text + '\n', 0, format, (d) => { out += d; }, (u) => { const p = parseUsage(format, { usage: u }); if (p) usageF = p; }); if (!out.trim()) throw contentError('流式响应为空,请尝试关闭流式输出'); onDelta(out); return { text: out, usage: usageF }; } const json = JSON.parse(text); const out = isAnthropic ? extractAnthropicText(json) : extractOpenAIText(json); if (!out) throw contentError('接口没有返回任何文本内容'); onDelta(out); return { text: out, usage: parseUsage(format, json) }; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let out = ''; try { for (;;) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line.startsWith('data:')) continue; const payload = line.slice(5).trim(); if (!payload || payload === '[DONE]') continue; let obj; try { obj = JSON.parse(payload); } catch (e) { continue; } if (obj.usage) sink(obj.usage); if (format === 'anthropic' && obj.message && obj.message.usage) sink(obj.message.usage); const d = streamDelta(format, obj); if (d) { out += d; onDelta(d); } } } } catch (e) { // 已经吐过内容就不能重发请求(会与已显示内容重复),直接报错 if (out.length > 0) throw contentError('连接在中途断开,请重试'); throw e; } if (!out.trim()) throw contentError('流式响应为空,请尝试关闭流式输出'); return { text: out, usage }; }; tryFetchStream() .then((r) => { if (!settled) { settled = true; resolve(r); } }) .catch((e) => { // CORS/网络层面的失败退回 GM 通道;接口本身的报错(如 401)直接抛出 if (settled) return; if (e && e.qaiApiError) { settled = true; reject(e); return; } gmStream(); }); function gmStream() { const xhr = getXhr(); if (!xhr) { fetch(url, { method: 'POST', headers, body: JSON.stringify(body) }) .then(async (res) => { const text = await res.text(); if (!res.ok) throw new Error(apiErrorText(text, res.status, url, profile)); // 服务端可能忽略 stream 参数:返回整段 SSE 或整段 JSON,两种都要能解析 if (looksLikeSSE(text)) { let out = ''; let usageF = null; sseConsume(text + '\n', 0, format, (d) => { out += d; }, (u) => { const p = parseUsage(format, { usage: u }); if (p) usageF = p; }); if (!out.trim()) throw new Error('流式响应为空,请尝试关闭流式输出'); full = out; onDelta(out); resolve({ text: out, usage: usageF }); return; } let json; try { json = JSON.parse(text); } catch (e) { throw new Error('无法解析接口返回的数据'); } const out = isAnthropic ? extractAnthropicText(json) : extractOpenAIText(json); if (!out) throw new Error('接口没有返回任何文本内容'); full = out; onDelta(out); resolve({ text: out, usage: parseUsage(format, json) }); }) .catch((e) => reject(e)); return; } xhr({ method: 'POST', url, headers, data: JSON.stringify(body), timeout: 180000, responseType: 'text', onprogress, onload: (res) => { if (settled) return; settled = true; const text = res.responseText || ''; if (res.status < 200 || res.status >= 300) { reject(new Error(apiErrorText(text, res.status, url, profile))); return; } // onprogress 在部分环境不会触发(如中转服务器整段缓冲 SSE 后一次性返回), // 所以这里按响应内容本身判断,而不是只依赖 sawSSE 标记 if (sawSSE || looksLikeSSE(text)) { try { sseConsume(text + '\n', sawSSE ? cursor : 0, format, (d) => { full += d; onDelta(d); }, sink); } catch (e) { /* ignore */ } if (!full.trim()) { reject(new Error('流式响应为空,请尝试关闭流式输出')); return; } resolve({ text: full, usage }); return; } try { const json = JSON.parse(text); const out = isAnthropic ? extractAnthropicText(json) : extractOpenAIText(json); if (!out) throw new Error('接口没有返回任何文本内容'); resolve({ text: out, usage: parseUsage(format, json) }); } catch (e) { reject(new Error('无法解析接口返回的数据:' + String(text || '').slice(0, 160))); } }, onerror: () => { if (!settled) { settled = true; reject(new Error('网络请求失败,请检查接口地址与网络')); } }, ontimeout: () => { if (!settled) { settled = true; reject(new Error('请求超时')); } } }); } // gmStream 结束 }); } /* ------------------------------------------------------------------ * * 提示词:输出 [P#] 行,便于流式解析与段落定位 * ------------------------------------------------------------------ */ function buildPrompt(blocks, meta, s = settings) { const langMap = { zh: '简体中文', en: 'English', auto: '与原文相同的语言' }; const lang = langMap[s.language] || langMap.zh; const n = blocks.length; const system = [ '你是一位严谨的阅读助手,擅长把长文提炼成少量关键要点。', '只依据用户提供的段落内容作答,不要补充原文没有的事实。', '你的输出会被程序逐行解析:每行必须以 [全文] 或 [P数字] 开头,方括号用半角,不要使用 Markdown 列表、编号、标题或代码块。' ].join('\n'); const lines = []; lines.push('请用' + lang + '总结下面这篇文章。正文已按顺序编号为 [P1] 至 [P' + n + '],共 ' + n + ' 段(段落编号用于定位)。'); lines.push(''); lines.push('输出要求(总共大约 4-7 行):'); lines.push('第一行 [全文]:文章简述,用 3-4 句话、不超过 200 字,讲清楚这篇文章的背景、核心事实与意义。'); lines.push('之后把全文划分成 3-6 个部分(把内容相近的相邻段落融合在一起,按行文顺序划分,覆盖全文、不留大段空白):每个部分占一行,以 [P#] 开头,# 填这个部分第一段的编号,后面用 1-2 句话概括该部分整体讲了什么。'); lines.push('注意:不是逐段总结,也不是挑零散的点,而是「几段融成一个部分、一个部分一行」;部分与部分之间不重叠;概括要融合该部分所有段落的信息,不要只复述第一段。'); lines.push('编号与页面上可见的段落一一对应:标注(图片)的是配图段落,标注(页脚/署名信息,可忽略)的是文末署名、投稿人、联系方式、责任编辑、广告推广,以及周刊/通讯类文章开头的固定开场白(发布时间、投稿方式、社群链接、邮箱)——这些一律不要写进简述,也不要为其设立部分,划分部分时直接跳过它们。'); if (s.extra) lines.push('额外要求:' + s.extra); lines.push(''); lines.push('参考示例(格式必须完全一致,内容仅为示例,段号按实际正文填):'); lines.push('[全文] 特朗普宣布奥迪将在佐治亚州投资 40 亿美元建厂,年产能 20 万辆 SUV、创造约 4500 个岗位。这是他在汽车关税持续施压背景下的最新成果,但批评者认为关税正推高车价并推迟行业投资,奥迪自身销量也已连续两年下滑。'); lines.push('[P1] 开篇:特朗普在佐治亚州集会宣布建厂计划,并交代关税施压的背景。'); lines.push('[P2] 背景与争议:关税已致奥迪去年损失 12 亿欧元;佐治亚州选情激烈,特朗普急于展示关税成效,批评者持反对意见。'); lines.push('[P6] 投资细节:项目将创造约 4500 个岗位,预计组装 Q7/Q8/Q9 等车型,建设明年开始、2030 年代初投产。'); lines.push('[P13] 行业影响:特朗普政策下美国电动车产量与制造业就业下滑,奥迪德国工厂面临关闭风险。'); lines.push('[P16] 同业动向:丰田、本田、现代等车企也在关税与贸易协定不确定性下调整美国产能布局。'); lines.push(''); lines.push('标题:' + (meta.title || '(无标题)')); if (meta.url) lines.push('链接:' + meta.url); lines.push(''); lines.push('正文(共 ' + n + ' 段):'); for (let i = 0; i < blocks.length; i++) { lines.push('[P' + (i + 1) + '] ' + blocks[i].text); } if (meta.truncated) lines.push('(正文过长,后面还有内容未提供,只总结以上部分。)'); return { system, user: lines.join('\n') }; } function stripInline(s) { return String(s || '') .replace(/\*\*/g, '') .replace(/^#+\s*/, '') .replace(/^[-*•]\s+/, '') .trim(); } // 解析模型输出:[P#] → 数字编号列表 → 数字+空格编号 → 纯文本兜底(Folo 思路) // 兼容模型硬换行:未匹配的行若属于上一条未完结的句子,并回上一条 function parseSegments(raw, blockCount) { const out = { overview: '', items: [], plain: [], sequential: false }; const text = String(raw || '').replace(/\r\n?/g, '\n'); const lines = text.split('\n') .filter((l) => l.trim() && !/^\s*```/.test(l.trim())) .map((l) => l.trim()); // 分隔符(冒号/短横线)可选:示例格式「[P1] 内容」后面没有冒号,不能强制要求 const OVERVIEW_RE = /^\s*(?:[-*•]\s*)?\**\[?\s*(?:全文|概述|总结|overview|tl;?dr)\s*\]?\**\s*(?:[::|\-—–]\s*)?(?=\S)(.+)$/i; const P_RE = /^\s*(?:[-*•]\s*)?\**\[?\s*P\s*#?(\d+)\s*\]?\**\s*(?:[::|\-—–]\s*)?(.+)$/i; const NUM_RE = /^\s*(?:[-*•]\s*)?\**\[?\s*(\d+)\s*[\).、.]\s*\]?\**\s*[::]?\s*(.+)$/; // 「1 开篇:…」这类数字+空格的编号(仅 1-2 位数字,避免误伤「2026 年…」) const NUM2_RE = /^\s*(?:\**\[?\s*)?(\d{1,2})[ \t]+(?=\S)(.+)$/; // 只有上一条以逗号/顿号/冒号这类「明确没写完」的软标点结尾才合并折行; // 句尾无标点(如「…投稿方式」)视为完整短句,不合并,避免污染独立要点 const endsOpen = (s) => /[,,、::]$/.test(String(s).trim()); const collect = (re, maxIdx) => { const items = []; const seen = new Set(); lines.forEach((line, lineNo) => { const m = line.match(re); if (!m) return; const idx = parseInt(m[1], 10); if (!idx || idx > maxIdx || seen.has(idx)) return; seen.add(idx); items.push({ i: idx, text: stripInline(m[2]), lineNo }); }); return items; }; let overviewLineNo = -1; lines.forEach((line, lineNo) => { if (overviewLineNo !== -1) return; const m = line.match(OVERVIEW_RE); if (m) { out.overview = stripInline(m[1]); overviewLineNo = lineNo; } }); let items = collect(P_RE, 9999); if (!items.length) items = collect(NUM_RE, blockCount || 999); if (!items.length) { // 数字+空格编号至少要出现两行才算真列表,防止把正文行误判 const cand = collect(NUM2_RE, blockCount || 999); if (cand.length >= 2) items = cand; } if (items.length) { items.sort((a, b) => a.lineNo - b.lineNo); const byLine = new Map(items.map((it) => [it.lineNo, it])); let last = null; lines.forEach((line, lineNo) => { if (byLine.has(lineNo)) { last = byLine.get(lineNo); return; } if (lineNo === overviewLineNo) return; // 真正的折行续句并回上一条;其余未匹配行保留为补充段落,不丢内容 if (last && endsOpen(last.text)) { last.text = (last.text + ' ' + stripInline(line)).trim(); } else { out.plain.push(stripInline(line)); } }); out.items = items.slice().sort((a, b) => a.i - b.i); return out; } // 纯文本兜底:先合并折行,再决定是否按顺序映射(行数必须与段数严格相等) const plain = []; for (const line of lines) { const m = line.match(OVERVIEW_RE); if (m && !out.overview) { out.overview = stripInline(m[1]); continue; } const t = stripInline(line); if (plain.length && endsOpen(plain[plain.length - 1])) { plain[plain.length - 1] = (plain[plain.length - 1] + ' ' + t).trim(); } else { plain.push(t); } } out.plain = plain.filter(Boolean); if (blockCount && out.plain.length >= 2 && out.plain.length === blockCount) { out.items = out.plain.map((t, idx) => ({ i: idx + 1, text: t })); out.sequential = true; } return out; } function segmentsToText(seg) { const out = []; if (seg.overview) out.push(seg.overview); for (const it of seg.items) out.push(it.i + '. ' + it.text); if (!out.length && seg.plain) out.push(...seg.plain); return out.join('\n'); } /* ------------------------------------------------------------------ * * 工具栏按钮 * ------------------------------------------------------------------ */ const slots = new WeakMap(); const liveSlots = new Set(); // 用于清理 nav 已移除的 slot 及其面板 function findBookmarkButton(nav) { for (const btn of nav.querySelectorAll('button')) { if (btn.hasAttribute('data-qai-btn')) continue; if (btn.querySelector('svg.lucide-bookmark')) return btn; } return null; } function articleRootOf(nav) { const direct = nav.closest('[data-article-url]'); if (direct) return direct; const page = nav.closest('.article-page'); if (page) return page.querySelector('[data-article-url]') || page; return nav.parentElement; } function entryKeyOf(root) { const el = root && root.matches && root.matches('[data-article-url]') ? root : (root ? root.querySelector('[data-article-url]') : null); const url = el && el.getAttribute('data-article-url'); return url || location.pathname + location.hash; } function createSlot(nav, bookmark) { const root = articleRootOf(nav); const btn = document.createElement('button'); btn.type = 'button'; btn.className = bookmark.className + ' qai-nav-btn'; btn.setAttribute('translate', 'no'); btn.setAttribute('data-qai-btn', '1'); btn.setAttribute('aria-label', 'AI 总结'); btn.setAttribute('aria-haspopup', 'dialog'); btn.title = 'AI 总结'; btn.innerHTML = '' + icon('sparkles', 20) + ''; bookmark.parentElement.insertBefore(btn, bookmark); const slot = { nav, root, btn, bookmark, entryKey: '', blocks: [], meta: null, state: { status: 'idle', raw: '', error: '', meta: null }, cacheKey: '', pop: null, lastPaint: 0, hoverBlock: null }; btn.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); if (slot.pop && !slot.pop.hidden) { closeSlot(slot); return; } openSlot(slot); if (slot.state.status === 'idle') generate(slot); }); return slot; } /* ------------------------------------------------------------------ * * 悬浮面板 * ------------------------------------------------------------------ */ function buildPop(slot) { const pop = document.createElement('div'); pop.className = 'qai-pop'; pop.setAttribute('data-qai-pop', '1'); pop.setAttribute('role', 'dialog'); pop.setAttribute('aria-label', 'AI 逐段总结'); themeVars(pop); pop.innerHTML = [ '
', ' ' + icon('sparkles', 14) + 'AI 总结', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '
', '
', '
' ].join(''); document.body.appendChild(pop); pop.addEventListener('click', (e) => { const actBtn = e.target.closest('[data-act]'); if (actBtn) { const act = actBtn.getAttribute('data-act'); if (act === 'close') closeSlot(slot); else if (act === 'regen') generate(slot, { force: true }); else if (act === 'copy') copySummary(slot); else if (act === 'settings') openSettings(); return; } const item = e.target.closest('[data-i]'); if (item) jumpToBlock(slot, parseInt(item.getAttribute('data-i'), 10)); const retry = e.target.closest('[data-qai-retry]'); if (retry) generate(slot, { force: true }); }); pop.addEventListener('mouseover', (e) => { const item = e.target.closest('[data-i]'); if (!item) return; const block = slot.blocks[parseInt(item.getAttribute('data-i'), 10) - 1]; if (!block || block === slot.hoverBlock) return; clearHover(slot); slot.hoverBlock = block; block.classList.add('qai-block-soft'); }); pop.addEventListener('mouseout', (e) => { const item = e.target.closest('[data-i]'); if (!item) return; if (e.relatedTarget && item.contains(e.relatedTarget)) return; clearHover(slot); }); const switcher = pop.querySelector('[data-qai-switcher]'); refreshSwitcher(slot, switcher); switcher.addEventListener('change', () => { const nextId = switcher.value; const target = settings.providers.find((p) => p.id === nextId); if (!target || nextId === settings.activeProviderId) return; settings.activeProviderId = nextId; saveSettings(settings); toast('已切换到「' + target.label + '」'); for (const nav of document.querySelectorAll('nav')) { const s2 = slots.get(nav); if (s2 && s2.pop) refreshSwitcher(s2, s2.pop.querySelector('[data-qai-switcher]')); } if (slot.state.status === 'done' || slot.state.status === 'error') { generate(slot, { force: true }); } }); return pop; } function refreshSwitcher(slot, switcher) { if (!switcher) return; const list = settings.providers || []; switcher.innerHTML = list.map((p) => '' ).join(''); } function clearHover(slot) { if (slot.hoverBlock) { slot.hoverBlock.classList.remove('qai-block-soft'); slot.hoverBlock = null; } } function positionPop(slot) { const pop = slot.pop; if (!pop || pop.hidden) return; const rect = slot.btn.getBoundingClientRect(); const margin = 10; const gap = 6; pop.style.left = '-9999px'; pop.style.top = '0px'; const pr = pop.getBoundingClientRect(); // 贴住视口右上角;顶边压在工具栏正下方,尽量高、不挡工具栏按钮 let left = window.innerWidth - pr.width - margin; let top = Math.max(margin, rect.bottom + gap); if (top + pr.height > window.innerHeight - margin) { top = Math.max(margin, window.innerHeight - pr.height - margin); } left = Math.min(Math.max(margin, left), window.innerWidth - pr.width - margin); pop.style.left = Math.round(left) + 'px'; pop.style.top = Math.round(top) + 'px'; } function openSlot(slot) { // 同一时间只保留一个面板:切换文章/多栏时关掉其他面板,避免叠罗汉 for (const nav of document.querySelectorAll('nav')) { const other = slots.get(nav); if (other && other !== slot && other.pop && !other.pop.hidden) closeSlot(other); } if (!slot.pop) slot.pop = buildPop(slot); slot.pop.hidden = false; themeVars(slot.pop); refreshSwitcher(slot, slot.pop.querySelector('[data-qai-switcher]')); slot.btn.setAttribute('data-qai-active', '1'); positionPop(slot); renderPop(slot); requestAnimationFrame(() => positionPop(slot)); bindGlobalClose(); } function closeSlot(slot) { clearHover(slot); if (slot.pop) slot.pop.hidden = true; slot.btn.removeAttribute('data-qai-active'); } let globalBound = false; function bindGlobalClose() { if (globalBound) return; globalBound = true; document.addEventListener('mousedown', (e) => { for (const nav of document.querySelectorAll('nav')) { const slot = slots.get(nav); if (!slot || !slot.pop || slot.pop.hidden) continue; if (slot.pop.contains(e.target) || slot.btn.contains(e.target)) continue; closeSlot(slot); } }, true); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; for (const nav of document.querySelectorAll('nav')) { const slot = slots.get(nav); if (slot && slot.pop && !slot.pop.hidden) closeSlot(slot); } }, true); const reposition = () => { for (const nav of document.querySelectorAll('nav')) { const slot = slots.get(nav); if (slot && slot.pop && !slot.pop.hidden) positionPop(slot); } }; window.addEventListener('scroll', reposition, { passive: true, capture: true }); window.addEventListener('resize', reposition); } function renderPop(slot) { if (!slot.pop) return; const body = slot.pop.querySelector('[data-qai-body]'); const foot = slot.pop.querySelector('[data-qai-foot]'); const st = slot.state; if (st.status === 'idle') { body.innerHTML = '

生成后会显示文章简述和按内容划分的几个部分,点击某个部分可跳到原文对应位置。

'; foot.textContent = ''; return; } // loading 且已有部分文本时,落到下方渐进渲染(流式的关键);没有文本就总是显示骨架屏, // 避免重新生成时残留上一次的总结 if (st.status === 'loading' && !String(st.raw || '').trim()) { body.innerHTML = [ '
', '
正在阅读全文并提炼大点…
' ].join(''); foot.textContent = ''; return; } if (st.status === 'error') { body.innerHTML = [ '
' + icon('alert', 15), '
' + escapeHtml(st.error) + '
', '
' ].join(''); foot.textContent = ''; return; } const seg = parseSegments(st.raw, slot.blocks.length); const hasContent = seg.overview || seg.items.length || seg.plain.length; if (!hasContent) { // 流式早期:文本还没解析出完整行,保留骨架屏等下一帧 if (st.status === 'loading') return; body.innerHTML = '

模型没有返回有效内容。' + '

'; foot.textContent = ''; return; } const html = []; const loading = st.status === 'loading'; if (seg.overview) html.push('

' + escapeHtml(seg.overview) + '

'); if (seg.items.length) { if (seg.sequential) { html.push('

未识别出段落编号,已按顺序对应到各段落。

'); } html.push('
    '); for (const it of seg.items) { const last = loading && it === seg.items[seg.items.length - 1]; html.push('
  1. '); } html.push('
'); } if (seg.plain.length) { if (!seg.items.length) { // 完全没有编号:像 Folo 一样不做结构化,直接当普通总结展示 html.push('

本次输出未包含段落编号,展示为普通总结;点 ↻ 重新生成通常可恢复逐段格式。

'); } html.push('
'); for (const p of seg.plain) { html.push('

' + escapeHtml(p) + '

'); } html.push('
'); } body.innerHTML = html.join(''); if (!loading) body.scrollTop = 0; const bits = []; if (st.meta) { if (st.meta.provider) bits.push(st.meta.provider); if (st.meta.model) bits.push(st.meta.model); if (st.meta.usage && st.meta.usage.total) { bits.push(st.meta.usage.total.toLocaleString() + ' tokens' + (st.meta.usage.estimated ? '(估算)' : '')); } if (st.meta.ms) bits.push((st.meta.ms / 1000).toFixed(1) + 's'); if (st.meta.cached) bits.push('本地缓存'); if (st.meta.truncated) bits.push('正文已截断'); } foot.innerHTML = bits.map((b) => '' + escapeHtml(b) + '') .join('·'); } function paintStreaming(slot, force) { // 面板未打开(静默生成)时跳过 DOM 写入,状态由 state.raw 维护 if (!slot.pop || slot.pop.hidden) return; const now = Date.now(); if (!force && now - slot.lastPaint < 130) return; slot.lastPaint = now; const body = slot.pop.querySelector('[data-qai-body]'); const keepScroll = body.querySelector('.qai-list') ? body.scrollTop : 0; renderPop(slot); body.scrollTop = keepScroll; positionPop(slot); } // 服务端把整个响应一次性缓冲时,用打字机节奏逐步放出,避免一次性砸出全文 function typewriterReveal(slot, fullText, fromPos) { return new Promise((resolve) => { const total = fullText.length - fromPos; if (total <= 0) { resolve(); return; } const step = Math.max(3, Math.ceil(total / 80)); // ~80 帧,约 1.3 秒放完 let pos = fromPos; const tick = () => { pos = Math.min(fullText.length, pos + step); slot.state.raw = fullText.slice(0, pos); paintStreaming(slot, true); if (pos < fullText.length) { requestAnimationFrame(tick); } else { resolve(); } }; requestAnimationFrame(tick); }); } // 找到元素所在的滚动容器(QiReader 正文在自己的滚动层里,不一定是 window) function findScrollParent(el) { let p = el.parentElement; while (p && p !== document.body) { const style = getComputedStyle(p); if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight + 1) { return p; } p = p.parentElement; } return null; } function jumpToBlock(slot, index) { const block = slot.blocks[index - 1]; if (!block || !block.isConnected) { toast('没有找到对应段落,可先点击「加载网页全文」'); return; } // 目标段落落在视口偏上约 1/4 处(下方留出继续阅读的空间),而不是正中央 const RATIO = 0.25; const scroller = findScrollParent(block); const rect = block.getBoundingClientRect(); if (scroller) { const sRect = scroller.getBoundingClientRect(); const delta = (rect.top - sRect.top) - scroller.clientHeight * RATIO; scroller.scrollBy({ top: delta, behavior: 'smooth' }); } else { const delta = rect.top - window.innerHeight * RATIO; window.scrollBy({ top: delta, behavior: 'smooth' }); } block.classList.remove('qai-block-flash'); void block.offsetWidth; block.classList.add('qai-block-flash'); clearTimeout(block._qaiFlash); block._qaiFlash = setTimeout(() => block.classList.remove('qai-block-flash'), 1600); } function copySummary(slot) { const seg = parseSegments(slot.state.raw, slot.blocks.length); const text = segmentsToText(seg); if (!text) { toast('还没有可复制的总结'); return; } const done = () => toast('已复制总结'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done)); } else { fallbackCopy(text, done); } } function fallbackCopy(text, done) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e) { toast('复制失败'); } ta.remove(); } /* ------------------------------------------------------------------ * * 生成 * ------------------------------------------------------------------ */ function ensureBlocks(slot) { if (slot.blocks && slot.blocks.length) return slot.blocks; slot.blocks = collectBlocks(slot.root); return slot.blocks; } function buildMeta(slot) { const root = slot.root; const h = root.querySelector('h1, h2'); const title = h && norm(h.textContent) ? norm(h.textContent) : norm(document.title.split(' - ')[0]); const url = entryKeyOf(root); return { title, url }; } async function generate(slot, opts = {}) { if (slot.state.status === 'loading') return; const s = settings; const profile = activeProfile(s); // 静默模式(自动触发):不弹面板,只让按钮图标旋转;完成后图标变色提示 const silent = !!opts.silent; slot.btn.removeAttribute('data-qai-ready'); if (!silent) openSlot(slot); if (!profile.apiKey && !/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)/.test(profile.baseUrl || '')) { slot.state = { status: 'error', raw: '', error: '还没有配置 API Key,请先在设置里填写。', meta: null }; renderPop(slot); if (!silent) { positionPop(slot); toast('请先在设置中填写 API Key'); openSettings(); } return; } if (!profile.model) { slot.state = { status: 'error', raw: '', error: '还没有配置模型名称。', meta: null }; renderPop(slot); if (!silent) { positionPop(slot); openSettings(); } return; } const blocks = ensureBlocks(slot); const meta = buildMeta(slot); const parts = []; let used = 0; let truncated = false; for (const el of blocks) { const t = blockPromptText(el); if (used + t.length > MAX_INPUT) { truncated = true; break; } used += t.length; parts.push({ el, text: t }); } if (!parts.length) { const fallback = articleText(slot.root); if (!fallback || fallback.length < 40) { slot.state = { status: 'error', raw: '', error: '没有抓到正文。若文章只显示摘要,请先点击工具栏的「加载网页全文」再试。', meta: null }; renderPop(slot); if (!silent) positionPop(slot); return; } parts.push({ el: null, text: fallback.slice(0, MAX_INPUT) }); truncated = fallback.length > MAX_INPUT; } // 只保留有元素的段落用于跳转;无元素的兜底文本仍会参与总结 slot.blocks = parts.map((p) => p.el).filter(Boolean); const prompt = buildPrompt(parts.map((p) => ({ text: p.text })), { title: meta.title, url: meta.url, truncated }, s); const cacheKey = meta.url + '#' + SUMMARY_STYLE + '#' + hashText(parts.map((p) => p.text).join('\n')); if (!opts.force && s.cache) { const hit = readCache(cacheKey); if (hit && hit.raw) { slot.state = { status: 'done', raw: hit.raw, error: '', meta: { ...(hit.meta || {}), cached: true } }; slot.btn.setAttribute('data-qai-ready', '1'); renderPop(slot); if (!silent) positionPop(slot); return; } } slot.state = { status: 'loading', raw: '', error: '', meta: null }; renderPop(slot); positionPop(slot); slot.btn.classList.add('qai-busy'); const started = Date.now(); let streamed = ''; let deltaCount = 0; // 等待期间显示已用时长,避免长时间无反馈 const timerEl = () => slot.pop && slot.pop.querySelector('[data-qai-timer]'); const timerId = setInterval(() => { const el = timerEl(); if (el) el.textContent = '正在阅读全文并提炼大点… 已用 ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000); try { const result = await callModel({ format: profile.format, profile, maxTokens: s.maxTokens, temperature: s.temperature, stream: s.stream }, prompt.system, prompt.user, (delta) => { deltaCount++; streamed += delta; slot.state.raw = streamed; paintStreaming(slot); }); const finalRaw = (result.text || streamed || '').trim(); if (!finalRaw) throw new Error('接口没有返回内容'); // 整段一次性到达(服务端缓冲成单块)时,改用打字机节奏从头逐步放出 // (面板未打开的静默生成不需要动画,直接落最终文本) const panelOpen = slot.pop && !slot.pop.hidden; const oneShot = deltaCount <= 1 && finalRaw.length > 160; if (panelOpen && oneShot) { slot.state.raw = ''; paintStreaming(slot, true); await typewriterReveal(slot, finalRaw, 0); } else if (panelOpen && finalRaw.length > streamed.length + 40) { await typewriterReveal(slot, finalRaw, streamed.length); } slot.state = { status: 'done', raw: finalRaw, error: '', meta: { provider: profile.label || '', model: profile.model, usage: result.usage || (s.stream ? estimateUsage(parts, finalRaw) : null), ms: Date.now() - started, truncated } }; slot.btn.setAttribute('data-qai-ready', '1'); renderPop(slot); positionPop(slot); if (s.cache) { writeCache(cacheKey, { raw: finalRaw, meta: slot.state.meta, ts: Date.now(), url: meta.url }); } } catch (err) { const message = err && err.message ? err.message : String(err); slot.state = { status: 'error', raw: '', error: message, meta: null }; renderPop(slot); if (!silent) positionPop(slot); if (/HTTP 4\d\d/.test(message)) toast('请检查接口设置'); } finally { clearInterval(timerId); slot.btn.classList.remove('qai-busy'); } } function estimateUsage(parts, output) { const input = parts.reduce((n, p) => n + p.text.length, 0); const est = (n) => Math.ceil(n / 1.7); return { input: est(input), output: est(output), total: est(input) + est(output), estimated: true }; } /* ------------------------------------------------------------------ * * 同步文章条目 * ------------------------------------------------------------------ */ // 自动总结:每篇文章至多自动触发一次;有缓存时 generate 内部直接展示缓存 function maybeAuto(slot) { if (!settings.auto) return; if (!slot.blocks.length || slot.state.status !== 'idle') return; if (slot._autoFiredFor === slot.entryKey) return; // 供应商没配好时静默跳过,避免每篇文章都弹错误面板 const profile = activeProfile(); const localService = /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)/.test(profile.baseUrl || ''); if ((!profile.apiKey && !localService) || !profile.model) return; slot._autoFiredFor = slot.entryKey; generate(slot, { silent: true }); } function syncSlot(slot) { if (!slot.nav.isConnected) return; // 每次都重新定位文章根:QiReader 可能复用工具栏而整体替换正文容器 const root = articleRootOf(slot.nav); const key = entryKeyOf(root); if (slot.entryKey !== key || slot.root !== root) { slot.root = root; slot.entryKey = key; slot.blocks = []; slot.state = { status: 'idle', raw: '', error: '', meta: null }; closeSlot(slot); } if (!slot.blocks.length) { // 文章正文可能在打开后被替换,延迟再抓一次 if (!slot._blockTimer) { slot._blockTimer = setTimeout(() => { slot._blockTimer = null; if (!slot.nav.isConnected) return; ensureBlocks(slot); maybeAuto(slot); }, 400); } return; } maybeAuto(slot); } function ensureUI() { refreshTheme(); // 清理:nav 已从页面移除的 slot,其面板也要移掉,否则会越积越多 for (const slot of liveSlots) { if (!slot.nav.isConnected) { if (slot.pop) slot.pop.remove(); clearTimeout(slot._blockTimer); liveSlots.delete(slot); } } for (const nav of document.querySelectorAll('nav')) { const bookmark = findBookmarkButton(nav); if (!bookmark) continue; let slot = slots.get(nav); if (slot && !slot.btn.isConnected) { if (slot.pop) { slot.pop.remove(); slot.pop = null; } liveSlots.delete(slot); slot = null; } if (!slot) { slot = createSlot(nav, bookmark); slots.set(nav, slot); liveSlots.add(slot); } syncSlot(slot); } } /* ------------------------------------------------------------------ * * 设置面板 * ------------------------------------------------------------------ */ let settingsOverlay = null; let escHandler = null; function openSettings() { if (settingsOverlay) return; const draft = loadSettings(); const overlay = document.createElement('div'); overlay.className = 'qai-overlay'; overlay.setAttribute('data-qai-settings', '1'); themeVars(overlay); overlay.innerHTML = [ '' ].join(''); document.body.appendChild(overlay); settingsOverlay = overlay; const field = (name) => overlay.querySelector('[data-f="' + name + '"]'); const presetSel = field('preset'); const providerSel = field('provider'); const currentTag = overlay.querySelector('[data-provider-current]'); let editingId = draft.activeProviderId; function editingProvider() { return draft.providers.find((p) => p.id === editingId) || draft.providers[0]; } function refreshProviderList() { providerSel.innerHTML = draft.providers.map((p) => { const mark = p.id === draft.activeProviderId ? '● ' : ''; return ''; }).join(''); const isActive = editingId === draft.activeProviderId; currentTag.textContent = isActive ? '● 当前正在使用这个配置' : '此配置未启用;点 ✓ 设为当前后保存即可切换。'; currentTag.style.color = isActive ? '#2f9e44' : ''; } function fillProviderFields(p) { field('label').value = p.label || ''; field('format').value = p.format || 'openai'; field('baseUrl').value = p.baseUrl || ''; field('apiKey').value = p.apiKey || ''; field('model').value = p.model || ''; presetSel.value = PRESETS.some((x) => x.id === p.id) ? p.id : 'custom'; } function collectProviderFields(p) { p.label = field('label').value.trim() || p.label || '未命名服务'; p.format = field('format').value; p.baseUrl = field('baseUrl').value.trim(); p.apiKey = field('apiKey').value.trim(); p.model = field('model').value.trim(); } function fillGeneralFields() { field('language').value = draft.language; field('maxTokens').value = draft.maxTokens; field('temperature').value = draft.temperature; field('extra').value = draft.extra || ''; field('stream').checked = !!draft.stream; field('auto').checked = !!draft.auto; field('cache').checked = !!draft.cache; } refreshProviderList(); fillProviderFields(editingProvider()); fillGeneralFields(); providerSel.addEventListener('change', () => { const next = providerSel.value; if (next === editingId) return; collectProviderFields(editingProvider()); editingId = next; fillProviderFields(editingProvider()); refreshProviderList(); }); overlay.querySelector('[data-act="add-provider"]').addEventListener('click', () => { collectProviderFields(editingProvider()); const id = 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1296).toString(36); draft.providers.push({ id, label: '新供应商 ' + (draft.providers.length + 1), format: 'openai', baseUrl: '', apiKey: '', model: '' }); editingId = id; refreshProviderList(); fillProviderFields(editingProvider()); }); overlay.querySelector('[data-act="use-provider"]').addEventListener('click', () => { collectProviderFields(editingProvider()); draft.activeProviderId = editingId; refreshProviderList(); toast('保存后将使用「' + (editingProvider().label || '未命名服务') + '」'); }); overlay.querySelector('[data-act="remove-provider"]').addEventListener('click', () => { if (draft.providers.length <= 1) { toast('至少保留一个供应商'); return; } const removed = editingProvider(); draft.providers = draft.providers.filter((p) => p.id !== editingId); if (draft.activeProviderId === editingId) { draft.activeProviderId = draft.providers[0].id; } editingId = draft.providers[0].id; refreshProviderList(); fillProviderFields(editingProvider()); toast('已删除「' + (removed.label || removed.id) + '」'); }); presetSel.addEventListener('change', () => { const preset = PRESETS.find((p) => p.id === presetSel.value); if (!preset || !preset.format) return; field('format').value = preset.format; field('baseUrl').value = preset.baseUrl; field('model').value = preset.model; if (!field('label').value.trim()) field('label').value = preset.label; }); const keyInput = field('apiKey'); overlay.querySelector('[data-act="toggle-key"]').addEventListener('click', (e) => { const isPwd = keyInput.type === 'password'; keyInput.type = isPwd ? 'text' : 'password'; e.currentTarget.textContent = isPwd ? '隐藏' : '显示'; }); const close = () => { overlay.remove(); settingsOverlay = null; if (escHandler) { document.removeEventListener('keydown', escHandler, true); escHandler = null; } }; escHandler = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; document.addEventListener('keydown', escHandler, true); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); const collect = () => { collectProviderFields(editingProvider()); return { ...draft, language: field('language').value, maxTokens: Math.max(128, parseInt(field('maxTokens').value, 10) || 1600), temperature: Math.min(2, Math.max(0, parseFloat(field('temperature').value) || 0)), extra: field('extra').value.trim(), stream: field('stream').checked, auto: field('auto').checked, cache: field('cache').checked }; }; const statusEl = overlay.querySelector('[data-test-status]'); overlay.addEventListener('click', async (e) => { const btn = e.target.closest('[data-act]'); if (!btn) return; const act = btn.getAttribute('data-act'); if (act === 'cancel') { close(); return; } if (act === 'save') { const next = collect(); saveSettings(next); close(); const active = next.providers.find((p) => p.id === next.activeProviderId); toast('设置已保存,当前使用:' + ((active && active.label) || '未命名服务')); return; } if (act === 'clear-cache') { gmSet(KEY_CACHE, {}); toast('缓存已清空'); return; } if (act === 'test') { const p = { ...editingProvider() }; collectProviderFields(p); if (!p.model) { statusEl.textContent = '请先填写模型'; return; } statusEl.textContent = '正在测试…'; statusEl.style.color = ''; btn.disabled = true; try { const res = await callModel( { format: p.format, profile: p, maxTokens: 32, temperature: 0, stream: false }, '你是一个连接测试助手。', '只回复两个字:正常', () => {} ); statusEl.textContent = '连接正常' + (res.text ? ':' + norm(res.text).slice(0, 20) : ''); statusEl.style.color = '#2f9e44'; } catch (err) { const url = endpoint(p.baseUrl, p.format === 'anthropic' ? 'v1/messages' : 'v1/chat/completions'); const auth = p.format === 'anthropic' ? '头发送:x-api-key ' + maskKey(p.apiKey) + ' + anthropic-version' : '头发送:Authorization Bearer ' + maskKey(p.apiKey); statusEl.textContent = ((err && err.message ? err.message : String(err)).slice(0, 220)) + '\n请求:' + url + '\n' + auth; statusEl.style.color = '#e5484d'; } finally { btn.disabled = false; } } }); } /* ------------------------------------------------------------------ * * 启动 * ------------------------------------------------------------------ */ let pending = false; function schedule() { if (pending) return; pending = true; setTimeout(() => { pending = false; ensureUI(); }, 180); } function boot() { injectStyle(); try { if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('AI 总结设置', openSettings); GM_registerMenuCommand('清空 AI 总结缓存', () => { gmSet(KEY_CACHE, {}); toast('缓存已清空'); }); } } catch (e) { /* ignore */ } new MutationObserver(schedule).observe(document.documentElement, { childList: true, subtree: true }); let lastHref = location.href; setInterval(() => { if (location.href !== lastHref) { lastHref = location.href; schedule(); } }, 800); setInterval(ensureUI, 3000); ensureUI(); setTimeout(ensureUI, 600); setTimeout(ensureUI, 1800); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();