// ==UserScript==
// @name B站省流助手 - 字幕AI摘要 Pro
// @namespace https://github.com/moonjoin/tampermonkey-scripts
// @version 5.0.23
// @description 自动提取B站视频字幕,通过自定义AI API生成极简摘要,支持模型切换、持续对话和评论区总结;支持自动解析开关、自动获取模型列表、flomo自动加标签、总结生图和API兜底功能
// @author 次元饺子
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/list/*
// @match https://labs.google/fx/*/tools/flow/project*
// @icon https://www.bilibili.com/favicon.ico
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @grant GM_openInTab
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect *
// @license MIT
// @downloadURL https://update.greasyfork.org/scripts/574935/B%E7%AB%99%E7%9C%81%E6%B5%81%E5%8A%A9%E6%89%8B%20-%20%E5%AD%97%E5%B9%95AI%E6%91%98%E8%A6%81%20Pro.user.js
// @updateURL https://update.greasyfork.org/scripts/574935/B%E7%AB%99%E7%9C%81%E6%B5%81%E5%8A%A9%E6%89%8B%20-%20%E5%AD%97%E5%B9%95AI%E6%91%98%E8%A6%81%20Pro.meta.js
// ==/UserScript==
(function() {
'use strict';
if (window.__BILI_SUBTITLE_SUMMARY__) return;
window.__BILI_SUBTITLE_SUMMARY__ = true;
const FLOW_PROMPT_JOB_KEY = 'tabbit_flow_prompt_job_v1';
const FLOW_HEARTBEAT_KEY = 'tabbit_flow_receiver_heartbeat_v1';
const FLOW_PROMPT_MESSAGE_TYPE = 'FLOW_PROMPT_SUBMIT';
const DEFAULT_FLOW_PROJECT_URL = 'https://labs.google/fx/zh/tools/flow/project/0ad40d66-236b-42f3-a95f-dde090db0fae';
const FLOW_HEARTBEAT_INTERVAL_MS = 10000;
const FLOW_HEARTBEAT_MAX_AGE_MS = 35000;
function isFlowProjectPage() {
return location.hostname === 'labs.google' && /\/fx\/.*\/tools\/flow\/project\//.test(location.pathname);
}
function parseFlowPromptJob(value) {
if (!value) return null;
if (typeof value === 'object') return value;
try {
return JSON.parse(value);
} catch(e) {
console.warn('[省流助手-Flow] 解析任务失败:', e.message);
return null;
}
}
function postFlowPromptJob(job) {
if (!job || !job.text) return;
const message = {
source: 'BiliSummaryFlowRelay',
type: FLOW_PROMPT_MESSAGE_TYPE,
id: job.id || '',
text: job.text,
options: job.options || {}
};
window.postMessage(message, window.location.origin);
if ('BroadcastChannel' in window) {
try {
const channel = new BroadcastChannel('flow-prompt-bridge');
channel.postMessage(message);
setTimeout(function() { channel.close(); }, 500);
} catch(e) {
console.warn('[省流助手-Flow] BroadcastChannel 转发失败:', e.message);
}
}
}
function setupFlowPromptRelayPage() {
console.log('[省流助手-Flow] Flow 接收端已启动,等待 B站脚本发送生图提示词');
const createReceiverWidget = function() {
const old = document.getElementById('tabbit-flow-receiver-widget');
if (old) old.remove();
const widget = document.createElement('div');
widget.id = 'tabbit-flow-receiver-widget';
widget.style.cssText = [
'position:fixed',
'right:16px',
'bottom:16px',
'z-index:999999',
'font-family:Arial,"Microsoft YaHei",sans-serif',
'color:oklch(94% .01 160)',
'line-height:1.35'
].join(';');
const panel = document.createElement('div');
panel.style.cssText = [
'width:276px',
'background:oklch(20% .018 165)',
'border:1px solid oklch(42% .04 165)',
'border-radius:8px',
'box-shadow:0 14px 36px rgba(0,0,0,.34)',
'overflow:hidden'
].join(';');
const header = document.createElement('button');
header.type = 'button';
header.style.cssText = [
'width:100%',
'display:flex',
'align-items:center',
'gap:8px',
'border:0',
'background:oklch(23% .022 165)',
'color:oklch(95% .01 165)',
'padding:10px 12px',
'cursor:pointer',
'text-align:left'
].join(';');
const dot = document.createElement('span');
dot.style.cssText = 'width:8px;height:8px;border-radius:999px;background:oklch(73% .16 155);box-shadow:0 0 0 3px oklch(73% .16 155 / .16);flex:0 0 auto;';
const titleWrap = document.createElement('span');
titleWrap.style.cssText = 'display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;';
const title = document.createElement('span');
title.textContent = 'Flow 接收端';
title.style.cssText = 'font-size:13px;font-weight:800;letter-spacing:0;';
const subtitle = document.createElement('span');
subtitle.textContent = '在线,等待生图任务';
subtitle.style.cssText = 'font-size:11px;color:oklch(78% .025 165);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
const toggleIcon = document.createElement('span');
toggleIcon.textContent = '收起';
toggleIcon.style.cssText = 'font-size:11px;color:oklch(76% .035 165);flex:0 0 auto;';
titleWrap.appendChild(title);
titleWrap.appendChild(subtitle);
header.appendChild(dot);
header.appendChild(titleWrap);
header.appendChild(toggleIcon);
const body = document.createElement('div');
body.style.cssText = 'padding:10px 12px 12px;background:oklch(18% .014 165);display:flex;flex-direction:column;gap:8px;';
const statusLine = document.createElement('div');
statusLine.textContent = '接收 B站摘要,转发给 Flow 页面。';
statusLine.style.cssText = 'font-size:12px;color:oklch(84% .018 165);';
const meta = document.createElement('div');
meta.style.cssText = 'display:grid;grid-template-columns:56px 1fr;gap:5px 8px;font-size:11px;color:oklch(76% .024 165);';
meta.innerHTML =
'最近任务暂无' +
'心跳刚刚';
const monitorBox = document.createElement('div');
monitorBox.style.cssText = 'border-top:1px solid oklch(33% .026 165);padding-top:9px;display:flex;flex-direction:column;gap:8px;';
const monitorTitle = document.createElement('div');
monitorTitle.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:oklch(89% .016 165);font-weight:800;';
monitorTitle.innerHTML = '新图监控下载已记 0 / 下载 0 / 生图 0';
const monitorButtons = document.createElement('div');
monitorButtons.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;';
const monitorBtnStyle = 'border:0;border-radius:6px;padding:7px 6px;font-size:12px;font-weight:800;cursor:pointer;';
const monitorToggleBtn = document.createElement('button');
monitorToggleBtn.type = 'button';
monitorToggleBtn.textContent = '开启监控';
monitorToggleBtn.style.cssText = monitorBtnStyle + 'background:oklch(63% .14 155);color:oklch(15% .02 155);';
const downloadCurrentBtn = document.createElement('button');
downloadCurrentBtn.type = 'button';
downloadCurrentBtn.textContent = '下载当前';
downloadCurrentBtn.style.cssText = monitorBtnStyle + 'background:oklch(58% .12 250);color:oklch(97% .01 250);opacity:.55;cursor:default;';
downloadCurrentBtn.disabled = true;
const clearMemoryBtn = document.createElement('button');
clearMemoryBtn.type = 'button';
clearMemoryBtn.textContent = '清记录';
clearMemoryBtn.style.cssText = monitorBtnStyle + 'background:oklch(32% .025 165);color:oklch(91% .01 165);border:1px solid oklch(43% .04 165);';
monitorButtons.appendChild(monitorToggleBtn);
monitorButtons.appendChild(downloadCurrentBtn);
monitorButtons.appendChild(clearMemoryBtn);
monitorBox.appendChild(monitorTitle);
monitorBox.appendChild(monitorButtons);
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:8px;';
const collapseBtn = document.createElement('button');
collapseBtn.type = 'button';
collapseBtn.textContent = '最小化';
collapseBtn.style.cssText = 'flex:1;border:1px solid oklch(43% .04 165);border-radius:6px;background:oklch(26% .02 165);color:oklch(94% .01 165);padding:7px 8px;font-size:12px;font-weight:700;cursor:pointer;';
const openBtn = document.createElement('span');
openBtn.textContent = '后台就绪';
openBtn.title = '这个标签页保持打开即可接收任务';
openBtn.style.cssText = 'flex:1;border-radius:6px;background:oklch(63% .14 155);color:oklch(16% .02 155);padding:7px 8px;font-size:12px;font-weight:800;text-align:center;';
actions.appendChild(collapseBtn);
actions.appendChild(openBtn);
body.appendChild(statusLine);
body.appendChild(meta);
body.appendChild(monitorBox);
body.appendChild(actions);
const mini = document.createElement('button');
mini.type = 'button';
mini.style.cssText = [
'display:none',
'align-items:center',
'gap:7px',
'border:1px solid oklch(42% .04 165)',
'border-radius:999px',
'background:oklch(21% .018 165)',
'color:oklch(94% .01 165)',
'box-shadow:0 10px 30px rgba(0,0,0,.32)',
'padding:8px 11px',
'font-size:12px',
'font-weight:800',
'cursor:pointer'
].join(';');
mini.innerHTML = 'Flow 接收';
panel.appendChild(header);
panel.appendChild(body);
widget.appendChild(panel);
widget.appendChild(mini);
let collapsed = localStorage.getItem('tabbit_flow_receiver_collapsed') !== 'false';
const applyCollapsed = function(nextCollapsed) {
collapsed = !!nextCollapsed;
localStorage.setItem('tabbit_flow_receiver_collapsed', collapsed ? 'true' : 'false');
panel.style.display = collapsed ? 'none' : 'block';
mini.style.display = collapsed ? 'inline-flex' : 'none';
toggleIcon.textContent = collapsed ? '展开' : '收起';
};
header.addEventListener('click', function() { applyCollapsed(true); });
collapseBtn.addEventListener('click', function() { applyCollapsed(true); });
mini.addEventListener('click', function() { applyCollapsed(false); });
const mount = function() {
if (!document.body || document.getElementById('tabbit-flow-receiver-widget')) return;
document.body.appendChild(widget);
applyCollapsed(collapsed);
};
document.addEventListener('DOMContentLoaded', mount);
mount();
return {
setStatus: function(text) {
subtitle.textContent = text || '在线,等待生图任务';
statusLine.textContent = text || '接收 B站摘要,转发给 Flow 页面。';
},
setLastJob: function(text) {
const el = widget.querySelector('#tabbit-flow-last-job');
if (el) el.textContent = text || '暂无';
},
beat: function() {
const el = widget.querySelector('#tabbit-flow-heartbeat');
if (el) el.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false });
},
setMonitorCount: function(text) {
const el = widget.querySelector('#tabbit-flow-monitor-count');
if (el) el.textContent = text || '已记 0 / 下载 0 / 生图 0';
},
controls: {
monitorToggleBtn: monitorToggleBtn,
downloadCurrentBtn: downloadCurrentBtn,
clearMemoryBtn: clearMemoryBtn
},
flashJob: function(text) {
this.setStatus('已转发 Flow 生图任务');
this.setLastJob(text || '刚刚收到');
mini.style.background = 'oklch(25% .04 155)';
setTimeout(function() {
mini.style.background = 'oklch(21% .018 165)';
}, 1400);
setTimeout(() => this.setStatus('在线,等待生图任务'), 2500);
}
};
};
const receiverWidget = createReceiverWidget();
const setupIntegratedFlowTools = function(widgetApi) {
const TOOL_CONFIG = {
scanIntervalMs: 2000,
menuOpenDelayMs: 650,
submenuDelayMs: 650,
afterClickDelayMs: 250,
toastTimeoutMs: 45000,
retryLimit: 6,
retryBaseDelayMs: 7000,
qualityText: '2K',
downloadText: '下载'
};
const HACK_ID = 'tabbit-integrated-flow-react-hack';
const STORAGE_ID = 'tabbit_flow_downloaded_' + (function() {
const match = location.pathname.match(/\/project\/([^/]+)/);
return match ? match[1] : 'default_project';
})();
const sleep = function(ms) { return new Promise(function(resolve) { setTimeout(resolve, ms); }); };
const safeJsonParse = function(value) {
try {
const parsed = JSON.parse(value || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch(e) {
return [];
}
};
let downloadedIds = new Set(safeJsonParse(typeof GM_getValue === 'function' ? GM_getValue(STORAGE_ID, '[]') : '[]'));
let seenTileIds = new Set();
let pendingTileIds = new Set();
let retryCountById = new Map();
let queue = [];
let observer = null;
let scanTimer = null;
let isMonitoring = false;
let isProcessing = false;
let promptQueue = [];
let isPromptProcessing = false;
let handledPromptJobIds = new Set();
const controls = widgetApi.controls || {};
const saveDownloadedIds = function() {
if (typeof GM_setValue === 'function') GM_setValue(STORAGE_ID, JSON.stringify(Array.from(downloadedIds)));
};
const updateCounts = function() {
if (widgetApi && widgetApi.setMonitorCount) {
widgetApi.setMonitorCount('已记 ' + downloadedIds.size + ' / 下载 ' + (queue.length + pendingTileIds.size) + ' / 生图 ' + promptQueue.length);
}
};
const setReceiverStatus = function(text) {
if (widgetApi && widgetApi.setStatus) widgetApi.setStatus(text);
updateCounts();
};
const attrEscape = function(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
};
const injectReactHack = function() {
if (document.getElementById(HACK_ID)) return;
const script = document.createElement('script');
script.id = HACK_ID;
script.textContent = [
'(function() {',
' if (window.__tabbit_flow_react_hack_installed) return;',
' window.__tabbit_flow_react_hack_installed = true;',
' var getProps = function(el) {',
' if (!el) return null;',
' var key = Object.keys(el).find(function(k) { return k.indexOf("__reactProps$") === 0 || k.indexOf("__reactEventHandlers$") === 0; });',
' return key ? el[key] : null;',
' };',
' var trigger = function(el, eventName, eventData) {',
' var curr = el;',
' while (curr && curr !== document.body) {',
' var props = getProps(curr);',
' if (props && typeof props[eventName] === "function") {',
' try {',
' props[eventName](Object.assign({',
' preventDefault: function() {},',
' stopPropagation: function() {},',
' nativeEvent: { isTrusted: true },',
' isTrusted: true',
' }, eventData || {}));',
' return true;',
' } catch(e) {}',
' }',
' curr = curr.parentElement;',
' }',
' return false;',
' };',
' var tileSelector = function(tileId) {',
' return "div[data-tile-id=\\"" + String(tileId).replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"") + "\\"]";',
' };',
' document.addEventListener("FMD_RightClick", function(e) {',
' var root = document.querySelector(tileSelector(e.detail));',
' var el = root && (root.querySelector("img") || root);',
' if (!el) return;',
' var rect = el.getBoundingClientRect();',
' trigger(el, "onContextMenu", { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, button: 2, buttons: 2 });',
' });',
' document.addEventListener("FMD_ClickItem", function(e) {',
' var keyword = e.detail;',
' var items = Array.from(document.querySelectorAll("[role=\\"menuitem\\"], button, [role=\\"menuitemradio\\"], div[role=\\"menuitem\\"]"));',
' var target = items.find(function(el) {',
' var rect = el.getBoundingClientRect();',
' return rect.width > 0 && rect.height > 0 && el.textContent && el.textContent.indexOf(keyword) !== -1;',
' });',
' if (!target) return;',
' var eventData = { button: 0, pointerType: "mouse", pointerId: 1 };',
' trigger(target, "onPointerDown", eventData);',
' trigger(target, "onPointerUp", eventData);',
' trigger(target, "onClick", eventData);',
' target.click();',
' });',
'})();'
].join('\n');
document.head.appendChild(script);
};
const findTileById = function(tileId) {
return document.querySelector('div[data-tile-id="' + attrEscape(tileId) + '"]');
};
const getReadyTileContainers = function() {
return Array.from(document.querySelectorAll('div[data-tile-id]')).filter(function(container) {
const tileId = container.getAttribute('data-tile-id');
if (!tileId) return false;
if (!container.querySelector('a[href*="/edit/"]')) return false;
if (container.querySelector('a[href*="/collection/"]')) return false;
return true;
});
};
const getCurrentTileIds = function() {
return getReadyTileContainers().map(function(container) {
return container.getAttribute('data-tile-id');
}).filter(Boolean);
};
const findMenuItem = function(keyword) {
const items = Array.from(document.querySelectorAll('[role="menuitem"], button, [role="menuitemradio"], div[role="menuitem"]'));
return items.find(function(el) {
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && el.textContent && el.textContent.indexOf(keyword) !== -1;
});
};
const closeVisibleToasts = async function() {
const closeButtons = Array.from(document.querySelectorAll('li[data-sonner-toast] button'))
.filter(function(button) { return button.textContent && button.textContent.indexOf('关闭') !== -1; });
for (const button of closeButtons) {
button.click();
await sleep(100);
}
};
const waitForDownloadToast = async function() {
let elapsed = 0;
while (elapsed < TOOL_CONFIG.toastTimeoutMs && isMonitoring) {
const toasts = Array.from(document.querySelectorAll('li[data-sonner-toast]'));
for (const toast of toasts) {
const text = toast.textContent || '';
if (text.indexOf('失败') !== -1 || text.toLowerCase().indexOf('error') !== -1) return -1;
if (text.indexOf('已完成高清重塑') !== -1 || text.indexOf('已下载') !== -1 || text.indexOf('保存') !== -1 || text.indexOf('成功') !== -1 || text.toLowerCase().indexOf('download') !== -1) {
await closeVisibleToasts();
return 1;
}
}
await sleep(500);
elapsed += 500;
}
return 0;
};
const markDownloaded = function(tileId) {
downloadedIds.add(tileId);
saveDownloadedIds();
const tile = findTileById(tileId);
if (tile && !tile.querySelector('.tabbit-flow-downloaded-mark')) {
const mark = document.createElement('div');
mark.className = 'tabbit-flow-downloaded-mark';
mark.textContent = '已下';
mark.style.cssText = 'position:absolute;left:8px;bottom:8px;z-index:99999;padding:2px 6px;border-radius:4px;background:rgba(16,185,129,.95);color:#fff;font-size:12px;font-weight:700;pointer-events:none;';
tile.style.position = 'relative';
tile.appendChild(mark);
}
updateCounts();
};
const downloadTile = async function(tileId) {
if (downloadedIds.has(tileId)) return { ok: true, skipped: true };
const tile = findTileById(tileId);
const editLink = tile && tile.querySelector('a[href*="/edit/"]');
if (!tile || !editLink) return { ok: false, reason: '图片卡片还没准备好' };
try {
editLink.scrollIntoView({ block: 'center', inline: 'center' });
await sleep(550);
await closeVisibleToasts();
document.body.click();
await sleep(200);
document.dispatchEvent(new CustomEvent('FMD_RightClick', { detail: tileId }));
await sleep(TOOL_CONFIG.menuOpenDelayMs);
let qualityItem = findMenuItem(TOOL_CONFIG.qualityText);
if (!qualityItem) {
document.dispatchEvent(new CustomEvent('FMD_ClickItem', { detail: TOOL_CONFIG.downloadText }));
await sleep(TOOL_CONFIG.submenuDelayMs);
qualityItem = findMenuItem(TOOL_CONFIG.qualityText);
}
if (!qualityItem) {
document.body.click();
return { ok: false, reason: '下载菜单未出现' };
}
document.dispatchEvent(new CustomEvent('FMD_ClickItem', { detail: TOOL_CONFIG.qualityText }));
await sleep(TOOL_CONFIG.afterClickDelayMs);
document.body.click();
const toastStatus = await waitForDownloadToast();
if (toastStatus === -1) return { ok: false, reason: '页面提示下载失败' };
markDownloaded(tileId);
return { ok: true, timeout: toastStatus === 0 };
} catch(e) {
console.error('[省流助手-Flow] 下载异常:', e);
return { ok: false, reason: e.message || '下载异常' };
}
};
const enqueueTile = function(tileId, source) {
if (!tileId || downloadedIds.has(tileId) || pendingTileIds.has(tileId)) return;
pendingTileIds.add(tileId);
queue.push(tileId);
console.log('[省流助手-Flow] 下载队列:', tileId, source);
setReceiverStatus('发现新图,准备下载');
processQueue();
};
const retryLater = function(tileId, reason) {
const nextCount = (retryCountById.get(tileId) || 0) + 1;
retryCountById.set(tileId, nextCount);
if (nextCount > TOOL_CONFIG.retryLimit) {
console.warn('[省流助手-Flow] 已放弃下载 ' + tileId + ': ' + reason);
setReceiverStatus('部分图片下载失败');
return;
}
setTimeout(function() {
if (isMonitoring && !downloadedIds.has(tileId)) enqueueTile(tileId, 'retry');
}, TOOL_CONFIG.retryBaseDelayMs * nextCount);
};
async function processQueue() {
if (isProcessing) return;
isProcessing = true;
while (isMonitoring && queue.length > 0) {
const tileId = queue.shift();
pendingTileIds.delete(tileId);
updateCounts();
if (downloadedIds.has(tileId)) continue;
setReceiverStatus('下载中');
const result = await downloadTile(tileId);
if (!result.ok && isMonitoring) retryLater(tileId, result.reason);
await sleep(900);
}
isProcessing = false;
if (isMonitoring) setReceiverStatus(queue.length ? '排队中' : '监控中');
}
const scanForNewTiles = function() {
if (!isMonitoring) return;
getCurrentTileIds().forEach(function(tileId) {
if (seenTileIds.has(tileId)) return;
seenTileIds.add(tileId);
enqueueTile(tileId, 'new');
});
};
const enqueueCurrentTiles = function() {
const ids = getCurrentTileIds();
ids.forEach(function(tileId) {
seenTileIds.add(tileId);
if (!downloadedIds.has(tileId)) enqueueTile(tileId, 'current');
});
setReceiverStatus(ids.length ? '当前图片已入队' : '未发现图片');
};
const startMonitoring = function() {
if (isMonitoring) return;
isMonitoring = true;
queue = [];
pendingTileIds.clear();
retryCountById.clear();
seenTileIds = new Set(getCurrentTileIds());
observer = new MutationObserver(scanForNewTiles);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-tile-id', 'href', 'src'] });
scanTimer = setInterval(scanForNewTiles, TOOL_CONFIG.scanIntervalMs);
if (controls.monitorToggleBtn) {
controls.monitorToggleBtn.textContent = '停止监控';
controls.monitorToggleBtn.style.background = 'oklch(62% .18 28)';
controls.monitorToggleBtn.style.color = 'oklch(98% .01 28)';
}
if (controls.downloadCurrentBtn) {
controls.downloadCurrentBtn.disabled = false;
controls.downloadCurrentBtn.style.opacity = '1';
controls.downloadCurrentBtn.style.cursor = 'pointer';
}
setReceiverStatus('监控中,已忽略当前 ' + seenTileIds.size + ' 张');
};
const stopMonitoring = function() {
isMonitoring = false;
queue = [];
pendingTileIds.clear();
if (observer) observer.disconnect();
observer = null;
if (scanTimer) clearInterval(scanTimer);
scanTimer = null;
if (controls.monitorToggleBtn) {
controls.monitorToggleBtn.textContent = '开启监控';
controls.monitorToggleBtn.style.background = 'oklch(63% .14 155)';
controls.monitorToggleBtn.style.color = 'oklch(15% .02 155)';
}
if (controls.downloadCurrentBtn) {
controls.downloadCurrentBtn.disabled = true;
controls.downloadCurrentBtn.style.opacity = '.55';
controls.downloadCurrentBtn.style.cursor = 'default';
}
setReceiverStatus('在线,等待生图任务');
};
const clearDownloadedMemory = function() {
downloadedIds = new Set();
saveDownloadedIds();
document.querySelectorAll('.tabbit-flow-downloaded-mark').forEach(function(el) { el.remove(); });
setReceiverStatus('下载记录已清空');
};
const findSubmitButton = function() {
return Array.from(document.querySelectorAll('button')).find(function(button) {
const icon = button.querySelector('i.google-symbols');
const text = button.textContent || '';
const isSubmitLike = (icon && icon.textContent.trim() === 'arrow_forward') || text.indexOf('创建') !== -1;
return isSubmitLike && !button.disabled && button.getAttribute('aria-disabled') !== 'true';
});
};
const fillPromptText = async function(text) {
const editor = document.querySelector('[data-slate-editor="true"]');
if (!editor) return { ok: false, reason: '未找到 Flow 输入框' };
try {
editor.scrollIntoView({ block: 'center', inline: 'center' });
await sleep(200);
editor.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
editor.focus();
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editor);
selection.removeAllRanges();
selection.addRange(range);
await sleep(50);
const dt = new DataTransfer();
dt.setData('text/plain', text);
editor.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertFromPaste', dataTransfer: dt, bubbles: true, cancelable: true }));
editor.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
return { ok: true };
} catch(e) {
console.error('[省流助手-Flow] 填入失败:', e);
return { ok: false, reason: e.message || '填入失败' };
}
};
const submitGeneration = async function() {
await sleep(600);
document.dispatchEvent(new CustomEvent('FMD_ClickItem', { detail: '创建' }));
document.dispatchEvent(new CustomEvent('FMD_ClickItem', { detail: 'arrow_forward' }));
let submitBtn = findSubmitButton();
let waited = 0;
while (!submitBtn && waited < 4000) {
await sleep(500);
waited += 500;
submitBtn = findSubmitButton();
}
if (submitBtn) {
submitBtn.click();
return { ok: true };
}
return { ok: false, reason: '未找到可点击的创建按钮' };
};
const waitForGenerationReady = async function(timeoutMs) {
let elapsed = 0;
while (elapsed < timeoutMs) {
if (findSubmitButton()) return true;
await sleep(1000);
elapsed += 1000;
}
return false;
};
const runPromptJob = async function(job) {
const text = String(job.text || '').trim();
if (!text) return { ok: false, reason: '收到的文字为空' };
setReceiverStatus('填词中');
const fillResult = await fillPromptText(text);
if (!fillResult.ok) return fillResult;
setReceiverStatus('提交生图');
const submitResult = await submitGeneration();
if (!submitResult.ok) return submitResult;
setReceiverStatus('等待生图完成');
await sleep(2000);
await waitForGenerationReady(Number(job.options && job.options.waitMs) || 120000);
setReceiverStatus(isMonitoring ? '监控中' : '已提交生图');
return { ok: true };
};
async function processPromptQueue() {
if (isPromptProcessing) return;
isPromptProcessing = true;
while (promptQueue.length > 0) {
const job = promptQueue.shift();
updateCounts();
const result = await runPromptJob(job);
if (!result.ok) {
console.warn('[省流助手-Flow] 生图任务失败:', result.reason);
setReceiverStatus(result.reason || '接口任务失败');
}
await sleep(800);
}
isPromptProcessing = false;
if (isMonitoring) setReceiverStatus('监控中');
updateCounts();
}
const enqueuePromptJob = function(text, options) {
options = options || {};
promptQueue.push({ text: text, options: options, createdAt: Date.now() });
setReceiverStatus('收到生图任务');
updateCounts();
processPromptQueue();
};
const acceptPromptMessage = function(data) {
if (!data || data.type !== FLOW_PROMPT_MESSAGE_TYPE) return;
if (data.id) {
if (handledPromptJobIds.has(data.id)) return;
handledPromptJobIds.add(data.id);
if (handledPromptJobIds.size > 300) handledPromptJobIds = new Set(Array.from(handledPromptJobIds).slice(-120));
}
enqueuePromptJob(data.text, data.options || {});
};
window.addEventListener('message', function(event) {
if (event.origin && event.origin !== window.location.origin) return;
acceptPromptMessage(event.data);
});
if ('BroadcastChannel' in window) {
const channel = new BroadcastChannel('flow-prompt-bridge');
channel.onmessage = function(event) { acceptPromptMessage(event.data); };
}
window.FlowPromptBridge = {
submit: function(text, options) {
enqueuePromptJob(text, options || {});
},
send: function(text, options) {
enqueuePromptJob(text, options || {});
}
};
window.FlowSubmitPrompt = window.FlowPromptBridge.submit;
injectReactHack();
if (controls.monitorToggleBtn) controls.monitorToggleBtn.addEventListener('click', function() {
if (isMonitoring) stopMonitoring();
else startMonitoring();
});
if (controls.downloadCurrentBtn) controls.downloadCurrentBtn.addEventListener('click', enqueueCurrentTiles);
if (controls.clearMemoryBtn) controls.clearMemoryBtn.addEventListener('click', clearDownloadedMemory);
updateCounts();
};
setupIntegratedFlowTools(receiverWidget);
let lastJobId = '';
const handleJob = function(value) {
const job = parseFlowPromptJob(value);
if (!job || !job.text || job.id === lastJobId) return;
lastJobId = job.id || String(Date.now());
postFlowPromptJob(job);
setTimeout(function() { postFlowPromptJob(job); }, 1500);
setTimeout(function() { postFlowPromptJob(job); }, 4000);
receiverWidget.flashJob(job.title || job.id || '新任务');
};
if (typeof GM_addValueChangeListener === 'function') {
GM_addValueChangeListener(FLOW_PROMPT_JOB_KEY, function(_name, _oldValue, newValue, remote) {
if (!remote) return;
handleJob(newValue);
});
} else {
console.warn('[省流助手-Flow] 当前脚本环境缺少 GM_addValueChangeListener');
}
if (typeof GM_getValue === 'function') {
setTimeout(function() { handleJob(GM_getValue(FLOW_PROMPT_JOB_KEY, '')); }, 1200);
}
const writeHeartbeat = function() {
if (typeof GM_setValue !== 'function') return;
GM_setValue(FLOW_HEARTBEAT_KEY, JSON.stringify({
url: location.href,
ts: Date.now()
}));
receiverWidget.beat();
};
writeHeartbeat();
setInterval(writeHeartbeat, FLOW_HEARTBEAT_INTERVAL_MS);
}
if (isFlowProjectPage()) {
setupFlowPromptRelayPage();
return;
}
const PROMPT_TEXT = '我极度没有耐心,不想动脑子,脾气暴躁且阅读困难。请用最直白的大白话给我解释这视频到底在说什么,在能解释清楚的前提下废话越少越好,禁止使用任何专业术语。请按以下顺序直接输出:1.【结论】直接告诉我核心意思;2.【具体讲了啥】用极简的白话说明来龙去脉;3.【关键点】列出最重要的几个要点;4.【对我有什么用】直接说明价值,如果是纯广告或水视频请直接告诉我避雷;5.【原链接】在最后附上视频原始链接。记住,不要任何寒暄、铺垫和解释,直接开始回答!';
const COMMENT_PROMPT_TEXT = '你是一个专业的评论分析助手。请对以下B站视频评论进行总结分析,包括:\n1. 评论整体情感倾向(正面/负面/中性)\n2. 主要讨论话题(列出3-5个)\n3. 有趣/高赞评论摘录\n4. 我理解能力差、没耐心,别讲铺垫、别讲背景、别讲废话,只告诉我:这东西核心结论是什么、有哪几个关键点、对我有什么用。';
const DANMAKU_PROMPT_TEXT = '你是一个专业的弹幕分析助手。请对以下B站视频弹幕进行总结分析,包括:\n1. 弹幕整体情感倾向(正面/负面/中性/混合)\n2. 弹幕讨论的热点话题(列出3-5个)\n3. 高频出现的关键词或梗\n4. 观众对视频内容的反应(哪些片段引发热烈讨论)\n5. 有趣/有代表性的弹幕摘录\n6. 我理解能力差、没耐心,别讲铺垫、别讲背景、别讲废话,只告诉我:这东西核心结论是什么、有哪几个关键点、对我有什么用。';
const IMAGE_GEN_PROMPT_TEXT = '根据以下视频内容总结,生成一张信息可视化的精美配图,风格清晰美观,适合作为视频总结的封面图:\n\n{summary}';
// ==================== localStorage 配置存储层 ====================
const STORAGE_KEY = 'bili_summary_pro_config';
const POSITION_KEY = 'bili_summary_pro_positions';
const SUMMARY_CACHE_KEY = 'bili_summary_pro_summary_cache_v1';
const SUMMARY_CACHE_MAX_ENTRIES = 20;
const SUMMARY_CACHE_MAX_CHARS = 250000;
const RESULT_ACTION_BUTTON_IDS = [
'copy_summary', 'edit_summary', 'generate_image', 'copy_image_prompt',
'post_comment', 'send_flomo', 'download_transcript',
'download_srt', 'save_image', 'fill_image_comment'
];
function getDefaultResultActionButtons() {
return RESULT_ACTION_BUTTON_IDS.map(function(id) {
return { id: id, enabled: true };
});
}
function normalizeResultActionButtons(raw) {
if (!Array.isArray(raw)) return getDefaultResultActionButtons();
if (raw.length === 0) return [];
var known = new Set(RESULT_ACTION_BUTTON_IDS);
var seen = new Set();
var out = [];
raw.forEach(function(item) {
if (!item || !known.has(item.id) || seen.has(item.id)) return;
seen.add(item.id);
out.push({ id: item.id, enabled: item.enabled !== false });
});
RESULT_ACTION_BUTTON_IDS.forEach(function(id) {
if (!seen.has(id)) out.push({ id: id, enabled: true });
});
return out;
}
const DEFAULT_PRESETS = [
{
id: 'preset_default',
name: '极简白话版',
icon: '🎯',
prompt: PROMPT_TEXT
},
{
id: 'preset_detailed',
name: '详细笔记版',
icon: '📝',
prompt: '请基于视频字幕内容,生成一份结构清晰的学习笔记,包括:\n1. 【主题概述】视频主题与核心论点\n2. 【内容大纲】用层级列表展示视频结构\n3. 【关键概念】解释视频中出现的重要概念/术语\n4. 【金句摘录】3-5条值得记录的话\n5. 【个人启发】你认为这个视频对观众的价值\n请使用 Markdown 格式输出。'
},
{
id: 'preset_critical',
name: '批判分析版',
icon: '🔍',
prompt: '请以批判性思维审视这个视频,输出:\n1. 【核心观点】视频在表达什么\n2. 【论据评估】UP主用了哪些证据,是否充分\n3. 【逻辑漏洞】存在哪些推理瑕疵或片面表达\n4. 【对立观点】可能存在的反驳或不同视角\n5. 【最终判断】这个视频值得相信吗?是否广告/带货/水视频?\n请直接输出,不要寒暄。'
},
{
id: 'preset_action',
name: '行动清单版',
icon: '✅',
prompt: '请把视频内容转化为可执行的行动清单:\n1. 【核心收获】用一句话说清视频教了什么\n2. 【具体步骤】列出可立即执行的步骤(带数字编号)\n3. 【注意事项】容易踩的坑\n4. 【适用场景】什么情况下用得上\n5. 【预期效果】照做能达到什么\n保持简洁,重在可操作性。'
},
{
id: 'preset_timeline',
name: '时间轴定位版',
icon: '🕒',
prompt: '请基于带时间范围的字幕内容做一份“可定位”的视频摘要,方便我后续追问时快速跳到具体片段。按下面格式直接输出:\n1. 【一句话结论】这个视频核心在讲什么\n2. 【时间轴速览】列出 5-10 个关键片段,每条必须带输入里已有的 [开始-结束] 时间范围,再写这一段讲了什么\n3. 【重点结论】提炼 3-5 个最重要的信息点\n4. 【值得追问】列出 3 个适合继续追问的片段,也必须带输入里已有的 [开始-结束] 时间范围\n\n硬性要求:\n- 只能引用输入里已经出现的时间范围,禁止猜测、补写或改写时间。\n- 如果某个内容无法定位到明确时间范围,就直接写“未定位到明确时间范围”。\n- 不要空谈,不要寒暄,不要复述这些规则,直接开始输出。'
}
];
const DEFAULT_FULL_ANALYSIS_PRESETS = [
{
id: 'fullpreset_video_review',
name: '内容审核版',
icon: '🎬',
prompt: '你是一个专业的视频内容审核员。请基于以下视频字幕、弹幕和评论数据,完成以下分析:\n\n首先,以视频字幕内容为主体,完整梳理视频的核心论点、论述逻辑和关键信息。然后,结合弹幕和评论作为观众舆情参考,从以下维度进行分析:\n\n1. 【视频核心内容】用简洁的话概括视频到底在讲什么\n2. 【内容准确性】结合弹幕和评论中的纠错、质疑信息,检查视频内容是否存在事实错误、数据不准、逻辑漏洞或断章取义,如果有请明确指出并给出正确的信息\n3. 【观点补充】弹幕和评论中有哪些对视频内容的重要补充、不同视角或反驳\n4. 【观众反馈】弹幕和评论的整体情感倾向,观众最关注/最认可/最质疑的点\n5. 【综合评价】视频内容质量和可信度如何,值不值得看\n\n注意:弹幕和评论只作为舆情参考,分析应以视频内容本身为主。不要任何废话,直接输出。'
},
{
id: 'fullpreset_quick_review',
name: '极简速览版',
icon: '⚡',
prompt: '你是一个高效的视频内容分析助手。请基于以下视频字幕、弹幕和评论数据,用最简洁的方式告诉我:\n\n1. 【一句话总结】这个视频讲了什么\n2. 【内容要点】3-5个关键点\n3. 【内容纠错】结合弹幕评论,视频中有没有说错的地方(没有就不写)\n4. 【口碑】弹幕和评论里大家怎么看这个视频\n5. 【值不值】花时间看这个视频值不值\n\n弹幕和评论仅作舆情参考,重点分析视频本身内容。废话越少越好,直接开始。'
},
{
id: 'fullpreset_deep_critique',
name: '深度批判版',
icon: '🔍',
prompt: '你是一个深度内容分析师。请基于以下视频字幕、弹幕和评论数据,以视频内容为主体进行全面批判性分析:\n\n1. 【核心论点】视频在表达什么观点/主张\n2. 【论证质量】UP主用了哪些论据和逻辑,是否充分、严谨\n3. 【事实核查】结合弹幕和评论中的纠错信息,视频中是否存在事实错误、数据不准确、过度简化或误导性表达\n4. 【多方视角】弹幕和评论中呈现了哪些不同的观点和争议\n5. 【内容价值】综合评估这个视频的信息密度、可信度和观看价值\n\n以视频内容为核心,弹幕和评论作为辅助验证。直接输出,不要寒暄。'
}
];
const DEFAULT_CONFIG = {
apiUrl: 'https://xxxx/v1',
apiKey: 'sk-xxxx',
model: 'deepseek-v4-flash',
flomoApiUrl: '',
flomoAutoSend: false,
flomoTags: '#B站省流助手 #视频摘要',
modelList: [
'claude-opus-4-6',
'gemini-3-flash-preview',
'gpt-5.4',
'deepseek-v4-flash',
],
apiProfiles: [],
activeApiProfileId: 'api_profile_default',
promptText: PROMPT_TEXT,
commentPromptText: COMMENT_PROMPT_TEXT,
commentTextPresets: ['省流'],
danmakuPromptText: DANMAKU_PROMPT_TEXT,
fullAnalysisPromptText: DEFAULT_FULL_ANALYSIS_PRESETS[0].prompt,
fullAnalysisPresets: DEFAULT_FULL_ANALYSIS_PRESETS,
activeFullAnalysisPresetId: 'fullpreset_video_review',
fullDataMaxChars: 64000,
summaryMaxTokens: 4000,
skipDuration: 60,
autoParse: true,
autoOpenPanelWhileProcessing: false,
showWindowControls: false,
enableThinking: true,
promptPresets: DEFAULT_PRESETS,
activePresetId: 'preset_default',
enableImageGen: false,
imageGenApiUrl: '',
imageGenApiKey: '',
imageGenModel: 'gemini-3.1-flash-image-preview',
imageGenSize: '1024x1024',
enableImageAutoDownload: true,
imageGenMode: 'api',
flowProjectUrl: DEFAULT_FLOW_PROJECT_URL,
enableFlowBackgroundOpen: true,
imageGenPromptText: IMAGE_GEN_PROMPT_TEXT,
commentMaxPages: 8,
commentLimit: 188,
commentMinDelay: 1800,
commentMaxDelay: 3800,
autoSubmitCommentSummary: false,
enableAutoDownloadSubtitle: false,
resultActionButtons: getDefaultResultActionButtons()
};
const SUMMARY_LENGTH_ERROR_MESSAGE = 'AI 输出被截断:finish_reason=length。请在设置中调大「普通摘要最大输出 tokens」,或换支持更大输出上限的模型/API。';
function mergeBuiltInPromptPresets(presets) {
var savedPresets = Array.isArray(presets) ? presets : [];
var merged = savedPresets.slice();
for (var i = 0; i < DEFAULT_PRESETS.length; i++) {
var builtin = DEFAULT_PRESETS[i];
if (!merged.some(function(p) { return p && p.id === builtin.id; })) {
merged.push(JSON.parse(JSON.stringify(builtin)));
}
}
return merged;
}
function loadConfig() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const saved = JSON.parse(raw);
const merged = Object.assign({}, DEFAULT_CONFIG, saved);
merged.apiProfiles = normalizeApiProfiles(saved.apiProfiles, merged);
if (!merged.apiProfiles.some(function(profile) { return profile.id === merged.activeApiProfileId; })) {
merged.activeApiProfileId = merged.apiProfiles[0].id;
}
applyActiveApiProfile(merged);
merged.promptPresets = mergeBuiltInPromptPresets(merged.promptPresets);
merged.resultActionButtons = normalizeResultActionButtons(saved.resultActionButtons);
return merged;
}
} catch(e) {
console.warn('[省流助手] 读取配置失败,使用默认配置:', e.message);
}
return Object.assign({}, DEFAULT_CONFIG, {
apiProfiles: normalizeApiProfiles([], DEFAULT_CONFIG),
promptPresets: mergeBuiltInPromptPresets(DEFAULT_CONFIG.promptPresets),
resultActionButtons: normalizeResultActionButtons(undefined)
});
}
function createApiProfileId() {
return 'api_profile_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
}
function normalizeApiProfiles(profiles, fallback) {
var source = Array.isArray(profiles) ? profiles : [];
var normalized = source.map(function(profile, index) {
if (!profile || typeof profile !== 'object') return null;
var models = Array.isArray(profile.modelList) ? profile.modelList.map(String).filter(Boolean) : [];
return {
id: String(profile.id || createApiProfileId()),
name: String(profile.name || ('配置 ' + (index + 1))),
apiUrl: String(profile.apiUrl || ''),
apiKey: String(profile.apiKey || ''),
model: String(profile.model || models[0] || ''),
modelList: models
};
}).filter(Boolean);
if (normalized.length) return normalized;
return [{
id: 'api_profile_default',
name: '默认配置',
apiUrl: String(fallback.apiUrl || ''),
apiKey: String(fallback.apiKey || ''),
model: String(fallback.model || ''),
modelList: Array.isArray(fallback.modelList) ? fallback.modelList.slice() : []
}];
}
function applyActiveApiProfile(config) {
var profiles = Array.isArray(config.apiProfiles) ? config.apiProfiles : [];
var active = profiles.find(function(profile) { return profile.id === config.activeApiProfileId; }) || profiles[0];
if (!active) return;
config.activeApiProfileId = active.id;
config.apiUrl = active.apiUrl;
config.apiKey = active.apiKey;
config.model = active.model;
config.modelList = active.modelList.slice();
}
function saveConfig(cfg) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
return true;
} catch(e) {
console.warn('[省流助手] 保存配置失败:', e.message);
return false;
}
}
function loadPositions() {
try {
const raw = localStorage.getItem(POSITION_KEY);
if (raw) {
const saved = JSON.parse(raw);
if (saved.layoutVersion === 1) return saved;
return { ...saved, layoutVersion: 1, floatBtn: null };
}
} catch(e) {}
return { layoutVersion: 1 };
}
function savePositions(pos) {
try {
localStorage.setItem(POSITION_KEY, JSON.stringify(pos));
} catch(e) {}
}
function hashString(str) {
let h = 2166136261;
const text = String(str || '');
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(36);
}
function loadSummaryCache() {
try {
const raw = localStorage.getItem(SUMMARY_CACHE_KEY);
if (raw) {
const cache = JSON.parse(raw);
if (cache && cache.entries && Array.isArray(cache.order)) return cache;
}
} catch(e) {
console.warn('[省流助手] 读取摘要缓存失败:', e.message);
}
return { version: 1, entries: {}, order: [] };
}
function saveSummaryCache(cache) {
try {
localStorage.setItem(SUMMARY_CACHE_KEY, JSON.stringify(cache));
} catch(e) {
console.warn('[省流助手] 保存摘要缓存失败,尝试清理旧缓存:', e.message);
try {
cache.order = cache.order.slice(-Math.ceil(SUMMARY_CACHE_MAX_ENTRIES / 2));
const keep = new Set(cache.order);
Object.keys(cache.entries).forEach(k => { if (!keep.has(k)) delete cache.entries[k]; });
localStorage.setItem(SUMMARY_CACHE_KEY, JSON.stringify(cache));
} catch(e2) {
console.warn('[省流助手] 二次保存摘要缓存失败:', e2.message);
}
}
}
function pruneSummaryCache(cache) {
let totalChars = cache.order.reduce((sum, key) => sum + ((cache.entries[key]?.summary || '').length), 0);
while (cache.order.length > SUMMARY_CACHE_MAX_ENTRIES || totalChars > SUMMARY_CACHE_MAX_CHARS) {
const oldKey = cache.order.shift();
if (!oldKey) break;
totalChars -= (cache.entries[oldKey]?.summary || '').length;
delete cache.entries[oldKey];
}
return cache;
}
function buildSummaryCacheKey(videoInfo, model, presetId, promptText, transcript) {
const bvid = videoInfo?.bvid || location.pathname;
return [
'v2',
bvid,
String(videoInfo?.cid || videoInfo?.part || ''),
model || '',
presetId || '',
hashString(promptText || ''),
hashString(transcript || '')
].join('::');
}
function getCachedSummary(cacheKey) {
const cache = loadSummaryCache();
const item = cache.entries[cacheKey];
if (!item || !item.summary) return null;
cache.order = cache.order.filter(k => k !== cacheKey);
cache.order.push(cacheKey);
item.lastUsedAt = Date.now();
saveSummaryCache(pruneSummaryCache(cache));
return item.summary;
}
function setCachedSummary(cacheKey, payload) {
if (!cacheKey || !payload || !payload.summary) return;
const cache = loadSummaryCache();
cache.entries[cacheKey] = {
summary: payload.summary,
model: payload.model || '',
presetId: payload.presetId || '',
title: payload.title || '',
createdAt: Date.now(),
lastUsedAt: Date.now()
};
cache.order = cache.order.filter(k => k !== cacheKey);
cache.order.push(cacheKey);
saveSummaryCache(pruneSummaryCache(cache));
}
function clearSummaryCache() {
try {
localStorage.removeItem(SUMMARY_CACHE_KEY);
} catch(e) {}
}
function getSummaryCacheStats() {
const cache = loadSummaryCache();
const count = cache.order.length;
const chars = cache.order.reduce((sum, key) => sum + ((cache.entries[key]?.summary || '').length), 0);
return { count, chars };
}
// ==================== API 配置检测 ====================
function isApiConfigured() {
var url = normalizeApiUrlInput(CONFIG.apiUrl);
var key = String(CONFIG.apiKey || '').trim();
var defaultUrl = normalizeApiUrlInput(DEFAULT_CONFIG.apiUrl);
var defaultKey = String(DEFAULT_CONFIG.apiKey || '').trim();
if (!url || !key) {
return { configured: false, reason: 'API URL 或 API Key 未填写' };
}
if (url === defaultUrl) {
return { configured: false, reason: 'API URL 仍是默认占位符,请填写真实的 API 地址' };
}
if (key === defaultKey) {
return { configured: false, reason: 'API Key 仍是默认占位符,请填写真实的 API Key' };
}
return { configured: true, reason: '' };
}
/**
* 构建完整的"提示词+视频信息+字幕"文本,供用户一键复制到其他AI app
*/
function buildPromptWithTranscript(videoInfo, transcript, subtitleBody) {
var activePresetId = getCurrentPresetId();
var activePreset = (CONFIG.promptPresets || []).find(function(p) { return p.id === activePresetId; });
var activePrompt = (activePreset && activePreset.prompt) || CONFIG.promptText || PROMPT_TEXT;
var videoDesc = limitText(videoInfo.desc || '', 1500);
var pageUrl = window.location.href;
var aiTranscript = buildAiTranscript(subtitleBody, transcript);
var subtitleTitle = subtitleBody && subtitleBody.length > 0
? '===== 📄 字幕内容(带时间轴) ====='
: '===== 📄 字幕内容 =====';
var parts = [];
parts.push('===== 📝 AI 提示词 =====');
parts.push(activePrompt);
parts.push('');
parts.push('===== 📺 视频信息 =====');
parts.push('视频URL: ' + pageUrl);
parts.push('视频标题: ' + (videoInfo.title || ''));
parts.push('UP主: ' + (videoInfo.upName || ''));
if (videoDesc) {
parts.push('视频简介: ' + videoDesc);
}
parts.push('');
parts.push(subtitleTitle);
if (subtitleBody && subtitleBody.length > 0) {
parts.push('说明:下方先给出字幕覆盖范围总览,再给出每个片段的 [开始-结束] 时间范围。回答涉及具体片段时,请尽量带上对应时间范围。');
}
parts.push(aiTranscript);
parts.push('');
parts.push('===== 💡 使用说明 =====');
parts.push('请将以上全部内容复制粘贴到任意 AI 对话(如 ChatGPT、DeepSeek、Kimi 等),即可生成视频摘要。');
return parts.join('\n');
}
/**
* 显示 API 未配置时的兜底 UI:提供下载字幕和一键复制提示词+字幕的功能
*/
function showApiNotConfiguredFallback(contentDiv, videoInfo, reason) {
var apiCheck = reason || isApiConfigured().reason;
var fallbackHtml =
'
' +
'
⚠️ API 未配置或配置错误
' +
'
' +
escapeHtml(apiCheck) + '
' +
'但别担心!字幕已成功获取,你仍可以:
' +
'① 一键复制下方内容,粘贴到任意 AI 对话框生成摘要
' +
'② 直接下载字幕文件留作备份' +
'
' +
'
' +
'' +
'
' +
'
💡 复制后打开 ChatGPT / DeepSeek / Kimi 等任意 AI,粘贴发送即可获得视频摘要
' +
'
' +
'';
var resultContainer = contentDiv.querySelector('.tabbit-result');
if (resultContainer) {
resultContainer.innerHTML = fallbackHtml;
}
contentDiv._tabbitResultActionContext = {
resultType: 'fallback', contentDiv: contentDiv, videoInfo: videoInfo,
summaryFallback: '', hasText: false, hasImage: false,
hasTimelineSubtitle: hasStructuredSubtitleData(rawSubtitleBody) && !!buildSrtContent(rawSubtitleBody),
modelLabel: '📌 v4.0 兜底模式'
};
refreshResultActionButtons(contentDiv);
// 绑定一键复制按钮
var copyBtn = contentDiv.querySelector('#tabbit-copy-prompt-transcript');
if (copyBtn) {
copyBtn.addEventListener('click', function() {
var promptText = buildPromptWithTranscript(videoInfo, rawTranscript, rawSubtitleBody);
copyToClipboard(promptText).then(function() {
copyBtn.textContent = '✅ 已复制!去 AI 粘贴吧';
copyBtn.style.background = 'linear-gradient(135deg,#43a047 0%,#2e7d32 100%)';
setTimeout(function() {
copyBtn.textContent = '📋 一键复制 提示词+字幕';
copyBtn.style.background = 'linear-gradient(135deg,#667eea 0%,#764ba2 100%)';
}, 3000);
});
});
}
// 绑定去设置按钮
var settingsBtn = contentDiv.querySelector('#tabbit-open-settings-fallback');
if (settingsBtn) {
settingsBtn.addEventListener('click', function() {
var panel = contentDiv.closest('#tabbit-ai-summary-panel');
if (panel) openSettingsPanel(panel);
});
}
// 启用评论区按钮和模型芯片
var panel = contentDiv.closest('#tabbit-ai-summary-panel');
if (panel) {
panel.querySelectorAll('.tabbit-model-chip').forEach(function(c) { c.classList.remove('disabled'); });
panel.querySelectorAll('.tabbit-preset-chip').forEach(function(c) { c.classList.remove('disabled'); });
var commentBtn = contentDiv.querySelector('#tabbit-comment-btn');
if (commentBtn) commentBtn.disabled = false;
var danmakuBtn = contentDiv.querySelector('#tabbit-danmaku-btn');
if (danmakuBtn) danmakuBtn.disabled = false;
var fullBtn = contentDiv.querySelector('#tabbit-full-btn');
if (fullBtn) fullBtn.disabled = false;
}
}
let CONFIG = loadConfig();
let currentPresetId = CONFIG.activePresetId || 'preset_default';
let POSITIONS = loadPositions();
const INIT_DELAY_MS = 2000;
const MAX_CONVERSATION_HISTORY = 21;
const IMAGE_GEN_SUMMARY_MAX_LEN = 5000;
const BILI_API_TIMEOUT_MS = 12000;
const BILI_SUBTITLE_TIMEOUT_MS = 15000;
// 自动解析优先等待播放器实际发出的字幕响应;未捕获到再走原字幕接口。
// 控件出现和网络响应分开等待:播放器已经就绪时不人为停顿;慢页面仍给响应留出余量。
const SUBTITLE_CAPTURE_CONTROL_TIMEOUT_MS = 3500;
const SUBTITLE_CAPTURE_WAIT_TIMEOUT_MS = 5000;
const AUX_REQUEST_TIMEOUT_MS = 30000;
// 🆕 流式渲染节流间隔(ms),控制 UI 重绘频率
const STREAM_RENDER_THROTTLE = 80;
// 借鉴 bpi-rs 的语义错误分类;只保存诊断字段,不附带 Cookie 或原始响应。
// https://github.com/Yuelioi/bpi-rs/blob/main/src/err/error.rs
class BiliApiError extends Error {
constructor(message, kind, code, retryable) {
super(message);
this.name = 'BiliApiError';
this.kind = kind;
this.code = code;
this.retryable = retryable === true;
}
}
class BiliRiskControlError extends BiliApiError {
constructor(message, code) {
super(message, 'risk_control', code, false);
this.name = 'BiliRiskControlError';
}
}
function makeBiliError(code, isHttp) {
const label = (isHttp ? 'HTTP ' : 'code=') + code;
if ((isHttp && code === 401) || (!isHttp && [-101, -401, 800501007].includes(code))) {
return new BiliApiError('需要登录或登录已失效(' + label + '),请登录后重试', 'login', code, false);
}
if (!isHttp && [-106, -650].includes(code)) {
return new BiliApiError('当前账号缺少会员权限(' + label + ')', 'permission', code, false);
}
if ((isHttp && code === 412) || (!isHttp && [-352, -412].includes(code))) {
return new BiliRiskControlError('B站风控拦截(' + label + '),已停止自动重试,请稍后再试', code);
}
if (isHttp && code === 429) {
return new BiliApiError('B站请求过于频繁(HTTP 429),已停止自动重试,请稍后再试', 'rate_limit', code, false);
}
if ((isHttp && code === 403) || (!isHttp && [-403, -4].includes(code))) {
return new BiliApiError('B站拒绝访问(' + label + '),请检查账号权限', 'permission', code, false);
}
return new BiliApiError('B站接口请求失败(' + label + ')', isHttp ? 'http' : 'business', code,
isHttp && (code === 408 || code >= 500));
}
function checkBiliHttp(response) {
if (!response.ok) throw makeBiliError(response.status, true);
}
async function readBiliJson(response, signal) {
checkBiliHttp(response);
throwIfAborted(signal);
let data;
try {
data = await response.json();
} catch (err) {
if (isAbortError(err)) throw err;
if (err.name !== 'SyntaxError') {
throw new BiliApiError('读取B站响应失败,请稍后重试', 'network', null, true);
}
throw new BiliApiError('B站响应不是有效 JSON,请稍后重试', 'decode', null, false);
}
throwIfAborted(signal);
return data;
}
async function readBiliPayload(response, signal) {
const envelope = await readBiliJson(response, signal);
if (!envelope || typeof envelope.code !== 'number') {
throw new BiliApiError('B站响应缺少业务状态码', 'decode', null, false);
}
if (envelope.code !== 0) throw makeBiliError(envelope.code, false);
if (!envelope.data || typeof envelope.data !== 'object' || Array.isArray(envelope.data)) {
throw new BiliApiError('B站响应缺少有效数据', 'decode', null, false);
}
return envelope.data;
}
const SAFE_FETCH_HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Origin': 'https://www.bilibili.com'
};
const COMMENT_CONFIG = {
pageSize: 20,
maxPages: 8,
sortType: 2,
includeReplies: true,
commentLimit: 188,
minDelay: 1800,
maxDelay: 3800,
maxRetries: 1,
retryBaseDelay: 8000
};
const DANMAKU_CONFIG = {
maxDanmaku: 3000,
segmentSize: 2000,
maxSegments: 5,
minDelay: 1200,
maxDelay: 2500,
maxRetries: 2,
retryBaseDelay: 5000
};
let rawMarkdownResult = '';
let rawTranscript = '';
let rawSubtitleBody = [];
let currentVideoInfo = null;
let currentModel = CONFIG.model;
let conversationHistory = [];
let commentConversationHistory = [];
let isCommentSummarizing = false;
let isDanmakuAnalyzing = false;
let hasParsed = false;
let lastRouteKey = '';
let routeRestartTimer = null;
let routeGeneration = 0;
let summaryNoticeTimer = null;
let panelOutsidePointerHandler = null;
// 🆕 当前正在进行的 AI 任务的 AbortController(用于打断流式输出)
let currentAbortController = null;
let currentSubtitleManualFallback = null;
let subtitleCaptureInstalled = false;
let subtitleCaptureEntries = [];
let subtitleListCaptureEntries = [];
let subtitleCaptureWaiters = [];
let isSubtitleCaptureInProgress = false;
function getSummaryMaxTokens() {
const n = parseInt(CONFIG.summaryMaxTokens, 10);
return isNaN(n) ? DEFAULT_CONFIG.summaryMaxTokens : Math.max(500, Math.min(30000, n));
}
function buildSummaryStreamOptions(signal, extraOptions) {
const opts = Object.assign({
maxTokens: getSummaryMaxTokens(),
errorMessageOverride: SUMMARY_LENGTH_ERROR_MESSAGE
}, extraOptions || {});
if (signal) opts.signal = signal;
return opts;
}
// ==================== 视频信息获取 ====================
function cleanVideoDescription(text) {
return String(text || '')
.replace(/\r/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function limitText(text, maxLen) {
const clean = cleanVideoDescription(text);
if (!clean || clean.length <= maxLen) return clean;
return clean.slice(0, maxLen) + '\n...(简介过长,已截断)';
}
function normalizeImageSizeInput(raw) {
const value = String(raw || '').trim().replace(/[×**]/g, 'x').toLowerCase();
if (!value) return '1024x1024';
if (value === 'auto') return value;
if (!/^\d{2,5}x\d{2,5}$/.test(value)) return '';
return value;
}
function normalizeApiUrlInput(apiUrl) {
return String(apiUrl || '').trim().replace(/\/+$/, '');
}
function escapeRegExp(text) {
return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildApiUrl(apiUrl, endpointPath) {
const endpoint = String(endpointPath || '').replace(/^\/+/, '');
let url = normalizeApiUrlInput(apiUrl);
if (!url || !endpoint) return url;
const exactEndpointReg = new RegExp('/' + escapeRegExp(endpoint) + '$', 'i');
if (exactEndpointReg.test(url)) return url;
const versionMatch = url.match(/^(.*\/v\d+)(?:\/.*)?$/i);
if (versionMatch) {
return versionMatch[1] + '/' + endpoint;
}
if (/\/(?:chat\/completions|completions|images\/generations|images\/edits|responses|models)$/i.test(url)) {
return url.replace(/\/(?:chat\/completions|completions|images\/generations|images\/edits|responses|models)$/i, '/' + endpoint);
}
return url + '/' + endpoint;
}
function buildChatCompletionsUrl(apiUrl) {
return buildApiUrl(apiUrl, 'chat/completions');
}
async function fetchWithTimeout(url, options, timeoutMs) {
options = options || {};
timeoutMs = timeoutMs || BILI_API_TIMEOUT_MS;
const externalSignal = options.signal;
const controller = new AbortController();
throwIfAborted(externalSignal);
let rejectStopped;
const stopped = new Promise(function(_, reject) { rejectStopped = reject; });
const onExternalAbort = function() {
const err = new Error('用户已打断');
err.name = 'AbortError';
rejectStopped(err);
try { controller.abort(); } catch(e) {}
};
const timer = setTimeout(function() {
rejectStopped(new BiliApiError('请求超时(' + Math.round(timeoutMs / 1000) + '秒),请稍后重试', 'network', null, true));
try { controller.abort(); } catch(e) {}
}, timeoutMs);
if (externalSignal) {
if (externalSignal.aborted) {
onExternalAbort();
} else {
externalSignal.addEventListener('abort', onExternalAbort);
}
}
try {
// 此函数仅用于 B站非流式 JSON/XML;完整读取正文后才清理超时与取消监听。
// AI 流式请求使用自己的请求链路,不经过这里。
return await Promise.race([
(async function() {
const response = await fetch(url, Object.assign({}, options, { signal: controller.signal }));
if (controller.signal.aborted) throw Object.assign(new Error('用户已打断'), { name: 'AbortError' });
if (!response.ok) {
// 错误响应不等正文,立即让调用方按状态码分类。
if (response.body) response.body.cancel().catch(function() {});
return response;
}
const body = await response.arrayBuffer();
throwIfAborted(controller.signal);
return new Response([204, 205, 304].includes(response.status) ? null : body, {
status: response.status, statusText: response.statusText, headers: response.headers
});
})(),
stopped
]);
} finally {
clearTimeout(timer);
if (externalSignal) externalSignal.removeEventListener('abort', onExternalAbort);
}
}
function buildImagePrompt(textContent) {
const summaryForImage = String(textContent || '').slice(0, IMAGE_GEN_SUMMARY_MAX_LEN).replace(/[#*_\[\]()]/g, '');
const promptTemplate = CONFIG.imageGenPromptText || IMAGE_GEN_PROMPT_TEXT;
return promptTemplate.includes('{summary}')
? promptTemplate.replace(/\{summary\}/g, summaryForImage)
: promptTemplate + '\n\n' + summaryForImage;
}
function getImageGenMode() {
return CONFIG.imageGenMode === 'flow' ? 'flow' : 'api';
}
function isFlowImageGenMode() {
return getImageGenMode() === 'flow';
}
function getFlowProjectUrl() {
return String(CONFIG.flowProjectUrl || DEFAULT_FLOW_PROJECT_URL || '').trim();
}
function getFlowReceiverHeartbeat() {
if (typeof GM_getValue !== 'function') return null;
return parseFlowPromptJob(GM_getValue(FLOW_HEARTBEAT_KEY, ''));
}
function normalizeFlowUrlForCompare(url) {
return String(url || '').replace(/[?#].*$/, '').replace(/\/+$/, '');
}
function isFlowReceiverAlive(flowUrl) {
const heartbeat = getFlowReceiverHeartbeat();
if (!heartbeat || !heartbeat.ts) return false;
const age = Date.now() - Number(heartbeat.ts);
if (!isFinite(age) || age < 0 || age > FLOW_HEARTBEAT_MAX_AGE_MS) return false;
const expected = normalizeFlowUrlForCompare(flowUrl);
const actual = normalizeFlowUrlForCompare(heartbeat.url);
return !expected || !actual || expected === actual;
}
function openFlowProjectInBackground(flowUrl) {
if (!flowUrl || CONFIG.enableFlowBackgroundOpen === false) return false;
try {
if (typeof GM_openInTab === 'function') {
const tab = GM_openInTab(flowUrl, {
active: false,
insert: true,
setParent: true
});
return !!tab;
}
} catch(e) {
console.warn('[省流助手-Flow] GM_openInTab 后台打开失败:', e.message);
}
try {
return !!window.open(flowUrl, '_blank', 'noopener,noreferrer');
} catch(e) {
return false;
}
}
function sendPromptToFlowProject(imagePrompt, videoInfo) {
if (!imagePrompt || !imagePrompt.trim()) {
throw new Error('没有可发送的生图提示词');
}
if (typeof GM_setValue !== 'function') {
throw new Error('当前脚本缺少 GM_setValue 授权,请重新安装脚本');
}
const job = {
id: 'flow_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8),
text: imagePrompt,
options: { id: '' },
source: 'bili-summary',
title: videoInfo && videoInfo.title ? videoInfo.title : '',
pageUrl: location.href,
createdAt: Date.now()
};
job.options.id = job.id;
GM_setValue(FLOW_PROMPT_JOB_KEY, JSON.stringify(job));
const flowUrl = getFlowProjectUrl();
let opened = false;
const receiverAlive = isFlowReceiverAlive(flowUrl);
if (flowUrl && !receiverAlive) {
opened = openFlowProjectInBackground(flowUrl);
}
return { job: job, opened: opened, flowUrl: flowUrl, receiverAlive: receiverAlive };
}
function renderFlowDispatchResult(contentDiv, dispatchResult) {
const imageSlot = contentDiv && (contentDiv.querySelector('.tabbit-image-slot') || contentDiv.querySelector('.tabbit-result'));
if (!imageSlot) return;
const flowUrl = dispatchResult && dispatchResult.flowUrl ? dispatchResult.flowUrl : getFlowProjectUrl();
imageSlot.innerHTML =
'' +
'
已发送到 Google Flow 生图
' +
'
本模式只发送提示词,不等待图片回传。图片由 Flow 页面脚本自动下载到本地。
' +
'
' + (dispatchResult && dispatchResult.receiverAlive ? '已检测到后台 Flow 接收页。' : (dispatchResult && dispatchResult.opened ? '未检测到接收页,已后台打开 Flow。' : '未检测到接收页,请确认 Flow 页面已打开。')) + '
' +
(flowUrl ? '
Flow: ' + escapeHtml(flowUrl) + '
' : '') +
'
';
}
async function triggerFlowImageGen(contentDiv, summaryText, videoInfo, btn) {
const originalText = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = '发送中...';
}
try {
const imagePrompt = buildImagePrompt(summaryText || '');
const dispatchResult = sendPromptToFlowProject(imagePrompt, videoInfo);
renderFlowDispatchResult(contentDiv, dispatchResult);
if (btn) {
btn.textContent = '已发送到 Flow';
setTimeout(function() {
btn.textContent = '发送到 Flow';
btn.disabled = false;
}, 1800);
}
return dispatchResult;
} catch (err) {
console.error('[省流助手-Flow] 发送失败:', err);
alert('发送到 Flow 失败: ' + err.message);
if (btn) {
btn.textContent = originalText || '发送到 Flow';
btn.disabled = false;
}
return null;
}
}
function addUniqueImageCandidate(candidates, kind, url) {
url = normalizeApiUrlInput(url);
if (!url) return;
const key = kind + '|' + url;
if (candidates.some(function(item) { return item.key === key; })) return;
candidates.push({ key: key, kind: kind, url: url });
}
function buildImageApiCandidates(apiUrl) {
const url = normalizeApiUrlInput(apiUrl);
const candidates = [];
if (!url) return candidates;
if (/\/images\/generations$/i.test(url)) addUniqueImageCandidate(candidates, 'images', url);
if (/\/responses$/i.test(url)) addUniqueImageCandidate(candidates, 'responses', url);
// 保持旧版行为:优先使用支持 size 字段的 Images API,避免 chat 生图忽略 1:1 尺寸。
addUniqueImageCandidate(candidates, 'images', buildApiUrl(url, 'images/generations'));
addUniqueImageCandidate(candidates, 'responses', buildApiUrl(url, 'responses'));
if (/\/chat\/completions$/i.test(url)) addUniqueImageCandidate(candidates, 'chat', url);
addUniqueImageCandidate(candidates, 'chat', buildApiUrl(url, 'chat/completions'));
return candidates;
}
function getImageRequestBodies(kind, model, imagePrompt) {
const size = CONFIG.imageGenSize || '1024x1024';
if (kind === 'images') {
const baseBody = { model: model, prompt: imagePrompt, n: 1, size: size };
return [
Object.assign({}, baseBody, { response_format: 'b64_json' }),
baseBody
];
}
if (kind === 'responses') {
const tool = { type: 'image_generation' };
if (size && size !== 'auto') tool.size = size;
return [
{ model: model, input: imagePrompt, tools: [tool] },
{ model: model, input: imagePrompt, tools: [{ type: 'image_generation' }] }
];
}
const sizeHint = size && size !== 'auto' ? '\n\n请严格按 ' + size + ' 画布尺寸生成,保持对应宽高比。' : '';
return [
{ model: model, messages: [{ role: 'user', content: imagePrompt + sizeHint }] },
{ model: model, messages: [{ role: 'user', content: imagePrompt + sizeHint }], modalities: ['text', 'image'] }
];
}
function normalizeExtractedImage(value) {
const text = String(value || '').trim();
if (!text) return '';
if (/^data:image\//i.test(text) || /^https?:\/\//i.test(text)) return text;
const compact = text.replace(/\s/g, '');
if (compact.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(compact)) {
return 'data:image/png;base64,' + compact;
}
return '';
}
function extractImageDataUrlFromResponse(data) {
if (!data) return '';
const seen = new Set();
function walk(node, key, parent) {
if (node == null) return '';
if (typeof node === 'string') {
if (/^(b64_json|base64|image_base64|result)$/i.test(key || '')) {
const parentType = String(parent && (parent.type || parent.kind || parent.object) || '');
if (!parentType || /image|generation/i.test(parentType)) {
const direct = normalizeExtractedImage(node);
if (direct) return direct;
}
}
if (/^(url|image_url|output_url)$/i.test(key || '')) {
const direct = normalizeExtractedImage(node);
if (direct) return direct;
}
const dataUrlMatch = node.match(/data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+/i);
if (dataUrlMatch) return normalizeExtractedImage(dataUrlMatch[0]);
const markdownImageMatch = node.match(/!\[[^\]]*]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+)\)/i);
if (markdownImageMatch) return normalizeExtractedImage(markdownImageMatch[1]);
return '';
}
if (typeof node !== 'object') return '';
if (seen.has(node)) return '';
seen.add(node);
if (node.image_url && typeof node.image_url === 'object') {
const directImageUrl = walk(node.image_url.url || node.image_url.data || node.image_url.b64_json, 'image_url', node.image_url);
if (directImageUrl) return directImageUrl;
}
if (Array.isArray(node)) {
for (const item of node) {
const found = walk(item, key, parent);
if (found) return found;
}
return '';
}
for (const prop of Object.keys(node)) {
const found = walk(node[prop], prop, node);
if (found) return found;
}
return '';
}
return walk(data, '', null);
}
async function requestImageFromCandidate(candidate, apiKey, model, imagePrompt, signal) {
const bodies = getImageRequestBodies(candidate.kind, model, imagePrompt);
let lastError = '';
for (const body of bodies) {
try {
const res = await gmRequest({
method: 'POST',
url: candidate.url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
},
data: JSON.stringify(body),
signal: signal
});
const text = String(res.responseText || '');
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (e) {}
if (res.status < 200 || res.status >= 300) {
lastError = 'HTTP ' + res.status + ': ' + (text || '').slice(0, 200);
continue;
}
const imageDataUrl = extractImageDataUrlFromResponse(data || text);
if (imageDataUrl) return imageDataUrl;
lastError = '响应里没有解析到图片数据';
} catch (err) {
if (isAbortError(err)) throw err;
lastError = err.message || String(err);
}
}
throw new Error(lastError || '生图请求失败');
}
async function generateImageByApi(apiUrl, apiKey, model, imagePrompt, signal) {
const candidates = buildImageApiCandidates(apiUrl);
let lastError = '';
for (const candidate of candidates) {
try {
console.log('[省流助手-生图] 尝试 ' + candidate.kind + ' 接口:', candidate.url);
const imageDataUrl = await requestImageFromCandidate(candidate, apiKey, model, imagePrompt, signal);
console.log('[省流助手-生图] ✅ ' + candidate.kind + ' 接口返回图片');
return imageDataUrl;
} catch (err) {
if (isAbortError(err)) throw err;
lastError = candidate.kind + ' ' + candidate.url + ' -> ' + (err.message || String(err));
console.warn('[省流助手-生图] ' + lastError);
}
}
throw new Error(lastError || '生图 API 未返回图片数据');
}
function pickDescriptionFromDom() {
const selectors = [
'.desc-info-text',
'.basic-desc-info',
'.video-desc-container .desc-info-text',
'.video-desc .desc-info-text',
'.video-desc .desc',
'#v_desc .info'
];
for (const selector of selectors) {
const el = document.querySelector(selector);
const text = cleanVideoDescription(el?.textContent || '');
if (text) return text;
}
return '';
}
function getVideoInfo() {
let cid = null, bvid = null, aid = null, title = '', upName = '', desc = '', duration = 0;
const urlParams = new URLSearchParams(window.location.search);
const routeBvid = location.pathname.match(/\/video\/(BV\w+)/)?.[1] || urlParams.get('bvid');
const requestedPage = urlParams.get('p');
let part = requestedPage === null ? 1 : Number(requestedPage);
const validPart = Number.isInteger(part) && part > 0;
try {
const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
const state = pageWindow.__INITIAL_STATE__ || window.__INITIAL_STATE__;
// SPA 切换时 INITIAL_STATE 可能仍属于上一条视频,不能拿它的 cid 发请求。
if (state?.videoData && (!routeBvid || state.videoData.bvid === routeBvid)) {
bvid = state.videoData.bvid;
aid = state.videoData.aid || state.aid;
const pages = Array.isArray(state.videoData.pages) ? state.videoData.pages : [];
const currentPage = requestedPage === null ? pages.find(page => String(page.cid) === String(state.videoData.cid)) : null;
const selectedPage = currentPage || (validPart ? pages.find((page, index) => Number(page.page || index + 1) === part) : null);
if (currentPage) part = Number(currentPage.page || pages.indexOf(currentPage) + 1);
cid = selectedPage?.cid || (validPart && part === 1 ? state.videoData.cid : null);
title = state.videoData.title || '';
if (selectedPage && pages.length > 1) title += ' - P' + part + ' ' + (selectedPage.part || '');
upName = state.videoData.owner?.name || '';
desc = cleanVideoDescription(state.videoData.desc || '');
if (!desc && Array.isArray(state.videoData.desc_v2)) {
desc = cleanVideoDescription(state.videoData.desc_v2.map(function(item) {
return item && item.raw_text ? item.raw_text : '';
}).filter(Boolean).join('\n'));
}
duration = selectedPage?.duration ?? state.videoData.duration ?? 0;
}
} catch(e) {
console.log('[省流助手] 无法从 __INITIAL_STATE__ 获取信息:', e.message);
}
if (routeBvid) bvid = routeBvid;
if (!bvid) {
const match = location.pathname.match(/\/video\/(BV\w+)/);
if (match) bvid = match[1];
}
if (!aid) {
const urlParams = new URLSearchParams(window.location.search);
aid = urlParams.get('aid');
}
if (!cid && requestedPage === null) {
const player = document.querySelector('iframe[src*="cid="]');
if (player) {
const match = player.src.match(/cid=(\d+)/);
if (match) cid = match[1];
}
}
if (!cid && requestedPage === null) {
const urlParams = new URLSearchParams(window.location.search);
cid = urlParams.get('cid');
}
if (!title) {
title = document.querySelector('h1.video-title, h1.title, .video-title, [data-title]')?.textContent?.trim() || '';
}
if (!upName) {
const upElement = document.querySelector('.up-name, .username, a[href*="space.bilibili.com"]');
if (upElement) {
upName = upElement.getAttribute('title') || upElement.textContent?.trim() || '';
}
}
if (!desc) {
desc = pickDescriptionFromDom();
}
return { bvid, cid, aid, title, upName, desc, duration, part };
}
// ==================== 字幕获取部分 ====================
async function fetchSubtitlesResult(cid, bvid, signal) {
if (!cid || !bvid) {
console.warn('[省流助手] 缺少 cid 或 bvid,跳过字幕接口请求:', { cid: cid, bvid: bvid });
return { subtitles: [], status: 'error', reason: '缺少当前分 P 的 cid 或 bvid,请等待播放器就绪后重试', retryable: false };
}
throwIfAborted(signal);
let lastError = null;
// 优先已有的普通接口,避免先向 WBI 接口发送未签名请求而触发风控。
for (const endpoint of ['/x/player/v2', '/x/player/wbi/v2']) {
throwIfAborted(signal);
try {
const url = 'https://api.bilibili.com' + endpoint + '?cid=' + encodeURIComponent(cid) + '&bvid=' + encodeURIComponent(bvid);
const res = await fetchWithTimeout(url, { credentials: 'include', signal: signal }, BILI_API_TIMEOUT_MS);
const data = await readBiliPayload(res, signal);
const subtitles = readSubtitleList(data.subtitle);
if (!Array.isArray(subtitles) || subtitles.some(s => !s || typeof s.subtitle_url !== 'string' || !s.subtitle_url.trim())) {
throw new BiliApiError('字幕列表格式异常,不能判定为无字幕', 'decode', null, false);
}
if (subtitles.length) return { subtitles, status: 'available', reason: '' };
// 一次明确的成功空列表足够结束本轮;主流程仍会二次确认。
return { subtitles: [], status: 'empty', reason: '' };
} catch (err) {
if (isAbortError(err)) throw err;
lastError = err;
if (['login', 'permission', 'risk_control', 'rate_limit'].includes(err.kind)) break;
}
}
return { subtitles: [], status: 'error', reason: lastError?.message || '字幕接口请求失败',
retryable: lastError?.retryable !== false, error: lastError };
}
async function fetchSubtitles(cid, bvid, signal) {
const result = await fetchSubtitlesResult(cid, bvid, signal);
if (result.status === 'error') throw result.error || new BiliApiError(result.reason, 'data', null, false);
return result.subtitles;
}
function fetchSubtitleJsonWithGM(url, signal, timeoutMs) {
timeoutMs = timeoutMs || BILI_SUBTITLE_TIMEOUT_MS;
return new Promise(function(resolve, reject) {
throwIfAborted(signal);
let request;
let settled = false;
const finish = function(error, data) {
if (settled) return;
settled = true;
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', onAbort);
if (error) reject(error);
else resolve(data);
};
const stop = function(error) {
finish(error);
if (request) { try { request.abort(); } catch(e) {} }
};
const onAbort = function() {
stop(Object.assign(new Error('用户已打断'), { name: 'AbortError' }));
};
// 外层计时器保证扩展没有回调时也能退出。
const timer = setTimeout(function() {
stop(new BiliApiError('字幕文件下载超时,请稍后重试', 'network', null, true));
}, timeoutMs);
if (signal) signal.addEventListener('abort', onAbort, { once: true });
try {
request = GM_xmlhttpRequest({
method: 'GET', url: url, timeout: timeoutMs,
onload: function(response) {
if (response.status < 200 || response.status >= 300) {
finish(makeBiliError(response.status, true));
return;
}
try { finish(null, JSON.parse(response.responseText.replace(/^\uFEFF/, ''))); }
catch(e) { finish(new BiliApiError('字幕文件不是有效 JSON', 'decode', null, false)); }
},
onerror: function() { finish(new BiliApiError('字幕文件下载失败', 'network', null, true)); },
ontimeout: function() { stop(new BiliApiError('字幕文件下载超时,请稍后重试', 'network', null, true)); },
onabort: onAbort
});
} catch(e) { finish(e); }
});
}
async function fetchSubtitleContent(subtitleUrl, signal, timeoutMs) {
if (!subtitleUrl) throw new BiliApiError('字幕地址缺失', 'data', null, false);
throwIfAborted(signal);
const url = subtitleUrl.startsWith('http') ? subtitleUrl : 'https:' + subtitleUrl;
const data = await fetchSubtitleJsonWithGM(url, signal, timeoutMs);
throwIfAborted(signal);
if (data && typeof data.code === 'number' && data.code !== 0) throw makeBiliError(data.code, false);
if (!Array.isArray(data) && !Array.isArray(data?.body)) {
throw new BiliApiError('字幕内容格式异常', 'decode', null, false);
}
const segments = normalizeSubtitleSegments(data);
if (!segments.length) throw new BiliApiError('字幕文件没有有效文本,请稍后重试', 'data', null, false);
return segments;
}
function parseSubtitleJsonText(text) {
if (!text || typeof text !== 'string') return null;
var cleaned = text.replace(/^\uFEFF/, '');
try {
return JSON.parse(cleaned);
} catch(e) {}
var match = cleaned.match(/\{[\s\S]*\}$/);
if (match) {
try {
return JSON.parse(match[0]);
} catch(e) {}
}
return null;
}
function findSubtitleSegmentsDeep(node, seen) {
if (!node || typeof node !== 'object') return [];
seen = seen || new Set();
if (seen.has(node)) return [];
seen.add(node);
var direct = normalizeSubtitleSegments(node);
if (direct.length) return direct;
if (Array.isArray(node)) {
for (var i = 0; i < node.length; i++) {
var fromItem = findSubtitleSegmentsDeep(node[i], seen);
if (fromItem.length) return fromItem;
}
return [];
}
for (var key of Object.keys(node)) {
var found = findSubtitleSegmentsDeep(node[key], seen);
if (found.length) return found;
}
return [];
}
function getSubtitleCaptureContext() {
const info = getVideoInfo();
return { route: window.location.href, bvid: info.bvid, cid: String(info.cid || ''), part: info.part };
}
function isCurrentSubtitleCapture(context) {
if (!context) return false;
const current = getSubtitleCaptureContext();
return !!context.bvid && new URL(context.route).pathname === new URL(current.route).pathname && context.bvid === current.bvid &&
context.cid === current.cid && context.part === current.part;
}
function readSubtitleList(subtitle) {
return subtitle?.list ?? subtitle?.subtitles;
}
function isPlayerSubtitleListUrl(url) {
try {
const parsed = new URL(url, location.href);
return parsed.hostname === 'api.bilibili.com' &&
['/x/player/v2', '/x/player/wbi/v2'].includes(parsed.pathname);
} catch(e) { return false; }
}
function acceptCapturedSubtitleResponse(url, text, json, context) {
if (!isPlayerSubtitleListUrl(url)) {
acceptCapturedSubtitle(url, text, json, context);
return;
}
if (!isCurrentSubtitleCapture(context)) return;
const request = new URL(url, location.href);
// 同页预加载其他分 P 的请求也会经过监听,必须核对请求参数。
if (!context.cid || request.searchParams.get('cid') !== context.cid) return;
const bvid = request.searchParams.get('bvid');
if (bvid && bvid !== context.bvid) return;
const payload = json || parseSubtitleJsonText(text);
if (!payload || payload.code !== 0) return;
const subtitles = readSubtitleList(payload.data?.subtitle);
if (!Array.isArray(subtitles) || !subtitles.length ||
subtitles.some(s => !s || typeof s.subtitle_url !== 'string' || !s.subtitle_url.trim())) return;
subtitleListCaptureEntries.push({ subtitles, context, createdAt: Date.now() });
if (subtitleListCaptureEntries.length > 5) subtitleListCaptureEntries.shift();
}
async function waitForSubtitleVideoReady(expectedBvid, signal) {
const deadline = Date.now() + 4000;
let previous = '';
while (Date.now() < deadline) {
throwIfAborted(signal);
const info = getVideoInfo();
if (info.bvid !== expectedBvid) throw new Error('获取字幕时视频已切换');
const identity = info.cid ? info.bvid + ':' + info.cid + ':' + info.part : '';
if (identity && identity === previous) return info;
previous = identity;
await randomDelay(200, 200, signal);
}
throw new Error('播放器视频信息尚未就绪,请稍后重试');
}
async function fetchCapturedListContent(entry, signal) {
let subtitles = entry.subtitles;
const startedAt = Date.now();
for (let attempt = 1; attempt <= 2; attempt++) {
throwIfAborted(signal);
if (!isCurrentSubtitleCapture(entry.context)) throw new Error('字幕列表不属于当前视频');
const target = subtitles.find(s => s.lan === 'zh-CN' || s.lan === 'ai-zh') || subtitles[0];
console.log('[省流助手-字幕] 正文请求开始', { attempt, bvid: entry.context.bvid,
cid: entry.context.cid, language: target.lan, cacheAgeMs: Date.now() - entry.createdAt });
try {
const content = await fetchSubtitleContent(target.subtitle_url, signal, 8000);
if (!isCurrentSubtitleCapture(entry.context)) throw new Error('字幕下载期间视频已切换');
console.log('[省流助手-字幕] 正文读取成功', { attempt, segments: content.length, elapsedMs: Date.now() - startedAt });
return content;
} catch(error) {
console.warn('[省流助手-字幕] 正文读取失败', { attempt, kind: error.kind || error.name,
reason: error.message, elapsedMs: Date.now() - startedAt });
if (isAbortError(error) || attempt === 2) throw error;
if (!isCurrentSubtitleCapture(entry.context)) throw error;
// 不复用失败地址:重新读取当前视频的字幕列表,最多重试一次。
const fresh = await fetchSubtitlesResult(entry.context.cid, entry.context.bvid, signal);
console.log('[省流助手-字幕] 刷新列表结果', { status: fresh.status, reason: fresh.reason });
if (fresh.status !== 'available') throw fresh.error || new Error(fresh.reason || '刷新后未找到可用字幕');
subtitles = fresh.subtitles;
}
}
}
function getCurrentCapturedSubtitleList() {
return subtitleListCaptureEntries.slice().reverse().find(entry =>
isCurrentSubtitleCapture(entry.context) && Date.now() - entry.createdAt < 60000);
}
function acceptCapturedSubtitle(url, text, json, context) {
// 在请求发出时绑定当前视频;切换后到达的旧响应不能污染新视频。
if (!isCurrentSubtitleCapture(context)) return;
var payload = json || parseSubtitleJsonText(text);
var segments = findSubtitleSegmentsDeep(payload);
if (!segments.length) return;
var transcript = formatTranscript(segments);
if (!transcript.trim()) return;
var entry = {
url: url || '',
segments: segments,
transcript: transcript,
context: context,
createdAt: Date.now()
};
subtitleCaptureEntries.push(entry);
if (subtitleCaptureEntries.length > 5) subtitleCaptureEntries.shift();
console.log('[省流助手-捕获模式] 已捕获字幕,段数:', segments.length);
var waiters = subtitleCaptureWaiters.slice();
subtitleCaptureWaiters = [];
waiters.forEach(function(waiter) {
waiter.resolve(entry);
});
}
function installSubtitleCaptureInterceptor() {
if (subtitleCaptureInstalled) return;
subtitleCaptureInstalled = true;
var urlRe = /subtitle/i;
// 带 @grant 时播放器运行在页面 realm;只改沙箱 window 会漏掉播放器请求。
var captureWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
var originalFetch = captureWindow.fetch;
if (typeof originalFetch === 'function') {
captureWindow.fetch = async function() {
var req = arguments[0];
var url = typeof req === 'string' ? req : (req && req.url) || '';
var context = (urlRe.test(url) || isPlayerSubtitleListUrl(url)) ? getSubtitleCaptureContext() : null;
var response = await originalFetch.apply(this, arguments);
try {
if (context && response && response.ok && typeof response.clone === 'function') {
response.clone().text().then(function(text) {
acceptCapturedSubtitleResponse(url, text, null, context);
}).catch(function(e) {
console.log('[省流助手-捕获模式] fetch 响应读取失败:', e.message);
});
}
} catch(e) {
console.log('[省流助手-捕获模式] fetch 捕获失败:', e.message);
}
return response;
};
}
var XHR = captureWindow.XMLHttpRequest;
if (XHR && XHR.prototype) {
var originalOpen = XHR.prototype.open;
var originalSend = XHR.prototype.send;
XHR.prototype.open = function(method, url) {
this._tabbitSubtitleCaptureUrl = url || '';
return originalOpen.apply(this, arguments);
};
XHR.prototype.send = function() {
var xhr = this;
var url = xhr._tabbitSubtitleCaptureUrl || '';
if (urlRe.test(url) || isPlayerSubtitleListUrl(url)) {
var context = getSubtitleCaptureContext();
xhr.addEventListener('readystatechange', function() {
if (xhr.readyState !== 4 || xhr.status < 200 || xhr.status >= 300) return;
try {
var json = xhr.responseType === 'json' ? xhr.response : null;
var text = !xhr.responseType || xhr.responseType === 'text' ? xhr.responseText : '';
acceptCapturedSubtitleResponse(url, text, json, context);
} catch(e) {
console.log('[省流助手-捕获模式] XHR 捕获失败:', e.message);
}
});
}
return originalSend.apply(this, arguments);
};
}
console.log('[省流助手-捕获模式] 字幕请求监听已启用');
}
function waitForCapturedSubtitle(timeoutMs, signal, minCreatedAt) {
installSubtitleCaptureInterceptor();
var latest = subtitleCaptureEntries[subtitleCaptureEntries.length - 1];
if (latest && isCurrentSubtitleCapture(latest.context) && latest.createdAt >= (minCreatedAt || 0) && Date.now() - latest.createdAt < 60000) {
return Promise.resolve(latest);
}
return new Promise(function(resolve, reject) {
var done = false;
var waiter = {
resolve: function(entry) {
if (entry && isCurrentSubtitleCapture(entry.context) && entry.createdAt >= (minCreatedAt || 0)) finish(entry);
},
reject: fail
};
var timer = setTimeout(function() {
fail(new Error('等待超时:请先点击播放器右下角的字幕按钮,并选择可用字幕'));
}, timeoutMs || 20000);
function cleanup() {
clearTimeout(timer);
subtitleCaptureWaiters = subtitleCaptureWaiters.filter(function(item) {
return item !== waiter;
});
if (signal) signal.removeEventListener('abort', onAbort);
}
function finish(entry) {
if (done) return;
done = true;
cleanup();
resolve(entry);
}
function fail(err) {
if (done) return;
done = true;
cleanup();
reject(err);
}
function onAbort() {
var abortErr = new Error('用户已打断');
abortErr.name = 'AbortError';
fail(abortErr);
}
subtitleCaptureWaiters.push(waiter);
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort);
}
});
}
if (location.hostname === 'www.bilibili.com') {
installSubtitleCaptureInterceptor();
}
function normalizeSubtitleSegments(source) {
var list = Array.isArray(source) ? source : (source && Array.isArray(source.body) ? source.body : []);
var segments = [];
for (var i = 0; i < list.length; i++) {
var seg = list[i];
if (!seg || typeof seg !== 'object') continue;
var from = Number(seg.from != null ? seg.from : (seg.start_time != null ? seg.start_time : (seg.start != null ? seg.start : seg.st)));
if (!isFinite(from)) from = 0;
var to = Number(seg.to != null ? seg.to : (seg.end_time != null ? seg.end_time : (seg.end != null ? seg.end : null)));
if (!isFinite(to)) {
var duration = Number(seg.d);
to = from + (isFinite(duration) && duration > 0 ? duration : 2);
}
if (to < from) to = from;
var text = (seg.content || seg.text || seg.sentence || seg.t || seg.c || '').toString().replace(/\s+/g, ' ').trim();
if (!text) continue;
segments.push({ from: from, to: to, text: text });
}
segments.sort(function(a, b) {
if (a.from !== b.from) return a.from - b.from;
return a.to - b.to;
});
return segments;
}
function formatTranscript(subtitles) {
if (!subtitles || subtitles.length === 0) return '';
return subtitles.map(function(item) { return item.content || item.text || item.sentence || item.t || item.c || ''; })
.filter(text => text.trim())
.join('\n');
}
function formatTimelineRange(from, to) {
var startLabel = formatTimelineTimestamp(from);
var endLabel = formatTimelineTimestamp(to);
if (!isFinite(Number(to)) || Number(to) <= Number(from) || startLabel === endLabel) {
return startLabel;
}
return startLabel + '-' + endLabel;
}
function formatTimelineTimestamp(sec) {
var totalSeconds = Math.max(0, Math.floor(Number(sec) || 0));
var hours = Math.floor(totalSeconds / 3600);
var minutes = Math.floor((totalSeconds % 3600) / 60);
var seconds = totalSeconds % 60;
if (hours > 0) {
return hours + ':' + String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
}
return minutes + ':' + String(seconds).padStart(2, '0');
}
function buildSubtitleBlocks(subtitles, options) {
var segments = normalizeSubtitleSegments(subtitles);
if (!segments.length) return [];
options = options || {};
var pauseBreakSec = Number(options.pauseBreakSec);
if (!isFinite(pauseBreakSec) || pauseBreakSec < 0) pauseBreakSec = 1.2;
var maxChars = Number(options.maxChars);
if (!isFinite(maxChars) || maxChars < 20) maxChars = 110;
var blocks = [];
var current = {
from: segments[0].from,
to: segments[0].to,
text: segments[0].text
};
for (var i = 1; i < segments.length; i++) {
var seg = segments[i];
var gap = seg.from - current.to;
var mergedText = current.text + ' ' + seg.text;
if (gap <= pauseBreakSec && mergedText.length <= maxChars) {
current.to = Math.max(current.to, seg.to);
current.text = mergedText;
} else {
blocks.push(current);
current = {
from: seg.from,
to: seg.to,
text: seg.text
};
}
}
blocks.push(current);
return blocks;
}
function buildSubtitleTimelineMeta(subtitles) {
var segments = normalizeSubtitleSegments(subtitles);
if (!segments.length) return null;
var first = segments[0];
var last = segments[segments.length - 1];
var start = Number(first.from) || 0;
var end = Number(last.to != null ? last.to : last.from) || start;
if (end < start) end = start;
return {
start: start,
end: end,
segmentCount: segments.length,
blockCount: buildSubtitleBlocks(segments).length
};
}
function formatTranscriptWithTimeline(subtitles) {
var blocks = buildSubtitleBlocks(subtitles);
if (!blocks.length) return '';
return blocks.map(function(block) {
return '[' + formatTimelineRange(block.from, block.to) + '] ' + block.text;
}).join('\n');
}
function buildAiTranscript(subtitleBody, fallbackTranscript) {
var timelineTranscript = formatTranscriptWithTimeline(subtitleBody);
if (timelineTranscript) {
var meta = buildSubtitleTimelineMeta(subtitleBody);
if (meta) {
return [
'【字幕时间范围总览】',
'字幕覆盖范围: [' + formatTimelineRange(meta.start, meta.end) + ']',
'字幕段数: ' + meta.segmentCount,
'整理后时间块: ' + meta.blockCount,
'',
'【按时间范围整理的字幕】',
timelineTranscript
].join('\n');
}
return timelineTranscript;
}
return fallbackTranscript || '';
}
function normalizeSubtitleSearchText(text) {
return String(text || '').toLowerCase().replace(/[^0-9a-z\u4e00-\u9fa5]+/g, '');
}
function extractSubtitleSearchTerms(text) {
var raw = String(text || '').toLowerCase();
var matches = raw.match(/[0-9a-z\u4e00-\u9fa5]{2,}/g) || [];
var terms = [];
var seen = {};
var compact = normalizeSubtitleSearchText(raw);
if (compact.length >= 2 && compact.length <= 48) {
seen[compact] = true;
terms.push(compact);
}
for (var i = 0; i < matches.length; i++) {
var term = normalizeSubtitleSearchText(matches[i]);
if (term.length < 2 || seen[term]) continue;
seen[term] = true;
terms.push(term);
}
return terms.slice(0, 12);
}
function buildSubtitleSearchBigrams(text) {
var compact = normalizeSubtitleSearchText(text);
var bigrams = [];
var seen = {};
for (var i = 0; i < compact.length - 1; i++) {
var gram = compact.slice(i, i + 2);
if (!seen[gram]) {
seen[gram] = true;
bigrams.push(gram);
}
}
return bigrams;
}
function scoreSubtitleSegmentForQuery(segmentText, compactQuery, terms, bigrams) {
var hay = normalizeSubtitleSearchText(segmentText);
if (!hay) return 0;
var score = 0;
if (compactQuery && compactQuery.length >= 2 && hay.indexOf(compactQuery) !== -1) {
score += 200 + Math.min(80, compactQuery.length * 2);
}
for (var i = 0; i < terms.length; i++) {
var term = terms[i];
if (hay.indexOf(term) !== -1) {
score += Math.min(60, term.length * 8);
}
}
for (var j = 0; j < bigrams.length; j++) {
if (hay.indexOf(bigrams[j]) !== -1) {
score += 3;
}
}
return score;
}
function buildSubtitleLocatorContext(question, subtitles) {
var segments = normalizeSubtitleSegments(subtitles);
if (!segments.length) return '';
var compactQuery = normalizeSubtitleSearchText(question);
var terms = extractSubtitleSearchTerms(question);
var bigrams = buildSubtitleSearchBigrams(question);
var meta = buildSubtitleTimelineMeta(segments);
var scored = [];
for (var i = 0; i < segments.length; i++) {
var score = scoreSubtitleSegmentForQuery(segments[i].text, compactQuery, terms, bigrams);
if (score > 0) {
scored.push({ idx: i, score: score });
}
}
if (!scored.length) return '';
scored.sort(function(a, b) {
if (b.score !== a.score) return b.score - a.score;
return a.idx - b.idx;
});
var selected = [];
for (var j = 0; j < scored.length && selected.length < 6; j++) {
var item = scored[j];
var tooClose = selected.some(function(existing) {
return Math.abs(existing.idx - item.idx) <= 1;
});
if (!tooClose) {
selected.push(item);
}
}
if (!selected.length) return '';
var windows = selected.map(function(item) {
return {
start: Math.max(0, item.idx - 1),
end: Math.min(segments.length - 1, item.idx + 1)
};
}).sort(function(a, b) {
return a.start - b.start;
});
var merged = [];
for (var k = 0; k < windows.length; k++) {
var current = windows[k];
var last = merged[merged.length - 1];
if (last && current.start <= last.end + 1) {
last.end = Math.max(last.end, current.end);
} else {
merged.push({ start: current.start, end: current.end });
}
}
var lines = [];
lines.push('【字幕定位参考】');
lines.push('以下是和当前问题最相关的原始字幕片段。回答定位问题时,只能引用这里已经出现的时间范围。');
if (meta) {
lines.push('当前字幕覆盖范围: [' + formatTimelineRange(meta.start, meta.end) + ']');
}
for (var m = 0; m < merged.length; m++) {
var windowItem = merged[m];
var startSeg = segments[windowItem.start];
var endSeg = segments[windowItem.end];
lines.push('');
lines.push('片段' + (m + 1) + ' [' + formatTimelineRange(startSeg.from, endSeg.to) + ']');
for (var n = windowItem.start; n <= windowItem.end; n++) {
var seg = segments[n];
lines.push('[' + formatTimelineRange(seg.from, seg.to) + '] ' + seg.text);
}
}
return lines.join('\n');
}
function sanitizeFilename(str) {
return str.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim();
}
const DOWNLOAD_DIRECTORY_DB = 'tabbit_subtitle_download_v1';
const DOWNLOAD_DIRECTORY_STORE = 'handles';
const DOWNLOAD_DIRECTORY_KEY = 'subtitle-directory';
function openDownloadDirectoryDb() {
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (!pageWindow.indexedDB) return Promise.resolve(null);
return new Promise(function(resolve, reject) {
const request = pageWindow.indexedDB.open(DOWNLOAD_DIRECTORY_DB, 1);
request.onupgradeneeded = function() {
if (!request.result.objectStoreNames.contains(DOWNLOAD_DIRECTORY_STORE)) {
request.result.createObjectStore(DOWNLOAD_DIRECTORY_STORE);
}
};
request.onsuccess = function() { resolve(request.result); };
request.onerror = function() { reject(request.error); };
});
}
async function loadDownloadDirectoryHandle() {
try {
const db = await openDownloadDirectoryDb();
if (!db) return null;
return await new Promise(function(resolve, reject) {
const request = db.transaction(DOWNLOAD_DIRECTORY_STORE, 'readonly').objectStore(DOWNLOAD_DIRECTORY_STORE).get(DOWNLOAD_DIRECTORY_KEY);
request.onsuccess = function() { resolve(request.result || null); };
request.onerror = function() { reject(request.error); };
});
} catch(e) {
console.warn('[省流助手] 读取字幕保存目录失败:', e);
return null;
}
}
async function saveDownloadDirectoryHandle(handle) {
try {
const db = await openDownloadDirectoryDb();
if (!db) return false;
await new Promise(function(resolve, reject) {
const transaction = db.transaction(DOWNLOAD_DIRECTORY_STORE, 'readwrite');
transaction.objectStore(DOWNLOAD_DIRECTORY_STORE).put(handle, DOWNLOAD_DIRECTORY_KEY);
transaction.oncomplete = function() { resolve(); };
transaction.onerror = function() { reject(transaction.error); };
});
return true;
} catch(e) {
console.warn('[省流助手] 记住字幕保存目录失败:', e);
return false;
}
}
async function clearDownloadDirectoryHandle() {
try {
const db = await openDownloadDirectoryDb();
if (!db) return false;
await new Promise(function(resolve, reject) {
const transaction = db.transaction(DOWNLOAD_DIRECTORY_STORE, 'readwrite');
transaction.objectStore(DOWNLOAD_DIRECTORY_STORE).delete(DOWNLOAD_DIRECTORY_KEY);
transaction.oncomplete = function() { resolve(); };
transaction.onerror = function() { reject(transaction.error); };
});
return true;
} catch(e) {
console.warn('[省流助手] 清除失效字幕目录失败:', e);
return false;
}
}
async function triggerDownload(content, filename, mimeType, options) {
options = options || {};
const downloadWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const BlobCtor = downloadWindow.Blob || Blob;
const blob = new BlobCtor([content], { type: mimeType });
if (options.useDirectory && typeof downloadWindow.showDirectoryPicker === 'function') {
async function getAvailableFilename(directoryHandle, desiredName) {
const match = String(desiredName || 'download').match(/^(.*?)(\.[^.]+)?$/);
const base = match ? match[1] : desiredName;
const extension = match && match[2] ? match[2] : '';
for (let index = 0; index < 1000; index++) {
const candidate = index === 0 ? desiredName : base + ' (' + index + ')' + extension;
try {
await directoryHandle.getFileHandle(candidate, { create: false });
} catch(e) {
if (e && e.name === 'NotFoundError') return candidate;
throw e;
}
}
return base + ' (' + Date.now() + ')' + extension;
}
async function writeToDirectory(directoryHandle) {
const targetDirectory = options.directorySubdirectory
? await directoryHandle.getDirectoryHandle(options.directorySubdirectory, { create: true })
: directoryHandle;
const availableFilename = await getAvailableFilename(targetDirectory, filename);
const fileHandle = await targetDirectory.getFileHandle(availableFilename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
}
let directoryHandle = await loadDownloadDirectoryHandle();
if (directoryHandle) {
try {
let permission = await directoryHandle.queryPermission({ mode: 'readwrite' });
if (permission === 'prompt') permission = await directoryHandle.requestPermission({ mode: 'readwrite' });
if (permission !== 'granted') throw new DOMException('目录授权已失效', 'NotAllowedError');
await writeToDirectory(directoryHandle);
return 'downloaded';
} catch(e) {
if (e && e.name === 'AbortError') return 'cancelled';
console.warn('[省流助手] 原字幕目录已失效,需要重新选择:', e);
await clearDownloadDirectoryHandle();
directoryHandle = null;
}
}
if (options.allowDirectoryPicker === false && !directoryHandle) return 'needs-directory';
try {
directoryHandle = await downloadWindow.showDirectoryPicker({ id: 'tabbit-subtitle-downloads', mode: 'readwrite' });
await saveDownloadDirectoryHandle(directoryHandle);
await writeToDirectory(directoryHandle);
return 'downloaded';
} catch(e) {
if (e && e.name === 'AbortError') return 'cancelled';
console.warn('[省流助手] 保存到已授权目录失败,改用单文件另存为:', e);
}
}
if (options.allowFilePicker === false) return 'needs-interaction';
if (typeof downloadWindow.showSaveFilePicker === 'function') {
try {
const extensionMatch = String(filename || '').match(/(\.[^.]+)$/);
const extension = extensionMatch ? extensionMatch[1] : '.txt';
const plainMime = String(mimeType || 'application/octet-stream').split(';')[0];
const handle = await downloadWindow.showSaveFilePicker({
suggestedName: filename,
types: [{ description: extension.toUpperCase().slice(1) + ' 文件', accept: { [plainMime]: [extension] } }]
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return 'downloaded';
} catch(e) {
if (e && e.name === 'AbortError') return 'cancelled';
console.warn('[省流助手] 原生另存为失败,改用浏览器下载:', e);
}
}
try {
const urlApi = downloadWindow.URL || URL;
const url = urlApi.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function() { urlApi.revokeObjectURL(url); }, 60000);
return 'started';
} catch(e) {
console.error('[省流助手] 下载失败:', e);
return 'failed';
}
}
async function downloadTranscript(text, title, upName, bvid, options) {
const safeTitle = sanitizeFilename(title) || '未知标题';
const safeUpName = sanitizeFilename(upName) || '未知UP主';
const safeBvid = bvid || '未知BV号';
const filename = safeUpName + '__' + safeTitle + '__' + safeBvid + '.txt';
const downloadOptions = Object.assign({ useDirectory: true, allowDirectoryPicker: true }, options || {});
const result = await triggerDownload(text, filename, 'text/plain;charset=utf-8', downloadOptions);
if (result === 'downloaded') console.log('[省流助手] 已下载字幕: ' + filename);
else if (result === 'started') console.log('[省流助手] 已发起字幕下载: ' + filename);
return result;
}
function toSrtTimestamp(sec) {
var seconds = Math.max(0, Number(sec) || 0);
var totalMs = Math.floor(seconds * 1000);
var hours = Math.floor(totalMs / 3600000);
var minutes = Math.floor((totalMs % 3600000) / 60000);
var secs = Math.floor((totalMs % 60000) / 1000);
var ms = totalMs % 1000;
return String(hours).padStart(2, '0') + ':'
+ String(minutes).padStart(2, '0') + ':'
+ String(secs).padStart(2, '0') + ','
+ String(ms).padStart(3, '0');
}
function buildSrtContent(subtitles) {
var segments = normalizeSubtitleSegments(subtitles);
if (!segments.length) return '';
return segments.map(function(seg, idx) {
return String(idx + 1)
+ '\n' + toSrtTimestamp(seg.from) + ' --> ' + toSrtTimestamp(seg.to)
+ '\n' + seg.text;
}).join('\n\n');
}
async function downloadSubtitleSrt(subtitles, title, upName, bvid) {
var srt = buildSrtContent(subtitles);
if (!srt) return false;
const safeTitle = sanitizeFilename(title) || '未知标题';
const safeUpName = sanitizeFilename(upName) || '未知UP主';
const safeBvid = bvid || '未知BV号';
const filename = safeUpName + '__' + safeTitle + '__' + safeBvid + '.srt';
const result = await triggerDownload(srt, filename, 'text/plain;charset=utf-8', { useDirectory: true, allowDirectoryPicker: true });
if (result === 'downloaded') console.log('[省流助手] 已下载 SRT: ' + filename);
else if (result === 'started') console.log('[省流助手] 已发起 SRT 下载: ' + filename);
return result;
}
function hasStructuredSubtitleData(subtitles) {
return normalizeSubtitleSegments(subtitles).length > 0;
}
async function downloadGeneratedImage(imageDataUrl, videoInfo, suffix, options) {
if (!imageDataUrl || imageDataUrl === 'ERROR') return false;
const safeTitle = sanitizeFilename((videoInfo && videoInfo.title) || '视频总结') || '视频总结';
const filename = safeTitle + (suffix || '_总结') + '.png';
const match = String(imageDataUrl).match(/^data:([^;,]+)?(;base64)?,([\s\S]*)$/);
if (!match) return 'failed';
const downloadWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const binary = match[2] ? downloadWindow.atob(match[3]) : decodeURIComponent(match[3]);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const result = await triggerDownload(bytes, filename, match[1] || 'image/png', options || {});
if (result === 'downloaded') console.log('[省流助手-生图] 已保存图片: ' + filename);
else if (result === 'needs-directory' || result === 'needs-interaction') console.warn('[省流助手-生图] 自动保存未执行:请先选择字幕保存目录,或点击“保存图片”手动保存');
return result;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function isUsableBiliElement(el) {
if (!el || el.closest('#tabbit-ai-summary-panel') || el.closest('#tabbit-settings-overlay')) return false;
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
const style = getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}
function deepQuerySelectorAll(selector, root) {
const results = [];
const visited = new Set();
function walk(node) {
if (!node || visited.has(node)) return;
visited.add(node);
if (node.matches) {
try {
if (node.matches(selector)) results.push(node);
} catch(e) {}
}
if (node.querySelectorAll) {
try {
node.querySelectorAll(selector).forEach(el => results.push(el));
} catch(e) {}
}
if (node.shadowRoot) walk(node.shadowRoot);
const children = node.querySelectorAll ? Array.from(node.querySelectorAll('*')) : [];
children.forEach(el => {
if (el.shadowRoot) walk(el.shadowRoot);
});
}
walk(root || document);
return results;
}
function getCommentAreaRoot() {
return document.querySelector('bili-comment-box')
|| document.querySelector('#comment')
|| document.querySelector('.reply-box')
|| document.querySelector('.comment-container')
|| document.querySelector('[class*="comment"]');
}
function isBiliCommentEditorCandidate(el) {
if (!isUsableBiliElement(el)) return false;
const tag = (el.tagName || '').toLowerCase();
if (el.id === 'body' || tag === 'bili-comment-box' || tag === 'bili-comment-rich-textarea') return false;
if (tag === 'textarea') return true;
if (el.classList && el.classList.contains('brt-editor')) return true;
if (el.getAttribute('contenteditable') === 'true') {
const placeholder = el.getAttribute('placeholder') || el.getAttribute('aria-placeholder') || '';
return !!(el.closest('.brt-root') || el.closest('#input') || /评论|回复/.test(placeholder));
}
return false;
}
function findBiliRichTextEditor(root) {
const richTextareas = deepQuerySelectorAll('bili-comment-rich-textarea', root || document);
for (const host of richTextareas) {
if (!host || !host.shadowRoot) continue;
const editor = deepQuerySelectorAll('.brt-editor, [contenteditable="true"], textarea', host.shadowRoot)
.find(isBiliCommentEditorCandidate);
if (editor) return editor;
}
return null;
}
async function findBiliCommentEditor() {
const root = getCommentAreaRoot();
if (root) root.scrollIntoView({ behavior: 'smooth', block: 'center' });
const selectors = [
'.brt-editor',
'[contenteditable="true"].brt-editor',
'textarea[placeholder*="评论"]',
'textarea[placeholder*="回复"]',
'#comment textarea',
'.reply-box textarea',
'.comment-send textarea',
'#comment [contenteditable="true"]',
'.reply-box [contenteditable="true"]',
'.comment-send [contenteditable="true"]',
'[contenteditable="true"][placeholder*="评论"]',
'[contenteditable="true"][aria-placeholder*="评论"]'
];
for (let attempt = 0; attempt < 8; attempt++) {
const roots = [root, document].filter(Boolean);
for (const searchRoot of roots) {
const richEditor = findBiliRichTextEditor(searchRoot);
if (richEditor) return richEditor;
}
for (const selector of selectors) {
for (const searchRoot of roots) {
const item = deepQuerySelectorAll(selector, searchRoot).find(isBiliCommentEditorCandidate);
if (item) return item;
}
}
await sleep(250);
}
return null;
}
function findBiliCommentHost(editor) {
let node = editor;
const visited = new Set();
while (node && !visited.has(node)) {
visited.add(node);
if (node.tagName && node.tagName.toLowerCase() === 'bili-comment-box') return node;
const root = node.getRootNode ? node.getRootNode() : null;
if (root && root.host && root.host !== node) {
if (root.host.tagName && root.host.tagName.toLowerCase() === 'bili-comment-box') return root.host;
node = root.host;
continue;
}
node = node.parentElement || node.parentNode;
if (node === document) break;
}
return document.querySelector('bili-comment-box');
}
function getBiliCommentApi(editor) {
const host = findBiliCommentHost(editor);
const root = editor && editor.getRootNode ? editor.getRootNode() : null;
const richHost = root && root.host && root.host.tagName && root.host.tagName.toLowerCase() === 'bili-comment-rich-textarea'
? root.host
: null;
const body = editor;
return { host, richHost, body };
}
function notifyBiliCommentInput(editor, text) {
const { host, richHost, body } = getBiliCommentApi(editor);
const events = [
new InputEvent('beforeinput', { bubbles: true, composed: true, cancelable: true, inputType: 'insertText', data: text }),
new InputEvent('input', { bubbles: true, composed: true, cancelable: true, inputType: 'insertText', data: text }),
new Event('change', { bubbles: true, composed: true })
];
events.forEach(ev => {
try { body.dispatchEvent(ev); } catch(e) {}
try { richHost && richHost.dispatchEvent(new Event(ev.type, { bubbles: true, composed: true })); } catch(e) {}
try { host && host.dispatchEvent(new Event(ev.type, { bubbles: true, composed: true })); } catch(e) {}
});
}
function getCommentTextPresets() {
let presets = CONFIG.commentTextPresets;
if (typeof presets === 'string') {
presets = presets.split('\n');
}
if (!Array.isArray(presets)) presets = DEFAULT_CONFIG.commentTextPresets;
presets = presets
.map(function(text) { return String(text || '').trim(); })
.filter(Boolean);
return presets.length > 0 ? presets : DEFAULT_CONFIG.commentTextPresets;
}
function pickRandomCommentText() {
const presets = getCommentTextPresets();
return presets[Math.floor(Math.random() * presets.length)] || '省流';
}
function findBiliCommentImageButton(editor) {
const host = findBiliCommentHost(editor);
const roots = [
host && host.shadowRoot,
getCommentAreaRoot(),
document
].filter(Boolean);
const specificSelectors = [
'button[title*="图片"]',
'button[aria-label*="图片"]',
'button[class*="image"]',
'button[class*="pic"]',
'button[class*="picture"]',
'button[class*="upload"]',
'[role="button"][title*="图片"]',
'[role="button"][aria-label*="图片"]',
'[role="button"][class*="image"]',
'[role="button"][class*="pic"]',
'[role="button"][class*="upload"]'
];
for (const root of roots) {
for (const selector of specificSelectors) {
const btn = deepQuerySelectorAll(selector, root).find(isUsableBiliElement);
if (btn) return btn;
}
}
for (const root of roots) {
const toolBtns = deepQuerySelectorAll('#footer button.tool-btn, button.tool-btn, #footer [role="button"], [class*="tool-btn"]', root)
.filter(isUsableBiliElement);
const imageBtn = toolBtns.find(function(btn) {
const text = (btn.textContent || '').trim();
const title = btn.getAttribute('title') || btn.getAttribute('aria-label') || '';
const cls = btn.className && typeof btn.className === 'string' ? btn.className : '';
const meta = text + ' ' + title + ' ' + cls;
if (/表情|emoji|emote|at|mention|话题|投票|vote/i.test(meta)) return false;
return /图片|image|pic|picture|upload|photo|image-upload/i.test(meta);
});
if (imageBtn) return imageBtn;
const fallbackBtns = toolBtns.filter(function(btn) {
const meta = ((btn.textContent || '') + ' ' + (btn.getAttribute('title') || '') + ' ' + (btn.className || '')).trim();
return !/表情|emoji|emote|at|mention|话题|投票|vote/i.test(meta);
});
if (fallbackBtns.length === 1) return fallbackBtns[0];
if (fallbackBtns.length > 1) return fallbackBtns[fallbackBtns.length - 1];
}
for (const root of roots) {
const fileInput = deepQuerySelectorAll('input[type="file"]', root).find(function(input) {
if (!isUsableBiliElement(input) && input.style.display !== 'none') return false;
return /image|\.(png|jpe?g|webp|gif)/i.test(input.accept || '');
});
if (fileInput) return fileInput;
}
return null;
}
function clickBiliCommentImageButton(editor) {
const btn = findBiliCommentImageButton(editor);
if (!btn) return false;
try {
btn.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, composed: true, cancelable: true }));
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, composed: true, cancelable: true }));
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, composed: true, cancelable: true }));
btn.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, composed: true, cancelable: true }));
btn.click();
return true;
} catch(e) {
console.warn('[省流助手-评论上传按钮] 点击失败:', e);
return false;
}
}
// 🆕 查找并点击B站评论区的"发送/发布"按钮
function clickBiliCommentSubmitButton(editor) {
const host = findBiliCommentHost(editor);
const roots = [
host && host.shadowRoot,
getCommentAreaRoot(),
document
].filter(Boolean);
const submitSelectors = [
'.comment-send button.submit-btn',
'.comment-send button[aria-label*="发布"]',
'.comment-send button[aria-label*="发送"]',
'button.submit-btn',
'button[aria-label*="发布"]',
'button[aria-label*="发送"]',
'#comment button.submit-btn',
'.reply-box button.submit-btn'
];
for (const root of roots) {
for (const selector of submitSelectors) {
const btns = deepQuerySelectorAll(selector, root);
const btn = btns.find(function(b) {
if (!isUsableBiliElement(b)) return false;
const text = (b.textContent || '').trim();
return /发布|发送|提交|send|submit/i.test(text) || b.classList.contains('submit-btn');
});
if (btn) {
try { btn.click(); return true; } catch(e) {}
}
}
}
// 兜底:在评论区域找含有"发布"文字的按钮
for (const root of roots) {
const allBtns = deepQuerySelectorAll('button, [role="button"]', root);
const submitBtn = allBtns.find(function(b) {
if (!isUsableBiliElement(b)) return false;
const text = (b.textContent || '').trim();
return text === '发布' || text === '发送';
});
if (submitBtn) {
try { submitBtn.click(); return true; } catch(e) {}
}
}
return false;
}
// 🆕 一键将摘要内容插入评论框(或自动发送)
const BILI_COMMENT_MAX_LEN = 1990;
async function fillBiliCommentSummary(btn) {
const summaryText = getCurrentSummaryText(null, '');
if (!summaryText || !summaryText.trim()) {
alert('暂无摘要内容,请先生成摘要');
return;
}
const originalText = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ 填写中...';
}
try {
// 转纯文本 + 拼后缀
let plainText = markdownToPlainText(summaryText).trim();
const suffix = '\n\n#B站省流助手';
const maxContentLen = BILI_COMMENT_MAX_LEN - suffix.length;
if (plainText.length > maxContentLen) {
plainText = plainText.slice(0, maxContentLen).trim() + '\n...';
}
const finalText = plainText + suffix;
const editor = await findBiliCommentEditor();
if (!editor) throw new Error('没找到评论输入框,请先滚到评论区或点一下评论框');
setBiliCommentText(editor, finalText);
if (btn) {
btn.textContent = '✅ 已填入';
}
// 如果开启了自动发送
if (CONFIG.autoSubmitCommentSummary) {
if (btn) btn.textContent = '⏳ 正在发送...';
await sleep(300);
const sent = clickBiliCommentSubmitButton(editor);
if (sent) {
if (btn) btn.textContent = '✅ 已发送!';
} else {
if (btn) btn.textContent = '✅ 已填入(未找到发送按钮)';
}
}
setTimeout(function() {
if (btn) {
btn.textContent = originalText || '📋 摘要发评论';
btn.disabled = false;
}
}, 2500);
} catch(err) {
console.error('[省流助手-摘要发评论]', err);
if (btn) {
btn.textContent = '❌ 失败';
setTimeout(function() {
btn.textContent = originalText || '📋 摘要发评论';
btn.disabled = false;
}, 2000);
}
}
}
function setBiliCommentText(editor, text) {
if (!isBiliCommentEditorCandidate(editor)) {
throw new Error('找到的不是评论输入框,已停止写入,避免破坏评论区');
}
editor.focus();
if ('value' in editor) {
editor.value = text;
notifyBiliCommentInput(editor, text);
} else {
try {
editor.click();
editor.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editor);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('insertText', false, text);
} catch(e) {}
if ((editor.textContent || '').trim() !== text) {
editor.innerHTML = escapeHtml(text);
}
editor.setAttribute('data-inputed', 'true');
notifyBiliCommentInput(editor, text);
}
}
async function fillBiliCommentTextOnly(btn) {
const originalText = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ 填写中...';
}
try {
const editor = await findBiliCommentEditor();
if (!editor) throw new Error('没找到评论输入框,请先滚到评论区或点一下评论框');
const commentText = pickRandomCommentText();
setBiliCommentText(editor, commentText);
const openedUpload = clickBiliCommentImageButton(editor);
if (btn) {
btn.textContent = openedUpload ? '✅ 已打开上传' : '✅ 已填入';
setTimeout(function() {
btn.textContent = originalText || '💬 填字并点上传';
btn.disabled = false;
}, 1800);
}
if (!openedUpload) {
console.warn('[省流助手-评论上传按钮] 没找到可点击的图片上传按钮,请手动上传');
}
} catch(err) {
console.error('[省流助手-评论填字]', err);
alert('填入评论失败:' + err.message);
if (btn) {
btn.textContent = originalText || '💬 填字并点上传';
btn.disabled = false;
}
}
}
// ==================== 评论区防Ban工具函数 ====================
function throwIfAborted(signal) {
if (signal && signal.aborted) {
const abortErr = new Error('用户已打断');
abortErr.name = 'AbortError';
throw abortErr;
}
}
function randomDelay(min, max, signal) {
const delay = min + Math.random() * (max - min);
console.log('[省流助手] 等待 ' + (delay / 1000).toFixed(1) + 's...');
return new Promise(function(resolve, reject) {
let timer = null;
function cleanup() {
if (signal) signal.removeEventListener('abort', onAbort);
}
function onAbort() {
if (timer) clearTimeout(timer);
cleanup();
const abortErr = new Error('用户已打断');
abortErr.name = 'AbortError';
reject(abortErr);
}
if (signal && signal.aborted) {
onAbort();
return;
}
if (signal) signal.addEventListener('abort', onAbort);
timer = setTimeout(function() {
cleanup();
resolve();
}, delay);
});
}
async function retryWithBackoff(fn, maxRetries, baseDelay, signal) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
throwIfAborted(signal);
try {
return await fn();
} catch (e) {
if (isAbortError(e)) throw e;
if (e.retryable === false) throw e;
if (attempt === maxRetries) throw e;
const backoffDelay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
console.warn('[省流助手] 第' + (attempt + 1) + '次失败,' + (backoffDelay / 1000).toFixed(1) + 's 后重试: ' + e.message);
await randomDelay(backoffDelay, backoffDelay, signal);
}
}
}
function createSafeFetcher(signal) {
return async function safeFetch(url, options) {
options = options || {};
const mergedOptions = Object.assign({}, options, {
credentials: 'include',
headers: Object.assign({}, SAFE_FETCH_HEADERS, { 'Referer': window.location.href }, options.headers || {})
});
if (signal) mergedOptions.signal = signal;
const resp = await fetchWithTimeout(url, mergedOptions, BILI_API_TIMEOUT_MS);
checkBiliHttp(resp);
return resp;
};
}
// ==================== 评论区获取部分 ====================
function getAid(videoInfo) {
if (videoInfo.aid) return videoInfo.aid;
try {
for (const script of document.querySelectorAll('script')) {
const match = (script.textContent || '').match(/"aid"\s*:\s*(\d+)/);
if (match) return parseInt(match[1], 10);
}
} catch (e) {}
return null;
}
async function fetchComments(safeFetch, aid, page) {
const url = 'https://api.bilibili.com/x/v2/reply?type=1&oid=' + aid + '&pn=' + page + '&ps=' + COMMENT_CONFIG.pageSize + '&sort=' + COMMENT_CONFIG.sortType;
const resp = await safeFetch(url);
const data = await readBiliPayload(resp);
if (data.replies !== null && !Array.isArray(data.replies)) {
throw new BiliApiError('评论列表格式异常', 'decode', null, false);
}
return data;
}
async function fetchAllComments(aid, statusCallback, signal) {
const allComments = [];
allComments.fetchMeta = { pagesFetched: 0, stop: 'page_limit', reason: '' };
const safeFetch = createSafeFetcher(signal);
const maxPages = CONFIG.commentMaxPages || COMMENT_CONFIG.maxPages;
const commentLimit = CONFIG.commentLimit || COMMENT_CONFIG.commentLimit;
const minDelay = CONFIG.commentMinDelay || COMMENT_CONFIG.minDelay;
const maxDelay = CONFIG.commentMaxDelay || COMMENT_CONFIG.maxDelay;
for (let page = 1; page <= maxPages; page++) {
try {
throwIfAborted(signal);
if (page > 1) {
await randomDelay(minDelay, maxDelay, signal);
}
const result = await retryWithBackoff(
() => fetchComments(safeFetch, aid, page),
COMMENT_CONFIG.maxRetries,
COMMENT_CONFIG.retryBaseDelay,
signal
);
throwIfAborted(signal);
const replies = result?.replies;
allComments.fetchMeta.pagesFetched = page;
if (!replies || replies.length === 0) {
allComments.fetchMeta.stop = 'end';
break;
}
for (const reply of replies) {
if (allComments.length >= commentLimit) break;
const name = reply.member?.uname || '匿名';
const text = reply.content?.message || '';
const like = reply.like || 0;
allComments.push({ name, text, like });
if (COMMENT_CONFIG.includeReplies && reply.replies) {
for (const sub of reply.replies) {
if (allComments.length >= commentLimit) break;
const subName = sub.member?.uname || '匿名';
const subText = sub.content?.message || '';
const subLike = sub.like || 0;
allComments.push({ name: subName, text: subText, like: subLike, isReply: true });
}
}
}
if (statusCallback) statusCallback('已获取 ' + allComments.length + ' 条评论 (第' + page + '页)...');
if (allComments.length >= commentLimit) {
allComments.fetchMeta.stop = 'item_limit';
break;
}
if (replies.length < COMMENT_CONFIG.pageSize) {
allComments.fetchMeta.stop = 'end';
break;
}
} catch (e) {
if (isAbortError(e)) throw e;
console.warn('[省流助手] 获取第' + page + '页评论失败:', e.message);
if (!allComments.length) throw e;
allComments.fetchMeta.stop = 'error';
allComments.fetchMeta.reason = '第' + page + '页获取失败:' + e.message;
if (e instanceof BiliRiskControlError) {
console.warn('[省流助手] 检测到风控,停止请求');
}
break;
}
}
return allComments;
}
function commentCoverageText(comments) {
const meta = comments && comments.fetchMeta;
if (!meta) return '评论采集范围未确认;不得据此推断整个评论区的观点。';
const reasons = { end: '当前分页已读完', page_limit: '达到设置的页数上限',
item_limit: '达到设置的评论条数上限', error: meta.reason, unavailable: meta.reason };
return '评论采集范围:已读取' + meta.pagesFetched + '页,保留' + comments.length + '条;' +
(reasons[meta.stop] || '采集范围未确认') + '。' +
(meta.stop === 'end' ? '' : '本次评论数据不完整。') +
'楼中楼仅包含接口附带的回复;仅分析本次样本,不代表整个评论区的观点。';
}
function commentCoverageHtml(comments) {
return '' +
escapeHtml(commentCoverageText(comments)) + '
';
}
function formatCommentsText(comments) {
return commentCoverageText(comments) + '\n\n' + comments.map((c, i) => {
const prefix = c.isReply ? ' └' : '';
return prefix + '[' + (i + 1) + '] ' + c.name + ' (👍' + c.like + '): ' + c.text;
}).join('\n');
}
// ==================== 弹幕获取部分 ====================
async function fetchDanmakuXml(safeFetch, cid) {
const url = 'https://api.bilibili.com/x/v1/dm/list.so?oid=' + cid;
const resp = await safeFetch(url);
checkBiliHttp(resp);
const buf = await resp.arrayBuffer();
const decoder = new TextDecoder('utf-8');
const xmlText = decoder.decode(buf);
// XML 接口也可能返回 JSON 错误或 HTML 拦截页,不能把它们当作零条弹幕。
if (xmlText.trimStart().startsWith('{')) {
await readBiliPayload({ ok: true, json: async () => JSON.parse(xmlText) });
throw new BiliApiError('弹幕接口返回了非 XML 数据', 'decode', null, false);
}
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, 'text/xml');
if (doc.querySelector('parsererror') || doc.documentElement?.localName !== 'i') {
throw new BiliApiError('弹幕 XML 格式异常', 'decode', null, false);
}
const dNodes = doc.querySelectorAll('d');
const danmakuList = [];
dNodes.forEach(function(d) {
const p = d.getAttribute('p') || '';
const parts = p.split(',');
const time = parseFloat(parts[0]) || 0;
const text = d.textContent || '';
if (text.trim()) {
danmakuList.push({ time: time, text: text.trim() });
}
});
return danmakuList;
}
async function fetchAllDanmaku(cid, statusCallback, signal) {
const allDanmaku = [];
const safeFetch = createSafeFetcher(signal);
const maxDanmaku = DANMAKU_CONFIG.maxDanmaku;
try {
throwIfAborted(signal);
if (statusCallback) statusCallback('正在获取弹幕...');
const danmaku = await retryWithBackoff(
function() { return fetchDanmakuXml(safeFetch, cid); },
DANMAKU_CONFIG.maxRetries,
DANMAKU_CONFIG.retryBaseDelay,
signal
);
throwIfAborted(signal);
allDanmaku.push.apply(allDanmaku, danmaku);
if (statusCallback) statusCallback('已获取 ' + allDanmaku.length + ' 条弹幕...');
} catch (e) {
if (isAbortError(e)) throw e;
console.warn('[省流助手] 获取弹幕失败:', e.message);
if (e instanceof BiliRiskControlError) {
console.warn('[省流助手] 检测到风控,停止弹幕请求');
}
throw e;
}
return allDanmaku.slice(0, maxDanmaku);
}
function formatDanmakuText(danmaku) {
var timeFormat = function(sec) {
var m = Math.floor(sec / 60);
var s = Math.floor(sec % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
};
return danmaku.map(function(d, i) {
return '[' + (i + 1) + '] [' + timeFormat(d.time) + '] ' + d.text;
}).join('\n');
}
// ==================== 全面分析:时间轴对齐 ====================
function alignTimeline(subtitleBody, danmaku) {
if (!subtitleBody || subtitleBody.length === 0) {
return [];
}
var subtitleBlocks = buildSubtitleBlocks(subtitleBody);
if (!subtitleBlocks.length) {
return [];
}
var sortedDanmaku = danmaku.slice().sort(function(a, b) { return a.time - b.time; });
var segments = [];
for (var i = 0; i < subtitleBlocks.length; i++) {
var seg = subtitleBlocks[i];
var from = seg.from || 0;
var to = seg.to || from;
var text = seg.text || '';
if (!text.trim()) continue;
var matchedDanmaku = [];
for (var j = 0; j < sortedDanmaku.length; j++) {
var d = sortedDanmaku[j];
if (d.time < from - 2) continue;
if (d.time > to + 2) break;
matchedDanmaku.push(d.text);
}
var timeLabel = formatTimelineRange(from, to);
var entry = { time: from, from: from, to: to, label: timeLabel, subtitle: text, danmaku: matchedDanmaku };
segments.push(entry);
}
return segments;
}
function timeFormatAlign(sec) {
return formatTimelineTimestamp(sec);
}
// 全面分析数据量上限(字符),超限时才开始截断
var FULL_DATA_MAX_CHARS_DEFAULT = 64000;
function formatFullData(videoInfo, subtitleBody, danmaku, comments, fallbackTranscript) {
var lines = [];
// 放在长字幕之前,避免范围说明被长度截断丢失。
lines.push('【评论采集范围】' + commentCoverageText(comments));
lines.push('【视频信息】');
lines.push('标题: ' + (videoInfo.title || ''));
lines.push('UP主: ' + (videoInfo.upName || ''));
if (videoInfo.desc) lines.push('简介: ' + videoInfo.desc);
lines.push('链接: https://www.bilibili.com/video/' + (videoInfo.bvid || ''));
lines.push('');
// --- 字幕 + 弹幕时间轴对齐(不过滤,全量输出)---
if (subtitleBody && subtitleBody.length > 0) {
var aligned = alignTimeline(subtitleBody, danmaku);
lines.push('【字幕 + 弹幕时间轴对齐】');
lines.push('(字幕' + subtitleBody.length + '句,整理为' + aligned.length + '个时间块,弹幕' + (danmaku ? danmaku.length : 0) + '条)');
lines.push('');
for (var i = 0; i < aligned.length; i++) {
var seg = aligned[i];
lines.push('[' + seg.label + '] ' + seg.subtitle);
if (seg.danmaku.length > 0) {
for (var j = 0; j < seg.danmaku.length; j++) {
lines.push(' 💬 ' + seg.danmaku[j]);
}
}
}
} else if (fallbackTranscript && fallbackTranscript.trim()) {
lines.push('【字幕文本】');
lines.push('(该字幕不含结构化时间轴,以下为纯文本)');
lines.push('');
lines.push(fallbackTranscript.trim());
} else if (danmaku && danmaku.length > 0) {
lines.push('【字幕】');
lines.push('(该视频无字幕)');
}
// --- 弹幕精选(按字数排序,字多 = 有内容,过滤刷屏短弹幕)---
if (danmaku && danmaku.length > 0) {
// 去重:相同文本只保留一条
var seen = {};
var uniqueDanmaku = [];
for (var k = 0; k < danmaku.length; k++) {
var txt = (danmaku[k].text || '').trim();
if (txt.length >= 4 && !seen[txt]) {
seen[txt] = true;
uniqueDanmaku.push(txt);
}
}
// 按字数降序排列,字越多含金量越高
uniqueDanmaku.sort(function(a, b) { return b.length - a.length; });
if (uniqueDanmaku.length > 0) {
lines.push('');
lines.push('【弹幕精选 TOP50(按内容含量排序,共' + danmaku.length + '条弹幕,去重后' + uniqueDanmaku.length + '条)】');
var showCount = Math.min(50, uniqueDanmaku.length);
for (var s = 0; s < showCount; s++) {
lines.push(uniqueDanmaku[s]);
}
}
}
// --- 评论区(不过滤,全量输出)---
if (comments && comments.length > 0) {
lines.push('');
lines.push('【评论区(' + comments.length + '条,按热度排序)】');
for (var c = 0; c < comments.length; c++) {
var cm = comments[c];
var prefix = cm.isReply ? ' └' : '';
lines.push(prefix + '[' + (c + 1) + '] ' + cm.name + ' (👍' + cm.like + '): ' + cm.text);
}
}
return lines.join("\n");
}
// ==================== Markdown 处理 ====================
function markdownToPlainText(text) {
let plain = text;
plain = plain.replace(/^#{1,6}\s+/gm, '');
plain = plain.replace(/\*\*(.+?)\*\*/g, '$1');
plain = plain.replace(/\*(.+?)\*/g, '$1');
plain = plain.replace(/_(.+?)_/g, '$1');
plain = plain.replace(/`([^`]+)`/g, '$1');
plain = plain.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
plain = plain.replace(/^>\s+/gm, '');
plain = plain.replace(/^[-*]\s+/gm, '• ');
plain = plain.replace(/^---$/gm, '');
plain = plain.replace(/\n{3,}/g, '\n\n');
return plain.trim();
}
const parseMarkdown = (function() {
function safeImageSrc(raw) {
const value = String(raw || '').trim();
const compact = value.replace(/[\u0000-\u0020\u007f]+/g, '');
if (/^(https?:\/\/|\/|\.\.?\/)/i.test(compact)) return escapeAttr(value);
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(compact)) return escapeAttr(value);
return '#';
}
function findClosing(text, start, open, close) {
let depth = 1;
for (let i = start; i < text.length; i++) {
if (text[i] === '\\') { i++; continue; }
if (text[i] === open) depth++;
else if (text[i] === close && --depth === 0) return i;
}
return -1;
}
function inline(text) {
const tokens = [];
const token = html => { const id = '\u0000MD' + tokens.length + '\u0000'; tokens.push(html); return id; };
let source = String(text || '').replace(/`([^`\n]+?)`/g, (_, code) => token('' + escapeHtml(code) + ''));
let linked = '', cursor = 0;
while (cursor < source.length) {
let start = source.indexOf('[', cursor), image = false;
const imageStart = source.indexOf('![', cursor);
if (imageStart >= 0 && (start < 0 || imageStart <= start)) { start = imageStart; image = true; }
if (start < 0) { linked += source.slice(cursor); break; }
linked += source.slice(cursor, start);
const labelStart = start + (image ? 2 : 1);
const labelEnd = findClosing(source, labelStart, '[', ']');
if (labelEnd < 0 || source[labelEnd + 1] !== '(') { linked += source.slice(start, labelStart); cursor = labelStart; continue; }
const targetEnd = findClosing(source, labelEnd + 2, '(', ')');
if (targetEnd < 0) { linked += source.slice(start, labelStart); cursor = labelStart; continue; }
const label = source.slice(labelStart, labelEnd).replace(/\\([\[\]])/g, '$1');
const targetRaw = source.slice(labelEnd + 2, targetEnd).trim();
const target = targetRaw.replace(/\s+(?:"[^"]*"|'[^']*')\s*$/, '').trim();
linked += image
? token('
')
: token('' + escapeHtml(label) + '');
cursor = targetEnd + 1;
}
source = linked.replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, (_, ch) => token(escapeHtml(ch)));
let html = escapeHtml(source);
html = html.replace(/\*\*([^\*]+?)\*\*/g, '$1');
html = html.replace(/__([^_]+?)__/g, '$1');
html = html.replace(/(^|[^\*])\*([^\*\n]+?)\*(?!\*)/g, '$1$2');
html = html.replace(/(^|[^_])_([^_\n]+?)_(?!_)/g, '$1$2');
html = html.replace(/~~([^~]+?)~~/g, '$1');
html = html.replace(/(^|[\s>])(https?:\/\/[^\s<]+)/g, (_, lead, url) => lead + '' + url + '');
return html.replace(/\u0000MD(\d+)\u0000/g, (_, idx) => tokens[Number(idx)] || '');
}
function tableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
const cells = []; let buf = '', inCode = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '\\' && s[i + 1] === '|') { buf += '|'; i++; continue; }
if (ch === '`') { inCode = !inCode; buf += ch; continue; }
if (ch === '|' && !inCode) { cells.push(buf.trim()); buf = ''; continue; }
buf += ch;
}
cells.push(buf.trim());
return cells;
}
function tableSeparator(line) {
const cells = tableRow(line);
return /\|/.test(line) && cells.length > 0 && cells.every(cell => /^:?-{1,}:?$/.test(cell));
}
return function(md) {
if (!md) return '';
const lines = String(md).replace(/\r\n?/g, '\n').split('\n');
let html = '', i = 0, inCode = false, codeLang = '', codeLines = [], listStack = [];
const closeLists = () => { while (listStack.length) html += '' + listStack.pop().type + '>'; };
while (i < lines.length) {
const line = lines[i];
const fence = line.match(/^```\s*([^\s`]*)[^`]*$/);
if (fence) {
if (!inCode) { closeLists(); inCode = true; codeLang = fence[1] || ''; codeLines = []; }
else { html += '' + escapeHtml(codeLines.join('\n')) + '
'; inCode = false; codeLang = ''; }
i++; continue;
}
if (inCode) { codeLines.push(line); i++; continue; }
if (/^\s*$/.test(line)) {
let next = i + 1;
while (next < lines.length && /^\s*$/.test(lines[next])) next++;
if (listStack.length && next < lines.length && (/^\s+\S/.test(lines[next]) || /^(\s*)(?:[-*+]|\d+\.)\s+/.test(lines[next]))) { i++; continue; }
closeLists(); i++; continue;
}
if (/\|/.test(line) && i + 1 < lines.length && tableSeparator(lines[i + 1])) {
closeLists();
const headers = tableRow(line), separators = tableRow(lines[i + 1]);
const aligns = separators.map(cell => cell.startsWith(':') && cell.endsWith(':') ? 'center' : cell.endsWith(':') ? 'right' : cell.startsWith(':') ? 'left' : '');
i += 2; const rows = [];
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) rows.push(tableRow(lines[i++]));
html += '' + headers.map((cell, col) => '| ' + inline(cell) + ' | ').join('') + '
';
rows.forEach(row => { html += '' + headers.map((_, col) => '| ' + inline(row[col] || '') + ' | ').join('') + '
'; });
html += '
'; continue;
}
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) { closeLists(); const level = heading[1].length; html += '' + inline(heading[2]) + ''; i++; continue; }
if (/^\s*([-*_])\s*\1\s*\1[-*_\s]*$/.test(line)) { closeLists(); html += '
'; i++; continue; }
if (/^\s*>\s?/.test(line)) {
closeLists(); const quoted = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) quoted.push(lines[i++].replace(/^\s*>\s?/, ''));
html += '' + parseMarkdown(quoted.join('\n')) + '
'; continue;
}
const ul = line.match(/^(\s*)[-*+]\s+(.*)$/), ol = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (ul || ol) {
const match = ul || ol, type = ul ? 'ul' : 'ol', indent = match[1].length;
const content = ul ? match[2] : match[3], start = ol ? Number(match[2]) : 1;
while (listStack.length && listStack[listStack.length - 1].indent > indent) html += '' + listStack.pop().type + '>';
if (listStack.length && listStack[listStack.length - 1].indent === indent && listStack[listStack.length - 1].type !== type) html += '' + listStack.pop().type + '>';
if (!listStack.length || listStack[listStack.length - 1].indent < indent) { html += '<' + type + ' class="md-' + type + '"' + (type === 'ol' && start !== 1 ? ' start="' + start + '"' : '') + '>'; listStack.push({ type, indent }); }
else html += '';
const task = content.match(/^\[([ xX])\]\s+(.*)$/);
html += task ? ' ' + inline(task[2]) : inline(content);
i++; continue;
}
if (listStack.length && /^\s+\S/.test(line)) { html += '
' + inline(line.trim()); i++; continue; }
closeLists(); const paragraph = [line]; i++;
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```/.test(lines[i]) && !/^#{1,6}\s+/.test(lines[i]) && !/^\s*>\s?/.test(lines[i]) && !/^(\s*)(?:[-*+]|\d+\.)\s+/.test(lines[i])) {
if (/\|/.test(lines[i]) && i + 1 < lines.length && tableSeparator(lines[i + 1])) break;
paragraph.push(lines[i++]);
}
html += '' + inline(paragraph.join(' ').trim()) + '
';
}
if (inCode) html += '' + escapeHtml(codeLines.join('\n')) + '
';
closeLists(); return html;
};
})();
// ==================== UI 样式 ====================
function createStyles() {
if (document.querySelector('#tabbit-ai-summary-styles')) return;
const style = document.createElement('style');
style.id = 'tabbit-ai-summary-styles';
style.textContent = `
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 2px 8px rgba(251,114,153,0.3); }
50% { box-shadow: 0 2px 16px rgba(251,114,153,0.6); }
}
/* 🆕 流式打字光标 */
@keyframes tabbitBlink {
0%, 50% { opacity: 1; }
50.01%, 100% { opacity: 0; }
}
.tabbit-typing-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: #667eea;
vertical-align: text-bottom;
margin-left: 2px;
animation: tabbitBlink 1s steps(2) infinite;
}
#tabbit-ai-summary-panel {
position: fixed;
top: 20px;
right: 20px;
width: 480px;
height: min(720px, 90vh);
min-width: 360px;
min-height: 360px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
box-sizing: border-box;
background: white;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
z-index: 9999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
}
#tabbit-ai-summary-panel.dragging,
#tabbit-ai-summary-panel.resizing {
animation: none !important;
transition: none !important;
}
.tabbit-panel-resizer {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
z-index: 2;
}
.tabbit-panel-resizer::after {
content: '';
position: absolute;
right: 4px;
bottom: 4px;
width: 9px;
height: 9px;
border-right: 2px solid rgba(102,126,234,0.55);
border-bottom: 2px solid rgba(102,126,234,0.55);
}
#tabbit-ai-summary-panel.tabbit-window-managed { min-width: 0; min-height: 0; max-width: none; max-height: none; }
#tabbit-ai-summary-panel[data-window-mode="right"] .tabbit-panel-resizer,
#tabbit-ai-summary-panel[data-window-mode="full"] .tabbit-panel-resizer { display: none; }
#tabbit-ai-summary-panel .tabbit-window-menu[hidden], #tabbit-window-button[hidden],
.tabbit-window-menu [hidden] { display: none !important; }
.tabbit-window-menu { position: absolute; right: 6px; z-index: 20; box-sizing: border-box; max-width: calc(100% - 12px); padding: 1em; background: white; color: #333; border: 1px solid #e5e5ef; border-radius: 12px; box-shadow: 0 8px 30px #0003; overflow: auto; overscroll-behavior: contain; line-height: 1.5; }
.tabbit-window-menu button { font: inherit; min-height: 3em; padding: .4em; border: 1px solid #e2e2eb; border-radius: 8px; color: #444; background: #f5f5fa; cursor: pointer; }
.tabbit-window-managed .tabbit-panel-header > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tabbit-window-managed .tabbit-header-actions { flex-shrink: 0; }
.tabbit-window-modes { display: flex; gap: .4em; margin: .8em 0; flex-wrap: wrap; }
.tabbit-window-modes button { flex: 1; white-space: nowrap; }
.tabbit-window-menu button[aria-pressed="true"], .tabbit-window-menu [data-window-done] { background: #7660df; color: white; }
.tabbit-window-menu label { display: block; margin-top: .5em; }
.tabbit-window-menu output { float: right; color: #7660df; }
.tabbit-window-menu input[type="range"] { display: block; width: 100%; height: 3em; margin: 0; accent-color: #7660df; touch-action: none; font-size: inherit; appearance: none; background: transparent; }
.tabbit-window-menu input[type="range"]::-webkit-slider-runnable-track { height: .35em; background: #e7e4f3; border-radius: 1em; }
.tabbit-window-menu input[type="range"]::-webkit-slider-thumb { appearance: none; width: 1.5em; height: 1.5em; margin-top: -.575em; border-radius: 50%; background: #7660df; border: 2px solid white; box-shadow: 0 1px 5px #0003; }
.tabbit-window-menu input[type="range"]::-moz-range-track { height: .35em; background: #e7e4f3; border-radius: 1em; }
.tabbit-window-menu input[type="range"]::-moz-range-thumb { width: 1.5em; height: 1.5em; border-radius: 50%; background: #7660df; border: 2px solid white; }
.tabbit-window-menu p { margin: .5em 0; color: #777; font-size: .85em; }
.tabbit-window-footer { display: flex; justify-content: space-between; gap: .5em; margin-top: .6em; }
.tabbit-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
font-size: 16px;
flex-shrink: 0;
cursor: move;
user-select: none;
}
.tabbit-close-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
line-height: 1;
padding: 0 4px;
}
.tabbit-close-btn:hover { opacity: 1; }
.tabbit-model-bar {
padding: 0;
background: #f5f6fa;
border-bottom: 1px solid #e8e8ef;
flex-shrink: 0;
}
.tabbit-model-bar-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
user-select: none;
list-style: none;
}
.tabbit-model-bar-summary::-webkit-details-marker { display: none; }
.tabbit-model-bar-summary::marker { display: none; content: ''; }
.tabbit-model-bar-title {
font-size: 11px;
color: #999;
font-weight: 600;
letter-spacing: 0.5px;
}
details[open] > .tabbit-model-bar-summary .tabbit-video-info-toggle {
transform: rotate(90deg);
}
.tabbit-model-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 14px 10px;
}
.tabbit-model-chip {
padding: 4px 10px;
background: white;
border: 1px solid #d8d8e0;
border-radius: 14px;
font-size: 11px;
color: #555;
cursor: pointer;
transition: all 0.18s;
user-select: none;
white-space: nowrap;
}
.tabbit-model-chip:hover {
border-color: #667eea;
color: #667eea;
transform: translateY(-1px);
}
.tabbit-model-chip.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
font-weight: 600;
}
.tabbit-model-chip.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tabbit-panel-content {
padding: 16px 20px;
overflow-y: auto;
font-size: 14px;
line-height: 1.7;
color: #333;
flex: 1;
min-height: 100px;
}
.tabbit-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 40px 20px;
color: #666;
}
.tabbit-loading .tabbit-auto-subtitle-stop {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5253 100%);
color: white;
border: none;
border-radius: 18px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 2px 10px rgba(238,82,83,0.35);
position: relative;
z-index: 2;
pointer-events: auto;
}
.tabbit-loading .tabbit-auto-subtitle-stop:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(238,82,83,0.5);
}
.tabbit-loading .tabbit-auto-subtitle-stop:disabled {
opacity: 0.65;
cursor: not-allowed;
transform: none;
}
.tabbit-spinner {
width: 36px;
height: 36px;
border: 3px solid #e0e0e0;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.tabbit-video-info {
background: #e8f4f8;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 12px;
font-size: 13px;
color: #555;
}
.tabbit-video-info .tabbit-video-title {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.tabbit-video-info-bottom {
margin: 0 0 12px;
background: #f6fbfd;
border: 1px solid #ddebf2;
border-radius: 8px;
overflow: hidden;
}
.tabbit-video-info-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #444;
user-select: none;
list-style: none;
}
.tabbit-video-info-summary::-webkit-details-marker { display: none; }
.tabbit-video-info-summary::marker { display: none; content: ''; }
.tabbit-video-info-title-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 8px;
}
.tabbit-video-info-toggle {
font-size: 10px;
color: #999;
transition: transform 0.25s ease;
flex-shrink: 0;
}
details[open] > .tabbit-video-info-summary .tabbit-video-info-toggle {
transform: rotate(90deg);
}
.tabbit-video-meta-body {
padding: 0 10px 10px;
border-top: 1px solid #ddebf2;
font-size: 12.5px;
color: #555;
}
.tabbit-video-url-inline {
margin-top: 6px;
font-size: 12px;
word-break: break-all;
}
.tabbit-result {
background: #f8f9fa;
border-radius: 12px;
padding: 14px 16px;
word-break: break-word;
}
.tabbit-summary-editor {
width: 100%;
min-height: 220px;
box-sizing: border-box;
resize: vertical;
border: 1px solid #d8d8e0;
border-radius: 8px;
padding: 10px 12px;
background: #fff;
color: #333;
font: inherit;
line-height: 1.6;
outline: none;
}
.tabbit-summary-editor:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102,126,234,0.12);
}
.tabbit-summary-edit-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.tabbit-summary-edit-status {
font-size: 11px;
color: #2e7d32;
}
.tabbit-image-slot:empty {
display: none;
}
.tabbit-image-slot {
margin-bottom: 12px;
}
.tabbit-comment-section:empty {
display: none;
}
.tabbit-result-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.tabbit-result-actions:empty, .tabbit-img-actions:empty { display: none !important; }
.tabbit-copy-btn, .tabbit-download-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #f0f0f5;
color: #555;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.tabbit-copy-btn:hover, .tabbit-download-btn:hover {
background: #e8e8f0;
color: #333;
border-color: #667eea;
}
.tabbit-copy-btn.copied {
background: #e8f5e9;
color: #2e7d32;
border-color: #4caf50;
}
.tabbit-error {
background: #fff3f3;
border: 1px solid #ffcccc;
border-radius: 8px;
padding: 14px;
color: #c00;
}
.tabbit-error-title { font-weight: 600; margin-bottom: 6px; }
.tabbit-no-subtitle {
background: #fff8e1;
border: 1px solid #ffe082;
border-radius: 8px;
padding: 14px;
color: #8d6e00;
text-align: center;
}
.tabbit-no-subtitle-icon { font-size: 32px; margin-bottom: 8px; }
.tabbit-no-subtitle-text { font-weight: 600; margin-bottom: 4px; }
.tabbit-comment-summary-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 20px;
margin-top: 14px;
background: linear-gradient(135deg, #fb7299 0%, #f25d8e 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s;
animation: pulseGlow 2s ease-in-out infinite;
letter-spacing: 0.5px;
}
.tabbit-comment-summary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(251,114,153,0.5);
background: linear-gradient(135deg, #f25d8e 0%, #e04e7e 100%);
}
.tabbit-comment-summary-btn:active { transform: translateY(0); }
.tabbit-manual-fetch-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 20px;
margin-top: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s;
letter-spacing: 0.5px;
}
.tabbit-manual-fetch-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(102,126,234,0.5);
background: linear-gradient(135deg, #5a6fd6 0%, #6a3d96 100%);
}
.tabbit-manual-fetch-btn:active { transform: translateY(0); }
.tabbit-manual-fetch-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 🆕 手动上传字幕按钮(绿色调,区分手动获取) */
.tabbit-manual-upload-btn {
background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
}
.tabbit-manual-upload-btn:hover {
background: linear-gradient(135deg, #388e3c 0%, #1b5e20 100%);
box-shadow: 0 4px 20px rgba(67,160,71,0.45);
}
.tabbit-manual-fetch-btn .tabbit-btn-icon { font-size: 18px; }
.tabbit-comment-summary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
animation: none;
box-shadow: none;
}
.tabbit-comment-summary-btn .tabbit-btn-icon { font-size: 18px; }
.tabbit-comment-section {
margin-top: 16px;
padding-top: 16px;
border-top: 2px solid #e8e8ef;
}
.tabbit-comment-section-title {
font-size: 14px;
font-weight: 600;
color: #fb7299;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.tabbit-comment-result {
background: #fff5f8;
border-radius: 12px;
padding: 14px 16px;
word-break: break-word;
}
.tabbit-comment-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.tabbit-danmaku-summary-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 20px;
margin-top: 10px;
background: linear-gradient(135deg, #00a1d6 0%, #0086c9 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s;
letter-spacing: 0.5px;
}
.tabbit-danmaku-summary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,161,214,0.5);
background: linear-gradient(135deg, #0086c9 0%, #006fa3 100%);
}
.tabbit-danmaku-summary-btn:active { transform: translateY(0); }
.tabbit-danmaku-summary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.tabbit-danmaku-summary-btn .tabbit-btn-icon { font-size: 18px; }
.tabbit-danmaku-section {
margin-top: 16px;
padding-top: 16px;
border-top: 2px solid #e8e8ef;
}
.tabbit-danmaku-section-title {
font-size: 14px;
font-weight: 600;
color: #00a1d6;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.tabbit-danmaku-result {
background: #f0fafe;
border-radius: 12px;
padding: 14px 16px;
word-break: break-word;
}
.tabbit-danmaku-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.tabbit-full-summary-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 20px;
margin-top: 10px;
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.25s;
letter-spacing: 0.5px;
}
.tabbit-full-summary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(255,152,0,0.5);
background: linear-gradient(135deg, #f57c00 0%, #ef6c00 100%);
}
.tabbit-full-summary-btn:active { transform: translateY(0); }
.tabbit-full-summary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.tabbit-full-summary-btn .tabbit-btn-icon { font-size: 18px; }
.tabbit-full-section {
margin-top: 16px;
padding-top: 16px;
border-top: 2px solid #e8e8ef;
}
.tabbit-full-section-title {
font-size: 14px;
font-weight: 600;
color: #f57c00;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.tabbit-full-result {
background: #fff8e1;
border-radius: 12px;
padding: 14px 16px;
word-break: break-word;
}
.tabbit-full-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.tabbit-chat-messages {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.tabbit-msg {
animation: fadeIn 0.25s ease;
max-width: 90%;
padding: 10px 14px;
border-radius: 12px;
font-size: 13.5px;
line-height: 1.65;
word-break: break-word;
}
.tabbit-msg-user {
align-self: flex-end;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-bottom-right-radius: 4px;
}
.tabbit-msg-ai {
align-self: flex-start;
background: #f0f2f7;
color: #333;
border-bottom-left-radius: 4px;
}
.tabbit-msg-model {
font-size: 10px;
color: #999;
margin-top: 4px;
text-align: left;
}
.tabbit-msg-loading {
align-self: flex-start;
padding: 10px 14px;
background: #f0f2f7;
border-radius: 12px;
border-bottom-left-radius: 4px;
display: flex;
gap: 4px;
}
.tabbit-msg-loading span {
width: 8px;
height: 8px;
background: #999;
border-radius: 50%;
animation: bounce 1.2s infinite ease-in-out;
}
.tabbit-msg-loading span:nth-child(2) { animation-delay: 0.15s; }
.tabbit-msg-loading span:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
.tabbit-chat-input-bar {
flex-shrink: 0;
padding: 10px 14px;
background: #fafbfc;
border-top: 1px solid #e8e8ef;
display: flex;
gap: 8px;
align-items: flex-end;
}
.tabbit-chat-input {
flex: 1;
min-height: 36px;
max-height: 100px;
padding: 8px 12px;
border: 1px solid #d8d8e0;
border-radius: 18px;
font-size: 13px;
font-family: inherit;
resize: none;
outline: none;
line-height: 1.4;
transition: border-color 0.2s;
}
.tabbit-chat-input:focus { border-color: #667eea; }
.tabbit-chat-input:disabled {
background: #f0f0f0;
cursor: not-allowed;
}
.tabbit-chat-send {
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s;
}
.tabbit-chat-send:hover:not(:disabled) { transform: scale(1.08); }
.tabbit-chat-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tabbit-chat-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 🆕 打断按钮样式(替代发送按钮) */
.tabbit-chat-send.tabbit-abort-mode {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5253 100%);
opacity: 1 !important;
cursor: pointer !important;
animation: pulseGlow 1.5s ease-in-out infinite;
}
.tabbit-chat-send.tabbit-abort-mode:hover {
transform: scale(1.1);
}
/* 🆕 内联打断按钮(用于初始总结/评论总结/预设切换的流式过程中) */
.tabbit-inline-abort-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
margin-top: 10px;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5253 100%);
color: white;
border: none;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s;
box-shadow: 0 2px 8px rgba(238,82,83,0.35);
}
.tabbit-inline-abort-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(238,82,83,0.5);
}
.tabbit-inline-abort-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.md-h1 { font-size: 18px; font-weight: 700; color: #1a1a2e; margin: 12px 0 10px; padding-bottom: 6px; border-bottom: 2px solid #667eea; }
.md-h2 { font-size: 16px; font-weight: 600; color: #2d2d44; margin: 12px 0 8px; }
.md-h3 { font-size: 15px; font-weight: 600; color: #3d3d5c; margin: 10px 0 6px; }
.md-p { margin: 8px 0; color: #333; line-height: 1.7; }
.md-bold { font-weight: 600; color: #1a1a2e; }
.md-em { font-style: italic; color: #555; }
.md-code { background: #e8e8f0; color: #c7254e; padding: 2px 6px; border-radius: 4px; font-size: 12.5px; font-family: Consolas, Monaco, monospace; }
.md-pre { background: #23272f; color: #f3f4f6; padding: 12px 14px; border-radius: 8px; overflow:auto; font-size: 12.5px; line-height: 1.55; }
.md-link { color: #667eea; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
.md-link:hover { border-bottom-color: #667eea; }
.md-quote { background: linear-gradient(to right, #667eea 0, #667eea 4px, #f0f4ff 4px); padding: 10px 14px; margin: 10px 0; border-radius: 0 8px 8px 0; color: #555; font-style: italic; }
.md-ul, .md-ol { margin: 10px 0; padding-left: 22px; }
.md-ul { list-style: none; }
.md-li { margin: 5px 0; color: #333; position: relative; }
.md-ul .md-li::before { content: '•'; color: #667eea; font-weight: bold; position: absolute; left: -14px; }
.md-ol { list-style-type: decimal; }
.md-li-ol { margin: 5px 0; color: #333; }
.md-hr { border: none; height: 1px; background: linear-gradient(to right, transparent, #667eea, transparent); margin: 14px 0; }
.md-table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 12.5px; }
.md-table th, .md-table td { border: 1px solid #e0e3ee; padding: 6px 8px; text-align: left; vertical-align: top; }
.md-table th { background: #f0f4ff; font-weight: 600; color: #333; }
/* ==================== 设置面板样式 ==================== */
#tabbit-settings-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 10000000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
#tabbit-settings-panel {
background: white;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
width: 540px;
max-width: 95vw;
max-height: 88vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
}
.tabbit-settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
font-size: 16px;
flex-shrink: 0;
}
.tabbit-settings-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.tabbit-settings-group {
margin-bottom: 18px;
}
.tabbit-result-action-settings-item {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 8px;
border: 1px solid #e3e5ee;
border-radius: 8px;
background: #fff;
margin-top: 6px;
font-size: 12px;
}
.tabbit-result-action-settings-item label { flex: 1; cursor: pointer; }
.tabbit-result-action-settings-item button { min-width: 28px; padding: 3px 6px; }
.tabbit-result-action-settings-item button:disabled { opacity: 0.45; cursor: not-allowed; }
.tabbit-collapse {
margin-bottom: 18px;
border: 1px solid #e8e8ef;
border-radius: 12px;
overflow: hidden;
background: #fafbfc;
}
.tabbit-collapse-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.tabbit-collapse-header:hover {
background: #f0f2f8;
}
.tabbit-collapse-header .tabbit-collapse-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #444;
}
.tabbit-collapse-header .tabbit-collapse-arrow {
font-size: 12px;
color: #999;
transition: transform 0.3s ease;
}
.tabbit-collapse.open .tabbit-collapse-arrow {
transform: rotate(90deg);
}
.tabbit-collapse-body {
display: none;
padding: 0 16px 16px;
}
.tabbit-collapse.open .tabbit-collapse-body {
display: block;
}
.tabbit-collapse-body .tabbit-settings-group:last-child {
margin-bottom: 0;
}
.tabbit-settings-subsection-title {
margin: 14px 0 10px;
padding-bottom: 7px;
border-bottom: 1px solid #ececf2;
color: #4f5360;
font-size: 13px;
font-weight: 700;
}
.tabbit-settings-subsection-title:first-child { margin-top: 0; }
.tabbit-settings-label {
font-size: 12px;
font-weight: 600;
color: #666;
margin-bottom: 6px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.tabbit-settings-input {
width: 100%;
padding: 9px 12px;
border: 1px solid #d8d8e0;
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s;
color: #333;
}
.tabbit-settings-input:focus { border-color: #667eea; }
.tabbit-api-profile-help { margin-bottom: 10px; }
.tabbit-api-profile-row { display: grid; grid-template-columns: minmax(190px, 1fr) auto auto auto auto; gap: 8px; align-items: center; margin-bottom: 14px; }
.tabbit-api-profile-row .tabbit-settings-input { min-width: 0; }
.tabbit-api-profile-btn { height: 38px; padding: 0 14px; border: 1px solid #dedee8; border-radius: 9px; background: #f7f7fa; color: #444; cursor: pointer; white-space: nowrap; font: inherit; }
.tabbit-api-profile-btn:hover { border-color: #667eea; color: #5368dd; background: #f5f7ff; }
.tabbit-api-profile-btn-danger { color: #d9534f; border-color: #f2b8b5; background: #fff4f3; }
.tabbit-api-test-row { display: flex; gap: 10px; flex-wrap: wrap; margin: 2px 0 14px; }
@media (max-width: 720px) {
.tabbit-api-profile-row { grid-template-columns: 1fr 1fr; }
.tabbit-api-profile-row select { grid-column: 1 / -1; }
}
.tabbit-settings-textarea {
width: 100%;
padding: 9px 12px;
border: 1px solid #d8d8e0;
border-radius: 8px;
font-size: 12.5px;
font-family: Consolas, Monaco, monospace;
outline: none;
box-sizing: border-box;
resize: vertical;
min-height: 120px;
line-height: 1.6;
color: #333;
transition: border-color 0.2s;
}
.tabbit-settings-textarea:focus { border-color: #667eea; }
.tabbit-settings-hint {
font-size: 11px;
color: #aaa;
margin-top: 4px;
}
.tabbit-settings-footer {
padding: 14px 20px;
background: #f8f9fa;
border-top: 1px solid #e8e8ef;
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
}
.tabbit-settings-btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.tabbit-settings-btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.tabbit-settings-btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
.tabbit-settings-btn-secondary {
background: #f0f0f5;
color: #555;
border: 1px solid #ddd;
}
.tabbit-settings-btn-secondary:hover { background: #e8e8f0; color: #333; }
.tabbit-settings-btn-danger {
background: #fff3f3;
color: #c00;
border: 1px solid #ffcccc;
}
.tabbit-settings-btn-danger:hover { background: #ffe0e0; }
.tabbit-settings-spacer { flex: 1; }
.tabbit-settings-saved {
font-size: 12px;
color: #2e7d32;
font-weight: 600;
display: none;
}
.tabbit-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
vertical-align: middle;
}
.tabbit-switch input { opacity: 0; width: 0; height: 0; }
.tabbit-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 24px;
}
.tabbit-slider:before {
position: absolute;
content: "";
height: 18px; width: 18px;
left: 3px; bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.tabbit-switch input:checked + .tabbit-slider {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.tabbit-switch input:checked + .tabbit-slider:before {
transform: translateX(20px);
}
.tabbit-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tabbit-top-switch-row .tabbit-switch-copy {
flex: 1;
min-width: 0;
}
.tabbit-top-switch-row .tabbit-switch {
flex-shrink: 0;
}
.tabbit-switch-row .tabbit-settings-label {
margin-bottom: 0;
}
.tabbit-input-with-btn {
display: flex;
gap: 8px;
align-items: stretch;
}
.tabbit-input-with-btn .tabbit-settings-input { flex: 1; }
.tabbit-fetch-models-btn {
padding: 0 14px;
border: 1px solid #667eea;
border-radius: 8px;
background: white;
color: #667eea;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.tabbit-fetch-models-btn:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}
.tabbit-fetch-models-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tabbit-settings-icon-btn {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
opacity: 0.85;
transition: opacity 0.2s, transform 0.2s;
padding: 0 4px;
line-height: 1;
}
.tabbit-settings-icon-btn:hover { opacity: 1; transform: rotate(30deg); }
.tabbit-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
@keyframes tabbitFloatIn {
from { opacity: 0; transform: scale(0.5); }
to { opacity: 1; transform: scale(1); }
}
#tabbit-float-btn {
position: fixed !important;
top: 50%;
right: 18px;
z-index: 2147483647 !important;
display: flex !important;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: linear-gradient(135deg, #8b5cf6, #3b82f6) !important;
color: white !important;
border: none !important;
border-radius: 50%;
cursor: grab;
font-size: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 6px 20px rgba(0,0,0,.25);
animation: tabbitFloatIn 0.35s cubic-bezier(0.34,1.56,0.64,1) forwards;
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
user-select: none;
pointer-events: auto !important;
visibility: visible !important;
opacity: 1 !important;
}
#tabbit-float-btn.dragging { cursor: grabbing; animation: none; }
#tabbit-float-btn:hover {
transform: scale(1.08);
box-shadow: 0 8px 24px rgba(59,130,246,.34);
}
#tabbit-float-btn .tabbit-float-icon {
font-size: 20px;
line-height: 1;
}
#tabbit-float-btn .tabbit-float-label {
position: absolute;
top: -5px;
right: -5px;
min-width: 16px;
height: 16px;
padding: 0 3px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
border-radius: 999px;
background: #64748b;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 1;
}
#tabbit-float-btn.tabbit-float-processing {
background: linear-gradient(160deg, #2563eb 0%, #7c3aed 100%) !important;
}
#tabbit-float-btn.tabbit-float-ready {
background: linear-gradient(160deg, #16a34a 0%, #0891b2 100%) !important;
}
#tabbit-float-btn.tabbit-float-error {
background: linear-gradient(160deg, #dc2626 0%, #ea580c 100%) !important;
}
@media (max-width: 480px) {
#tabbit-float-btn {
right: 12px;
}
}
#tabbit-summary-notice {
position: fixed;
right: 18px;
bottom: 24px;
z-index: 2147483647;
width: min(360px, calc(100vw - 36px));
padding: 0;
border: 0;
border-radius: 12px;
background: rgba(255,255,255,0.96);
color: #222;
box-shadow: 0 10px 28px rgba(8,145,178,0.34);
overflow: hidden;
font: 14px/1.55 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: tabbitNoticeIn 0.24s ease-out;
}
#tabbit-summary-notice .tabbit-summary-notice-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: linear-gradient(135deg, rgba(22,163,74,0.96) 0%, rgba(8,145,178,0.96) 100%);
color: #fff;
font-weight: 700;
}
#tabbit-summary-notice .tabbit-summary-notice-title { flex: 1; }
#tabbit-summary-notice .tabbit-summary-notice-close {
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(255,255,255,0.18);
color: #fff;
cursor: pointer;
font-size: 18px;
line-height: 26px;
}
#tabbit-summary-notice .tabbit-summary-notice-body {
max-height: 220px;
overflow-y: auto;
padding: 14px 16px;
overflow-wrap: anywhere;
text-align: left;
}
#tabbit-summary-notice .tabbit-summary-notice-body > :first-child { margin-top: 0; }
#tabbit-summary-notice .tabbit-summary-notice-body > :last-child { margin-bottom: 0; }
#tabbit-summary-notice .tabbit-summary-notice-footer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
box-sizing: border-box;
padding: 13px 12px;
border: 0;
border-top: 1px solid rgba(203,213,225,0.7);
background: rgba(248,250,252,0.72);
color: #475569;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: background 0.18s ease;
}
#tabbit-summary-notice .tabbit-summary-notice-footer:hover,
#tabbit-summary-notice .tabbit-summary-notice-footer:focus-visible {
background: rgba(8,145,178,0.08);
outline: none;
}
#tabbit-summary-notice.tabbit-summary-notice-error {
background: linear-gradient(135deg, #dc2626 0%, #ea580c 100%);
color: #fff;
box-shadow: 0 10px 28px rgba(220,38,38,0.3);
padding: 13px 16px;
cursor: pointer;
}
@keyframes tabbitNoticeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.tabbit-preset-bar {
background: linear-gradient(135deg, #fff5f8 0%, #f0f4ff 100%);
border: 1px solid #ffd6e3;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 12px;
}
.tabbit-preset-bar-title {
font-size: 11px;
color: #888;
margin-bottom: 8px;
font-weight: 600;
letter-spacing: 0.5px;
}
.tabbit-preset-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tabbit-preset-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
background: white;
border: 1px solid #d8d8e0;
border-radius: 14px;
font-size: 12px;
color: #555;
cursor: pointer;
transition: all 0.18s;
user-select: none;
white-space: nowrap;
}
.tabbit-preset-chip:hover {
border-color: #fb7299;
color: #fb7299;
transform: translateY(-1px);
}
.tabbit-preset-chip.active {
background: linear-gradient(135deg, #fb7299 0%, #f25d8e 100%);
color: white;
border-color: transparent;
font-weight: 600;
box-shadow: 0 2px 8px rgba(251,114,153,0.3);
}
.tabbit-preset-chip.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tabbit-preset-icon { font-size: 13px; }
.tabbit-preset-manage-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.tabbit-preset-item {
background: #f8f9fa;
border: 1px solid #e0e0e8;
border-radius: 10px;
padding: 10px 12px;
transition: border-color 0.2s;
}
.tabbit-preset-item:hover {
border-color: #667eea;
}
.tabbit-preset-item-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
}
.tabbit-preset-item-row .tabbit-settings-input {
padding: 6px 10px;
font-size: 12.5px;
}
.tabbit-preset-icon-input {
width: 60px;
text-align: center;
flex-shrink: 0;
}
.tabbit-preset-name-input { flex: 1; }
.tabbit-preset-prompt-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #d8d8e0;
border-radius: 6px;
font-size: 12px;
font-family: Consolas, Monaco, monospace;
outline: none;
box-sizing: border-box;
resize: vertical;
min-height: 70px;
line-height: 1.5;
color: #333;
}
.tabbit-preset-prompt-textarea:focus { border-color: #667eea; }
.tabbit-preset-del-btn {
padding: 6px 10px;
background: #fff3f3;
color: #c00;
border: 1px solid #ffcccc;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
}
.tabbit-preset-del-btn:hover { background: #ffe0e0; }
.tabbit-preset-add-btn {
margin-top: 8px;
width: 100%;
padding: 8px;
border: 1px dashed #aaa;
border-radius: 8px;
background: white;
color: #666;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.tabbit-preset-add-btn:hover {
border-color: #667eea;
color: #667eea;
border-style: solid;
}
`;
document.head.appendChild(style);
}
// ==================== 拖动功能 ====================
function makeDraggable(target, handle, onEnd) {
handle = handle || target;
const handleIsTarget = (handle === target);
let isDragging = false;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
let moved = false;
handle.addEventListener('mousedown', function(e) {
if (e.button !== 0) return;
if (target._windowTools && !target._windowTools.canDrag()) return;
if (!handleIsTarget) {
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
}
isDragging = true;
moved = false;
const rect = target.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
target.style.left = startLeft + 'px';
target.style.top = startTop + 'px';
target.style.right = 'auto';
target.style.bottom = 'auto';
target.style.transform = 'none';
target.classList.add('dragging');
e.preventDefault();
e.stopPropagation();
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
const rect = target.getBoundingClientRect();
const maxLeft = window.innerWidth - rect.width;
const maxTop = window.innerHeight - rect.height;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
target.style.left = newLeft + 'px';
target.style.top = newTop + 'px';
e.preventDefault();
});
document.addEventListener('mouseup', function() {
if (!isDragging) return;
isDragging = false;
target.classList.remove('dragging');
if (moved && onEnd) {
const rect = target.getBoundingClientRect();
onEnd(rect.left, rect.top);
}
});
}
function clampPanelGeometry(panel, geom) {
const minW = 360;
const minH = 360;
const maxW = Math.max(minW, window.innerWidth - 24);
const maxH = Math.max(minH, window.innerHeight - 24);
const width = Math.max(minW, Math.min(Number(geom.width) || 480, maxW));
const height = Math.max(minH, Math.min(Number(geom.height) || Math.min(720, maxH), maxH));
let left = Number.isFinite(Number(geom.left)) ? Number(geom.left) : null;
let top = Number.isFinite(Number(geom.top)) ? Number(geom.top) : 20;
panel.style.width = width + 'px';
panel.style.height = height + 'px';
if (left !== null) {
left = Math.max(0, Math.min(left, window.innerWidth - width));
top = Math.max(0, Math.min(top, window.innerHeight - height));
panel.style.left = left + 'px';
panel.style.top = top + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.transform = 'none';
} else {
top = Math.max(0, Math.min(top, window.innerHeight - height));
panel.style.top = top + 'px';
}
}
function readPanelGeometry(panel) {
const rect = panel.getBoundingClientRect();
return {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
}
function makeResizable(panel, handle, onEnd) {
let isResizing = false;
let startX = 0, startY = 0, startW = 0, startH = 0, startLeft = 0, startTop = 0;
handle.addEventListener('mousedown', function(e) {
if (e.button !== 0) return;
if (panel._windowTools && !panel._windowTools.canDrag()) return;
const rect = panel.getBoundingClientRect();
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startW = rect.width;
startH = rect.height;
startLeft = rect.left;
startTop = rect.top;
panel.style.left = startLeft + 'px';
panel.style.top = startTop + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.classList.add('resizing');
document.body.style.userSelect = 'none';
e.preventDefault();
e.stopPropagation();
});
document.addEventListener('mousemove', function(e) {
if (!isResizing) return;
const maxW = window.innerWidth - startLeft;
const maxH = window.innerHeight - startTop;
const newW = Math.max(360, Math.min(startW + e.clientX - startX, maxW));
const newH = Math.max(360, Math.min(startH + e.clientY - startY, maxH));
panel.style.width = newW + 'px';
panel.style.height = newH + 'px';
e.preventDefault();
});
document.addEventListener('mouseup', function() {
if (!isResizing) return;
isResizing = false;
panel.classList.remove('resizing');
document.body.style.userSelect = '';
if (onEnd) onEnd(readPanelGeometry(panel));
});
}
// ==================== 面板创建 ====================
function bindModelChips(panel) {
panel.querySelectorAll('.tabbit-model-chip').forEach(chip => {
const newChip = chip.cloneNode(true);
chip.parentNode.replaceChild(newChip, chip);
newChip.addEventListener('click', async () => {
if (newChip.classList.contains('disabled')) return;
const newModel = newChip.dataset.model;
if (newModel === currentModel) return;
currentModel = newModel;
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.remove('active'));
newChip.classList.add('active');
conversationHistory = [];
commentConversationHistory = [];
rawDanmakuChatContext = '';
rawCommentsChatContext = '';
rawFullDataChatContext = '';
if (rawTranscript) {
await runSummary(panel, rawTranscript, currentVideoInfo, rawSubtitleBody);
} else {
showNoSubtitleState(panel, currentVideoInfo);
}
});
});
}
// 窗口布局独立于字幕流程;比例基于可视视口,兼容手机的桌面网页缩放。
function setupWindowTools(panel) {
const button = panel.querySelector('#tabbit-window-button');
const menu = panel.querySelector('.tabbit-window-menu');
const widthInput = menu.querySelector('[data-window-width]');
const heightInput = menu.querySelector('[data-window-height]');
const events = new AbortController();
const touch = () => navigator.maxTouchPoints > 0;
const slotKey = () => !touch() ? 'desktop' :
((screen.orientation?.type || (screen.width > screen.height ? 'landscape' : 'portrait')).startsWith('landscape') ? 'landscape' : 'portrait');
const viewport = () => {
const v = window.visualViewport;
const gap = 8 / (v?.scale || 1);
return { x: (v?.offsetLeft || 0) + gap, y: (v?.offsetTop || 0) + gap,
w: Math.max(1, (v?.width || innerWidth) - 2 * gap), h: Math.max(1, (v?.height || innerHeight) - 2 * gap) };
};
const bound = (v, low, high) => Math.max(low, Math.min(Number.isFinite(v) ? v : low, high));
const getState = () => POSITIONS.windowLayouts?.[slotKey()];
const normalize = () => {
const v = viewport(), r = panel.getBoundingClientRect();
return { x: bound((r.x - v.x) / v.w, 0, 1), y: bound((r.y - v.y) / v.h, 0, 1),
w: bound(r.width / v.w, .25, 1), h: bound(r.height / v.h, .25, 1) };
};
const ensureState = () => {
if (!POSITIONS.windowLayouts) POSITIONS.windowLayouts = {};
const key = slotKey();
if (!POSITIONS.windowLayouts[key]) POSITIONS.windowLayouts[key] = { mode: 'free', width: .42, free: normalize() };
return POSITIONS.windowLayouts[key];
};
let open = false, drag = null;
const setOpen = value => {
open = value;
menu.hidden = !value;
button.setAttribute('aria-expanded', String(value));
};
const render = () => {
const state = getState(), v = viewport();
button.hidden = !CONFIG.showWindowControls;
if (button.hidden) setOpen(false);
panel.classList.toggle('tabbit-window-managed', !!state);
panel.dataset.windowMode = state?.mode || 'free';
const minHeightRatio = Math.min(1, Math.max(.25, 180 / (touch() ? (window.visualViewport?.scale || 1) : 1) / v.h));
heightInput.min = String(Math.ceil(minHeightRatio * 100));
if (state) {
const free = state.free || { x: .5, y: 0, w: .5, h: .8 };
const w = v.w * (state.mode === 'full' ? 1 : state.mode === 'right' ? bound(state.width, .25, .75) : bound(free.w, .25, 1));
const h = v.h * (state.mode === 'free' ? bound(free.h, minHeightRatio, 1) : 1);
const x = state.mode === 'right' ? v.x + v.w - w : state.mode === 'full' ? v.x : v.x + bound(free.x * v.w, 0, v.w - w);
const y = state.mode === 'free' ? v.y + bound(free.y * v.h, 0, v.h - h) : v.y;
Object.assign(panel.style, { left: x + 'px', top: y + 'px', width: w + 'px', height: h + 'px', right: 'auto', bottom: 'auto', transform: 'none' });
}
const mode = state?.mode || 'free', free = state?.free || normalize();
menu.querySelectorAll('[data-window-mode]').forEach(el => el.setAttribute('aria-pressed', String(el.dataset.windowMode === mode)));
widthInput.disabled = mode === 'full';
widthInput.max = mode === 'free' ? '100' : '75';
widthInput.value = Math.round((mode === 'full' ? 1 : mode === 'right' ? state.width : free.w) * 100);
heightInput.value = Math.max(Number(heightInput.min), Math.round(free.h * 100));
menu.querySelector('[data-width-value]').textContent = widthInput.value + '%';
menu.querySelector('[data-height-value]').textContent = heightInput.value + '%';
menu.querySelector('[data-height-row]').hidden = mode !== 'free';
menu.querySelector('[data-height-hint]').hidden = mode === 'free';
// 菜单控件按手机实际缩放补偿,避免电脑模式下滑块太小。
const scale = touch() ? 1 / (window.visualViewport?.scale || 1) : 1;
button.style.fontSize = 16 * scale + 'px';
button.style.minWidth = 40 * scale + 'px';
button.style.minHeight = 40 * scale + 'px';
button.setAttribute('aria-label', '调整窗口');
menu.style.fontSize = 14 * scale + 'px';
menu.style.width = Math.min(320 * scale, v.w - 12) + 'px';
const header = panel.querySelector('.tabbit-panel-header').getBoundingClientRect();
menu.style.top = header.height + 'px';
menu.style.maxHeight = Math.max(44 * scale, panel.getBoundingClientRect().height - header.height - 8) + 'px';
};
const save = () => { savePositions(POSITIONS); };
const reset = () => {
if (POSITIONS.windowLayouts) delete POSITIONS.windowLayouts[slotKey()];
panel.classList.remove('tabbit-window-managed');
Object.assign(panel.style, { left: '', top: '', right: '', bottom: '', width: '', height: '', transform: '' });
applyPanelPosition(panel); setOpen(false); render(); save();
};
button.addEventListener('click', () => { render(); setOpen(!open); });
menu.querySelectorAll('[data-window-mode]').forEach(el => el.addEventListener('click', () => {
const state = ensureState(); state.mode = el.dataset.windowMode; render(); save();
}));
widthInput.addEventListener('input', () => {
const state = ensureState();
if (state.mode === 'right') state.width = Number(widthInput.value) / 100;
else if (state.mode === 'free') state.free.w = Number(widthInput.value) / 100;
render();
});
heightInput.addEventListener('input', () => { ensureState().free.h = Number(heightInput.value) / 100; render(); });
[widthInput, heightInput].forEach(el => el.addEventListener('change', save));
menu.querySelector('[data-window-done]').addEventListener('click', () => setOpen(false));
menu.querySelector('[data-window-reset]').addEventListener('click', reset);
panel.addEventListener('pointerdown', e => { if (!menu.contains(e.target) && !button.contains(e.target)) setOpen(false); });
panel.addEventListener('keydown', e => { if (e.key === 'Escape' && open) { setOpen(false); button.focus(); } });
const header = panel.querySelector('.tabbit-panel-header');
header.style.touchAction = 'none';
header.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse' || !e.isPrimary || e.target.closest('button') || (getState()?.mode || 'free') !== 'free') return;
const r = panel.getBoundingClientRect();
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, left: r.x, top: r.y, width: r.width, height: r.height };
header.setPointerCapture(e.pointerId);
});
header.addEventListener('pointermove', e => {
if (!drag || drag.id !== e.pointerId) return;
const v = viewport();
Object.assign(panel.style, { left: bound(drag.left + e.clientX - drag.x, v.x, v.x + Math.max(0, v.w - drag.width)) + 'px',
top: bound(drag.top + e.clientY - drag.y, v.y, v.y + Math.max(0, v.h - drag.height)) + 'px', right: 'auto', bottom: 'auto', transform: 'none' });
});
const finish = e => {
if (!drag || drag.id !== e.pointerId) return;
if (e.type === 'pointerup') { ensureState().free = normalize(); save(); }
else { panel.style.left = drag.left + 'px'; panel.style.top = drag.top + 'px'; }
drag = null;
if (header.hasPointerCapture(e.pointerId)) header.releasePointerCapture(e.pointerId);
};
header.addEventListener('pointerup', finish);
header.addEventListener('pointercancel', finish);
header.addEventListener('lostpointercapture', finish);
let lastKey = slotKey();
const sync = () => {
if (!panel.isConnected) { events.abort(); return; }
const key = slotKey();
if (lastKey !== key) { lastKey = key; drag = null; if (!getState()) { panel.classList.remove('tabbit-window-managed'); Object.assign(panel.style, { left: '', top: '', right: '', bottom: '', transform: '' }); applyPanelPosition(panel); } }
render();
};
window.addEventListener('resize', sync, { signal: events.signal });
window.visualViewport?.addEventListener('resize', sync, { signal: events.signal });
window.visualViewport?.addEventListener('scroll', sync, { signal: events.signal });
screen.orientation?.addEventListener('change', sync, { signal: events.signal });
panel._windowTools = { refresh: render, reset, dispose: () => events.abort(),
canDrag: () => !touch() && (getState()?.mode || 'free') === 'free',
saveFree: () => { if (getState()) { getState().mode = 'free'; getState().free = normalize(); save(); render(); } } };
render();
}
function applyPanelPosition(panel) {
clampPanelGeometry(panel, POSITIONS.panel || {});
}
function createPanel(videoInfo) {
const existing = document.querySelector('#tabbit-ai-summary-panel');
if (existing) { existing._windowTools?.dispose(); existing.remove(); }
const panel = document.createElement('div');
panel.id = 'tabbit-ai-summary-panel';
const modelChips = CONFIG.modelList.map(m =>
'' + escapeHtml(m) + '
'
).join('');
panel.innerHTML = `
🤖 选择模型
▶
${modelChips}
${renderVideoMetaBottomHtml(videoInfo, window.location.href)}
${renderPresetBarHtml()}
`;
applyPanelPosition(panel);
document.body.appendChild(panel);
setupWindowTools(panel);
const header = panel.querySelector('.tabbit-panel-header');
makeDraggable(panel, header, function(left, top) {
const geom = readPanelGeometry(panel);
panel._windowTools?.saveFree();
POSITIONS.panel = { left, top, width: geom.width, height: geom.height };
savePositions(POSITIONS);
});
const resizer = panel.querySelector('.tabbit-panel-resizer');
if (resizer) {
makeResizable(panel, resizer, function(geom) {
panel._windowTools?.saveFree();
POSITIONS.panel = geom;
savePositions(POSITIONS);
});
}
panel.querySelector('.tabbit-close-btn').addEventListener('click', () => {
collapsePanel(panel);
});
bindOutsidePanelCollapse(panel);
bindModelChips(panel);
const input = panel.querySelector('.tabbit-chat-input');
const sendBtn = panel.querySelector('.tabbit-chat-send');
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 100) + 'px';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend(panel);
}
});
sendBtn.addEventListener('click', () => handleSend(panel));
const settingsBtn = panel.querySelector('#tabbit-open-settings');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => openSettingsPanel(panel));
}
const autoSubtitleStopBtn = panel.querySelector('#tabbit-auto-subtitle-stop');
if (autoSubtitleStopBtn) {
const stopAutoSubtitle = function(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
autoSubtitleStopBtn.disabled = true;
autoSubtitleStopBtn.textContent = '⏳ 正在切换...';
if (typeof currentSubtitleManualFallback === 'function') {
currentSubtitleManualFallback();
return;
}
try { abortCurrentTask(); } catch(err) {}
showNoSubtitleState(panel, currentVideoInfo || videoInfo || getVideoInfo(), false, {
icon: '⏹',
title: '已停止自动获取',
desc: '自动字幕获取已停止。现在可以手动获取字幕,或直接上传 srt/txt/粘贴字幕内容。'
});
};
autoSubtitleStopBtn.addEventListener('pointerdown', stopAutoSubtitle, true);
autoSubtitleStopBtn.addEventListener('click', stopAutoSubtitle, true);
}
return panel;
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&').replace(//g, '>');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
}
function safeHref(raw) {
const href = String(raw || '').trim();
if (!href) return '#';
const normalized = href.replace(/[\u0000-\u001F\u007F\s]+/g, '').toLowerCase();
if (/^(https?:\/\/|mailto:|\/|\.\.?\/|#)/i.test(normalized)) return escapeAttr(href);
return '#';
}
function renderVideoMetaBottomHtml(videoInfo, url) {
const safeInfo = videoInfo || {};
const pageUrl = url || window.location.href;
return `
${escapeHtml(safeInfo.title || '未知标题')}
▶
UP主: ${escapeHtml(safeInfo.upName || '未知')}
${safeInfo.desc ? '
简介: ' + escapeHtml(limitText(safeInfo.desc, 500)) + '
' : ''}
`;
}
// 🆕 ==================== 中断控制 ====================
function abortCurrentTask() {
if (currentAbortController) {
try {
currentAbortController.abort();
console.log('[省流助手] 用户主动打断当前任务');
} catch(e) {
console.warn('[省流助手] 打断异常:', e.message);
}
currentAbortController = null;
}
}
// 🆕 判断错误是否为用户主动打断
function isAbortError(err) {
return err && (err.name === 'AbortError' || /aborted|abort/i.test(err.message || ''));
}
// 🆕 在指定容器内插入"打断"按钮,返回按钮元素
function insertInlineAbortBtn(container, onAbort, label) {
const btn = document.createElement('button');
btn.className = 'tabbit-inline-abort-btn';
btn.innerHTML = label || '⏹ 打断生成';
let handled = false;
function handleAbortClick(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (handled) return;
handled = true;
btn.disabled = true;
btn.innerHTML = '⏳ 打断中...';
if (typeof onAbort === 'function') onAbort();
}
btn.addEventListener('pointerdown', handleAbortClick, true);
btn.addEventListener('click', handleAbortClick, true);
container.appendChild(btn);
return btn;
}
// 🆕 把发送按钮切换为"打断"模式
function setSendBtnAsAbort(sendBtn, onAbort) {
if (!sendBtn) return;
sendBtn.classList.add('tabbit-abort-mode');
sendBtn.disabled = false;
sendBtn.innerHTML = '⏹';
sendBtn.title = '打断生成';
sendBtn._abortHandler = function(e) {
e.preventDefault();
e.stopPropagation();
if (typeof onAbort === 'function') onAbort();
};
sendBtn.addEventListener('click', sendBtn._abortHandler, true);
}
// 🆕 还原发送按钮
function restoreSendBtn(sendBtn) {
if (!sendBtn) return;
sendBtn.classList.remove('tabbit-abort-mode');
sendBtn.innerHTML = '➤';
sendBtn.title = '发送 (Enter)';
if (sendBtn._abortHandler) {
sendBtn.removeEventListener('click', sendBtn._abortHandler, true);
sendBtn._abortHandler = null;
}
}
// ==================== 悬浮按钮 ====================
function applyFloatBtnPosition(btn) {
if (POSITIONS.floatBtn) {
const side = POSITIONS.floatBtn.side === 'left' ? 'left' : 'right';
const bottom = Math.max(0, Math.min(POSITIONS.floatBtn.bottom ?? 170, window.innerHeight - btn.offsetHeight));
btn.style[side] = '16px';
btn.style[side === 'left' ? 'right' : 'left'] = 'auto';
btn.style.bottom = bottom + 'px';
btn.style.top = 'auto';
btn.style.transform = 'none';
} else {
btn.style.right = '16px';
btn.style.left = 'auto';
btn.style.bottom = '170px';
btn.style.top = 'auto';
btn.style.transform = 'none';
}
}
window.addEventListener('resize', function() {
const btn = document.querySelector('#tabbit-float-btn');
if (!btn) return;
if (POSITIONS.floatBtn) {
POSITIONS.floatBtn.bottom = Math.max(0, Math.min(POSITIONS.floatBtn.bottom ?? 170, window.innerHeight - btn.offsetHeight));
savePositions(POSITIONS);
}
applyFloatBtnPosition(btn);
});
function clearSummaryNotice() {
if (summaryNoticeTimer) clearTimeout(summaryNoticeTimer);
summaryNoticeTimer = null;
const notice = document.querySelector('#tabbit-summary-notice');
if (notice) notice.remove();
}
function openPanel(panel) {
if (!panel) return;
if (panel._tabbitCollapseTimer) clearTimeout(panel._tabbitCollapseTimer);
panel._tabbitCollapseTimer = null;
panel._tabbitClosing = false;
clearSummaryNotice();
hideFloatBtn();
panel.style.animation = 'none';
panel.style.display = 'flex';
void panel.offsetWidth;
panel.style.animation = 'slideInRight 0.3s ease';
}
function collapsePanel(panel) {
if (!panel || panel.style.display === 'none' || panel._tabbitClosing) return;
panel._tabbitClosing = true;
panel.style.animation = 'slideOutRight 0.3s ease forwards';
panel._tabbitCollapseTimer = setTimeout(() => {
panel.style.display = 'none';
panel._tabbitClosing = false;
panel._tabbitCollapseTimer = null;
try { showFloatBtn(panel, panel._tabbitFloatState || 'idle'); } catch(e) { console.warn('[省流助手] 显示悬浮窗失败:', e); }
}, 350);
}
function bindOutsidePanelCollapse(panel) {
if (panelOutsidePointerHandler) {
document.removeEventListener('pointerdown', panelOutsidePointerHandler, true);
}
panelOutsidePointerHandler = function(event) {
if (!panel || panel.style.display === 'none' || panel._tabbitClosing) return;
const target = event.target;
if (target && target.closest && target.closest(
'#tabbit-ai-summary-panel, #tabbit-settings-overlay, #tabbit-upload-overlay, #tabbit-summary-notice, #tabbit-float-btn'
)) return;
collapsePanel(panel);
};
document.addEventListener('pointerdown', panelOutsidePointerHandler, true);
}
function showSummaryNotice(panel, text, type, summaryText) {
clearSummaryNotice();
if (!panel || panel.style.display !== 'none') return;
const notice = document.createElement(type === 'error' ? 'button' : 'section');
notice.id = 'tabbit-summary-notice';
if (type === 'error') {
notice.classList.add('tabbit-summary-notice-error');
notice.type = 'button';
notice.textContent = text;
notice.addEventListener('click', function() {
clearSummaryNotice();
openPanel(panel);
});
} else {
notice.setAttribute('role', 'region');
notice.setAttribute('aria-label', '视频摘要已完成');
notice.innerHTML =
'' +
'' +
'';
const body = notice.querySelector('.tabbit-summary-notice-body');
if (body) body.innerHTML = parseMarkdown(String(summaryText || '摘要已生成,可打开完整面板查看。').trim());
const closeBtn = notice.querySelector('.tabbit-summary-notice-close');
if (closeBtn) closeBtn.addEventListener('click', clearSummaryNotice);
const moreArea = notice.querySelector('.tabbit-summary-notice-footer');
if (moreArea) moreArea.addEventListener('click', function() {
clearSummaryNotice();
openPanel(panel);
});
}
document.body.appendChild(notice);
}
function setFloatBtnState(panel, state) {
if (!panel) return;
panel._tabbitFloatState = state || 'idle';
if (panel.style.display === 'none') showFloatBtn(panel, panel._tabbitFloatState);
}
function notifyBackgroundSummaryReady(panel, generation, expectedBvid) {
if (isStaleSummaryContext(generation, expectedBvid)) return;
setFloatBtnState(panel, 'ready');
const contentDiv = panel.querySelector('.tabbit-panel-content');
const summaryText = contentDiv && contentDiv._tabbitSummaryText;
showSummaryNotice(panel, '✅ 摘要已完成', 'ready', summaryText);
}
function notifyBackgroundSummaryFailed(panel, generation, message, state, expectedBvid) {
if (isStaleSummaryContext(generation, expectedBvid)) return;
setFloatBtnState(panel, state || 'error');
showSummaryNotice(panel, '⚠️ ' + (message || '摘要失败,点击查看'), 'error');
}
function showFloatBtn(panel, state) {
createStyles();
const old = document.querySelector('#tabbit-float-btn');
if (old) old.remove();
const btn = document.createElement('button');
btn.id = 'tabbit-float-btn';
const floatState = state || (panel && panel._tabbitFloatState) || 'idle';
const labels = {
processing: { badge: '…', label: '处理中', title: '处理中,点击查看' },
ready: { badge: '✓', label: '已完成', title: '已完成,点击查看' },
error: { badge: '!', label: '未完成', title: '未完成,点击查看' },
'no-subtitle': { badge: '!', label: '无字幕', title: '无字幕,点击查看' },
interrupted: { badge: '!', label: '已中断', title: '已中断,点击查看' },
idle: { badge: '', label: '省流助手', title: panel ? '打开省流助手' : '开始处理' }
};
const info = labels[floatState] || labels.idle;
if (floatState === 'error' || floatState === 'no-subtitle' || floatState === 'interrupted') {
btn.classList.add('tabbit-float-error');
} else if (floatState !== 'idle') {
btn.classList.add('tabbit-float-' + floatState);
}
btn.title = info.title;
btn.setAttribute('aria-label', '省流助手:' + info.label);
btn.innerHTML = '🎬' + (info.badge ? '' + info.badge + '' : '');
btn.style.cssText = 'position:fixed!important;z-index:2147483647!important;display:flex!important;visibility:visible!important;opacity:1!important;pointer-events:auto!important;';
document.body.appendChild(btn);
applyFloatBtnPosition(btn);
let dragMoved = false;
makeDraggable(btn, btn, function(left, top) {
const rect = btn.getBoundingClientRect();
POSITIONS.layoutVersion = 1;
POSITIONS.floatBtn = {
side: rect.left + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right',
bottom: Math.max(0, Math.round(window.innerHeight - rect.bottom))
};
savePositions(POSITIONS);
applyFloatBtnPosition(btn);
dragMoved = true;
setTimeout(() => { dragMoved = false; }, 150);
});
btn.addEventListener('click', async () => {
if (dragMoved) return;
if (panel) {
openPanel(panel);
} else {
hideFloatBtn();
await startParsing();
}
});
}
function hideFloatBtn() {
const btn = document.querySelector('#tabbit-float-btn');
if (btn) btn.remove();
}
// ==================== 发送到 flomo ====================
function buildFlomoContent(text, videoInfo, pageUrl = window.location.href, kind = '视频摘要', includeDialog = true) {
const lines = [];
if (videoInfo) {
lines.push('📄 ' + (videoInfo.title || '未知标题'));
lines.push('🔗 ' + pageUrl);
lines.push('');
}
lines.push('===== 🤖 AI 总结 =====');
lines.push(text);
// 后续对话(跳过第一条 user 消息,那是含字幕的完整 prompt)
const dialog = includeDialog ? conversationHistory.filter(m => m.role !== 'system').slice(2) : [];
if (dialog.length) {
lines.push('');
lines.push('===== 💬 后续对话 =====');
for (let i = 0; i < dialog.length; i++) {
const m = dialog[i];
const tag = m.role === 'user' ? '【我】' : '【AI · ' + currentModel + '】';
lines.push(tag);
lines.push(m.content);
lines.push('');
}
}
return formatFlomoNote(lines.join('\n'), 'B站', kind, CONFIG.flomoTags);
}
// 两个摘要脚本保持相同的检索标签和请求结果规则。
function formatFlomoNote(text, source, kind, customTags) {
const tags = [...new Set(('#AI摘要 #AI摘要/' + source + ' ' + (customTags || ''))
.split(/\s+/).filter(Boolean).map(tag => '#' + tag.replace(/^#+/, '')))];
return text.trim() + '\n\n类型:' + kind + '\n\n' + tags.join(' ');
}
const flomoPending = new Set();
function postFlomoNote(content, apiUrl) {
// 阻止同一页面上自动发送与手动点击同时提交同一条笔记;不自动重试超时请求。
const key = apiUrl + '\n' + content;
if (flomoPending.has(key)) return Promise.resolve(false);
flomoPending.add(key);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST', url: apiUrl,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ content }), timeout: 30000,
onload(res) {
try {
const data = JSON.parse(res.responseText || '{}');
const ok = res.status >= 200 && res.status < 300 &&
(data.code === 0 || data.code === 200 ||
(data.code == null && (data.message === 'ok' || data.message === 'success')));
if (!ok) throw new Error(data.message || 'HTTP ' + res.status);
resolve(true);
} catch (err) { reject(err); }
},
onerror() { reject(new Error('网络错误')); },
ontimeout() { reject(new Error('发送超时,可能已送达,请先检查 flomo')); },
onabort() { reject(new Error('发送已取消')); }
});
}).finally(() => flomoPending.delete(key));
}
function showFlomoStatus(message) {
const notice = document.createElement('div');
notice.textContent = message;
notice.style.cssText = 'position:fixed;bottom:24px;right:24px;z-index:2147483647;background:#263238;color:white;padding:12px 18px;border-radius:8px;max-width:360px;font-size:14px';
document.body.appendChild(notice);
setTimeout(() => notice.remove(), 5000);
}
async function sendToFlomo(text, btn, options = {}) {
if (!CONFIG.flomoApiUrl) {
if (!options.auto) alert('请先在设置中配置 flomo API 地址');
return;
}
if (!String(text || '').trim()) return;
const content = buildFlomoContent(text, options.videoInfo || currentVideoInfo,
options.pageUrl || window.location.href, options.kind || '视频摘要', !options.auto && !options.kind);
const originalText = btn?.textContent;
if (btn) { btn.textContent = '⏳ 发送中...'; btn.disabled = true; }
try {
if (await postFlomoNote(content, CONFIG.flomoApiUrl)) showFlomoStatus('✅ 已发送到 flomo');
} catch (err) {
showFlomoStatus('发送到 flomo 失败:' + err.message);
} finally {
if (btn) { btn.textContent = originalText; btn.disabled = false; }
}
}
function autoSendFlomo(text, videoInfo, pageUrl, kind = '视频摘要') {
if (CONFIG.flomoAutoSend === true && CONFIG.flomoApiUrl) {
void sendToFlomo(text, null, { auto: true, videoInfo, pageUrl, kind });
}
}
// ==================== 复制 / 结果展示 ====================
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
async function copyResult(btn) {
if (!rawMarkdownResult) return;
await copyToClipboard(markdownToPlainText(rawMarkdownResult));
btn.textContent = '✅ 已复制';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = '📋 复制摘要';
btn.classList.remove('copied');
}, 2000);
}
async function copyCommentResult(btn, text) {
await copyToClipboard(markdownToPlainText(text));
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.textContent = '📋 复制评论总结'; }, 2000);
}
function getCurrentSummaryText(contentDiv, fallbackText) {
if (contentDiv && typeof contentDiv._tabbitSummaryText === 'string') {
return contentDiv._tabbitSummaryText;
}
return rawMarkdownResult || fallbackText || '';
}
function updateConversationSummary(text) {
for (let i = conversationHistory.length - 1; i >= 0; i--) {
if (conversationHistory[i] && conversationHistory[i].role === 'assistant') {
conversationHistory[i].content = text;
return;
}
}
}
function nextImageGenerationSeq(contentDiv) {
if (!contentDiv) return 0;
contentDiv._tabbitImageGenSeq = (contentDiv._tabbitImageGenSeq || 0) + 1;
return contentDiv._tabbitImageGenSeq;
}
function invalidatePendingImageGeneration(contentDiv) {
if (!contentDiv) return;
nextImageGenerationSeq(contentDiv);
const loadingWrap = contentDiv.querySelector('.tabbit-img-wrap.tabbit-img-loading');
if (loadingWrap) {
loadingWrap.outerHTML = '摘要已修改,请重新生成配图
';
}
}
function setCurrentSummaryText(contentDiv, text) {
const nextText = String(text || '');
if (contentDiv) contentDiv._tabbitSummaryText = nextText;
rawMarkdownResult = nextText;
updateConversationSummary(nextText);
if (contentDiv && contentDiv._tabbitSummaryCacheKey) {
setCachedSummary(contentDiv._tabbitSummaryCacheKey, {
summary: nextText,
model: currentModel,
presetId: contentDiv._tabbitSummaryPresetId || getCurrentPresetId(),
title: contentDiv._tabbitSummaryTitle || currentVideoInfo?.title
});
}
}
function renderSummaryMarkdown(contentDiv, text) {
const resultContainer = contentDiv ? contentDiv.querySelector('.tabbit-result') : null;
if (!resultContainer) return;
resultContainer.classList.remove('tabbit-summary-editing');
resultContainer.innerHTML = parseMarkdown(text || '');
}
function startSummaryEdit(contentDiv, fallbackText) {
const resultContainer = contentDiv ? contentDiv.querySelector('.tabbit-result') : null;
if (!resultContainer || resultContainer.classList.contains('tabbit-summary-editing')) return;
const originalText = getCurrentSummaryText(contentDiv, fallbackText);
resultContainer.classList.add('tabbit-summary-editing');
resultContainer.innerHTML = '';
const textarea = document.createElement('textarea');
textarea.className = 'tabbit-summary-editor';
textarea.value = originalText;
const actions = document.createElement('div');
actions.className = 'tabbit-summary-edit-actions';
const saveBtn = document.createElement('button');
saveBtn.className = 'tabbit-copy-btn';
saveBtn.textContent = '✅ 保存摘要';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'tabbit-copy-btn';
cancelBtn.textContent = '取消';
const status = document.createElement('span');
status.className = 'tabbit-summary-edit-status';
saveBtn.addEventListener('click', function() {
const nextText = textarea.value.trim();
if (!nextText) {
alert('摘要不能为空');
return;
}
setCurrentSummaryText(contentDiv, nextText);
invalidatePendingImageGeneration(contentDiv);
renderSummaryMarkdown(contentDiv, nextText);
refreshResultActionButtons(contentDiv);
const actionsDiv = contentDiv.querySelector('.tabbit-result-actions');
const statusEl = actionsDiv ? actionsDiv.querySelector('.tabbit-summary-edit-status') : null;
if (statusEl) {
statusEl.textContent = '已保存,生成配图会使用新摘要';
setTimeout(function() { statusEl.textContent = ''; }, 2500);
}
});
cancelBtn.addEventListener('click', function() {
renderSummaryMarkdown(contentDiv, originalText);
});
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
actions.appendChild(status);
resultContainer.appendChild(textarea);
resultContainer.appendChild(actions);
textarea.focus();
}
// ==================== 手动生图功能 ====================
async function triggerManualImageGen(contentDiv, summaryText, videoInfo, btn) {
if (isFlowImageGenMode()) {
await triggerFlowImageGen(contentDiv, summaryText, videoInfo, btn);
return;
}
const apiUrl = CONFIG.imageGenApiUrl || CONFIG.apiUrl;
const apiKey = CONFIG.imageGenApiKey || CONFIG.apiKey;
const model = CONFIG.imageGenModel || 'gemini-2.0-flash-preview-image-generation';
if (!apiUrl || !apiKey) {
alert('请先在设置中配置生图模型的 API URL 和 API Key');
return;
}
btn.disabled = true;
btn.textContent = '⏳ 生成中...';
const imagePrompt = buildImagePrompt(summaryText || '');
const imageSeq = nextImageGenerationSeq(contentDiv);
const imageSlot = contentDiv.querySelector('.tabbit-image-slot');
if (imageSlot) {
imageSlot.innerHTML =
'' +
'
' +
'
🖼️ 配图生成中,请稍候...
' +
'
使用当前保存的摘要
' +
'
';
}
try {
const imageDataUrl = await generateImageByApi(apiUrl, apiKey, model, imagePrompt);
if (contentDiv._tabbitImageGenSeq !== imageSeq) {
btn.textContent = '🖼️ 生成配图';
btn.disabled = false;
return;
}
if (imageSlot) {
updateImageResult(contentDiv, imageDataUrl, videoInfo);
} else {
const resultContainer = contentDiv.querySelector('.tabbit-result');
if (resultContainer) {
const imgDiv = document.createElement('div');
imgDiv.style.cssText = 'text-align:center;margin-top:14px;padding-top:14px;border-top:1px dashed #e0e0e0;';
imgDiv.innerHTML = '🖼️ AI 生成配图
';
const img = document.createElement('img');
img.src = imageDataUrl;
img.style.cssText = 'max-width:100%;border-radius:10px;box-shadow:0 4px 16px rgba(0,0,0,0.12);cursor:pointer;';
img.title = '点击查看大图';
img.addEventListener('click', function() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:99999999;display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:fadeIn 0.2s ease;';
const bigImg = document.createElement('img');
bigImg.src = imageDataUrl;
bigImg.style.cssText = 'max-width:95vw;max-height:95vh;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);';
overlay.appendChild(bigImg);
overlay.addEventListener('click', function() { overlay.remove(); });
document.body.appendChild(overlay);
});
imgDiv.appendChild(img);
imgDiv.appendChild(createImageActionRow(imageDataUrl, videoInfo, '_配图'));
resultContainer.appendChild(imgDiv);
}
if (contentDiv._tabbitResultActionContext) {
contentDiv._tabbitResultActionContext.resultType = 'image';
contentDiv._tabbitResultActionContext.hasImage = true;
contentDiv._tabbitResultActionContext.imageDataUrl = imageDataUrl;
contentDiv._tabbitResultActionContext.videoInfo = videoInfo;
refreshResultActionButtons(contentDiv);
}
}
if (!imageSlot && CONFIG.enableImageAutoDownload !== false) {
await downloadGeneratedImage(imageDataUrl, videoInfo, '_配图', { useDirectory: true, directorySubdirectory: '图片', allowDirectoryPicker: false });
}
btn.textContent = '🔁 重新生成配图';
btn.disabled = false;
console.log('[省流助手-手动生图] ✅ 图片生成成功');
} catch (err) {
if (contentDiv._tabbitImageGenSeq !== imageSeq) {
btn.textContent = '🖼️ 生成配图';
btn.disabled = false;
return;
}
console.error('[省流助手-手动生图] 失败:', err);
alert('生成配图失败: ' + err.message);
btn.textContent = '🖼️ 生成配图';
btn.disabled = false;
}
}
// 🆕 流式总结的最终装配(流式结束时调用,挂上完整的按钮区)
function finalizeSummaryUI(contentDiv, result, _url, videoInfo) {
setCurrentSummaryText(contentDiv, result);
const resultContainer = contentDiv.querySelector('.tabbit-result');
if (resultContainer) {
resultContainer.innerHTML = parseMarkdown(result);
}
contentDiv._tabbitResultActionContext = {
resultType: 'summary',
contentDiv: contentDiv,
videoInfo: videoInfo,
summaryFallback: result,
hasText: !!String(result || '').trim(),
hasImage: false,
hasTimelineSubtitle: hasStructuredSubtitleData(rawSubtitleBody) && !!buildSrtContent(rawSubtitleBody),
modelLabel: '🤖 ' + currentModel
};
refreshResultActionButtons(contentDiv);
}
// ==================== 无字幕状态展示 ====================
function showNoSubtitleState(panel, videoInfo, isShortVideo, options) {
options = options || {};
const contentDiv = panel.querySelector('.tabbit-panel-content');
const input = panel.querySelector('.tabbit-chat-input');
const sendBtn = panel.querySelector('.tabbit-chat-send');
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.add('disabled'));
const skipSec = CONFIG.skipDuration || 60;
const noSubIcon = options.icon || (isShortVideo ? '⏱️' : '🔇');
const noSubTitle = options.title || (isShortVideo ? '视频时长不足' + skipSec + '秒,已跳过自动获取' : '未检测到字幕');
const noSubDesc = options.desc || (isShortVideo
? '短视频已自动跳过字幕获取(阈值' + skipSec + '秒,可在设置中修改)。如仍需摘要,可点击下方按钮手动获取,或总结评论区!'
: '该视频暂无可用字幕,无法生成视频摘要。可尝试手动获取,或使用下方按钮总结评论区!');
contentDiv.innerHTML = `
${renderVideoMetaBottomHtml(videoInfo, window.location.href)}
${noSubIcon}
${noSubTitle}
${noSubDesc}
`;
const manualFetchBtn = contentDiv.querySelector('#tabbit-manual-fetch-btn');
if (manualFetchBtn) {
manualFetchBtn.addEventListener('click', () => manualFetchSubtitle(panel, videoInfo));
}
const captureSubtitleBtn = contentDiv.querySelector('#tabbit-capture-subtitle-btn');
if (captureSubtitleBtn) {
captureSubtitleBtn.addEventListener('click', () => captureSubtitleFromPlayer(panel, videoInfo));
}
// 🆕 手动上传字幕按钮
const manualUploadBtn = contentDiv.querySelector('#tabbit-manual-upload-btn');
if (manualUploadBtn) {
manualUploadBtn.addEventListener('click', () => openManualUploadDialog(panel, videoInfo));
}
const commentBtn = contentDiv.querySelector('#tabbit-comment-btn');
if (commentBtn) {
commentBtn.addEventListener('click', () => runCommentSummary(panel, videoInfo));
}
const danmakuBtn = contentDiv.querySelector('#tabbit-danmaku-btn');
if (danmakuBtn) {
danmakuBtn.addEventListener('click', () => runDanmakuSummary(panel, videoInfo));
}
const fullBtn = contentDiv.querySelector('#tabbit-full-btn');
if (fullBtn) {
fullBtn.addEventListener('click', () => runFullAnalysis(panel, videoInfo));
}
input.disabled = true;
sendBtn.disabled = true;
input.placeholder = '无字幕,无法基于视频内容对话';
}
// ==================== 手动获取字幕 ====================
function setFetchBtnState(btn, text, icon, resetAfterMs) {
if (!btn) return;
if (btn._tabbitFetchResetTimer) clearTimeout(btn._tabbitFetchResetTimer);
btn._tabbitFetchResetTimer = null;
const spanEl = btn.querySelector('span:last-child');
const iconEl = btn.querySelector('span:first-child');
if (spanEl && !btn.dataset.defaultText) btn.dataset.defaultText = spanEl.textContent || '';
if (iconEl && !btn.dataset.defaultIcon) btn.dataset.defaultIcon = iconEl.textContent || '';
if (spanEl) spanEl.textContent = text;
if (iconEl) iconEl.textContent = icon;
if (resetAfterMs) {
btn._tabbitFetchResetTimer = setTimeout(() => {
btn._tabbitFetchResetTimer = null;
if (spanEl) spanEl.textContent = btn.dataset.defaultText || '手动获取字幕总结';
if (iconEl) iconEl.textContent = btn.dataset.defaultIcon || '🔄';
btn.disabled = false;
}, resetAfterMs);
}
}
function clickBiliElement(el) {
if (!el) return false;
try {
['mouseover', 'mouseenter', 'mousedown', 'mouseup', 'click'].forEach(function(type) {
el.dispatchEvent(new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window
}));
});
if (typeof el.click === 'function') el.click();
return true;
} catch(e) {
try {
if (typeof el.click === 'function') {
el.click();
return true;
}
} catch(_) {}
}
return false;
}
// 字幕总开关是二态控件,不能复用会派发 click 再调用 .click() 的通用方法,避免被反向切回。
function clickBiliSubtitleToggleOnce(el) {
if (!el || !el.isConnected) return false;
try {
['mouseover', 'mouseenter', 'mousedown', 'mouseup'].forEach(function(type) {
el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
});
el.click();
return true;
} catch(e) {
console.log('[省流助手-捕获模式] 字幕开关点击失败:', e.message);
return false;
}
}
function pickSubtitleLanguageItem() {
var selectors = [
'.bpx-player-ctrl-subtitle-language-item[data-lan="zh-CN"]',
'.bpx-player-ctrl-subtitle-language-item[data-lan="ai-zh"]',
'.bpx-player-ctrl-subtitle-language-item'
];
for (var i = 0; i < selectors.length; i++) {
var item = document.querySelector(selectors[i]);
if (item) return item;
}
return null;
}
function getSubtitleToggleElement() {
return document.querySelector('.bpx-player-ctrl-subtitle,.bpx-player-ctrl-btn-subtitle');
}
// B 站不同播放器版本的状态标记不完全一致;只有能明确判断时才执行恢复点击。
function getSubtitleToggleState(toggle) {
toggle = toggle || getSubtitleToggleElement();
if (!toggle || !toggle.isConnected) return { state: 'unknown', toggle: toggle };
var ariaPressed = String(toggle.getAttribute('aria-pressed') || '').toLowerCase();
if (ariaPressed === 'true') return { state: 'on', toggle: toggle, source: 'aria-pressed' };
if (ariaPressed === 'false') return { state: 'off', toggle: toggle, source: 'aria-pressed' };
var ariaChecked = String(toggle.getAttribute('aria-checked') || '').toLowerCase();
if (ariaChecked === 'true') return { state: 'on', toggle: toggle, source: 'aria-checked' };
if (ariaChecked === 'false') return { state: 'off', toggle: toggle, source: 'aria-checked' };
var classText = String(toggle.className || '') + ' ' + String(toggle.getAttribute('data-state') || '');
if (/(^|\s|[-_])(active|on|enabled|show|selected)(\s|$|[-_])/i.test(classText)) {
return { state: 'on', toggle: toggle, source: 'class' };
}
if (/(^|\s|[-_])(off|disabled|hide|hidden)(\s|$|[-_])/i.test(classText)) {
return { state: 'off', toggle: toggle, source: 'class' };
}
var label = [toggle.getAttribute('title'), toggle.getAttribute('aria-label'), toggle.textContent].filter(Boolean).join(' ');
if (/关闭.*字幕|隐藏.*字幕/.test(label)) return { state: 'on', toggle: toggle, source: 'label' };
if (/打开.*字幕|显示.*字幕/.test(label)) return { state: 'off', toggle: toggle, source: 'label' };
var subtitleLayer = document.querySelector('.bpx-player-subtitle-wrap,.bpx-player-subtitle,.bpx-player-subtitle-panel');
if (subtitleLayer && subtitleLayer.isConnected && subtitleLayer.textContent && subtitleLayer.getClientRects().length) {
return { state: 'on', toggle: toggle, source: 'visible-layer' };
}
return { state: 'unknown', toggle: toggle };
}
function waitForSubtitleCaptureControl(timeoutMs, signal) {
timeoutMs = timeoutMs || SUBTITLE_CAPTURE_CONTROL_TIMEOUT_MS;
return new Promise(function(resolve, reject) {
var startedAt = Date.now();
var timer = null;
function cleanup() {
if (timer) clearTimeout(timer);
if (signal) signal.removeEventListener('abort', onAbort);
}
function onAbort() {
cleanup();
var err = new Error('用户已打断');
err.name = 'AbortError';
reject(err);
}
function check() {
var toggle = getSubtitleToggleElement();
if (toggle) {
cleanup();
resolve(toggle);
return;
}
if (Date.now() - startedAt >= timeoutMs) {
cleanup();
resolve(null);
return;
}
timer = setTimeout(check, 50);
}
if (signal && signal.aborted) return onAbort();
if (signal) signal.addEventListener('abort', onAbort);
check(); // 已经加载时首轮立刻触发,不等轮询周期。
});
}
function isCaptureSessionCurrent(session) {
return !!(session && session.generation === routeGeneration && session.routeKey === getRouteKey() &&
session.toggle && session.toggle.isConnected && document.contains(session.toggle));
}
function restoreSubtitleAfterCapture(session, reason) {
if (!session || !session.toggledByScript) return false;
if (!isCaptureSessionCurrent(session)) {
console.log('[省流助手-捕获模式] 路由或字幕控件已变化,跳过字幕恢复:', reason || 'unknown');
return false;
}
var current = getSubtitleToggleState(session.toggle);
if (current.state !== 'on') {
console.log('[省流助手-捕获模式] 字幕状态不确定或已关闭,跳过恢复点击:', current.state);
return false;
}
var restored = clickBiliSubtitleToggleOnce(session.toggle);
if (restored) {
// click 的状态更新可能异步,标记已处理,避免 finally 紧跟着再次点击而反向打开字幕。
session.toggledByScript = false;
console.log('[省流助手-捕获模式] 已恢复捕获前的字幕关闭状态:', reason || 'completed');
}
return restored;
}
async function triggerSubtitleForCapture(signal, generation) {
var toggle = await waitForSubtitleCaptureControl(SUBTITLE_CAPTURE_CONTROL_TIMEOUT_MS, signal);
if (!toggle) return { triggered: false, session: null };
var before = getSubtitleToggleState(toggle);
var session = {
generation: generation,
routeKey: getRouteKey(),
toggle: toggle,
originalState: before.state,
toggledByScript: false
};
if (!isCaptureSessionCurrent(session)) return { triggered: false, session: session };
if (before.state === 'off') {
// 监听已由调用方先挂好;控件出现后立即打开字幕,不插入固定等待。
session.toggledByScript = clickBiliSubtitleToggleOnce(toggle);
return { triggered: session.toggledByScript, session: session };
}
// 用户原本开着字幕时不碰总开关,只尝试现成的语言项来促发一次字幕请求。
var languageItem = pickSubtitleLanguageItem();
if (languageItem && languageItem.isConnected) {
return { triggered: clickBiliElement(languageItem), session: session };
}
return { triggered: false, session: session };
}
async function captureSubtitleFromPlayer(panel, videoInfo) {
if (isSubtitleCaptureInProgress) {
console.log('[省流助手-捕获模式] 已有捕获任务在进行,忽略重复触发');
return;
}
console.log('[省流助手-捕获模式] 开始等待播放器字幕请求...');
const contentDiv = panel.querySelector('.tabbit-panel-content');
const captureBtn = contentDiv.querySelector('#tabbit-capture-subtitle-btn');
const captureAbortController = new AbortController();
const freshVideoInfo = getVideoInfo();
if (freshVideoInfo.bvid) {
videoInfo = freshVideoInfo;
currentVideoInfo = videoInfo;
}
if (captureBtn) {
captureBtn.disabled = true;
setFetchBtnState(captureBtn, '监听中:正在自动触发字幕', '👂');
}
isSubtitleCaptureInProgress = true;
currentAbortController = captureAbortController;
const captureGeneration = routeGeneration;
let captureSession = null;
try {
// 必须先注册 waiter,再操作播放器,避免极快的字幕响应从监听窗口溜走。
const captureStartedAt = Date.now();
const capturePromise = waitForCapturedSubtitle(SUBTITLE_CAPTURE_WAIT_TIMEOUT_MS, captureAbortController.signal, captureStartedAt);
capturePromise.catch(function() {});
const triggerResult = await triggerSubtitleForCapture(captureAbortController.signal, captureGeneration);
captureSession = triggerResult.session;
const triggered = triggerResult.triggered;
if (captureBtn) {
setFetchBtnState(
captureBtn,
triggered ? '已自动触发,等待字幕响应' : '自动触发失败,正在回退字幕接口',
triggered ? '⏳' : '👉'
);
}
if (!triggered) throw new Error('未找到可安全触发的字幕控件');
const entry = await capturePromise;
if (isStaleRoute(captureGeneration)) return;
restoreSubtitleAfterCapture(captureSession, 'manual-captured');
rawSubtitleBody = entry.segments;
rawTranscript = entry.transcript;
if (!rawTranscript.trim()) {
setFetchBtnState(captureBtn, '捕获到字幕但文本为空', '😢', 3000);
return;
}
console.log('[省流助手-捕获模式] 捕获成功,开始摘要');
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.remove('disabled'));
await runSummary(panel, rawTranscript, videoInfo, rawSubtitleBody);
} catch (err) {
if (isAbortError(err)) {
setFetchBtnState(captureBtn, '已取消字幕捕获', '⏹️', 2500);
return;
}
console.warn('[省流助手-捕获模式] 捕获失败,回退字幕接口:', err.message || err);
try {
if (captureBtn) setFetchBtnState(captureBtn, '捕获未命中,正在回退字幕接口', '⏳');
var fallbackSubtitles = await fetchSubtitles(videoInfo.cid, videoInfo.bvid, captureAbortController.signal);
var fallbackTarget = fallbackSubtitles.find(function(subtitle) {
return subtitle.lan === 'zh-CN' || subtitle.lan === 'ai-zh';
}) || fallbackSubtitles[0];
var fallbackSegments = fallbackTarget ? await fetchSubtitleContent(fallbackTarget.subtitle_url, captureAbortController.signal) : [];
var fallbackTranscript = formatTranscript(fallbackSegments);
if (!fallbackTranscript.trim()) throw err;
rawSubtitleBody = fallbackSegments;
rawTranscript = fallbackTranscript;
console.log('[省流助手-捕获模式] 手动捕获未命中,已用接口字幕回退');
restoreSubtitleAfterCapture(captureSession, 'manual-fallback');
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.remove('disabled'));
await runSummary(panel, rawTranscript, videoInfo, rawSubtitleBody);
} catch (fallbackErr) {
console.error('[省流助手-捕获模式] 捕获和接口回退均失败:', fallbackErr);
setFetchBtnState(captureBtn, fallbackErr.message || '捕获失败,请重试', '❌', 3500);
}
} finally {
// 无论成功、超时、Abort 还是 SPA 切页都尝试恢复;内部会做路由和当前控件校验。
restoreSubtitleAfterCapture(captureSession, 'manual-finally');
isSubtitleCaptureInProgress = false;
if (currentAbortController === captureAbortController) currentAbortController = null;
}
}
// ==================== 手动上传字幕 ====================
function parseSubtitleTimeToSeconds(timeText) {
if (!timeText) return 0;
var cleaned = timeText.trim().replace(',', '.');
var parts = cleaned.split(':');
if (parts.length < 3) return 0;
var h = Number(parts[0]) || 0;
var m = Number(parts[1]) || 0;
var s = Number(parts[2]) || 0;
return h * 3600 + m * 60 + s;
}
function parseSrtToSegments(srtText) {
if (!srtText) return [];
var blocks = srtText.replace(/\r/g, '').trim().split(/\n{2,}/);
var segments = [];
for (var i = 0; i < blocks.length; i++) {
var lines = blocks[i].split('\n').map(function(line) { return line.trim(); }).filter(Boolean);
if (!lines.length) continue;
if (/^\d+$/.test(lines[0])) lines.shift();
if (!lines.length) continue;
var timeLine = lines.shift();
var match = timeLine.match(/(\d{1,2}:\d{2}:\d{2}[,.]\d{1,3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}[,.]\d{1,3})/);
if (!match) continue;
var text = lines.join(' ').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (!text) continue;
segments.push({
from: parseSubtitleTimeToSeconds(match[1]),
to: parseSubtitleTimeToSeconds(match[2]),
text: text
});
}
return normalizeSubtitleSegments(segments);
}
// 智能解析上传的内容:自动判断是 SRT 还是普通文本
function parseUploadedContent(rawText, filename) {
if (!rawText) return { transcript: '', segments: [] };
var isSrt = (filename && /\.srt$/i.test(filename)) ||
/\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}/.test(rawText);
if (isSrt) {
console.log('[省流助手-手动上传] 识别为 SRT 格式,开始解析');
var segments = parseSrtToSegments(rawText);
return {
transcript: formatTranscript(segments),
segments: segments
};
}
console.log('[省流助手-手动上传] 识别为纯文本格式');
return {
transcript: rawText.trim(),
segments: []
};
}
// 打开手动上传字幕对话框
function openManualUploadDialog(panel, videoInfo) {
const oldOverlay = document.querySelector('#tabbit-upload-overlay');
if (oldOverlay) oldOverlay.remove();
const overlay = document.createElement('div');
overlay.id = 'tabbit-upload-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000000;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease;';
overlay.innerHTML = `
📤 手动上传字幕
💡 支持
.srt /
.txt 文件上传,或直接在下方文本框粘贴字幕内容。
推荐👉【
🔗飞书妙记】视频转文字,每月300分钟免费转写时长。
— 或 —
📌 文件上传后会自动填充到文本框
`;
document.body.appendChild(overlay);
const fileInput = overlay.querySelector('#tabbit-upload-file');
const textArea = overlay.querySelector('#tabbit-upload-text');
const hintEl = overlay.querySelector('#tabbit-upload-hint');
let uploadedFilename = '';
function updateHint() {
hintEl.textContent = '字符数:' + textArea.value.length;
}
textArea.addEventListener('input', updateHint);
fileInput.addEventListener('change', function() {
const file = fileInput.files[0];
if (!file) return;
uploadedFilename = file.name;
const reader = new FileReader();
reader.onload = function(e) {
textArea.value = e.target.result || '';
updateHint();
console.log('[省流助手-手动上传] 文件已读取:', file.name, '大小:', file.size, 'bytes');
};
reader.onerror = function() {
alert('文件读取失败');
};
reader.readAsText(file, 'utf-8');
});
function closeDialog() { overlay.remove(); }
overlay.querySelector('#tabbit-upload-close').addEventListener('click', closeDialog);
overlay.querySelector('#tabbit-upload-cancel').addEventListener('click', closeDialog);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeDialog();
});
overlay.querySelector('#tabbit-upload-confirm').addEventListener('click', async function() {
const rawText = textArea.value.trim();
if (!rawText) {
alert('请先上传文件或粘贴字幕内容');
return;
}
const parsed = parseUploadedContent(rawText, uploadedFilename);
const transcript = parsed.transcript;
if (!transcript || !transcript.trim()) {
alert('解析后内容为空,请检查字幕格式');
return;
}
console.log('[省流助手-手动上传] 解析完成,文本长度:', transcript.length);
closeDialog();
// ✅ 走与自动获取字幕完全一致的后续流程
rawTranscript = transcript;
rawSubtitleBody = parsed.segments || [];
const freshVideoInfo = getVideoInfo();
if (freshVideoInfo.bvid) {
videoInfo = freshVideoInfo;
currentVideoInfo = videoInfo;
}
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.remove('disabled'));
await runSummary(panel, transcript, videoInfo, rawSubtitleBody);
});
}
async function manualFetchSubtitle(panel, videoInfo) {
if (isSubtitleCaptureInProgress) {
const captureBtn = panel.querySelector('#tabbit-capture-subtitle-btn');
setFetchBtnState(captureBtn, '捕获模式进行中,请先等待或打断', '⏳', 3000);
return;
}
console.log('[省流助手] 手动获取字幕...');
const contentDiv = panel.querySelector('.tabbit-panel-content');
const fetchBtn = contentDiv.querySelector('#tabbit-manual-fetch-btn');
const freshVideoInfo = getVideoInfo();
if (freshVideoInfo.bvid) {
videoInfo = freshVideoInfo;
currentVideoInfo = videoInfo;
}
if (!videoInfo.cid || !videoInfo.bvid) {
console.log('[省流助手] 手动获取:缺少 cid 或 bvid');
setFetchBtnState(fetchBtn, '❌ 无法获取视频信息,请刷新页面重试', '❌', 3000);
return;
}
abortCurrentTask();
const controller = new AbortController();
currentAbortController = controller;
contentDiv._tabbitManualSubtitleController = controller;
const generation = routeGeneration;
const context = getSubtitleCaptureContext();
const isCurrent = function() {
return contentDiv._tabbitManualSubtitleController === controller && panel.isConnected &&
generation === routeGeneration && isCurrentSubtitleCapture(context);
};
const abortBtn = insertInlineAbortBtn(contentDiv, function() { controller.abort(); }, '⏹ 停止获取字幕');
if (fetchBtn) {
fetchBtn.disabled = true;
setFetchBtnState(fetchBtn, '正在获取字幕...', '⏳');
}
try {
const subtitles = await fetchSubtitles(videoInfo.cid, videoInfo.bvid, controller.signal);
if (!isCurrent()) return;
throwIfAborted(controller.signal);
if (subtitles.length === 0) {
setFetchBtnState(fetchBtn, '仍未获取到字幕,请稍后再试', '😢', 3000);
return;
}
const targetSubtitle = subtitles.find(s => s.lan === 'zh-CN' || s.lan === 'ai-zh') || subtitles[0];
const content = await fetchSubtitleContent(targetSubtitle.subtitle_url, controller.signal);
if (!isCurrent()) return;
throwIfAborted(controller.signal);
if (content.length === 0) {
setFetchBtnState(fetchBtn, '字幕内容为空,请稍后再试', '😢', 3000);
return;
}
rawSubtitleBody = content;
const transcript = formatTranscript(content);
if (!transcript.trim()) {
setFetchBtnState(fetchBtn, '字幕文本为空,请稍后再试', '😢', 3000);
return;
}
rawTranscript = transcript;
console.log('[省流助手] 手动获取成功!');
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.remove('disabled'));
abortBtn.remove();
if (currentAbortController === controller) currentAbortController = null;
await runSummary(panel, transcript, videoInfo, content, { generation: generation });
} catch (err) {
if (!isCurrent()) return;
if (isAbortError(err)) {
setFetchBtnState(fetchBtn, '已停止获取字幕', '⏹', 3000);
return;
}
console.error('[省流助手] 手动获取字幕失败:', err);
setFetchBtnState(fetchBtn, '获取失败: ' + err.message, '❌', 3000);
} finally {
abortBtn.remove();
if (currentAbortController === controller) currentAbortController = null;
if (isCurrent() && fetchBtn) fetchBtn.disabled = false;
if (contentDiv._tabbitManualSubtitleController === controller) delete contentDiv._tabbitManualSubtitleController;
}
}
function showError(contentDiv, msg) {
const errDiv = document.createElement('div');
errDiv.className = 'tabbit-error';
errDiv.innerHTML = '⚠️ 请求失败
' + escapeHtml(msg) + '
';
const resultContainer = contentDiv.querySelector('.tabbit-result');
if (resultContainer) {
resultContainer.innerHTML = '';
resultContainer.appendChild(errDiv);
} else {
contentDiv.innerHTML = '';
contentDiv.appendChild(errDiv);
}
}
// ==================== AI 调用 ====================
function buildReqBody(model, messages, opts) {
var body = { model: model, messages: messages, temperature: opts.temperature || 0.7, max_tokens: opts.maxTokens || 2000 };
if (opts.stream) body.stream = true;
if (CONFIG.enableThinking === false) { body.thinking = { type: "disabled" }; body.enable_thinking = false; }
return body;
}
function gmRequest(options) {
return new Promise(function(resolve, reject) {
var request = GM_xmlhttpRequest(Object.assign({}, options, {
timeout: options.timeout || 120000,
onload: resolve,
onerror: function(error) { reject(new Error('网络请求失败: ' + (error && error.error ? error.error : 'GM_xmlhttpRequest error'))); },
ontimeout: function() { reject(new Error('API 请求超时')); },
onabort: function() {
var error = new Error('用户已打断');
error.name = 'AbortError';
reject(error);
}
}));
if (options.signal) {
var abort = function() { try { request.abort(); } catch (e) {} };
if (options.signal.aborted) abort();
else options.signal.addEventListener('abort', abort, { once: true });
}
});
}
function callAIStreamWithGM(apiUrl, apiKey, model, messages, onDelta, options) {
return new Promise(function(resolve, reject) {
var fullText = '';
var receivedLength = 0;
var buffer = '';
var finishReason = '';
var receivedDone = false;
var settled = false;
var signal = options.signal;
var lengthErrorMessage = options.lengthErrorMessage;
function cancelPendingDelta() {
if (typeof onDelta === 'function' && typeof onDelta.cancel === 'function') {
onDelta.cancel();
}
}
function consume(text, flush) {
buffer += text;
var lines = buffer.split(/\r?\n/);
buffer = flush ? '' : (lines.pop() || '');
lines.forEach(function(line) {
var trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data:')) return;
var payload = trimmed.slice(5).trim();
if (!payload) return;
if (payload === '[DONE]') { receivedDone = true; return; }
try {
var json = JSON.parse(payload);
var choice = json.choices && json.choices[0];
if (choice && choice.finish_reason) finishReason = choice.finish_reason;
var delta = choice && ((choice.delta && choice.delta.content) || (choice.message && choice.message.content)) || '';
if (delta) {
fullText += delta;
if (typeof onDelta === 'function') onDelta(fullText, delta);
}
} catch (e) {}
});
}
var request = GM_xmlhttpRequest({
method: 'POST',
url: apiUrl,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey, 'Accept': 'text/event-stream' },
data: JSON.stringify(buildReqBody(model, messages, { temperature: options.temperature, maxTokens: options.maxTokens, stream: true })),
timeout: 180000,
onprogress: function(event) {
var text = String(event.responseText || '');
if (text.length <= receivedLength) return;
consume(text.slice(receivedLength), false);
receivedLength = text.length;
if (receivedDone && !settled) {
settled = true;
cancelPendingDelta();
if (finishReason === 'length') reject(new Error(lengthErrorMessage));
else if (!fullText) reject(new Error('API 响应格式异常'));
else resolve(fullText);
try { request.abort(); } catch (e) {}
}
},
onload: function(response) {
if (settled) return;
settled = true;
var text = String(response.responseText || '');
if (response.status < 200 || response.status >= 300) {
cancelPendingDelta();
reject(new Error('API 错误: ' + response.status + ' ' + text.slice(0, 500)));
return;
}
if (text.length > receivedLength) consume(text.slice(receivedLength), false);
consume('', true);
if (!fullText) {
try {
var data = JSON.parse(text);
fullText = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content || '';
finishReason = data.choices && data.choices[0] && data.choices[0].finish_reason || finishReason;
if (fullText && typeof onDelta === 'function') onDelta(fullText, fullText);
} catch (e) {}
}
cancelPendingDelta();
if (finishReason === 'length') { reject(new Error(lengthErrorMessage)); return; }
if (!fullText) { reject(new Error('API 响应格式异常')); return; }
resolve(fullText);
},
onerror: function() { if (!settled) { settled = true; cancelPendingDelta(); reject(new Error('网络请求失败')); } },
ontimeout: function() { if (!settled) { settled = true; cancelPendingDelta(); reject(new Error('API 请求超时')); } },
onabort: function() {
if (settled) return;
settled = true;
cancelPendingDelta();
if (fullText.trim()) resolve(fullText + '\n\n_⏹ 已被用户打断_');
else { var error = new Error('用户已打断'); error.name = 'AbortError'; reject(error); }
}
});
if (signal) {
var abort = function() { try { request.abort(); } catch (e) {} };
if (signal.aborted) abort();
else signal.addEventListener('abort', abort, { once: true });
}
});
}
// 兼容旧逻辑:非流式
async function callAI(messages) {
if (!CONFIG.apiUrl || !CONFIG.apiKey || !currentModel) {
throw new Error('请点击右上角 ⚙️ 设置按钮,填写 apiUrl、apiKey 和 model');
}
const chatApiUrl = buildChatCompletionsUrl(CONFIG.apiUrl);
const res = await gmRequest({
method: 'POST',
url: chatApiUrl,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + CONFIG.apiKey
},
data: JSON.stringify(buildReqBody(currentModel, messages, { temperature: 0.7, maxTokens: 2000 }))
});
if (res.status < 200 || res.status >= 300) {
throw new Error('API 错误: ' + res.status + ' ' + String(res.responseText || '').slice(0, 500));
}
const data = JSON.parse(res.responseText);
if (!data.choices?.[0]?.message?.content) {
throw new Error('API响应格式异常');
}
return data.choices[0].message.content;
}
/**
* 🆕 流式 AI 调用(真正支持 AbortSignal 打断)
* @param {Array} messages - 消息列表
* @param {Function} onDelta - 每收到一段时回调,参数 (fullText, deltaText)
* @param {Object} options - { apiUrl, apiKey, model, signal, temperature, maxTokens, errorMessageOverride } 自定义参数(可选)
* @returns {Promise} 完整文本
*/
async function callAIStream(messages, onDelta, options) {
options = options || {};
const apiUrl = buildChatCompletionsUrl(options.apiUrl || CONFIG.apiUrl);
const apiKey = options.apiKey || CONFIG.apiKey;
const model = options.model || currentModel;
const signal = options.signal; // 🆕 AbortSignal
const temperature = options.temperature !== undefined ? options.temperature : 0.7;
const maxTokens = options.maxTokens || 2000;
const lengthErrorMessage = options.errorMessageOverride || 'AI 输出被截断:finish_reason=length。请调大最大输出 tokens,或换支持更大输出上限的模型/API。';
if (!apiUrl || !apiKey || !model) {
throw new Error('请点击右上角 ⚙️ 设置按钮,填写 apiUrl、apiKey 和 model');
}
return callAIStreamWithGM(apiUrl, apiKey, model, messages, onDelta, {
signal: signal,
temperature: temperature,
maxTokens: maxTokens,
lengthErrorMessage: lengthErrorMessage
});
}
/**
* 🆕 创建一个节流回调,包装原始 onDelta,避免高频重绘
* 返回的函数带有 .cancel() 方法,可在流式结束后取消未触发的延迟回调,
* 防止延迟回调覆盖最终的 markdown 渲染(重要!)
*/
function createThrottledDelta(onDelta, intervalMs) {
intervalMs = intervalMs || STREAM_RENDER_THROTTLE;
let lastCall = 0;
let pendingFull = null;
let timer = null;
const throttled = function(fullText, delta) {
pendingFull = fullText;
const now = Date.now();
if (now - lastCall >= intervalMs) {
lastCall = now;
if (timer) { clearTimeout(timer); timer = null; }
try { onDelta(pendingFull, delta); } catch (e) {}
} else if (!timer) {
const wait = intervalMs - (now - lastCall);
timer = setTimeout(function() {
timer = null;
lastCall = Date.now();
try { onDelta(pendingFull, ''); } catch (e) {}
}, wait);
}
};
// 🆕 取消挂起的延迟回调(流式结束后调用)
throttled.cancel = function() {
if (timer) { clearTimeout(timer); timer = null; }
};
return throttled;
}
// 🆕 生图模式 - 第1步:流式获取文字总结
async function generateSummaryText(prompt, onDelta) {
const summaryApiUrl = CONFIG.apiUrl;
const summaryApiKey = CONFIG.apiKey;
const summaryModel = currentModel;
console.log('[省流助手-生图] 第1步:用默认模型', summaryModel, '流式获取文字总结...');
const textContent = await callAIStream(
[{ role: 'user', content: prompt }],
onDelta,
buildSummaryStreamOptions(null, { apiUrl: summaryApiUrl, apiKey: summaryApiKey, model: summaryModel })
);
console.log('[省流助手-生图] 文字总结完成,长度:', textContent.length);
if (!textContent.trim()) {
throw new Error('生图模型未返回文字总结内容');
}
return textContent;
}
// 🆕 生图模式 - 第2步:根据已有的总结文字生成配图
async function generateImageFromSummary(textContent, signal) {
const apiUrl = CONFIG.imageGenApiUrl || CONFIG.apiUrl;
const apiKey = CONFIG.imageGenApiKey || CONFIG.apiKey;
const model = CONFIG.imageGenModel || 'gemini-2.0-flash-preview-image-generation';
if (!apiUrl || !apiKey) {
throw new Error('请在设置中配置生图模型的 API URL 和 API Key');
}
const imagePrompt = buildImagePrompt(textContent || '');
return await generateImageByApi(apiUrl, apiKey, model, imagePrompt, signal);
}
function isImageGenEnabled() {
return CONFIG.enableImageGen === true;
}
function createResultActionButton(className, text, onClick, title) {
var btn = document.createElement('button');
btn.className = className;
btn.textContent = text;
if (title) btn.title = title;
btn.addEventListener('click', onClick);
return btn;
}
async function runDownloadButton(button, idleText, task) {
if (!button || button.disabled) return;
button.disabled = true;
button.textContent = '⏳ 下载中…';
var result = 'failed';
try {
result = await task();
button.textContent = result === 'downloaded' ? '✅ 已保存'
: (result === 'started' ? '✅ 已发起'
: (result === 'cancelled' ? '已取消'
: (result === 'needs-directory' ? '⚠️ 先选字幕目录'
: (result === 'needs-interaction' ? '⚠️ 请手动保存' : '❌ 下载失败'))));
} catch(e) {
console.error('[省流助手] 下载失败:', e);
button.textContent = '❌ 下载失败';
}
setTimeout(function() {
button.disabled = false;
button.textContent = idleText;
}, 2200);
return result;
}
const RESULT_ACTION_BUTTONS = {
copy_summary: {
id: 'copy_summary', scopes: ['summary', 'image'], requiresText: true, settingsLabel: '📋 复制摘要',
create: function(context) {
var label = context.resultType === 'image' ? '📋 复制文字' : '📋 复制摘要';
return createResultActionButton('tabbit-copy-btn', label, function() {
var btn = this;
copyToClipboard(markdownToPlainText(getCurrentSummaryText(context.contentDiv, context.summaryFallback || '')));
btn.textContent = '✅ 已复制';
btn.classList.add('copied');
setTimeout(function() { btn.textContent = label; btn.classList.remove('copied'); }, 2000);
});
}
},
edit_summary: {
id: 'edit_summary', scopes: ['summary', 'image'], requiresText: true, settingsLabel: '✏️ 编辑摘要',
create: function(context) {
return createResultActionButton('tabbit-copy-btn', '✏️ 编辑摘要', function() {
startSummaryEdit(context.contentDiv, context.summaryFallback || '');
});
}
},
generate_image: {
id: 'generate_image', scopes: ['summary', 'image'], requiresText: true, settingsLabel: '🖼️ 生成配图',
create: function(context) {
var hasImage = context.resultType === 'image' && context.hasImage;
var label = isFlowImageGenMode() ? '发送到 Flow' : (hasImage ? '🔁 重新生成配图' : '🖼️ 生成配图');
return createResultActionButton('tabbit-copy-btn', label, function() {
triggerManualImageGen(context.contentDiv, getCurrentSummaryText(context.contentDiv, context.summaryFallback || ''), context.videoInfo, this);
});
}
},
copy_image_prompt: {
id: 'copy_image_prompt', scopes: ['summary', 'image'], requiresText: true, settingsLabel: '复制 生图提示词',
create: function(context) {
return createResultActionButton('tabbit-copy-btn', '复制 生图提示词', function() {
var btn = this;
copyToClipboard(buildImagePrompt(getCurrentSummaryText(context.contentDiv, context.summaryFallback || '')));
btn.textContent = '✅ 已复制';
setTimeout(function() { btn.textContent = '复制 生图提示词'; }, 2000);
}, '一键复制生图提示词+摘要到剪贴板,粘贴到任意AI生图');
}
},
post_comment: {
id: 'post_comment', scopes: ['summary', 'image'], requiresText: true, settingsLabel: '📋 摘要发评论',
create: function() {
return createResultActionButton('tabbit-copy-btn', '📋 摘要发评论', function() { fillBiliCommentSummary(this); }, '一键将摘要插入B站评论框');
}
},
send_flomo: {
id: 'send_flomo', scopes: ['summary', 'image'], requiresText: true, settingsLabel: '发送 FLOMO',
create: function(context) {
return createResultActionButton('tabbit-copy-btn', '发送 FLOMO', function() {
sendToFlomo(getCurrentSummaryText(context.contentDiv, context.summaryFallback || ''), this);
});
}
},
download_transcript: {
id: 'download_transcript', scopes: ['summary', 'image', 'fallback'], settingsLabel: '💾 下载字幕',
create: function(context) {
return createResultActionButton('tabbit-download-btn', '💾 下载字幕', function() {
var btn = this;
runDownloadButton(btn, '💾 下载字幕', function() {
return downloadTranscript(rawTranscript, context.videoInfo.title, context.videoInfo.upName, context.videoInfo.bvid);
});
});
}
},
download_srt: {
id: 'download_srt', scopes: ['summary', 'image', 'fallback'], settingsLabel: '🕒 下载 SRT',
create: function(context) {
return createResultActionButton('tabbit-download-btn', '🕒 下载 SRT', function() {
var btn = this;
runDownloadButton(btn, '🕒 下载 SRT', async function() {
var result = await downloadSubtitleSrt(rawSubtitleBody, context.videoInfo.title, context.videoInfo.upName, context.videoInfo.bvid);
if (!result) alert('当前没有可导出的时间轴字幕');
return result;
});
}, '导出当前拿到的原始时间轴字幕');
}
},
save_image: {
id: 'save_image', imageRow: true, settingsLabel: '💾 保存图片',
create: function(context) {
return createResultActionButton('tabbit-copy-btn tabbit-save-img-btn', '💾 保存图片', function() {
var btn = this;
runDownloadButton(btn, '💾 保存图片', function() {
return downloadGeneratedImage(context.imageDataUrl, context.videoInfo, context.imageFilenameSuffix || '_总结');
});
});
}
},
fill_image_comment: {
id: 'fill_image_comment', imageRow: true, settingsLabel: '💬 填字并点上传',
create: function() {
return createResultActionButton('tabbit-copy-btn tabbit-comment-text-btn', '💬 填字并点上传', function() {
fillBiliCommentTextOnly(this);
}, '随机填入一条评论预设,并尝试点开B站评论区图片上传按钮');
}
}
};
function canRenderResultAction(definition, context) {
if (definition.imageRow) return context.resultType === 'image' && context.hasImage;
if (definition.id === 'download_srt') return context.hasTimelineSubtitle && !!buildSrtContent(rawSubtitleBody);
return definition.scopes.indexOf(context.resultType) !== -1 && (!definition.requiresText || context.hasText);
}
function appendResultActionStatus(container, context) {
if (!container.childElementCount) return;
var editStatus = document.createElement('span');
editStatus.className = 'tabbit-summary-edit-status';
container.appendChild(editStatus);
var modelTag = document.createElement('span');
modelTag.style.cssText = 'font-size:11px;color:#999;margin-left:auto;';
modelTag.textContent = context.modelLabel || (context.resultType === 'image' ? '🖼️ ' + (CONFIG.imageGenModel || 'gemini-2.0-flash-preview-image-generation') : '🤖 ' + currentModel);
container.appendChild(modelTag);
}
function renderResultActionButtons(container, context) {
if (!container) return;
container.innerHTML = '';
normalizeResultActionButtons(CONFIG.resultActionButtons).forEach(function(item) {
var definition = RESULT_ACTION_BUTTONS[item.id];
if (!item.enabled || !definition || definition.imageRow || !canRenderResultAction(definition, context)) return;
container.appendChild(definition.create(context));
});
appendResultActionStatus(container, context);
}
function renderImageActionButtons(container, context) {
if (!container) return;
container.innerHTML = '';
normalizeResultActionButtons(CONFIG.resultActionButtons).forEach(function(item) {
var definition = RESULT_ACTION_BUTTONS[item.id];
if (!item.enabled || !definition || !definition.imageRow || !canRenderResultAction(definition, context)) return;
container.appendChild(definition.create(context));
});
}
function refreshResultActionButtons(contentDiv) {
if (!contentDiv || !contentDiv.isConnected) return;
var context = contentDiv._tabbitResultActionContext;
if (!context) return;
context.hasTimelineSubtitle = hasStructuredSubtitleData(rawSubtitleBody) && !!buildSrtContent(rawSubtitleBody);
context.hasText = !!String(getCurrentSummaryText(contentDiv, context.summaryFallback || '')).trim();
renderResultActionButtons(contentDiv.querySelector('.tabbit-result-actions'), context);
contentDiv.querySelectorAll('.tabbit-img-actions').forEach(function(row) {
var imageContext = Object.assign({}, context, row._tabbitImageActionContext || {});
imageContext.resultType = 'image';
imageContext.hasImage = !!(imageContext.imageDataUrl && /^data:image\//i.test(imageContext.imageDataUrl));
renderImageActionButtons(row, imageContext);
});
}
function createImageActionRow(imageDataUrl, videoInfo, filenameSuffix) {
const row = document.createElement('div');
row.className = 'tabbit-img-actions';
row.style.cssText = 'display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:8px;';
row._tabbitImageActionContext = {
resultType: 'image',
hasImage: !!(imageDataUrl && /^data:image\//i.test(imageDataUrl)),
imageDataUrl: imageDataUrl,
imageFilenameSuffix: filenameSuffix || '_总结',
videoInfo: videoInfo
};
renderImageActionButtons(row, row._tabbitImageActionContext);
return row;
}
function showImageResult(contentDiv, textContent, imageDataUrl, _url, videoInfo) {
setCurrentSummaryText(contentDiv, textContent || '(生图模式 - 图片总结)');
const resultContainer = contentDiv.querySelector('.tabbit-result');
const imageSlot = contentDiv.querySelector('.tabbit-image-slot') || resultContainer;
if (imageSlot) {
let imageHtml = '';
if (imageDataUrl && imageDataUrl !== 'ERROR') {
imageHtml += '';
imageHtml += '
 + ')
';
imageHtml += '
';
} else if (imageDataUrl === 'ERROR') {
imageHtml += '⚠️ 配图生成失败,仅显示文字总结
';
} else {
imageHtml += '';
imageHtml += '
';
imageHtml += '
🖼️ 配图生成中,请稍候...
';
imageHtml += '
文字总结已就绪,可先阅读
';
imageHtml += '
';
}
imageSlot.innerHTML = imageHtml;
}
if (resultContainer) {
let html = '';
if (textContent && textContent.trim()) {
html += '' + parseMarkdown(textContent) + '
';
}
if (!imageDataUrl && !textContent) {
html += '⚠️ 生图模型未返回图片或文字内容
';
}
resultContainer.innerHTML = html;
const img = imageSlot ? imageSlot.querySelector('img') : null;
if (img && imageDataUrl && imageDataUrl !== 'ERROR') {
img.addEventListener('click', function() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:99999999;display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:fadeIn 0.2s ease;';
const bigImg = document.createElement('img');
bigImg.src = imageDataUrl;
bigImg.style.cssText = 'max-width:95vw;max-height:95vh;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);';
overlay.appendChild(bigImg);
overlay.addEventListener('click', function() { overlay.remove(); });
document.body.appendChild(overlay);
});
const wrap = img.closest('.tabbit-img-wrap');
if (wrap && !wrap.querySelector('.tabbit-img-actions')) {
wrap.appendChild(createImageActionRow(imageDataUrl, videoInfo, '_总结'));
}
}
}
contentDiv._tabbitResultActionContext = {
resultType: 'image',
contentDiv: contentDiv,
videoInfo: videoInfo,
summaryFallback: textContent || '',
hasText: !!String(textContent || '').trim(),
hasImage: !!(imageDataUrl && /^data:image\//i.test(imageDataUrl)),
imageDataUrl: imageDataUrl,
imageFilenameSuffix: '_总结',
hasTimelineSubtitle: hasStructuredSubtitleData(rawSubtitleBody) && !!buildSrtContent(rawSubtitleBody),
modelLabel: '🖼️ ' + (CONFIG.imageGenModel || 'gemini-2.0-flash-preview-image-generation')
};
refreshResultActionButtons(contentDiv);
}
function updateImageResult(contentDiv, imageDataUrl, videoInfo) {
const imageSlot = contentDiv.querySelector('.tabbit-image-slot') || contentDiv.querySelector('.tabbit-result');
if (!imageSlot) return;
const wrap = imageSlot.querySelector('.tabbit-img-wrap');
if (!wrap) return;
if (!imageDataUrl || imageDataUrl === 'ERROR') {
wrap.outerHTML = '⚠️ 配图生成失败,仅显示文字总结
';
if (contentDiv._tabbitResultActionContext) {
contentDiv._tabbitResultActionContext.hasImage = false;
contentDiv._tabbitResultActionContext.imageDataUrl = '';
refreshResultActionButtons(contentDiv);
}
return;
}
wrap.classList.remove('tabbit-img-loading');
wrap.removeAttribute('style');
wrap.style.cssText = 'text-align:center;margin-bottom:12px;animation:fadeIn 0.4s ease;';
wrap.innerHTML = '
';
const img = wrap.querySelector('img');
if (img) {
img.addEventListener('click', function() {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:99999999;display:flex;align-items:center;justify-content:center;cursor:zoom-out;animation:fadeIn 0.2s ease;';
const bigImg = document.createElement('img');
bigImg.src = imageDataUrl;
bigImg.style.cssText = 'max-width:95vw;max-height:95vh;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);';
overlay.appendChild(bigImg);
overlay.addEventListener('click', function() { overlay.remove(); });
document.body.appendChild(overlay);
});
}
if (!wrap.querySelector('.tabbit-img-actions')) {
wrap.appendChild(createImageActionRow(imageDataUrl, videoInfo, '_总结'));
if (CONFIG.enableImageAutoDownload !== false) {
setTimeout(async function() {
try { await downloadGeneratedImage(imageDataUrl, videoInfo, '_总结', { useDirectory: true, directorySubdirectory: '图片', allowDirectoryPicker: false }); } catch(e) { console.warn('[省流助手] 自动下载失败', e); }
}, 0);
}
}
if (contentDiv._tabbitResultActionContext) {
contentDiv._tabbitResultActionContext.resultType = 'image';
contentDiv._tabbitResultActionContext.hasImage = true;
contentDiv._tabbitResultActionContext.imageDataUrl = imageDataUrl;
contentDiv._tabbitResultActionContext.videoInfo = videoInfo;
refreshResultActionButtons(contentDiv);
}
}
// ==================== 自动获取模型列表 ====================
function deriveModelsUrl(apiUrl) {
return buildApiUrl(apiUrl, 'models');
}
async function fetchModelList(apiUrl, apiKey) {
if (!apiUrl || !apiKey) {
throw new Error('请先填写 API URL 和 API Key');
}
const modelsUrl = deriveModelsUrl(apiUrl);
console.log('[省流助手] 获取模型列表:', modelsUrl);
const res = await gmRequest({
method: 'GET',
url: modelsUrl,
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json'
},
timeout: AUX_REQUEST_TIMEOUT_MS
});
if (res.status < 200 || res.status >= 300) {
throw new Error('HTTP ' + res.status + ': ' + String(res.responseText || '').slice(0, 200));
}
const data = JSON.parse(res.responseText);
let models = [];
if (Array.isArray(data?.data)) {
models = data.data.map(m => m.id || m.model || m.name).filter(Boolean);
} else if (Array.isArray(data?.models)) {
models = data.models.map(m => typeof m === 'string' ? m : (m.id || m.name)).filter(Boolean);
} else if (Array.isArray(data)) {
models = data.map(m => typeof m === 'string' ? m : (m.id || m.name)).filter(Boolean);
}
if (models.length === 0) {
throw new Error('未解析到任何模型,响应: ' + JSON.stringify(data).slice(0, 200));
}
return models;
}
// ==================== 摘要主流程 ====================
function getCurrentPresetId() {
const presets = CONFIG.promptPresets || [];
if (presets.some(p => p.id === currentPresetId)) return currentPresetId;
if (presets.some(p => p.id === CONFIG.activePresetId)) {
currentPresetId = CONFIG.activePresetId;
return currentPresetId;
}
currentPresetId = presets[0]?.id || 'preset_default';
return currentPresetId;
}
function renderPresetBarHtml() {
const presets = CONFIG.promptPresets || [];
if (presets.length === 0) return '';
const activePresetId = getCurrentPresetId();
const chips = presets.map(p => {
const isActive = p.id === activePresetId;
return '' +
'' + escapeHtml(p.icon || '📄') + '' +
'' + escapeHtml(p.name) + '' +
'
';
}).join('');
return `
🎨 总结风格(点击切换重新分析)
${chips}
`;
}
function bindPresetChips(panel, videoInfo) {
panel.querySelectorAll('.tabbit-preset-chip').forEach(chip => {
chip.addEventListener('click', async function() {
if (chip.classList.contains('disabled')) return;
const newId = chip.dataset.presetId;
const preset = (CONFIG.promptPresets || []).find(p => p.id === newId);
if (!preset) return;
if (newId === getCurrentPresetId()) return;
currentPresetId = newId;
panel.querySelectorAll('.tabbit-preset-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
conversationHistory = [];
commentConversationHistory = [];
rawDanmakuChatContext = '';
rawCommentsChatContext = '';
rawFullDataChatContext = '';
if (rawTranscript) {
await runSummary(panel, rawTranscript, videoInfo || currentVideoInfo, rawSubtitleBody);
} else if (videoInfo || currentVideoInfo) {
showNoSubtitleState(panel, videoInfo || currentVideoInfo);
}
});
});
}
function bindCommentButton(contentDiv, panel, videoInfo, enabled) {
const oldBtn = contentDiv.querySelector('#tabbit-comment-btn');
if (!oldBtn) return null;
const btn = oldBtn.cloneNode(true);
oldBtn.parentNode.replaceChild(btn, oldBtn);
btn.disabled = !enabled;
btn.addEventListener('click', () => runCommentSummary(panel, videoInfo));
return btn;
}
function bindDanmakuButton(contentDiv, panel, videoInfo, enabled) {
const oldBtn = contentDiv.querySelector('#tabbit-danmaku-btn');
if (!oldBtn) return null;
const btn = oldBtn.cloneNode(true);
oldBtn.parentNode.replaceChild(btn, oldBtn);
btn.disabled = !enabled;
btn.addEventListener('click', () => runDanmakuSummary(panel, videoInfo));
return btn;
}
function bindFullAnalysisButton(contentDiv, panel, videoInfo, enabled) {
const oldBtn = contentDiv.querySelector('#tabbit-full-btn');
if (!oldBtn) return null;
const btn = oldBtn.cloneNode(true);
oldBtn.parentNode.replaceChild(btn, oldBtn);
btn.disabled = !enabled;
btn.addEventListener('click', () => runFullAnalysis(panel, videoInfo));
return btn;
}
function renderSummaryShell(panel, contentDiv, presetBarHtml, videoInfo, pageUrl) {
const hasShell = contentDiv.querySelector('.tabbit-result')
&& contentDiv.querySelector('.tabbit-result-actions')
&& contentDiv.querySelector('#tabbit-comment-btn')
&& contentDiv.querySelector('#tabbit-danmaku-btn')
&& contentDiv.querySelector('#tabbit-full-btn')
&& contentDiv.querySelector('.tabbit-chat-messages');
if (hasShell) {
const oldPresetBar = contentDiv.querySelector('.tabbit-preset-bar');
if (oldPresetBar) {
const wrapper = document.createElement('div');
wrapper.innerHTML = presetBarHtml;
const newPresetBar = wrapper.firstElementChild;
if (newPresetBar) oldPresetBar.replaceWith(newPresetBar);
} else {
const danmakuBtnEl = contentDiv.querySelector('#tabbit-danmaku-btn');
if (danmakuBtnEl) {
danmakuBtnEl.insertAdjacentHTML('beforebegin', presetBarHtml);
} else {
const commentBtnEl = contentDiv.querySelector('#tabbit-comment-btn');
if (commentBtnEl) {
commentBtnEl.insertAdjacentHTML('beforebegin', presetBarHtml);
} else {
contentDiv.insertAdjacentHTML('beforeend', presetBarHtml);
}
}
}
const resultContainer = contentDiv.querySelector('.tabbit-result');
resultContainer.innerHTML = '';
let imageSlot = contentDiv.querySelector('.tabbit-image-slot');
if (!imageSlot) {
resultContainer.insertAdjacentHTML('beforebegin', '');
imageSlot = contentDiv.querySelector('.tabbit-image-slot');
}
imageSlot.innerHTML = '';
contentDiv.querySelector('.tabbit-result-actions').innerHTML = '';
contentDiv.querySelector('.tabbit-chat-messages').innerHTML = '';
const oldDanmakuSection = contentDiv.querySelector('#tabbit-danmaku-section');
if (oldDanmakuSection) oldDanmakuSection.innerHTML = '';
const oldFullSection = contentDiv.querySelector('#tabbit-full-section');
if (oldFullSection) oldFullSection.innerHTML = '';
const oldMeta = contentDiv.querySelector('.tabbit-video-info-bottom');
const metaHtml = renderVideoMetaBottomHtml(videoInfo, pageUrl);
if (oldMeta) {
const wrapper = document.createElement('div');
wrapper.innerHTML = metaHtml;
const newMeta = wrapper.firstElementChild;
if (newMeta) oldMeta.replaceWith(newMeta);
} else {
contentDiv.insertAdjacentHTML('afterbegin', metaHtml);
}
const commentBtn = contentDiv.querySelector('#tabbit-comment-btn');
commentBtn.disabled = true;
const danmakuBtn = contentDiv.querySelector('#tabbit-danmaku-btn');
if (danmakuBtn) danmakuBtn.disabled = true;
const fullBtn = contentDiv.querySelector('#tabbit-full-btn');
if (fullBtn) fullBtn.disabled = true;
} else {
contentDiv.innerHTML = `
${renderVideoMetaBottomHtml(videoInfo, pageUrl)}
${presetBarHtml}
`;
}
bindPresetChips(panel, videoInfo);
bindCommentButton(contentDiv, panel, videoInfo, false);
bindDanmakuButton(contentDiv, panel, videoInfo, false);
bindFullAnalysisButton(contentDiv, panel, videoInfo, false);
}
function setSummaryReady(panel, contentDiv, videoInfo) {
setFloatBtnState(panel, 'ready');
const input = panel.querySelector('.tabbit-chat-input');
const sendBtn = panel.querySelector('.tabbit-chat-send');
if (input) {
input.disabled = false;
input.placeholder = '基于视频内容继续提问...';
}
if (sendBtn) sendBtn.disabled = false;
bindCommentButton(contentDiv, panel, videoInfo, true);
bindDanmakuButton(contentDiv, panel, videoInfo, true);
bindFullAnalysisButton(contentDiv, panel, videoInfo, true);
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.remove('disabled'));
panel.querySelectorAll('.tabbit-preset-chip').forEach(c => c.classList.remove('disabled'));
}
function startAsyncImageGeneration(contentDiv, textContent, videoInfo) {
if (isFlowImageGenMode()) {
triggerFlowImageGen(contentDiv, textContent, videoInfo, null);
return;
}
const imageSeq = nextImageGenerationSeq(contentDiv);
const imgWrap = contentDiv.querySelector('.tabbit-image-slot .tabbit-img-wrap.tabbit-img-loading') || contentDiv.querySelector('.tabbit-img-wrap.tabbit-img-loading');
let imgAbortBtnWrap = null;
let imgAbortController = new AbortController();
if (imgWrap) {
imgAbortBtnWrap = document.createElement('div');
imgAbortBtnWrap.style.cssText = 'text-align:center;margin-top:8px;';
insertInlineAbortBtn(imgAbortBtnWrap, function() {
try { imgAbortController.abort(); } catch(e) {}
console.log('[省流助手-生图] 用户打断生图');
});
imgWrap.appendChild(imgAbortBtnWrap);
}
currentAbortController = imgAbortController;
generateImageFromSummary(textContent, imgAbortController.signal)
.then(function(imageDataUrl) {
if (contentDiv._tabbitImageGenSeq !== imageSeq) {
if (currentAbortController === imgAbortController) currentAbortController = null;
return;
}
if (imgAbortBtnWrap && imgAbortBtnWrap.parentNode) imgAbortBtnWrap.remove();
updateImageResult(contentDiv, imageDataUrl, videoInfo);
if (currentAbortController === imgAbortController) currentAbortController = null;
})
.catch(function(imgErr) {
if (contentDiv._tabbitImageGenSeq !== imageSeq) {
if (currentAbortController === imgAbortController) currentAbortController = null;
return;
}
if (imgAbortBtnWrap && imgAbortBtnWrap.parentNode) imgAbortBtnWrap.remove();
if (isAbortError(imgErr)) {
const wrap = contentDiv.querySelector('.tabbit-image-slot .tabbit-img-wrap') || contentDiv.querySelector('.tabbit-img-wrap');
if (wrap) {
wrap.outerHTML = '⏹ 生图已被用户打断
';
}
} else {
console.warn('[省流助手-生图] 图片生成失败:', imgErr.message);
updateImageResult(contentDiv, 'ERROR', videoInfo);
}
if (currentAbortController === imgAbortController) currentAbortController = null;
});
}
/**
* 🆕 流式版:初始总结(支持打断)
*/
async function runSummary(panel, transcript, videoInfo, subtitleBody, backgroundOptions) {
const contentDiv = panel.querySelector('.tabbit-panel-content');
const input = panel.querySelector('.tabbit-chat-input');
const sendBtn = panel.querySelector('.tabbit-chat-send');
const isBackgroundRun = !!(backgroundOptions && backgroundOptions.background);
const backgroundGeneration = backgroundOptions && backgroundOptions.generation;
function finishSummaryRun(ok, message, failedState) {
if (typeof backgroundGeneration === 'number' && isStaleSummaryContext(backgroundGeneration, videoInfo.bvid)) return;
setFloatBtnState(panel, ok ? 'ready' : (failedState || 'error'));
if (!isBackgroundRun || typeof backgroundGeneration !== 'number') return;
if (ok) notifyBackgroundSummaryReady(panel, backgroundGeneration, videoInfo.bvid);
else notifyBackgroundSummaryFailed(panel, backgroundGeneration, message, failedState, videoInfo.bvid);
}
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.add('disabled'));
panel.querySelectorAll('.tabbit-preset-chip').forEach(c => c.classList.add('disabled'));
input.disabled = true;
sendBtn.disabled = true;
const pageUrl = window.location.href;
const activePresetId = getCurrentPresetId();
const activePreset = (CONFIG.promptPresets || []).find(p => p.id === activePresetId);
const activePrompt = (activePreset && activePreset.prompt) || CONFIG.promptText || PROMPT_TEXT;
const videoDesc = limitText(videoInfo.desc || '', 1500);
const aiTranscript = buildAiTranscript(subtitleBody, transcript);
const hasTimelineTranscript = !!(subtitleBody && subtitleBody.length > 0 && aiTranscript);
const _subMaxChars = (typeof CONFIG !== 'undefined' && CONFIG.fullDataMaxChars) || 64000;
let _subTruncated = false;
let _subTranscript = aiTranscript;
if (aiTranscript.length > _subMaxChars) {
_subTranscript = aiTranscript.substring(0, _subMaxChars);
_subTruncated = true;
}
const fullPrompt = activePrompt
+ '\n\n视频URL: ' + pageUrl
+ '\n视频标题: ' + (videoInfo.title || '')
+ '\nUP主: ' + (videoInfo.upName || '')
+ (videoDesc ? '\n视频简介: ' + videoDesc : '')
+ '\n\n'
+ (hasTimelineTranscript
? '字幕内容(每行开头的 [开始-结束] 是视频时间范围;回答涉及具体片段、原话或定位时,请尽量保留对应时间范围):\n'
: '字幕内容:\n')
+ _subTranscript;
const presetBarHtml = renderPresetBarHtml();
const useImageGen = isImageGenEnabled();
const cacheKey = buildSummaryCacheKey(videoInfo, currentModel, activePresetId, activePrompt, aiTranscript);
renderSummaryShell(panel, contentDiv, presetBarHtml, videoInfo, pageUrl);
if (_subTruncated) {
const resultContainer = contentDiv.querySelector('.tabbit-result');
if (resultContainer) {
const warnDiv = document.createElement('div');
warnDiv.style.cssText = 'background:#fff3cd;color:#856404;padding:6px 10px;border-radius:6px;font-size:12px;margin-bottom:8px;';
warnDiv.textContent = '⚠️ 发送给 AI 的字幕数据量(' + aiTranscript.length.toLocaleString() + '字符)超过上限(' + _subMaxChars.toLocaleString() + '字符),AI 仅分析了前 ' + _subMaxChars.toLocaleString() + ' 字符。点击「💾 下载字幕」可获取完整字幕。';
resultContainer.parentNode.insertBefore(warnDiv, resultContainer);
}
}
contentDiv._tabbitSummaryCacheKey = cacheKey;
contentDiv._tabbitSummaryPresetId = activePresetId;
contentDiv._tabbitSummaryTitle = videoInfo.title;
contentDiv._tabbitSummaryText = '';
// 🆕 v4.0 检测 API 配置:未配置或填错时显示兜底 UI(仍保留下载字幕+一键复制功能)
var apiCheck = isApiConfigured();
if (!apiCheck.configured) {
console.log('[省流助手] API 未配置,显示兜底模式');
showApiNotConfiguredFallback(contentDiv, videoInfo, apiCheck.reason);
finishSummaryRun(false, 'API 未配置,点击查看');
return;
}
const cachedSummary = getCachedSummary(cacheKey);
if (cachedSummary) {
console.log('[省流助手] 命中摘要缓存,跳过文字总结请求');
conversationHistory = [
{ role: 'user', content: fullPrompt },
{ role: 'assistant', content: cachedSummary }
];
if (useImageGen) {
showImageResult(contentDiv, cachedSummary, '', pageUrl, videoInfo);
setSummaryReady(panel, contentDiv, videoInfo);
startAsyncImageGeneration(contentDiv, cachedSummary, videoInfo);
} else {
finalizeSummaryUI(contentDiv, cachedSummary, pageUrl, videoInfo);
setSummaryReady(panel, contentDiv, videoInfo);
}
finishSummaryRun(true);
return;
}
// 🆕 创建 AbortController + 在 result 区域插入打断按钮
abortCurrentTask();
currentAbortController = new AbortController();
const localController = currentAbortController;
const resultContainerForBtn = contentDiv.querySelector('.tabbit-result');
let abortBtn = null;
if (resultContainerForBtn) {
const abortBtnWrap = document.createElement('div');
abortBtnWrap.style.cssText = 'text-align:center;';
abortBtn = insertInlineAbortBtn(abortBtnWrap, function() {
abortCurrentTask();
});
resultContainerForBtn.parentNode.insertBefore(abortBtnWrap, resultContainerForBtn.nextSibling);
abortBtn._wrap = abortBtnWrap;
}
try {
if (useImageGen) {
const resultContainer = contentDiv.querySelector('.tabbit-result');
const imageSlot = contentDiv.querySelector('.tabbit-image-slot');
if (imageSlot) {
imageSlot.innerHTML =
'' +
'
' +
'
🖼️ 配图生成中,请稍候...
' +
'
文字总结正在流式输出
' +
'
';
}
resultContainer.innerHTML = '
';
const textWrap = resultContainer.querySelector('.tabbit-text-wrap');
const onDelta = createThrottledDelta(function(fullText) {
textWrap.textContent = fullText;
const cursor = document.createElement('span');
cursor.className = 'tabbit-typing-cursor';
textWrap.appendChild(cursor);
});
// 🆕 透传 signal 给文字总结
const summaryApiUrl = CONFIG.apiUrl;
const summaryApiKey = CONFIG.apiKey;
const summaryModel = currentModel;
const textContent = await callAIStream(
[{ role: 'user', content: fullPrompt }],
onDelta,
buildSummaryStreamOptions(localController.signal, { apiUrl: summaryApiUrl, apiKey: summaryApiKey, model: summaryModel })
);
// 🆕 文字总结结束后,先清理"文字总结阶段"的打断按钮
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
abortBtn = null;
}
conversationHistory = [
{ role: 'user', content: fullPrompt },
{ role: 'assistant', content: textContent }
];
setCachedSummary(cacheKey, {
summary: textContent,
model: currentModel,
presetId: activePresetId,
title: videoInfo.title
});
showImageResult(contentDiv, textContent, '', pageUrl, videoInfo);
setSummaryReady(panel, contentDiv, videoInfo);
finishSummaryRun(true);
autoSendFlomo(textContent, videoInfo, pageUrl);
startAsyncImageGeneration(contentDiv, textContent, videoInfo);
return;
} else {
const resultContainer = contentDiv.querySelector('.tabbit-result');
const messages = [{ role: 'user', content: fullPrompt }];
const onDelta = createThrottledDelta(function(fullText) {
resultContainer.textContent = fullText;
const cursor = document.createElement('span');
cursor.className = 'tabbit-typing-cursor';
resultContainer.appendChild(cursor);
});
const reply = await callAIStream(messages, onDelta, buildSummaryStreamOptions(localController.signal));
conversationHistory = [
{ role: 'user', content: fullPrompt },
{ role: 'assistant', content: reply }
];
setCachedSummary(cacheKey, {
summary: reply,
model: currentModel,
presetId: activePresetId,
title: videoInfo.title
});
autoSendFlomo(reply, videoInfo, pageUrl);
finalizeSummaryUI(contentDiv, reply, pageUrl, videoInfo);
setSummaryReady(panel, contentDiv, videoInfo);
finishSummaryRun(true);
}
} catch (err) {
console.error('[省流助手]', err);
// 🆕 区分打断和真实错误
if (isAbortError(err)) {
const resultContainer = contentDiv.querySelector('.tabbit-result');
if (resultContainer) {
resultContainer.innerHTML = '⏹ 已被用户打断,未生成内容
';
}
finishSummaryRun(false, '已中断,点击查看', 'interrupted');
} else {
showError(contentDiv, err.message);
finishSummaryRun(false, '摘要失败,点击查看');
}
input.disabled = false;
sendBtn.disabled = false;
bindCommentButton(contentDiv, panel, videoInfo, true);
} finally {
// 🆕 清理打断按钮 + AbortController
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
}
if (currentAbortController === localController) currentAbortController = null;
panel.querySelectorAll('.tabbit-model-chip').forEach(c => c.classList.remove('disabled'));
panel.querySelectorAll('.tabbit-preset-chip').forEach(c => c.classList.remove('disabled'));
}
}
// ==================== 评论区总结主流程(流式 + 打断) ====================
async function runCommentSummary(panel, videoInfo) {
const flomoSourceUrl = window.location.href;
if (isCommentSummarizing) return;
isCommentSummarizing = true;
const contentDiv = panel.querySelector('.tabbit-panel-content');
const commentBtn = contentDiv.querySelector('#tabbit-comment-btn');
if (commentBtn) commentBtn.disabled = true;
let commentSection = contentDiv.querySelector('#tabbit-comment-section');
if (!commentSection) {
commentSection = document.createElement('div');
commentSection.className = 'tabbit-comment-section';
commentSection.id = 'tabbit-comment-section';
contentDiv.appendChild(commentSection);
}
commentSection.innerHTML = `
`;
const statusEl = commentSection.querySelector('#tabbit-comment-status');
// 🆕 准备 AbortController
abortCurrentTask();
currentAbortController = new AbortController();
const localController = currentAbortController;
let abortBtn = null;
const fetchAbortBtnWrap = document.createElement('div');
fetchAbortBtnWrap.style.cssText = 'text-align:center;';
abortBtn = insertInlineAbortBtn(fetchAbortBtnWrap, function() {
abortCurrentTask();
});
commentSection.appendChild(fetchAbortBtnWrap);
abortBtn._wrap = fetchAbortBtnWrap;
try {
const aid = getAid(videoInfo);
if (!aid) throw new Error('无法获取视频 aid');
const comments = await fetchAllComments(aid, (msg) => {
if (statusEl) statusEl.textContent = msg;
}, localController.signal);
if (comments.length === 0) throw new Error('该视频没有评论');
// 🆕 检查是否已被打断(评论抓取阶段)
if (localController.signal.aborted) {
const abortErr = new Error('用户已打断');
abortErr.name = 'AbortError';
throw abortErr;
}
if (statusEl) statusEl.textContent = '已获取 ' + comments.length + ' 条评论,AI 正在流式总结...';
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
abortBtn = null;
}
const commentsText = formatCommentsText(comments);
const _cmtMaxChars = (typeof CONFIG !== 'undefined' && CONFIG.fullDataMaxChars) || 64000;
let _cmtTruncated = false;
let _cmtText = commentsText;
if (commentsText.length > _cmtMaxChars) {
_cmtText = commentsText.substring(0, _cmtMaxChars);
_cmtTruncated = true;
}
rawCommentsChatContext = '\n\n[评论区原文数据(' + comments.length + '条,格式:[序号] 用户名 (👍点赞): 内容)]\n' + _cmtText;
const activeCommentPrompt = CONFIG.commentPromptText || COMMENT_PROMPT_TEXT;
const fullPrompt = activeCommentPrompt + '\n\n评论内容如下:\n' + _cmtText;
const _cmtWarnHtml = _cmtTruncated
? '⚠️ 评论数据量(' + commentsText.length.toLocaleString() + '字符)超过上限(' + _cmtMaxChars.toLocaleString() + '字符),AI 仅分析了前 ' + _cmtMaxChars.toLocaleString() + ' 字符。
'
: '';
commentSection.innerHTML = `
${_cmtWarnHtml}
${commentCoverageHtml(comments)}
`;
const resultEl = commentSection.querySelector('.tabbit-comment-result');
// 🆕 插入打断按钮
const abortBtnWrap = document.createElement('div');
abortBtnWrap.style.cssText = 'text-align:center;';
abortBtn = insertInlineAbortBtn(abortBtnWrap, function() {
abortCurrentTask();
});
commentSection.appendChild(abortBtnWrap);
abortBtn._wrap = abortBtnWrap;
const messages = [{ role: 'user', content: fullPrompt }];
const onDelta = createThrottledDelta(function(fullText) {
resultEl.textContent = fullText;
const cursor = document.createElement('span');
cursor.className = 'tabbit-typing-cursor';
resultEl.appendChild(cursor);
});
const reply = await callAIStream(messages, onDelta, buildSummaryStreamOptions(localController.signal));
autoSendFlomo(reply, videoInfo, flomoSourceUrl, '评论总结');
commentConversationHistory = [
{ role: 'user', content: fullPrompt },
{ role: 'assistant', content: reply }
];
resultEl.innerHTML = parseMarkdown(reply);
const actionsDiv = document.createElement('div');
actionsDiv.className = 'tabbit-comment-actions';
actionsDiv.innerHTML = `
🤖 ${currentModel}
`;
commentSection.appendChild(actionsDiv);
const copyCommentBtn = commentSection.querySelector('#tabbit-copy-comment');
if (copyCommentBtn) {
copyCommentBtn.addEventListener('click', function() { copyCommentResult(this, reply); });
}
const flomoCommentBtn = commentSection.querySelector('#tabbit-flomo-comment');
if (flomoCommentBtn) {
flomoCommentBtn.addEventListener('click', function() { sendToFlomo(reply, this, { videoInfo, pageUrl: flomoSourceUrl, kind: '评论总结' }); });
}
const exportCommentBtn = commentSection.querySelector('#tabbit-export-comment');
if (exportCommentBtn) {
exportCommentBtn.addEventListener('click', function() {
var title = (videoInfo && videoInfo.title) || '评论总结';
var safeTitle = sanitizeFilename(title) || '评论总结';
var ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
triggerDownload(commentsText, safeTitle + '__评论原文__' + ts + '.txt', 'text/plain;charset=utf-8');
});
}
} catch (err) {
console.error('[省流助手-评论区]', err);
// 🆕 区分打断和真实错误
if (isAbortError(err)) {
commentSection.innerHTML = `
⏹ 已被用户打断
`;
} else {
commentSection.innerHTML = `
⚠️ 评论区总结失败
${escapeHtml(err.message)}
`;
}
} finally {
// 🆕 清理打断按钮
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
}
if (currentAbortController === localController) currentAbortController = null;
isCommentSummarizing = false;
const btn = contentDiv.querySelector('#tabbit-comment-btn');
if (btn) btn.disabled = false;
}
}
// ==================== 弹幕分析主流程(流式 + 打断) ====================
async function runDanmakuSummary(panel, videoInfo) {
const flomoSourceUrl = window.location.href;
if (isDanmakuAnalyzing) return;
isDanmakuAnalyzing = true;
const contentDiv = panel.querySelector('.tabbit-panel-content');
const danmakuBtn = contentDiv.querySelector('#tabbit-danmaku-btn');
if (danmakuBtn) danmakuBtn.disabled = true;
let danmakuSection = contentDiv.querySelector('#tabbit-danmaku-section');
if (!danmakuSection) {
danmakuSection = document.createElement('div');
danmakuSection.className = 'tabbit-danmaku-section';
danmakuSection.id = 'tabbit-danmaku-section';
contentDiv.appendChild(danmakuSection);
}
danmakuSection.innerHTML = `
📡 弹幕分析
`;
const statusEl = danmakuSection.querySelector('#tabbit-danmaku-status');
abortCurrentTask();
currentAbortController = new AbortController();
const localController = currentAbortController;
let abortBtn = null;
const fetchAbortBtnWrap = document.createElement('div');
fetchAbortBtnWrap.style.cssText = 'text-align:center;';
abortBtn = insertInlineAbortBtn(fetchAbortBtnWrap, function() {
abortCurrentTask();
});
danmakuSection.appendChild(fetchAbortBtnWrap);
abortBtn._wrap = fetchAbortBtnWrap;
try {
const cid = videoInfo.cid;
if (!cid) throw new Error('无法获取视频 cid');
const danmaku = await fetchAllDanmaku(cid, function(msg) {
if (statusEl) statusEl.textContent = msg;
}, localController.signal);
if (danmaku.length === 0) throw new Error('该视频没有弹幕');
if (localController.signal.aborted) {
const abortErr = new Error('用户已打断');
abortErr.name = 'AbortError';
throw abortErr;
}
if (statusEl) statusEl.textContent = '已获取 ' + danmaku.length + ' 条弹幕,AI 正在流式分析...';
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
abortBtn = null;
}
const danmakuText = formatDanmakuText(danmaku);
const _dmMaxChars = (typeof CONFIG !== 'undefined' && CONFIG.fullDataMaxChars) || 64000;
let _dmTruncated = false;
let _dmText = danmakuText;
if (danmakuText.length > _dmMaxChars) {
_dmText = danmakuText.substring(0, _dmMaxChars);
_dmTruncated = true;
}
rawDanmakuChatContext = '\n\n[弹幕原文数据(' + danmaku.length + '条,格式:[时间] 内容)]\n' + _dmText;
const activeDanmakuPrompt = CONFIG.danmakuPromptText || DANMAKU_PROMPT_TEXT;
const fullPrompt = activeDanmakuPrompt + '\n\n弹幕内容如下:\n' + _dmText;
const _dmWarnHtml = _dmTruncated
? '⚠️ 弹幕数据量(' + danmakuText.length.toLocaleString() + '字符)超过上限(' + _dmMaxChars.toLocaleString() + '字符),AI 仅分析了前 ' + _dmMaxChars.toLocaleString() + ' 字符。
'
: '';
danmakuSection.innerHTML = `
📡 弹幕分析 ${danmaku.length}条弹幕
${_dmWarnHtml}
`;
const resultEl = danmakuSection.querySelector('.tabbit-danmaku-result');
const abortBtnWrap = document.createElement('div');
abortBtnWrap.style.cssText = 'text-align:center;';
abortBtn = insertInlineAbortBtn(abortBtnWrap, function() {
abortCurrentTask();
});
danmakuSection.appendChild(abortBtnWrap);
abortBtn._wrap = abortBtnWrap;
const messages = [{ role: 'user', content: fullPrompt }];
const onDelta = createThrottledDelta(function(fullText) {
resultEl.textContent = fullText;
const cursor = document.createElement('span');
cursor.className = 'tabbit-typing-cursor';
resultEl.appendChild(cursor);
});
const reply = await callAIStream(messages, onDelta, buildSummaryStreamOptions(localController.signal));
autoSendFlomo(reply, videoInfo, flomoSourceUrl, '弹幕总结');
resultEl.innerHTML = parseMarkdown(reply);
const actionsDiv = document.createElement('div');
actionsDiv.className = 'tabbit-danmaku-actions';
actionsDiv.innerHTML = `
🤖 ${currentModel}
`;
danmakuSection.appendChild(actionsDiv);
const copyDanmakuBtn = danmakuSection.querySelector('#tabbit-copy-danmaku');
if (copyDanmakuBtn) {
copyDanmakuBtn.addEventListener('click', function() { copyCommentResult(this, reply); });
}
const flomoDanmakuBtn = danmakuSection.querySelector('#tabbit-flomo-danmaku');
if (flomoDanmakuBtn) {
flomoDanmakuBtn.addEventListener('click', function() { sendToFlomo(reply, this, { videoInfo, pageUrl: flomoSourceUrl, kind: '弹幕总结' }); });
}
const exportDanmakuBtn = danmakuSection.querySelector('#tabbit-export-danmaku');
if (exportDanmakuBtn) {
exportDanmakuBtn.addEventListener('click', function() {
var title = (videoInfo && videoInfo.title) || '弹幕分析';
var safeTitle = sanitizeFilename(title) || '弹幕分析';
var ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
triggerDownload(danmakuText, safeTitle + '__弹幕原文__' + ts + '.txt', 'text/plain;charset=utf-8');
});
}
} catch (err) {
console.error('[省流助手-弹幕分析]', err);
if (isAbortError(err)) {
danmakuSection.innerHTML = `
📡 弹幕分析
⏹ 已被用户打断
`;
} else {
danmakuSection.innerHTML = `
📡 弹幕分析
⚠️ 弹幕分析失败
${escapeHtml(err.message)}
`;
}
} finally {
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
}
if (currentAbortController === localController) currentAbortController = null;
isDanmakuAnalyzing = false;
const btn = contentDiv.querySelector('#tabbit-danmaku-btn');
if (btn) btn.disabled = false;
}
}
// ==================== 全面分析主流程(流式 + 打断) ====================
let isFullAnalyzing = false;
let rawDanmakuChatContext = '';
let rawCommentsChatContext = '';
let rawFullDataChatContext = '';
let lastFullPromptText = '';
const FULL_ANALYSIS_PROMPT = '你是一个专业的视频内容全面分析师。请对以下视频的字幕内容、弹幕和评论进行综合分析,输出一份完整的分析报告,包括:\n1. 【视频核心内容】用简洁的话概括视频到底在讲什么\n2. 【弹幕热评联动】哪些字幕片段引发了最热烈的弹幕/评论讨论,分析观众的反应和情绪\n3. 【观众共鸣点】弹幕和评论中反复出现的话题、梗或观点\n4. 【争议与分歧】弹幕/评论中存在对立看法的地方\n5. 【时间轴亮点】按时间线标注视频中哪些时刻引发了最多的弹幕互动\n6. 【综合评价】综合字幕+弹幕+评论,给出这个视频的整体质量和口碑\n7. 我理解能力差、没耐心,别讲铺垫、别讲背景、别讲废话,只告诉我核心结论和关键点。\n\n重要约束:\n- 只能引用输入数据里已经出现的时间范围,禁止自行猜测、补写或编造时间点。\n- 如果某个结论无法从输入中定位到明确时间范围,直接写“未定位到明确时间点”,不要硬写时间。\n- 优先使用输入中的 [开始-结束] 时间范围;不要把一个时间范围私自改写成别的时间点。';
async function runFullAnalysis(panel, videoInfo) {
const flomoSourceUrl = window.location.href;
if (isFullAnalyzing) return;
isFullAnalyzing = true;
const contentDiv = panel.querySelector('.tabbit-panel-content');
const fullBtn = contentDiv.querySelector('#tabbit-full-btn');
if (fullBtn) fullBtn.disabled = true;
let fullSection = contentDiv.querySelector('#tabbit-full-section');
if (!fullSection) {
fullSection = document.createElement('div');
fullSection.className = 'tabbit-full-section';
fullSection.id = 'tabbit-full-section';
contentDiv.appendChild(fullSection);
}
fullSection.innerHTML = `
🔍 全面分析
`;
const statusEl = fullSection.querySelector('#tabbit-full-status');
abortCurrentTask();
currentAbortController = new AbortController();
const localController = currentAbortController;
let abortBtn = null;
const fetchAbortBtnWrap = document.createElement('div');
fetchAbortBtnWrap.style.cssText = 'text-align:center;';
abortBtn = insertInlineAbortBtn(fetchAbortBtnWrap, function() {
abortCurrentTask();
});
fullSection.appendChild(fetchAbortBtnWrap);
abortBtn._wrap = fetchAbortBtnWrap;
try {
const aid = getAid(videoInfo);
const cid = videoInfo.cid;
if (!aid && !cid) throw new Error('无法获取视频 aid 或 cid');
// 1. 获取弹幕
let danmaku = [];
if (cid) {
if (statusEl) statusEl.textContent = '正在获取弹幕...';
try {
danmaku = await fetchAllDanmaku(cid, function(msg) {
if (statusEl) statusEl.textContent = msg;
}, localController.signal);
} catch(e) {
if (isAbortError(e)) throw e;
console.warn('[省流助手-全面分析] 弹幕获取失败:', e.message);
}
}
if (localController.signal.aborted) throw Object.assign(new Error('用户已打断'), { name: 'AbortError' });
// 2. 获取评论
let comments = [];
comments.fetchMeta = { pagesFetched: 0, stop: 'unavailable', reason: '未获取到视频 aid,未请求评论' };
if (aid) {
if (statusEl) statusEl.textContent = '正在获取评论...';
try {
comments = await fetchAllComments(aid, function(msg) {
if (statusEl) statusEl.textContent = '弹幕' + danmaku.length + '条,' + msg;
}, localController.signal);
} catch(e) {
if (isAbortError(e)) throw e;
console.warn('[省流助手-全面分析] 评论获取失败:', e.message);
comments.fetchMeta = { pagesFetched: 0, stop: 'unavailable', reason: '评论获取失败:' + e.message };
}
}
if (localController.signal.aborted) throw Object.assign(new Error('用户已打断'), { name: 'AbortError' });
// 3. 检查数据量
if (danmaku.length === 0 && comments.length === 0 && !rawSubtitleBody.length && !rawTranscript) {
throw new Error('未获取到任何数据(弹幕、评论、字幕均为空)');
}
if (statusEl) statusEl.textContent = '数据就绪(弹幕' + danmaku.length + '条 + 评论' + comments.length + '条),AI 正在全面分析...';
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
abortBtn = null;
}
// 4. 构建全面数据
const fullDataText = formatFullData(videoInfo, rawSubtitleBody, danmaku, comments, rawTranscript);
const maxChars = (typeof CONFIG !== 'undefined' && CONFIG.fullDataMaxChars) || 64000;
let truncatedDataText = fullDataText;
let wasTruncated = false;
if (fullDataText.length > maxChars) {
truncatedDataText = fullDataText.substring(0, maxChars);
wasTruncated = true;
}
rawFullDataChatContext = '\n\n[全面分析原始数据]\n' + truncatedDataText;
const activeFullPrompt = CONFIG.fullAnalysisPromptText || FULL_ANALYSIS_PROMPT;
const fullPrompt = activeFullPrompt + '\n\n以下是本次采集数据,请遵守其中的范围说明:\n' + truncatedDataText;
lastFullPromptText = activeFullPrompt + '\n\n以下是本次采集数据,请遵守其中的范围说明:\n' + fullDataText;
const totalItems = (rawSubtitleBody.length || 0) + danmaku.length + comments.length;
fullSection.innerHTML = `
🔍 全面分析 (字幕${rawSubtitleBody.length}句 + 弹幕${danmaku.length}条 + 评论${comments.length}条)
${commentCoverageHtml(comments)}
${wasTruncated ? '⚠️ 原文数据量(' + fullDataText.length.toLocaleString() + '字符)超过上限(' + maxChars.toLocaleString() + '字符),AI 仅分析了前 ' + maxChars.toLocaleString() + ' 字符。点击「📄 导出原文」可获取完整数据。
' : ''}
`;
const resultEl = fullSection.querySelector('.tabbit-full-result');
const abortBtnWrap = document.createElement('div');
abortBtnWrap.style.cssText = 'text-align:center;';
abortBtn = insertInlineAbortBtn(abortBtnWrap, function() {
abortCurrentTask();
});
fullSection.appendChild(abortBtnWrap);
abortBtn._wrap = abortBtnWrap;
const messages = [{ role: 'user', content: fullPrompt }];
const onDelta = createThrottledDelta(function(fullText) {
resultEl.textContent = fullText;
const cursor = document.createElement('span');
cursor.className = 'tabbit-typing-cursor';
resultEl.appendChild(cursor);
});
const reply = await callAIStream(messages, onDelta, buildSummaryStreamOptions(localController.signal));
autoSendFlomo(reply, videoInfo, flomoSourceUrl, '全面分析');
resultEl.innerHTML = parseMarkdown(reply);
const actionsDiv = document.createElement('div');
actionsDiv.className = 'tabbit-full-actions';
actionsDiv.innerHTML = `
🤖 ${currentModel}
`;
fullSection.appendChild(actionsDiv);
const copyFullBtn = fullSection.querySelector('#tabbit-copy-full');
if (copyFullBtn) {
copyFullBtn.addEventListener('click', function() { copyCommentResult(this, reply); });
}
const flomoFullBtn = fullSection.querySelector('#tabbit-flomo-full');
if (flomoFullBtn) {
flomoFullBtn.addEventListener('click', function() { sendToFlomo(reply, this, { videoInfo, pageUrl: flomoSourceUrl, kind: '全面分析' }); });
}
const exportFullBtn = fullSection.querySelector('#tabbit-export-full');
if (exportFullBtn) {
exportFullBtn.addEventListener("click", function() {
if (!lastFullPromptText) { alert("没有可导出的原文数据"); return; }
var title = (videoInfo && videoInfo.title) || "全面分析";
var safeTitle = sanitizeFilename(title) || "全面分析";
var ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
triggerDownload(lastFullPromptText, safeTitle + "__全面分析原文__" + ts + ".txt", "text/plain;charset=utf-8");
});
}
} catch (err) {
console.error('[省流助手-全面分析]', err);
if (isAbortError(err)) {
fullSection.innerHTML = `
🔍 全面分析
⏹ 已被用户打断
`;
} else {
fullSection.innerHTML = `
🔍 全面分析
⚠️ 全面分析失败
${escapeHtml(err.message)}
`;
}
} finally {
if (abortBtn && abortBtn._wrap && abortBtn._wrap.parentNode) {
abortBtn._wrap.remove();
}
if (currentAbortController === localController) currentAbortController = null;
isFullAnalyzing = false;
const btn = contentDiv.querySelector('#tabbit-full-btn');
if (btn) btn.disabled = false;
}
}
// ==================== 对话功能(流式 + 打断) ====================
async function handleSend(panel) {
const input = panel.querySelector('.tabbit-chat-input');
const sendBtn = panel.querySelector('.tabbit-chat-send');
const text = input.value.trim();
if (!text) return;
if (conversationHistory.length === 0 && !rawDanmakuChatContext && !rawCommentsChatContext && !rawFullDataChatContext) {
alert('请先等待摘要完成或运行弹幕/评论/全面分析后再发起对话');
return;
}
// 如果没有初始摘要但有分析数据,创建初始对话上下文
if (conversationHistory.length === 0) {
let initContext = '你是一个B站视频分析助手,以下是当前视频的相关数据,请基于这些数据回答用户问题。';
if (rawFullDataChatContext) initContext += rawFullDataChatContext;
else {
if (rawDanmakuChatContext) initContext += rawDanmakuChatContext;
if (rawCommentsChatContext) initContext += rawCommentsChatContext;
}
conversationHistory = [{ role: 'user', content: initContext }, { role: 'assistant', content: '已收到视频相关数据,可以开始提问了。' }];
}
let messagesContainer = panel.querySelector('.tabbit-chat-messages');
if (!messagesContainer) {
const contentDiv = panel.querySelector('.tabbit-panel-content');
messagesContainer = document.createElement('div');
messagesContainer.className = 'tabbit-chat-messages';
contentDiv.appendChild(messagesContainer);
}
const userMsg = document.createElement('div');
userMsg.className = 'tabbit-msg tabbit-msg-user';
userMsg.textContent = text;
messagesContainer.appendChild(userMsg);
input.value = '';
input.style.height = 'auto';
input.disabled = true;
const aiWrap = document.createElement('div');
aiWrap.style.cssText = 'display:flex;flex-direction:column;align-items:flex-start;';
const aiMsg = document.createElement('div');
aiMsg.className = 'tabbit-msg tabbit-msg-ai';
aiMsg.innerHTML = '';
const modelTag = document.createElement('div');
modelTag.className = 'tabbit-msg-model';
modelTag.textContent = '🤖 ' + currentModel;
aiWrap.appendChild(aiMsg);
aiWrap.appendChild(modelTag);
messagesContainer.appendChild(aiWrap);
const contentDiv = panel.querySelector('.tabbit-panel-content');
// 🆕 把发送按钮变为打断按钮
abortCurrentTask();
currentAbortController = new AbortController();
const localController = currentAbortController;
setSendBtnAsAbort(sendBtn, function() {
abortCurrentTask();
});
try {
// 构建包含所有可用原始数据的用户消息
let userContext = text;
const subtitleLocatorContext = buildSubtitleLocatorContext(text, rawSubtitleBody);
let allContext = '';
if (rawFullDataChatContext) allContext += rawFullDataChatContext;
else {
if (rawDanmakuChatContext) allContext += rawDanmakuChatContext;
if (rawCommentsChatContext) allContext += rawCommentsChatContext;
}
if (subtitleLocatorContext) {
userContext += '\n\n[字幕定位参考-仅供回答当前问题,不要重复完整贴出原文]\n' + subtitleLocatorContext;
}
if (allContext) {
userContext += '\n\n[参考数据-仅供参考,不要重复展示]' + allContext;
}
conversationHistory.push({ role: 'user', content: userContext });
let sentMessages = conversationHistory;
if (conversationHistory.length > MAX_CONVERSATION_HISTORY) {
sentMessages = [conversationHistory[0], ...conversationHistory.slice(-(MAX_CONVERSATION_HISTORY - 1))];
}
const onDelta = createThrottledDelta(function(fullText) {
aiMsg.textContent = fullText;
const cursor = document.createElement('span');
cursor.className = 'tabbit-typing-cursor';
aiMsg.appendChild(cursor);
});
const reply = await callAIStream(sentMessages, onDelta, buildSummaryStreamOptions(localController.signal));
conversationHistory.push({ role: 'assistant', content: reply });
aiMsg.innerHTML = parseMarkdown(reply);
} catch (err) {
console.error('[省流助手-对话]', err);
conversationHistory.pop();
// 🆕 区分打断和真实错误
if (isAbortError(err)) {
aiMsg.style.background = '#fff7e6';
aiMsg.style.color = '#b76d00';
aiMsg.textContent = '⏹ 已被用户打断';
} else {
aiMsg.style.background = '#fff3f3';
aiMsg.style.color = '#c00';
aiMsg.textContent = '⚠️ ' + err.message;
}
} finally {
// 🆕 还原发送按钮
restoreSendBtn(sendBtn);
if (currentAbortController === localController) currentAbortController = null;
input.disabled = false;
sendBtn.disabled = false;
input.focus();
}
}
// ==================== 设置面板 ====================
function openSettingsPanel(mainPanel) {
if (document.querySelector('#tabbit-settings-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'tabbit-settings-overlay';
let editingApiProfiles = normalizeApiProfiles(CONFIG.apiProfiles, CONFIG);
let editingActiveApiProfileId = CONFIG.activeApiProfileId;
if (!editingApiProfiles.some(function(profile) { return profile.id === editingActiveApiProfileId; })) editingActiveApiProfileId = editingApiProfiles[0].id;
const activeApiProfile = editingApiProfiles.find(function(profile) { return profile.id === editingActiveApiProfileId; }) || editingApiProfiles[0];
const currentModelList = (activeApiProfile.modelList || DEFAULT_CONFIG.modelList).join('\n');
const currentCommentTextPresets = getCommentTextPresets().join('\n');
const cacheStats = getSummaryCacheStats();
overlay.innerHTML = `
勾选控制显示;上移、下移调整同一区域顺序。保存后才更新结果区。
不同 API 服务商可建多个预设,一键切换。提示词模板和其他设置跨预设共用。
默认模型
先获取模型列表,再选择用于摘要和测试的默认模型。
模型列表(可手动补充)
获取成功后会自动显示在上方下拉框。每行一个模型名称,也可以手动补充。
预设管理
设置里的「默认」决定启动默认预设;主面板切换只临时重新总结,不会改默认。
最大获取页数
最多获取多少页评论,范围 1-20 页
最大评论数量
最多获取多少条评论,范围 10-500 条
页面间最小延迟(毫秒)
每页评论之间的最小等待时间,防止触发风控
页面间最大延迟(毫秒)
每页评论之间的最大等待时间,防止触发风控
评论发布文字预设
用于图片下方「填字并点上传」按钮。每次随机选一条填入评论框。
🚀 摘要发评论自动发送
开启:点击「摘要发评论」后自动点发布按钮;关闭:只填入评论框,手动点发送
弹幕分析提示词
发送给 AI 的弹幕分析指令。弹幕将作为数据附在提示词后面。
全面分析预设
选择全面分析的提示词预设,可在下方编辑
当前预设提示词
编辑当前选中预设的提示词。弹幕和评论将作为舆情数据附在提示词后面。
生图模式
Flow 模式会把生图提示词发送到已打开/将打开的 Flow 项目页,由 Flow 页面脚本负责提交和下载。
复制 生图提示词(自动+手动生图共用)
用于指导生图模型的提示词模板。使用 {summary} 占位符表示视频总结内容(运行时自动替换)。如不写占位符,总结会自动追加在末尾。
📁 字幕保存目录
TXT/SRT 保存在这里;自动保存的图片进入“图片”子目录。评论、弹幕、全面分析和配置导出不会保存到这里。
🧠 摘要缓存
当前缓存 ${cacheStats.count} 条,约 ${Math.ceil(cacheStats.chars / 1000)}K 字符。最多保留 ${SUMMARY_CACHE_MAX_ENTRIES} 条,超出自动清理旧缓存。
`;
document.body.appendChild(overlay);
// 设置页信息架构:合并同类功能,并按常用配置流程排序。
(function organizeSettingsSections() {
var body = overlay.querySelector('.tabbit-settings-body');
var getCollapse = function(name) {
var header = overlay.querySelector('[data-collapse="' + name + '"]');
return header ? header.parentElement : null;
};
var addSectionTitle = function(container, text) {
var title = document.createElement('div');
title.className = 'tabbit-settings-subsection-title';
title.textContent = text;
container.appendChild(title);
};
var resultCollapse = getCollapse('result-action-settings');
var flomoCollapse = getCollapse('flomo-settings');
if (resultCollapse && flomoCollapse) {
var resultBody = resultCollapse.querySelector('.tabbit-collapse-body');
var flomoBody = flomoCollapse.querySelector('.tabbit-collapse-body');
var originalResultNodes = Array.from(resultBody.childNodes);
resultBody.innerHTML = '';
addSectionTitle(resultBody, '摘要操作按钮');
originalResultNodes.forEach(function(node) { resultBody.appendChild(node); });
addSectionTitle(resultBody, '发送到 Flomo(可选)');
Array.from(flomoBody.childNodes).forEach(function(node) { resultBody.appendChild(node); });
flomoCollapse.remove();
}
var commentCollapse = getCollapse('comment-settings');
var analysisCollapse = getCollapse('analysis-settings');
if (commentCollapse && analysisCollapse) {
var commentBody = commentCollapse.querySelector('.tabbit-collapse-body');
var analysisBody = analysisCollapse.querySelector('.tabbit-collapse-body');
var originalAnalysisNodes = Array.from(analysisBody.childNodes);
analysisBody.innerHTML = '';
addSectionTitle(analysisBody, '评论区分析');
Array.from(commentBody.childNodes).forEach(function(node) { analysisBody.appendChild(node); });
addSectionTitle(analysisBody, '弹幕与全面分析');
originalAnalysisNodes.forEach(function(node) { analysisBody.appendChild(node); });
commentCollapse.remove();
}
['api-settings', 'preset-settings', 'analysis-settings', 'image-settings', 'result-action-settings', 'other-settings']
.map(getCollapse).filter(Boolean).forEach(function(section) { body.appendChild(section); });
})();
let editingPresets = JSON.parse(JSON.stringify(CONFIG.promptPresets || DEFAULT_PRESETS));
let editingActiveId = CONFIG.activePresetId || (editingPresets[0] && editingPresets[0].id);
let editingResultActionButtons = normalizeResultActionButtons(CONFIG.resultActionButtons);
function moveEditingResultActionButton(index, direction) {
var target = index + direction;
if (target < 0 || target >= editingResultActionButtons.length) return;
var item = editingResultActionButtons[index];
editingResultActionButtons[index] = editingResultActionButtons[target];
editingResultActionButtons[target] = item;
renderResultActionButtonEditList(target);
}
function renderResultActionButtonEditList(focusIndex) {
var listEl = overlay.querySelector('#ts-result-action-buttons-list');
if (!listEl) return;
listEl.innerHTML = '';
editingResultActionButtons.forEach(function(item, index) {
var definition = RESULT_ACTION_BUTTONS[item.id];
if (!definition) return;
var row = document.createElement('div');
row.className = 'tabbit-result-action-settings-item';
row.dataset.index = String(index);
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = item.enabled !== false;
checkbox.addEventListener('change', function() { item.enabled = checkbox.checked; });
var label = document.createElement('label');
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' ' + definition.settingsLabel));
var upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.textContent = '↑';
upBtn.title = '上移';
upBtn.disabled = index === 0;
upBtn.addEventListener('click', function() { moveEditingResultActionButton(index, -1); });
var downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.textContent = '↓';
downBtn.title = '下移';
downBtn.disabled = index === editingResultActionButtons.length - 1;
downBtn.addEventListener('click', function() { moveEditingResultActionButton(index, 1); });
row.appendChild(label);
row.appendChild(upBtn);
row.appendChild(downBtn);
listEl.appendChild(row);
});
if (typeof focusIndex === 'number') {
var focused = listEl.querySelector('[data-index="' + focusIndex + '"]');
if (focused) focused.scrollIntoView({ block: 'nearest' });
}
}
renderResultActionButtonEditList();
function renderPresetEditList() {
const listEl = overlay.querySelector('#ts-preset-list');
if (!listEl) return;
listEl.innerHTML = '';
editingPresets.forEach(function(preset, idx) {
const item = document.createElement('div');
item.className = 'tabbit-preset-item';
const isActive = preset.id === editingActiveId;
item.innerHTML =
'' +
'' +
'' +
'' +
'' +
'
' +
'';
listEl.appendChild(item);
});
listEl.querySelectorAll('input[data-field], textarea[data-field]').forEach(function(el) {
el.addEventListener('input', function() {
const idx = parseInt(el.dataset.idx, 10);
const field = el.dataset.field;
if (editingPresets[idx]) {
editingPresets[idx][field] = el.value;
}
});
});
listEl.querySelectorAll('input[name="ts-active-preset"]').forEach(function(el) {
el.addEventListener('change', function() {
const idx = parseInt(el.dataset.idx, 10);
if (editingPresets[idx]) {
editingActiveId = editingPresets[idx].id;
}
});
});
listEl.querySelectorAll('.tabbit-preset-del-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const idx = parseInt(btn.dataset.idx, 10);
if (editingPresets.length <= 1) {
alert('至少保留一个预设');
return;
}
if (!confirm('确定删除「' + (editingPresets[idx].name || '未命名') + '」?')) return;
const removedId = editingPresets[idx].id;
editingPresets.splice(idx, 1);
if (editingActiveId === removedId) {
editingActiveId = editingPresets[0].id;
}
renderPresetEditList();
});
});
}
renderPresetEditList();
// ==================== 全面分析预设编辑器 ====================
let editingFullPresets = JSON.parse(JSON.stringify(CONFIG.fullAnalysisPresets || DEFAULT_FULL_ANALYSIS_PRESETS));
let editingFullActiveId = CONFIG.activeFullAnalysisPresetId || (editingFullPresets[0] && editingFullPresets[0].id);
function syncFullPromptTextarea() {
const ta = overlay.querySelector('#ts-fullAnalysisPromptText');
if (!ta) return;
const active = editingFullPresets.find(function(p) { return p.id === editingFullActiveId; });
ta.value = active ? (active.prompt || '') : '';
}
function renderFullPresetEditList() {
const listEl = overlay.querySelector('#ts-fullAnalysisPresets');
if (!listEl) return;
listEl.innerHTML = '';
editingFullPresets.forEach(function(preset, idx) {
const isActive = preset.id === editingFullActiveId;
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:6px;background:' + (isActive ? '#eef0ff' : '#f8f9fa') + ';border:1px solid ' + (isActive ? '#667eea' : '#e8e8f0') + ';border-radius:6px;padding:6px 8px;cursor:pointer;transition:all .15s;';
row.innerHTML =
'' + (preset.icon || '📄') + '' +
'' +
'' +
'';
listEl.appendChild(row);
});
listEl.querySelectorAll('input[data-ffield]').forEach(function(el) {
el.addEventListener('input', function() {
const idx = parseInt(el.dataset.fidx, 10);
if (editingFullPresets[idx]) {
editingFullPresets[idx].name = el.value;
}
});
});
listEl.querySelectorAll('input[name="ts-active-full-preset"]').forEach(function(el) {
el.addEventListener('change', function() {
const idx = parseInt(el.dataset.fidx, 10);
if (editingFullPresets[idx]) {
editingFullActiveId = editingFullPresets[idx].id;
syncFullPromptTextarea();
renderFullPresetEditList();
}
});
});
listEl.querySelectorAll('.tabbit-preset-del-btn[data-fidx]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const idx = parseInt(btn.dataset.fidx, 10);
if (editingFullPresets.length <= 1) {
alert('至少保留一个预设');
return;
}
if (!confirm('确定删除「' + (editingFullPresets[idx].name || '未命名') + '」?')) return;
const removedId = editingFullPresets[idx].id;
editingFullPresets.splice(idx, 1);
if (editingFullActiveId === removedId) {
editingFullActiveId = editingFullPresets[0].id;
}
syncFullPromptTextarea();
renderFullPresetEditList();
});
});
}
renderFullPresetEditList();
syncFullPromptTextarea();
overlay.querySelector('#ts-fullPreset-add').addEventListener('click', function() {
editingFullPresets.push({
id: 'fullpreset_' + Date.now(),
name: '新预设',
icon: '✨',
prompt: '请基于视频字幕、弹幕和评论进行分析...'
});
renderFullPresetEditList();
});
// Sync textarea changes back to the active preset
overlay.querySelector('#ts-fullAnalysisPromptText').addEventListener('input', function() {
const active = editingFullPresets.find(function(p) { return p.id === editingFullActiveId; });
if (active) active.prompt = this.value;
});
overlay.querySelector('#ts-preset-add').addEventListener('click', function() {
editingPresets.push({
id: 'preset_' + Date.now(),
name: '新预设',
icon: '✨',
prompt: '请总结视频内容...'
});
renderPresetEditList();
});
function getEditingApiProfile() {
return editingApiProfiles.find(function(profile) { return profile.id === editingActiveApiProfileId; }) || editingApiProfiles[0];
}
function syncEditingApiProfileFromForm() {
var profile = getEditingApiProfile();
if (!profile) return;
profile.name = overlay.querySelector('#ts-apiProfileName').value.trim() || profile.name || '未命名配置';
profile.apiUrl = overlay.querySelector('#ts-apiUrl').value.trim();
profile.apiKey = overlay.querySelector('#ts-apiKey').value.trim();
profile.model = overlay.querySelector('#ts-model').value.trim();
profile.modelList = overlay.querySelector('#ts-modelList').value.split('\n').map(function(item) { return item.trim(); }).filter(Boolean);
}
function renderModelOptions(preferredModel) {
var modelSelect = overlay.querySelector('#ts-model');
var models = overlay.querySelector('#ts-modelList').value.split('\n').map(function(item) { return item.trim(); }).filter(Boolean);
var selectedModel = preferredModel || modelSelect.value || '';
modelSelect.innerHTML = '';
if (!models.length) {
var emptyOption = document.createElement('option');
emptyOption.value = ''; emptyOption.textContent = '请先获取模型列表'; emptyOption.selected = true;
modelSelect.appendChild(emptyOption);
} else {
Array.from(new Set(models)).forEach(function(model) {
var option = document.createElement('option');
option.value = model; option.textContent = model; option.selected = model === selectedModel;
modelSelect.appendChild(option);
});
if (!models.includes(selectedModel)) modelSelect.value = models[0];
}
var testBtn = overlay.querySelector('#ts-test-api');
if (testBtn) testBtn.disabled = !modelSelect.value;
}
function renderApiProfileForm() {
var select = overlay.querySelector('#ts-apiProfileSelect');
select.innerHTML = '';
editingApiProfiles.forEach(function(profile) {
var option = document.createElement('option');
option.value = profile.id; option.textContent = profile.name; option.selected = profile.id === editingActiveApiProfileId;
select.appendChild(option);
});
var profile = getEditingApiProfile();
overlay.querySelector('#ts-apiProfileName').value = profile.name || '';
overlay.querySelector('#ts-apiUrl').value = profile.apiUrl || '';
overlay.querySelector('#ts-apiKey').value = profile.apiKey || '';
overlay.querySelector('#ts-modelList').value = (profile.modelList || []).join('\n');
renderModelOptions(profile.model || '');
}
renderApiProfileForm();
overlay.querySelector('#ts-apiProfileSelect').addEventListener('change', function() {
syncEditingApiProfileFromForm(); editingActiveApiProfileId = this.value; renderApiProfileForm();
});
overlay.querySelector('#ts-apiProfileAdd').addEventListener('click', function() {
syncEditingApiProfileFromForm();
var name = prompt('新预设名称:', '新配置'); if (!name) return;
var profile = { id: createApiProfileId(), name: name.trim() || '新配置', apiUrl: '', apiKey: '', model: '', modelList: [] };
editingApiProfiles.push(profile); editingActiveApiProfileId = profile.id; renderApiProfileForm();
});
overlay.querySelector('#ts-apiProfileClone').addEventListener('click', function() {
syncEditingApiProfileFromForm();
var source = getEditingApiProfile(); var name = prompt('复制为新预设:', source.name + '(副本)'); if (!name) return;
var profile = JSON.parse(JSON.stringify(source)); profile.id = createApiProfileId(); profile.name = name.trim() || source.name + '(副本)';
editingApiProfiles.push(profile); editingActiveApiProfileId = profile.id; renderApiProfileForm();
});
overlay.querySelector('#ts-apiProfileRename').addEventListener('click', function() {
syncEditingApiProfileFromForm();
var profile = getEditingApiProfile(); var name = prompt('重命名当前预设:', profile.name); if (!name) return;
profile.name = name.trim() || profile.name; renderApiProfileForm();
});
overlay.querySelector('#ts-apiProfileDelete').addEventListener('click', function() {
if (editingApiProfiles.length <= 1) { alert('至少保留一个 API 预设。'); return; }
var profile = getEditingApiProfile(); if (!confirm('确定删除预设「' + profile.name + '」?')) return;
editingApiProfiles = editingApiProfiles.filter(function(item) { return item.id !== profile.id; });
editingActiveApiProfileId = editingApiProfiles[0].id; renderApiProfileForm();
});
overlay.querySelector('#ts-model').addEventListener('change', function() {
var profile = getEditingApiProfile();
if (profile) profile.model = this.value;
overlay.querySelector('#ts-test-api').disabled = !this.value;
});
overlay.querySelector('#ts-modelList').addEventListener('input', function() {
renderModelOptions(overlay.querySelector('#ts-model').value);
});
overlay.querySelector('#ts-test-api').addEventListener('click', async function() {
syncEditingApiProfileFromForm();
var profile = getEditingApiProfile();
if (!profile.apiUrl || !profile.apiKey || !profile.model) { alert('请先填写 API 地址、API Key 和默认模型。'); return; }
var btn = this; var originalText = btn.textContent; btn.disabled = true; btn.textContent = '⏳ 测试中...';
try {
var response = await gmRequest({ method: 'POST', url: buildChatCompletionsUrl(profile.apiUrl),
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + profile.apiKey },
data: JSON.stringify(buildReqBody(profile.model, [{ role: 'user', content: '请只回复 OK' }], { temperature: 0.1, maxTokens: 20 })), timeout: AUX_REQUEST_TIMEOUT_MS });
if (response.status < 200 || response.status >= 300) throw new Error('HTTP ' + response.status + ': ' + String(response.responseText || '').slice(0, 500));
var data = JSON.parse(response.responseText);
if (!data.choices || !data.choices[0] || !data.choices[0].message) throw new Error('API 响应格式异常');
alert('✅ 预设「' + profile.name + '」API 测试成功。');
} catch (error) { alert('❌ API 测试失败:\n\n' + (error.message || String(error))); }
finally { btn.disabled = false; btn.textContent = originalText; }
});
overlay.querySelectorAll('.tabbit-collapse-header').forEach(function(header) {
header.addEventListener('click', function() {
const collapse = header.parentElement;
collapse.classList.toggle('open');
});
});
function closeSettings() { overlay.remove(); }
overlay.querySelector('#tabbit-settings-close').addEventListener('click', closeSettings);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeSettings();
});
async function doFetchModels(append) {
const apiUrl = overlay.querySelector('#ts-apiUrl').value.trim();
const apiKey = overlay.querySelector('#ts-apiKey').value.trim();
const fetchBtn = overlay.querySelector(append ? '#ts-append-models' : '#ts-fetch-models');
const originalText = fetchBtn.textContent;
fetchBtn.disabled = true;
fetchBtn.textContent = '⏳ 获取中...';
try {
const models = await fetchModelList(apiUrl, apiKey);
const textarea = overlay.querySelector('#ts-modelList');
if (append) {
const existing = textarea.value.split('\n').map(function(s) { return s.trim(); }).filter(Boolean);
const merged = Array.from(new Set(existing.concat(models)));
textarea.value = merged.join('\n');
} else {
textarea.value = models.join('\n');
}
renderModelOptions(overlay.querySelector('#ts-model').value);
syncEditingApiProfileFromForm();
fetchBtn.textContent = '✅ 已获取 ' + models.length + ' 个';
setTimeout(function() {
fetchBtn.textContent = originalText;
fetchBtn.disabled = false;
}, 2500);
} catch (err) {
console.error('[省流助手] 获取模型列表失败:', err);
alert('获取模型列表失败:\n' + err.message + '\n\n请检查 API URL 和 API Key 是否正确。');
fetchBtn.textContent = originalText;
fetchBtn.disabled = false;
}
}
overlay.querySelector('#ts-fetch-models').addEventListener('click', function() { doFetchModels(false); });
overlay.querySelector('#ts-append-models').addEventListener('click', function() { doFetchModels(true); });
overlay.querySelector('#ts-change-subtitle-directory').addEventListener('click', async function() {
const btn = this;
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (typeof pageWindow.showDirectoryPicker !== 'function') {
alert('当前浏览器不支持固定字幕保存目录');
return;
}
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '正在选择…';
try {
const handle = await pageWindow.showDirectoryPicker({ id: 'tabbit-subtitle-downloads', mode: 'readwrite' });
await clearDownloadDirectoryHandle();
await saveDownloadDirectoryHandle(handle);
btn.textContent = '✅ 已更新字幕目录';
} catch(e) {
btn.textContent = e && e.name === 'AbortError' ? '已取消' : '❌ 选择失败';
if (!e || e.name !== 'AbortError') console.warn('[省流助手] 重新选择字幕目录失败:', e);
}
setTimeout(function() { btn.disabled = false; btn.textContent = originalText; }, 2200);
});
overlay.querySelector('#ts-reset-pos').addEventListener('click', function() {
POSITIONS = {};
savePositions(POSITIONS);
mainPanel?._windowTools?.reset();
if (mainPanel) {
mainPanel.style.left = '';
mainPanel.style.top = '';
mainPanel.style.right = '';
mainPanel.style.bottom = '';
mainPanel.style.transform = '';
mainPanel.style.width = '';
mainPanel.style.height = '';
}
const floatBtn = document.querySelector('#tabbit-float-btn');
if (floatBtn) {
floatBtn.style.left = '';
floatBtn.style.top = '';
floatBtn.style.right = '';
floatBtn.style.transform = '';
}
alert('位置和尺寸已重置');
});
overlay.querySelector('#ts-clear-summary-cache').addEventListener('click', function() {
if (!confirm('确定要清理所有摘要缓存吗?清理后同一视频会重新请求 API。')) return;
clearSummaryCache();
const info = overlay.querySelector('#ts-cache-info');
if (info) {
info.textContent = '当前缓存 0 条,约 0K 字符。最多保留 ' + SUMMARY_CACHE_MAX_ENTRIES + ' 条,超出自动清理旧缓存。';
}
alert('摘要缓存已清理');
});
overlay.querySelector('#ts-save').addEventListener('click', function() {
syncEditingApiProfileFromForm();
const savedApiProfile = getEditingApiProfile();
const newApiUrl = overlay.querySelector('#ts-apiUrl').value.trim();
const newApiKey = overlay.querySelector('#ts-apiKey').value.trim();
const newModel = overlay.querySelector('#ts-model').value.trim();
const newSummaryMaxTokens = parseInt(overlay.querySelector('#ts-summaryMaxTokens').value, 10);
const newModelListRaw = overlay.querySelector('#ts-modelList').value;
const newModelList = newModelListRaw.split('\n').map(function(s) { return s.trim(); }).filter(function(s) { return s.length > 0; });
const newFlomoApiUrl = overlay.querySelector('#ts-flomoApiUrl').value.trim();
const newFlomoTags = overlay.querySelector('#ts-flomoTags').value.trim();
const newCommentPromptText = overlay.querySelector('#ts-commentPromptText').value.trim();
const newCommentTextPresets = (overlay.querySelector('#ts-commentTextPresets').value || '')
.split('\n')
.map(function(s) { return s.trim(); })
.filter(Boolean);
CONFIG.autoSubmitCommentSummary = overlay.querySelector('#ts-autoSubmitCommentSummary').checked;
const newAutoParse = overlay.querySelector('#ts-autoParse').checked;
const newAutoOpenPanelWhileProcessing = overlay.querySelector('#ts-autoOpenPanelWhileProcessing').checked;
const cleanedPresets = editingPresets
.map(function(p) {
return {
id: p.id || ('preset_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)),
name: (p.name || '').trim() || '未命名',
icon: (p.icon || '📄').trim(),
prompt: (p.prompt || '').trim()
};
})
.filter(function(p) { return p.prompt.length > 0; });
if (cleanedPresets.length === 0) {
alert('至少保留一个有效预设(提示词不能为空)');
return;
}
let validActiveId = editingActiveId;
if (!cleanedPresets.find(function(p) { return p.id === validActiveId; })) {
validActiveId = cleanedPresets[0].id;
}
CONFIG.apiUrl = newApiUrl;
CONFIG.apiKey = newApiKey;
CONFIG.model = newModel || CONFIG.model;
savedApiProfile.apiUrl = CONFIG.apiUrl;
savedApiProfile.apiKey = CONFIG.apiKey;
savedApiProfile.model = CONFIG.model;
savedApiProfile.modelList = newModelList.length > 0 ? newModelList.slice() : DEFAULT_CONFIG.modelList.slice();
CONFIG.apiProfiles = editingApiProfiles;
CONFIG.activeApiProfileId = editingActiveApiProfileId;
CONFIG.summaryMaxTokens = isNaN(newSummaryMaxTokens) ? DEFAULT_CONFIG.summaryMaxTokens : Math.max(500, Math.min(30000, newSummaryMaxTokens));
CONFIG.flomoAutoSend = overlay.querySelector('#ts-flomoAutoSend').checked;
CONFIG.flomoApiUrl = newFlomoApiUrl;
CONFIG.flomoTags = newFlomoTags;
CONFIG.modelList = newModelList.length > 0 ? newModelList : DEFAULT_CONFIG.modelList;
CONFIG.promptPresets = cleanedPresets;
CONFIG.activePresetId = validActiveId;
currentPresetId = validActiveId;
const activeP = cleanedPresets.find(function(p) { return p.id === validActiveId; });
if (activeP) CONFIG.promptText = activeP.prompt;
CONFIG.commentPromptText = newCommentPromptText || COMMENT_PROMPT_TEXT;
CONFIG.commentTextPresets = newCommentTextPresets.length > 0 ? newCommentTextPresets : DEFAULT_CONFIG.commentTextPresets.slice();
const newDanmakuPromptText = overlay.querySelector('#ts-danmakuPromptText').value.trim();
CONFIG.danmakuPromptText = newDanmakuPromptText || DANMAKU_PROMPT_TEXT;
// Save full analysis presets
const cleanedFullPresets = editingFullPresets
.map(function(p) {
return {
id: p.id || ('fullpreset_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)),
name: (p.name || '').trim() || '未命名',
icon: (p.icon || '📄').trim(),
prompt: (p.prompt || '').trim()
};
})
.filter(function(p) { return p.prompt.length > 0; });
if (cleanedFullPresets.length > 0) {
CONFIG.fullAnalysisPresets = cleanedFullPresets;
let validFullActiveId = editingFullActiveId;
if (!cleanedFullPresets.find(function(p) { return p.id === validFullActiveId; })) {
validFullActiveId = cleanedFullPresets[0].id;
}
CONFIG.activeFullAnalysisPresetId = validFullActiveId;
var newFullDataMaxChars = parseInt(overlay.querySelector('#ts-fullDataMaxChars').value, 10);
CONFIG.fullDataMaxChars = isNaN(newFullDataMaxChars) ? 64000 : Math.max(10000, Math.min(200000, newFullDataMaxChars));
const activeFP = cleanedFullPresets.find(function(p) { return p.id === validFullActiveId; });
if (activeFP) CONFIG.fullAnalysisPromptText = activeFP.prompt;
}
CONFIG.autoSubmitCommentSummary = overlay.querySelector('#ts-autoSubmitCommentSummary').checked;
CONFIG.enableAutoDownloadSubtitle = overlay.querySelector('#ts-enableAutoDownloadSubtitle').checked;
const newCommentMaxPages = parseInt(overlay.querySelector('#ts-commentMaxPages').value, 10);
CONFIG.commentMaxPages = isNaN(newCommentMaxPages) ? 8 : Math.max(1, Math.min(20, newCommentMaxPages));
const newCommentLimit = parseInt(overlay.querySelector('#ts-commentLimit').value, 10);
CONFIG.commentLimit = isNaN(newCommentLimit) ? 188 : Math.max(10, Math.min(500, newCommentLimit));
const newCommentMinDelay = parseInt(overlay.querySelector('#ts-commentMinDelay').value, 10);
CONFIG.commentMinDelay = isNaN(newCommentMinDelay) ? 1800 : Math.max(500, Math.min(10000, newCommentMinDelay));
const newCommentMaxDelay = parseInt(overlay.querySelector('#ts-commentMaxDelay').value, 10);
CONFIG.commentMaxDelay = isNaN(newCommentMaxDelay) ? 3800 : Math.max(1000, Math.min(15000, newCommentMaxDelay));
const newSkipDuration = parseInt(overlay.querySelector('#ts-skipDuration').value, 10);
CONFIG.skipDuration = isNaN(newSkipDuration) ? 60 : newSkipDuration;
CONFIG.autoParse = newAutoParse;
CONFIG.autoOpenPanelWhileProcessing = newAutoOpenPanelWhileProcessing;
CONFIG.enableThinking = overlay.querySelector('#ts-enableThinking') ? overlay.querySelector('#ts-enableThinking').checked : true;
CONFIG.enableImageGen = overlay.querySelector('#ts-enableImageGen').checked;
CONFIG.imageGenMode = overlay.querySelector('#ts-imageGenMode').value === 'flow' ? 'flow' : 'api';
CONFIG.flowProjectUrl = (overlay.querySelector('#ts-flowProjectUrl').value || '').trim() || DEFAULT_FLOW_PROJECT_URL;
CONFIG.enableFlowBackgroundOpen = overlay.querySelector('#ts-enableFlowBackgroundOpen')
? overlay.querySelector('#ts-enableFlowBackgroundOpen').checked
: true;
CONFIG.imageGenApiUrl = (overlay.querySelector('#ts-imageGenApiUrl').value || '').trim();
CONFIG.imageGenApiKey = (overlay.querySelector('#ts-imageGenApiKey').value || '').trim();
CONFIG.imageGenModel = (overlay.querySelector('#ts-imageGenModel').value || '').trim() || DEFAULT_CONFIG.imageGenModel;
const normalizedImageSize = normalizeImageSizeInput(overlay.querySelector('#ts-imageGenSize') ? overlay.querySelector('#ts-imageGenSize').value : '');
if (!normalizedImageSize) {
alert('生图尺寸格式不正确,请填写类似 1024x1024、1280x720,或 auto');
return;
}
CONFIG.imageGenSize = normalizedImageSize;
CONFIG.enableImageAutoDownload = overlay.querySelector('#ts-enableImageAutoDownload')
? overlay.querySelector('#ts-enableImageAutoDownload').checked
: true;
const newImageGenPromptText = (overlay.querySelector('#ts-imageGenPromptText').value || '').trim();
CONFIG.imageGenPromptText = newImageGenPromptText || IMAGE_GEN_PROMPT_TEXT;
CONFIG.resultActionButtons = normalizeResultActionButtons(editingResultActionButtons);
CONFIG.showWindowControls = overlay.querySelector('#ts-showWindowControls').checked;
currentModel = CONFIG.model;
if (!saveConfig(CONFIG)) {
alert('配置保存失败,请检查浏览器本地存储是否可用。');
return;
}
document.querySelectorAll('#tabbit-ai-summary-panel .tabbit-panel-content').forEach(function(contentDiv) {
refreshResultActionButtons(contentDiv);
});
if (mainPanel) {
mainPanel._windowTools?.refresh();
const modelListEl = mainPanel.querySelector('.tabbit-model-list');
if (modelListEl) {
modelListEl.innerHTML = CONFIG.modelList.map(function(m) {
return '' + escapeHtml(m) + '
';
}).join('');
bindModelChips(mainPanel);
}
const presetBar = mainPanel.querySelector('.tabbit-preset-bar');
if (presetBar) {
const wrapper = document.createElement('div');
wrapper.innerHTML = renderPresetBarHtml();
const newBar = wrapper.firstElementChild;
if (newBar) {
presetBar.replaceWith(newBar);
bindPresetChips(mainPanel, currentVideoInfo);
}
}
}
const savedEl = overlay.querySelector('#ts-saved');
savedEl.style.display = 'inline';
setTimeout(function() { savedEl.style.display = 'none'; }, 2000);
});
overlay.querySelector('#ts-export').addEventListener('click', function() {
triggerDownload(JSON.stringify(CONFIG, null, 2), 'bili-summary-config.json', 'application/json');
});
overlay.querySelector('#ts-import').addEventListener('click', function() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,application/json';
fileInput.style.cssText = 'position:fixed;left:-9999px;';
document.body.appendChild(fileInput);
fileInput.addEventListener('change', function() {
const file = fileInput.files[0];
if (!file) { fileInput.remove(); return; }
const reader = new FileReader();
reader.onload = function(e) {
try {
const imported = JSON.parse(e.target.result);
if (imported.apiUrl) overlay.querySelector('#ts-apiUrl').value = imported.apiUrl;
if (imported.apiKey) overlay.querySelector('#ts-apiKey').value = imported.apiKey;
if (imported.model) overlay.querySelector('#ts-model').value = imported.model;
if (imported.resultActionButtons !== undefined) {
editingResultActionButtons = normalizeResultActionButtons(imported.resultActionButtons);
renderResultActionButtonEditList();
}
if (imported.summaryMaxTokens !== undefined) {
var summaryMaxTokensEl = overlay.querySelector('#ts-summaryMaxTokens');
if (summaryMaxTokensEl) summaryMaxTokensEl.value = imported.summaryMaxTokens;
}
if (Array.isArray(imported.modelList)) {
overlay.querySelector('#ts-modelList').value = imported.modelList.join('\n');
}
overlay.querySelector('#ts-flomoAutoSend').checked = imported.flomoAutoSend === true;
if (imported.flomoApiUrl !== undefined) overlay.querySelector('#ts-flomoApiUrl').value = imported.flomoApiUrl;
if (imported.flomoTags !== undefined) overlay.querySelector('#ts-flomoTags').value = imported.flomoTags;
if (imported.commentPromptText) overlay.querySelector('#ts-commentPromptText').value = imported.commentPromptText;
if (imported.commentTextPresets !== undefined) {
var importedCommentTexts = Array.isArray(imported.commentTextPresets)
? imported.commentTextPresets
: String(imported.commentTextPresets || '').split('\n');
var commentTextPresetsEl = overlay.querySelector('#ts-commentTextPresets');
if (commentTextPresetsEl) commentTextPresetsEl.value = importedCommentTexts.map(function(s) { return String(s || '').trim(); }).filter(Boolean).join('\n') || '省流';
}
if (imported.autoSubmitCommentSummary !== undefined) {
var autoSubmitEl = overlay.querySelector('#ts-autoSubmitCommentSummary');
if (autoSubmitEl) autoSubmitEl.checked = !!imported.autoSubmitCommentSummary;
}
if (imported.enableAutoDownloadSubtitle !== undefined) {
var autoDlSubEl = overlay.querySelector('#ts-enableAutoDownloadSubtitle');
if (autoDlSubEl) autoDlSubEl.checked = !!imported.enableAutoDownloadSubtitle;
}
if (imported.danmakuPromptText !== undefined) overlay.querySelector('#ts-danmakuPromptText').value = imported.danmakuPromptText;
if (imported.fullDataMaxChars !== undefined) { var fdcEl = overlay.querySelector('#ts-fullDataMaxChars'); if (fdcEl) fdcEl.value = imported.fullDataMaxChars; }
if (Array.isArray(imported.fullAnalysisPresets) && imported.fullAnalysisPresets.length > 0) {
editingFullPresets = JSON.parse(JSON.stringify(imported.fullAnalysisPresets));
editingFullActiveId = imported.activeFullAnalysisPresetId || editingFullPresets[0].id;
renderFullPresetEditList();
syncFullPromptTextarea();
}
if (imported.commentMaxPages !== undefined) overlay.querySelector('#ts-commentMaxPages').value = imported.commentMaxPages;
if (imported.commentLimit !== undefined) overlay.querySelector('#ts-commentLimit').value = imported.commentLimit;
if (imported.commentMinDelay !== undefined) overlay.querySelector('#ts-commentMinDelay').value = imported.commentMinDelay;
if (imported.commentMaxDelay !== undefined) overlay.querySelector('#ts-commentMaxDelay').value = imported.commentMaxDelay;
if (imported.skipDuration !== undefined) overlay.querySelector('#ts-skipDuration').value = imported.skipDuration;
if (imported.autoParse !== undefined) overlay.querySelector('#ts-autoParse').checked = !!imported.autoParse;
if (imported.autoOpenPanelWhileProcessing !== undefined) overlay.querySelector('#ts-autoOpenPanelWhileProcessing').checked = imported.autoOpenPanelWhileProcessing === true;
if (imported.enableImageGen !== undefined) overlay.querySelector('#ts-enableImageGen').checked = !!imported.enableImageGen;
if (imported.imageGenMode !== undefined) { var igModeEl = overlay.querySelector('#ts-imageGenMode'); if (igModeEl) igModeEl.value = imported.imageGenMode === 'flow' ? 'flow' : 'api'; }
if (imported.flowProjectUrl !== undefined) { var flowProjectUrlEl = overlay.querySelector('#ts-flowProjectUrl'); if (flowProjectUrlEl) flowProjectUrlEl.value = imported.flowProjectUrl || DEFAULT_FLOW_PROJECT_URL; }
if (imported.enableFlowBackgroundOpen !== undefined) { var flowBackgroundEl = overlay.querySelector('#ts-enableFlowBackgroundOpen'); if (flowBackgroundEl) flowBackgroundEl.checked = !!imported.enableFlowBackgroundOpen; }
if (imported.imageGenApiUrl !== undefined) overlay.querySelector('#ts-imageGenApiUrl').value = imported.imageGenApiUrl;
if (imported.imageGenApiKey !== undefined) overlay.querySelector('#ts-imageGenApiKey').value = imported.imageGenApiKey;
if (imported.imageGenModel) overlay.querySelector('#ts-imageGenModel').value = imported.imageGenModel;
if (imported.imageGenSize) { var igSizeEl = overlay.querySelector('#ts-imageGenSize'); if (igSizeEl) igSizeEl.value = imported.imageGenSize; }
if (imported.enableImageAutoDownload !== undefined) {
var igAutoDownloadEl = overlay.querySelector('#ts-enableImageAutoDownload');
if (igAutoDownloadEl) igAutoDownloadEl.checked = !!imported.enableImageAutoDownload;
}
if (imported.imageGenPromptText !== undefined) overlay.querySelector('#ts-imageGenPromptText').value = imported.imageGenPromptText;
if (Array.isArray(imported.promptPresets) && imported.promptPresets.length > 0) {
editingPresets = JSON.parse(JSON.stringify(imported.promptPresets));
editingActiveId = imported.activePresetId || editingPresets[0].id;
renderPresetEditList();
} else if (imported.promptText) {
editingPresets = [{
id: 'preset_imported_' + Date.now(),
name: '导入的提示词',
icon: '📥',
prompt: imported.promptText
}];
editingActiveId = editingPresets[0].id;
renderPresetEditList();
}
alert('配置已导入,请点击「保存」使其生效。');
} catch(err) {
alert('导入失败:JSON 格式错误\n' + err.message);
}
};
reader.readAsText(file);
fileInput.remove();
});
fileInput.click();
});
overlay.querySelector('#ts-reset').addEventListener('click', function() {
if (!confirm('确定要重置所有配置为默认值吗?')) return;
overlay.querySelector('#ts-apiUrl').value = DEFAULT_CONFIG.apiUrl;
overlay.querySelector('#ts-apiKey').value = DEFAULT_CONFIG.apiKey;
overlay.querySelector('#ts-model').value = DEFAULT_CONFIG.model;
var summaryMaxTokensReset = overlay.querySelector('#ts-summaryMaxTokens'); if (summaryMaxTokensReset) summaryMaxTokensReset.value = DEFAULT_CONFIG.summaryMaxTokens;
overlay.querySelector('#ts-modelList').value = DEFAULT_CONFIG.modelList.join('\n');
overlay.querySelector('#ts-flomoAutoSend').checked = false;
overlay.querySelector('#ts-flomoApiUrl').value = '';
overlay.querySelector('#ts-flomoTags').value = DEFAULT_CONFIG.flomoTags;
overlay.querySelector('#ts-commentPromptText').value = COMMENT_PROMPT_TEXT;
overlay.querySelector('#ts-commentTextPresets').value = DEFAULT_CONFIG.commentTextPresets.join('\n');
CONFIG.autoSubmitCommentSummary = false;
var autoSubmitReset = overlay.querySelector('#ts-autoSubmitCommentSummary'); if (autoSubmitReset) autoSubmitReset.checked = false;
var autoDlSubReset = overlay.querySelector('#ts-enableAutoDownloadSubtitle'); if (autoDlSubReset) autoDlSubReset.checked = false;
overlay.querySelector('#ts-commentMaxPages').value = DEFAULT_CONFIG.commentMaxPages;
overlay.querySelector('#ts-commentLimit').value = DEFAULT_CONFIG.commentLimit;
overlay.querySelector('#ts-commentMinDelay').value = DEFAULT_CONFIG.commentMinDelay;
overlay.querySelector('#ts-commentMaxDelay').value = DEFAULT_CONFIG.commentMaxDelay;
overlay.querySelector('#ts-skipDuration').value = DEFAULT_CONFIG.skipDuration;
overlay.querySelector('#ts-showWindowControls').checked = DEFAULT_CONFIG.showWindowControls;
overlay.querySelector('#ts-autoParse').checked = DEFAULT_CONFIG.autoParse;
overlay.querySelector('#ts-autoOpenPanelWhileProcessing').checked = DEFAULT_CONFIG.autoOpenPanelWhileProcessing;
overlay.querySelector('#ts-enableImageGen').checked = false;
var imageGenModeReset = overlay.querySelector('#ts-imageGenMode'); if (imageGenModeReset) imageGenModeReset.value = DEFAULT_CONFIG.imageGenMode;
var flowProjectUrlReset = overlay.querySelector('#ts-flowProjectUrl'); if (flowProjectUrlReset) flowProjectUrlReset.value = DEFAULT_CONFIG.flowProjectUrl;
var flowBackgroundReset = overlay.querySelector('#ts-enableFlowBackgroundOpen'); if (flowBackgroundReset) flowBackgroundReset.checked = DEFAULT_CONFIG.enableFlowBackgroundOpen;
overlay.querySelector('#ts-imageGenApiUrl').value = '';
overlay.querySelector('#ts-imageGenApiKey').value = '';
overlay.querySelector('#ts-imageGenModel').value = DEFAULT_CONFIG.imageGenModel;
var igSizeReset = overlay.querySelector('#ts-imageGenSize'); if (igSizeReset) igSizeReset.value = '1024x1024';
var igAutoDownloadReset = overlay.querySelector('#ts-enableImageAutoDownload'); if (igAutoDownloadReset) igAutoDownloadReset.checked = DEFAULT_CONFIG.enableImageAutoDownload;
overlay.querySelector('#ts-imageGenPromptText').value = IMAGE_GEN_PROMPT_TEXT;
editingResultActionButtons = getDefaultResultActionButtons();
renderResultActionButtonEditList();
editingPresets = JSON.parse(JSON.stringify(DEFAULT_PRESETS));
editingActiveId = 'preset_default';
renderPresetEditList();
var danmakuPromptReset = overlay.querySelector('#ts-danmakuPromptText'); if (danmakuPromptReset) danmakuPromptReset.value = DANMAKU_PROMPT_TEXT;
var fullDataMaxCharsReset = overlay.querySelector('#ts-fullDataMaxChars'); if (fullDataMaxCharsReset) fullDataMaxCharsReset.value = DEFAULT_CONFIG.fullDataMaxChars;
editingFullPresets = JSON.parse(JSON.stringify(DEFAULT_FULL_ANALYSIS_PRESETS));
editingFullActiveId = 'fullpreset_video_review';
renderFullPresetEditList();
syncFullPromptTextarea();
});
}
function getRouteKey() {
return window.location.href;
}
function resetRuntimeForRouteChange() {
abortCurrentTask();
clearSummaryNotice();
currentSubtitleManualFallback = null;
// 捕获缓存已绑定请求发出时的视频上下文;切换路由后仍清理缓存和等待者。
subtitleCaptureEntries = [];
subtitleListCaptureEntries = [];
subtitleCaptureWaiters = [];
isSubtitleCaptureInProgress = false;
rawMarkdownResult = '';
rawTranscript = '';
rawSubtitleBody = [];
currentVideoInfo = null;
conversationHistory = [];
commentConversationHistory = [];
isCommentSummarizing = false;
isDanmakuAnalyzing = false;
isFullAnalyzing = false;
rawDanmakuChatContext = '';
rawCommentsChatContext = '';
rawFullDataChatContext = '';
hasParsed = false;
currentPresetId = CONFIG.activePresetId || 'preset_default';
const panel = document.querySelector('#tabbit-ai-summary-panel');
if (panel) panel.remove();
hideFloatBtn();
}
function isStaleRoute(generation) {
return generation !== routeGeneration || getRouteKey() !== lastRouteKey;
}
function isStaleSummaryContext(generation, expectedBvid, expectedCid) {
if (generation !== routeGeneration) return true;
if (isStaleRoute(generation)) return true;
if (!expectedBvid) return isStaleRoute(generation);
const current = getVideoInfo();
return current.bvid !== expectedBvid || (expectedCid != null && String(current.cid) !== String(expectedCid));
}
function scheduleRouteRestart() {
const routeKey = getRouteKey();
if (routeKey === lastRouteKey) return;
lastRouteKey = routeKey;
routeGeneration += 1;
if (routeRestartTimer) clearTimeout(routeRestartTimer);
routeRestartTimer = setTimeout(function() {
resetRuntimeForRouteChange();
if (CONFIG.autoParse) {
startParsing({ autoTriggered: true });
} else {
showFloatOnlyMode();
}
}, 800);
}
function installRouteWatcher() {
if (window.__BILI_SUBTITLE_SUMMARY_ROUTE_WATCHER__) return;
window.__BILI_SUBTITLE_SUMMARY_ROUTE_WATCHER__ = true;
const fire = function() {
setTimeout(scheduleRouteRestart, 120);
};
['pushState', 'replaceState'].forEach(function(method) {
const original = history[method];
history[method] = function() {
const ret = original.apply(this, arguments);
fire();
return ret;
};
});
window.addEventListener('popstate', fire);
}
// ==================== 自动启动主流程 ====================
// 捕获优先后仍给原接口链路留出完整回退时间,避免总超时提前掐掉回退。
const SUBTITLE_FETCH_OVERALL_TIMEOUT_MS = 40000;
async function startParsing(options) {
if (hasParsed) return;
hasParsed = true;
const isAutoTriggered = !!(options && options.autoTriggered);
const keepPanelVisible = !isAutoTriggered || CONFIG.autoOpenPanelWhileProcessing === true;
const parsingGeneration = routeGeneration;
let panel = null;
let videoInfo = null;
let subtitleAbortController = null;
let subtitleAbortBtnWrap = null;
let subtitleManualFallbackShown = false;
let subtitleForceStopped = false;
let overallTimer = null;
let captureFallbackUsed = false;
function isParsingVideoStale() {
if (parsingGeneration !== routeGeneration) return true;
if (!videoInfo) return false;
const current = getVideoInfo();
// 初始化时添加 vd_source 等参数不代表切换视频。
return current.bvid !== videoInfo.bvid || current.part !== videoInfo.part ||
(videoInfo.cid != null && String(current.cid) !== String(videoInfo.cid));
}
function cleanupSubtitleAbortUi() {
if (subtitleAbortBtnWrap && subtitleAbortBtnWrap.parentNode) {
subtitleAbortBtnWrap.remove();
}
subtitleAbortBtnWrap = null;
if (currentAbortController === subtitleAbortController) {
currentAbortController = null;
}
if (currentSubtitleManualFallback === forceStopAutoSubtitleFetch) {
currentSubtitleManualFallback = null;
}
}
function showInterruptedSubtitleManualState() {
if (subtitleManualFallbackShown) return;
subtitleManualFallbackShown = true;
if (overallTimer) {
clearTimeout(overallTimer);
overallTimer = null;
}
cleanupSubtitleAbortUi();
if (panel && videoInfo && !isParsingVideoStale()) {
showNoSubtitleState(panel, videoInfo, false, {
icon: '⏹',
title: '已停止自动获取',
desc: '自动字幕获取已打断。现在可以手动获取字幕,或直接上传 srt/txt/粘贴字幕内容。'
});
setFloatBtnState(panel, 'interrupted');
}
}
function forceStopAutoSubtitleFetch() {
if (subtitleForceStopped) return;
subtitleForceStopped = true;
console.log('[省流助手] 强制停止自动字幕获取,切换到手动处理');
showInterruptedSubtitleManualState();
if (subtitleAbortController && !subtitleAbortController.signal.aborted) {
try { subtitleAbortController.abort(); } catch(e) {}
}
if (currentAbortController === subtitleAbortController) {
currentAbortController = null;
}
}
function throwIfSubtitleForceStopped() {
if (subtitleForceStopped) {
const abortErr = new Error('用户已打断');
abortErr.name = 'AbortError';
throw abortErr;
}
throwIfAborted(subtitleAbortController && subtitleAbortController.signal);
}
try {
lastRouteKey = getRouteKey();
console.log('[省流助手] 开始解析...');
videoInfo = getVideoInfo();
if (!videoInfo.bvid) {
console.log('[省流助手] 无法获取BVID,跳过');
hasParsed = false;
return;
}
currentVideoInfo = videoInfo;
console.log('[省流助手] 视频信息 - 标题: ' + videoInfo.title + ', BVID: ' + videoInfo.bvid + ', CID: ' + videoInfo.cid);
createStyles();
panel = createPanel(videoInfo);
if (keepPanelVisible) {
hideFloatBtn();
} else {
panel.style.display = 'none';
}
setFloatBtnState(panel, 'processing');
const skipSec = CONFIG.skipDuration || 60;
if (videoInfo.duration > 0 && videoInfo.duration < skipSec) {
console.log('[省流助手] 视频时长不足' + skipSec + '秒,跳过自动字幕获取');
showNoSubtitleState(panel, videoInfo, true);
setFloatBtnState(panel, 'no-subtitle');
if (isAutoTriggered) notifyBackgroundSummaryFailed(panel, parsingGeneration, '视频时长过短,点击查看', 'no-subtitle');
return;
}
abortCurrentTask();
subtitleAbortController = new AbortController();
currentAbortController = subtitleAbortController;
currentSubtitleManualFallback = forceStopAutoSubtitleFetch;
// 🆕 用 Promise.race 包裹整个字幕获取流程,防止永久卡住
var overallTimeout = new Promise(function(_, reject) {
overallTimer = setTimeout(function() {
if (subtitleAbortController && !subtitleAbortController.signal.aborted) {
try { subtitleAbortController.abort(); } catch(e) {}
}
reject(new Error('字幕获取超时(' + Math.round(SUBTITLE_FETCH_OVERALL_TIMEOUT_MS / 1000) + '秒)'));
}, SUBTITLE_FETCH_OVERALL_TIMEOUT_MS);
});
var fetchFlow = (async function() {
const loadingSpan = panel.querySelector('.tabbit-panel-content .tabbit-loading span');
if (loadingSpan) loadingSpan.textContent = '正在等待播放器视频信息就绪...';
videoInfo = await waitForSubtitleVideoReady(videoInfo.bvid, subtitleAbortController.signal);
currentVideoInfo = videoInfo;
console.log('[省流助手-字幕] 视频信息已就绪', { bvid: videoInfo.bvid, cid: videoInfo.cid, part: videoInfo.part });
// 优先复用当前视频的正文或播放器字幕列表,不需要改变播放器字幕开关。
throwIfSubtitleForceStopped();
const cachedBody = subtitleCaptureEntries.slice().reverse().find(entry =>
isCurrentSubtitleCapture(entry.context) && Date.now() - entry.createdAt < 60000);
if (cachedBody) {
rawSubtitleBody = cachedBody.segments;
return cachedBody.transcript;
}
const cachedList = getCurrentCapturedSubtitleList();
if (cachedList) {
try {
if (loadingSpan) loadingSpan.textContent = '正在读取播放器提供的字幕...';
const content = await fetchCapturedListContent(cachedList, subtitleAbortController.signal);
throwIfSubtitleForceStopped();
if (isParsingVideoStale()) return 'stale';
if (!isCurrentSubtitleCapture(cachedList.context)) {
throw new Error('播放器字幕缓存上下文已变化,重新获取字幕');
}
rawSubtitleBody = content;
return formatTranscript(content);
} catch(err) {
if (isAbortError(err)) throw err;
throwIfSubtitleForceStopped();
console.warn('[省流助手] 播放器字幕地址读取失败,继续捕获和接口兜底:', err.message);
}
}
// 自动解析默认走捕获模式:实际播放器响应通常比字幕接口更可靠。
// 等待和自动触发共用当前 AbortController,因此「停止自动获取」会同时取消等待。
throwIfSubtitleForceStopped();
isSubtitleCaptureInProgress = true;
let captureSession = null;
try {
if (loadingSpan) loadingSpan.textContent = '正在捕获播放器字幕...';
console.log('[省流助手-捕获模式] 自动解析:优先等待播放器字幕响应');
// 先挂监听 waiter,再点字幕;字幕控件已在时不会额外 sleep。
const captureStartedAt = Date.now();
const capturePromise = waitForCapturedSubtitle(SUBTITLE_CAPTURE_WAIT_TIMEOUT_MS, subtitleAbortController.signal, captureStartedAt);
// autoTrigger 被中止时,capturePromise 也会同步 reject;提前挂处理器避免未处理拒绝。
capturePromise.catch(function() {});
const triggerResult = await triggerSubtitleForCapture(subtitleAbortController.signal, parsingGeneration);
captureSession = triggerResult.session;
const triggered = triggerResult.triggered;
throwIfSubtitleForceStopped();
if (!triggered) throw new Error('未找到可安全触发的字幕控件');
if (loadingSpan) {
loadingSpan.textContent = triggered ? '已触发播放器字幕,正在捕获...' : '等待播放器字幕响应...';
}
const captured = await capturePromise;
throwIfSubtitleForceStopped();
if (isParsingVideoStale()) return 'stale';
if (captured && captured.transcript && captured.transcript.trim()) {
restoreSubtitleAfterCapture(captureSession, 'auto-captured');
rawSubtitleBody = captured.segments || [];
console.log('[省流助手-捕获模式] 自动解析捕获成功,直接开始摘要');
return captured.transcript;
}
throw new Error('捕获到的字幕为空');
} catch (captureErr) {
if (isAbortError(captureErr)) throw captureErr;
throwIfSubtitleForceStopped();
captureFallbackUsed = true;
console.warn('[省流助手-捕获模式] 自动捕获未成功,回退字幕接口:', captureErr.message || captureErr);
if (loadingSpan) loadingSpan.textContent = '捕获未成功,正在回退接口获取字幕...';
} finally {
// 失败、超时、停止及切路由都走这里;恢复函数会拒绝碰新路由或已替换的控件。
restoreSubtitleAfterCapture(captureSession, 'auto-finally');
isSubtitleCaptureInProgress = false;
}
console.log('[省流助手] 捕获未命中,直接回退字幕接口...');
throwIfSubtitleForceStopped();
if (isParsingVideoStale()) return 'stale';
var freshInfo = getVideoInfo();
if (freshInfo.bvid) {
videoInfo = freshInfo;
currentVideoInfo = videoInfo;
}
if (loadingSpan) loadingSpan.textContent = '正在通过字幕接口获取...';
const firstSubtitleResult = await fetchSubtitlesResult(videoInfo.cid, videoInfo.bvid, subtitleAbortController.signal);
throwIfSubtitleForceStopped();
if (isParsingVideoStale()) return 'stale';
if (firstSubtitleResult.status === 'error' && firstSubtitleResult.retryable === false) {
throw firstSubtitleResult.error || new Error(firstSubtitleResult.reason);
}
let finalSubtitleResult = firstSubtitleResult;
if (firstSubtitleResult.status !== 'available') {
console.log('[省流助手] 首次字幕接口结果为 ' + firstSubtitleResult.status + ',刷新视频信息后重试...');
if (loadingSpan) loadingSpan.textContent = firstSubtitleResult.status === 'empty' ? '暂未发现字幕,正在二次确认...' : '字幕接口暂时失败,正在重试...';
await randomDelay(1500, 1500, subtitleAbortController.signal);
throwIfSubtitleForceStopped();
if (isParsingVideoStale()) return 'stale';
freshInfo = getVideoInfo();
if (freshInfo.bvid) {
videoInfo = freshInfo;
currentVideoInfo = videoInfo;
}
const retrySubtitleResult = await fetchSubtitlesResult(videoInfo.cid, videoInfo.bvid, subtitleAbortController.signal);
throwIfSubtitleForceStopped();
if (isParsingVideoStale()) return 'stale';
finalSubtitleResult = retrySubtitleResult;
if (retrySubtitleResult.status === 'empty') return 'no_subtitle';
if (retrySubtitleResult.status === 'error') {
throw new Error('字幕接口请求失败:' + (retrySubtitleResult.reason || firstSubtitleResult.reason || '请稍后重试'));
}
}
const subtitles = finalSubtitleResult.subtitles;
const targetSubtitle = subtitles.find(s => s.lan === 'zh-CN' || s.lan === 'ai-zh') || subtitles[0];
const content = await fetchSubtitleContent(targetSubtitle.subtitle_url, subtitleAbortController.signal);
throwIfSubtitleForceStopped();
if (isParsingVideoStale()) return 'stale';
if (content.length === 0) {
return 'no_subtitle';
}
rawSubtitleBody = content;
const transcript = formatTranscript(content);
if (!transcript.trim()) {
return 'no_subtitle';
}
return transcript;
})();
var fetchResult = await Promise.race([fetchFlow, overallTimeout]);
if (overallTimer) {
clearTimeout(overallTimer);
overallTimer = null;
}
throwIfSubtitleForceStopped();
cleanupSubtitleAbortUi();
if (fetchResult === 'stale') {
console.warn('[省流助手-字幕] 视频上下文变化,结束旧字幕任务');
if (parsingGeneration === routeGeneration) {
hasParsed = false;
showNoSubtitleState(panel, videoInfo, false, { title: '视频信息已变化,请重新获取字幕', desc: '旧字幕任务已结束,可点击手动获取重试。' });
setFloatBtnState(panel, 'interrupted');
scheduleRouteRestart();
}
return;
}
if (fetchResult === 'no_subtitle') {
showNoSubtitleState(panel, videoInfo, false, {
title: '该视频没有可用字幕',
desc: captureFallbackUsed
? '播放器捕获未命中,字幕接口二次确认后仍正常返回空列表。'
: '字幕接口二次确认后仍正常返回空列表。'
});
setFloatBtnState(panel, 'no-subtitle');
if (isAutoTriggered) notifyBackgroundSummaryFailed(panel, parsingGeneration, '未找到字幕,点击查看', 'no-subtitle');
return;
}
// fetchResult 是 transcript 字符串
rawTranscript = fetchResult;
console.log('[省流助手] 字幕获取完成');
if (isParsingVideoStale()) return;
// 字幕已到达时只刷新已有结果区;尚未生成结果则由后续渲染统一处理。
if (panel && videoInfo) {
var contentDivForDL = panel.querySelector('.tabbit-panel-content');
if (contentDivForDL && contentDivForDL._tabbitResultActionContext) refreshResultActionButtons(contentDivForDL);
// 🆕 自动下载字幕
if (CONFIG.enableAutoDownloadSubtitle) {
try {
const autoDownloadResult = await downloadTranscript(fetchResult, videoInfo.title, videoInfo.upName, videoInfo.bvid, { allowDirectoryPicker: false });
if (autoDownloadResult === 'downloaded') console.log('[省流助手] 已自动下载字幕');
else if (autoDownloadResult === 'needs-directory') console.warn('[省流助手] 自动下载未执行:请先手动下载一次字幕并授权保存目录');
else if (autoDownloadResult !== 'cancelled') console.warn('[省流助手] 自动下载字幕未完成:', autoDownloadResult);
} catch(e) {
console.warn('[省流助手] 自动下载字幕失败:', e.message);
}
}
}
await runSummary(panel, fetchResult, videoInfo, rawSubtitleBody, {
background: isAutoTriggered,
generation: parsingGeneration
});
} catch (err) {
if (overallTimer) {
clearTimeout(overallTimer);
overallTimer = null;
}
cleanupSubtitleAbortUi();
if (subtitleManualFallbackShown) return;
if (isAbortError(err)) {
console.log('[省流助手] 自动字幕获取已被用户打断');
showInterruptedSubtitleManualState();
if (panel && !isParsingVideoStale()) setFloatBtnState(panel, 'interrupted');
return;
}
console.error('[省流助手] 自动解析失败:', err);
hasParsed = false;
if (panel && videoInfo && !isParsingVideoStale()) {
showNoSubtitleState(panel, videoInfo, false, {
icon: '⚠️',
title: '字幕获取失败',
desc: '字幕接口请求异常或超时,这不代表视频没有字幕。可以点击下方按钮重试。'
});
const stateEl = panel.querySelector('.tabbit-no-subtitle');
if (stateEl) {
stateEl.insertAdjacentHTML('beforeend', '自动获取失败:' + escapeHtml(err.message || String(err)) + '
');
}
setFloatBtnState(panel, 'error');
if (isAutoTriggered) notifyBackgroundSummaryFailed(panel, parsingGeneration, '自动解析失败,点击查看');
}
}
}
function showFloatOnlyMode() {
console.log('[省流助手] 自动解析已关闭,仅显示悬浮唤出按钮');
createStyles();
showFloatBtn(null);
}
function init() {
lastRouteKey = getRouteKey();
// ✅ 延迟安装路由监听器:等第一次 startParsing 完成后再安装
// 避免 B 站页面初始化时的 replaceState(URL 规范化)被误判为路由切换
setTimeout(async () => {
if (CONFIG.autoParse) {
await startParsing({ autoTriggered: true });
} else {
showFloatOnlyMode();
}
// 第一次解析流程结束后再安装路由监听器
installRouteWatcher();
}, INIT_DELAY_MS);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();