// ==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('' + escapeAttr(label) + '') : 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 += ''; }; 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 += ''; if (listStack.length && listStack[listStack.length - 1].indent === indent && listStack[listStack.length - 1].type !== type) html += ''; if (!listStack.length || listStack[listStack.length - 1].indent < indent) { html += '<' + type + ' 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 = `
    🎬 b 站省流助手
    🤖 选择模型 ▶
    ${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分钟免费转写时长。
    📁 上传文件
    — 或 —
    📝 直接粘贴文本
    字符数:0
    📌 文件上传后会自动填充到文本框
    `; 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 = `
    💬 评论区总结 ${comments.length}条评论
    ${_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 = `
    ⚙️ AI 配置设置
    🚀 自动解析
    开启:进入视频页自动在后台获取字幕和生成摘要;关闭:仅在右侧显示悬浮窗,由你决定是否解析
    🪟 自动展开
    默认关闭。关闭后通过悬浮按钮查看进度,摘要完成会提示“点击查看”。
    🧠 深度思考
    关闭后请求不带 thinking 参数,可加快响应速度
    📤 结果与发送
    ▶
    勾选控制显示;上移、下移调整同一区域顺序。保存后才更新结果区。
    🤖 AI 连接与模型
    ▶
    不同 API 服务商可建多个预设,一键切换。提示词模板和其他设置跨预设共用。
    预设名称
    API 地址
    可填 https://xxx/v1,脚本会自动补成 /chat/completions 和 /models;也兼容完整 /v1/chat/completions
    API Key
    你的 API 密钥(本地存储,不会上传)
    默认模型
    先获取模型列表,再选择用于摘要和测试的默认模型。
    普通摘要最大输出 tokens
    影响视频摘要、评论总结、弹幕分析、全面分析和对话。详细笔记版容易超,可调到 6000-10000,前提是你的 API/模型支持。
    模型列表(可手动补充)
    获取成功后会自动显示在上方下拉框。每行一个模型名称,也可以手动补充。
    🌱 Flomo 设置(发送笔记相关,不填不影响)
    ▶
    默认关闭;配置 API 后生效。新生成的视频、评论、弹幕和全面总结会发送,缓存与追问不发送。
    Flomo API
    flomo 的 API 地址,在 flomo 设置 → API 中获取
    🏷️ 自动标签
    自动附加 #AI摘要 #AI摘要/B站;这里填写额外标签,多个标签用空格分隔
    🎨 摘要模板
    ▶
    预设管理
    设置里的「默认」决定启动默认预设;主面板切换只临时重新总结,不会改默认。
    💬 评论区设置
    ▶
    最大获取页数
    最多获取多少页评论,范围 1-20 页
    最大评论数量
    最多获取多少条评论,范围 10-500 条
    页面间最小延迟(毫秒)
    每页评论之间的最小等待时间,防止触发风控
    页面间最大延迟(毫秒)
    每页评论之间的最大等待时间,防止触发风控
    评论区总结提示词
    发送给AI的评论区总结指令
    评论发布文字预设
    用于图片下方「填字并点上传」按钮。每次随机选一条填入评论框。
    🚀 摘要发评论自动发送
    开启:点击「摘要发评论」后自动点发布按钮;关闭:只填入评论框,手动点发送
    💬 内容分析
    ▶
    弹幕分析提示词
    发送给 AI 的弹幕分析指令。弹幕将作为数据附在提示词后面。
    AI 分析数据上限(字符)
    所有分析(字幕摘要、评论总结、弹幕分析、全面分析)的原始数据最大字符数。超限时 AI 仅分析部分内容,页面会显示提示。可点「📄 导出原文」获取完整数据。建议先用默认值测试,被 API 拒绝就调小。
    全面分析预设
    选择全面分析的提示词预设,可在下方编辑
    当前预设提示词
    编辑当前选中预设的提示词。弹幕和评论将作为舆情数据附在提示词后面。
    🖼️ 摘要配图
    ▶
    自动生图
    开启后,每次总结完成时自动生成配图。关闭时仍可用结果区的「生成配图」手动触发。
    生图模式
    Flow 模式会把生图提示词发送到已打开/将打开的 Flow 项目页,由 Flow 页面脚本负责提交和下载。
    Flow 项目页 URL
    Flow 模式使用。建议让该项目页保持打开,右下角接收端会自动填词、生图并监控下载。
    发送时后台打开 Flow
    开启后,如果没检测到后台 Flow 接收页,会自动后台打开上面的项目页;已打开时不会重复打开。
    生图模型 API URL
    可填 /v1、/v1/images/generations、/v1/responses 或 /v1/chat/completions;脚本会自动尝试兼容格式
    生图模型 API Key
    生图模型的 API 密钥,留空则复用上方的 API Key
    生图模型名称
    支持图片输出的模型名称,默认 gemini-2.0-flash-preview-image-generation
    生图尺寸
    可选预设,也可手填「宽x高」。最终是否支持由你的生图 API 决定。
    生成后自动下载图片
    开启后,已授权字幕目录时自动保存到“图片”子目录;未授权时请点“保存图片”手动保存。
    复制 生图提示词(自动+手动生图共用)
    用于指导生图模型的提示词模板。使用 {summary} 占位符表示视频总结内容(运行时自动替换)。如不写占位符,总结会自动追加在末尾。
    ⚙️ 界面与数据
    ▶
    ⏱️ 短视频跳过阈值(秒)
    视频时长低于此秒数时自动跳过字幕获取,设为 0 则不跳过
    💾 获取字幕后自动下载
    开启后,字幕获取成功时自动下载 .txt 文件到本地。不影响AI摘要流程。
    📁 字幕保存目录
    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(); } })();