里(站点自定义 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 总结 ',
' ',
' ',
' ' + icon('refresh', 15) + ' ',
' ' + icon('copy', 15) + ' ',
' ' + icon('settings', 15) + ' ',
' ' + icon('close', 15) + ' ',
' ',
'
',
'
',
''
].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) =>
'' +
escapeHtml(p.label || '未命名服务') + ' '
).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('' +
'' + it.i + ' ' +
'' + escapeHtml(it.text) + ' ' +
' ');
}
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 = [
'',
'
' + icon('sparkles', 17) + 'AI 总结设置 ',
'
支持 OpenAI 兼容(/chat/completions)与 Anthropic 兼容(/messages)两种开放格式,可保存多个供应商配置随时切换。
',
'
',
'
供应商列表 (列表里选中要编辑的那个) ',
'
',
' ',
' ' + icon('zap', 15) + ' ',
' ' + icon('check', 15) + ' ',
' ' + icon('close', 15) + ' ',
'
',
'
',
'
',
'
',
' 服务名称 ',
' ',
'
',
'
',
' 服务预设 (选一个会自动填好地址与模型,Key 仍需自己填) ',
' ',
PRESETS.map((p) => '' + escapeHtml(p.label) + ' ').join(''),
' ',
'
',
'
',
'
',
' 接口格式 ',
' ',
' OpenAI 兼容 ',
' Anthropic 兼容 ',
' ',
'
',
'
',
' 模型 ',
' ',
'
',
'
',
'
',
' 接口地址 (到版本号为止,如 …/v1;也可直接粘贴完整 …/chat/completions 或 …/messages) ',
' ',
'
',
'
',
'
API Key ',
'
',
' ',
' 显示 ',
'
',
'
',
'
',
'
',
' 总结语言 ',
' ',
' 简体中文 ',
' 跟随原文 ',
' English ',
' ',
'
',
'
',
' 最大输出 tokens ',
' ',
'
',
'
',
'
',
'
',
' 附加要求 (可选) ',
' ',
'
',
'
',
' 温度 ',
' ',
'
',
'
',
'
流式输出(边生成边显示)',
'
打开文章时在后台静默生成(不弹面板,图标旋转;完成后 ✨ 变橙色,点开即看)',
'
缓存已有总结,避免重复调用',
'
API Key 保存时经过设备绑定盐 + XOR 混淆后再写入本地扩展存储(不是明文)。但它不是真正的加密:能同时拿到你浏览器配置目录与脚本源码的人仍可还原。建议给每个平台单独开一把可随时吊销的 Key。
',
' ',
' ',
'
'
].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 '' +
escapeHtml(mark + (p.label || '未命名服务')) + ' ';
}).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();
}
})();