M
// ==UserScript== // @name Thriller // @namespace http://tampermonkey.net/merged_tools // @version 1.0.1 // @description M // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://notebooklm.google.com/* // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js // @run-at document-idle // @grant GM_xmlhttpRequest // @connect notebooklm.google.com // @connect accounts.google.com // @license PolyForm-Noncommercial-1.0.0 https://polyformproject.org/licenses/noncommercial/1.0.0/ // ==/UserScript== (function () { 'use strict'; const SINGLETON_KEY = '__CHATGPT_EXPORTER_MARKDOWN_SINGLETON__'; if (window[SINGLETON_KEY]) { console.warn('[ChatGPT Exporter] 检测到重复实例,当前实例已跳过启动。'); return; } window[SINGLETON_KEY] = true; // --- 配置与全局变量 --- const BASE_DELAY = 600; const JITTER = 400; const PAGE_LIMIT = 100; const PROJECT_SIDEBAR_PREVIEW = 5; const AUTO_EXPORT_STORAGE_KEY = 'chatgpt_exporter_auto_export_schedule_v1'; const EXPORT_FILENAME_SETTINGS_STORAGE_KEY = 'chatgpt_exporter_filename_settings_v1'; const EXPORT_DOWNLOAD_SETTINGS_STORAGE_KEY = 'chatgpt_exporter_download_settings_v1'; const FILE_HANDLE_DB_NAME = 'chatgpt_exporter_file_handles_v1'; const FILE_HANDLE_STORE_NAME = 'handles'; const FILE_HANDLE_KEY_ZIP = 'zip_export_target'; const AUTO_EXPORT_MIN_MINUTES = 1; const DEFAULT_NOTIFY_MODE = 'toast'; // 可改为 'silent' 以完全静默 const SCHEDULE_NOTIFY_MODE = 'silent'; const TEAM_ONLY_MODE = true; // 仅保留团队空间导出 let accessToken = null; let capturedWorkspaceIds = new Set(); // 使用Set存储网络拦截到的ID,确保唯一性 let exportInProgress = false; let autoExportTimer = null; let autoExportTaskRunning = false; let autoExportState = loadAutoExportState(); let filenameSettings = loadFilenameSettings(); let downloadSettings = loadDownloadSettings(); let cachedZipFileHandle = null; let fileHandleDbPromise = null; // --- 核心:网络拦截与信息捕获 --- (function interceptNetwork() { const rawFetch = window.fetch; window.fetch = async function (resource, options) { tryCaptureToken(options?.headers); if (options?.headers?.['ChatGPT-Account-Id']) { const id = options.headers['ChatGPT-Account-Id']; if (id && !capturedWorkspaceIds.has(id)) { console.log('🎯 [Fetch] 捕获到 Workspace ID:', id); capturedWorkspaceIds.add(id); } } return rawFetch.apply(this, arguments); }; const rawOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function () { this.addEventListener('readystatechange', () => { if (this.readyState === 4) { try { tryCaptureToken(this.getRequestHeader('Authorization')); const id = this.getRequestHeader('ChatGPT-Account-Id'); if (id && !capturedWorkspaceIds.has(id)) { console.log('🎯 [XHR] 捕获到 Workspace ID:', id); capturedWorkspaceIds.add(id); } } catch (_) {} } }); return rawOpen.apply(this, arguments); }; })(); function tryCaptureToken(header) { if (!header) return; const h = typeof header === 'string' ? header : header instanceof Headers ? header.get('Authorization') : header.Authorization || header.authorization; if (h?.startsWith('Bearer ')) { const token = h.slice(7); // [v8.2.0 修复] 在捕获源头增加验证,拒绝已知的无效占位符Token if (token && token.toLowerCase() !== 'dummy') { accessToken = token; } } } async function ensureAccessToken(options = {}) { const { notifyMode = DEFAULT_NOTIFY_MODE } = options; if (accessToken) return accessToken; try { const session = await (await fetch('/api/auth/session?unstable_client=true')).json(); if (session.accessToken) { accessToken = session.accessToken; return accessToken; } } catch (_) {} notifyUser('无法获取 Access Token。请刷新页面或打开任意一个对话后再试。', { mode: notifyMode, level: 'error', duration: 3200 }); return null; } // --- 辅助函数 --- const sleep = ms => new Promise(r => setTimeout(r, ms)); const jitter = () => BASE_DELAY + Math.random() * JITTER; const sanitizeFilename = (name) => name.replace(/[\/\\?%*:|"<>]/g, '-').trim(); const normalizeEpochSeconds = (value) => { if (!value) return 0; if (typeof value === 'number' && Number.isFinite(value)) { return value > 1e12 ? Math.floor(value / 1000) : value; } if (typeof value === 'string') { const parsed = Date.parse(value); if (!Number.isNaN(parsed)) { return Math.floor(parsed / 1000); } } return 0; }; const formatTimestamp = (value) => { const seconds = normalizeEpochSeconds(value); if (!seconds) return ''; const date = new Date(seconds * 1000); return Number.isNaN(date.getTime()) ? '' : date.toLocaleString(); }; const parseDateInputToEpoch = (value, isEnd = false) => { if (!value) return null; const parts = value.split('-').map(Number); if (parts.length !== 3 || parts.some(Number.isNaN)) return null; const [year, month, day] = parts; const date = isEnd ? new Date(year, month - 1, day, 23, 59, 59, 999) : new Date(year, month - 1, day, 0, 0, 0, 0); const epochMs = date.getTime(); return Number.isNaN(epochMs) ? null : Math.floor(epochMs / 1000); }; function ensureToastStyles() { if (document.getElementById('chatgpt-exporter-toast-style')) return; const style = document.createElement('style'); style.id = 'chatgpt-exporter-toast-style'; style.textContent = ` #cge-toast-container { position: fixed; right: 16px; bottom: 16px; z-index: 100001; display: flex; flex-direction: column; gap: 8px; pointer-events: none; max-width: min(78vw, 360px); } .cge-toast { padding: 10px 12px; border-radius: 10px; color: #fff; font-size: 13px; line-height: 1.35; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.26); opacity: 0; transform: translateY(6px); transition: opacity 0.2s ease, transform 0.2s ease; pointer-events: none; word-break: break-word; } .cge-toast[data-level="info"] { background: rgba(15, 66, 116, 0.95); } .cge-toast[data-level="success"] { background: rgba(24, 122, 84, 0.95); } .cge-toast[data-level="error"] { background: rgba(164, 52, 66, 0.96); } .cge-toast.cge-toast-show { opacity: 1; transform: translateY(0); } `; document.head.appendChild(style); } function showToast(message, level = 'info', duration = 2600) { if (!message) return; ensureToastStyles(); let container = document.getElementById('cge-toast-container'); if (!container) { container = document.createElement('div'); container.id = 'cge-toast-container'; document.body.appendChild(container); } const toast = document.createElement('div'); toast.className = 'cge-toast'; toast.dataset.level = level; toast.textContent = message; container.appendChild(toast); requestAnimationFrame(() => toast.classList.add('cge-toast-show')); setTimeout(() => { toast.classList.remove('cge-toast-show'); setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); if (container && container.childElementCount === 0 && container.parentNode) { container.parentNode.removeChild(container); } }, 220); }, Math.max(1200, duration)); } function notifyUser(message, options = {}) { const { mode = DEFAULT_NOTIFY_MODE, level = 'info', duration = 2600 } = options; if (!message || mode === 'silent') return; if (mode === 'modal') { alert(message); return; } showToast(message, level, duration); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function normalizeFilenameSettings(input = {}) { const template = typeof input.template === 'string' ? input.template.trim() : ''; const appendDate = input.appendDate === undefined ? true : Boolean(input.appendDate); return { template, appendDate }; } function loadFilenameSettings() { try { const raw = localStorage.getItem(EXPORT_FILENAME_SETTINGS_STORAGE_KEY); if (!raw) return normalizeFilenameSettings(); const parsed = JSON.parse(raw); return normalizeFilenameSettings(parsed); } catch (_) { return normalizeFilenameSettings(); } } function saveFilenameSettings() { try { localStorage.setItem(EXPORT_FILENAME_SETTINGS_STORAGE_KEY, JSON.stringify(filenameSettings)); } catch (_) {} } function normalizeDownloadSettings(input = {}) { const mode = input.mode === 'file-handle' ? 'file-handle' : 'browser'; const handleName = typeof input.handleName === 'string' ? input.handleName.trim() : ''; return { mode, handleName }; } function loadDownloadSettings() { try { const raw = localStorage.getItem(EXPORT_DOWNLOAD_SETTINGS_STORAGE_KEY); if (!raw) return normalizeDownloadSettings(); const parsed = JSON.parse(raw); return normalizeDownloadSettings(parsed); } catch (_) { return normalizeDownloadSettings(); } } function saveDownloadSettings() { try { localStorage.setItem(EXPORT_DOWNLOAD_SETTINGS_STORAGE_KEY, JSON.stringify(downloadSettings)); } catch (_) {} } function supportsFixedFileOverwrite() { return typeof window.showSaveFilePicker === 'function' && typeof indexedDB !== 'undefined'; } function idbRequestToPromise(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('IndexedDB 请求失败')); }); } function idbTransactionToPromise(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error || new Error('IndexedDB 事务失败')); tx.onabort = () => reject(tx.error || new Error('IndexedDB 事务中止')); }); } function getFileHandleDb() { if (!fileHandleDbPromise) { fileHandleDbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(FILE_HANDLE_DB_NAME, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(FILE_HANDLE_STORE_NAME)) { db.createObjectStore(FILE_HANDLE_STORE_NAME); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error || new Error('打开 IndexedDB 失败')); }); } return fileHandleDbPromise; } async function getStoredZipFileHandle() { if (cachedZipFileHandle) return cachedZipFileHandle; if (!supportsFixedFileOverwrite()) return null; try { const db = await getFileHandleDb(); const tx = db.transaction(FILE_HANDLE_STORE_NAME, 'readonly'); const store = tx.objectStore(FILE_HANDLE_STORE_NAME); const handle = await idbRequestToPromise(store.get(FILE_HANDLE_KEY_ZIP)); await idbTransactionToPromise(tx); cachedZipFileHandle = handle || null; return cachedZipFileHandle; } catch (_) { return null; } } async function setStoredZipFileHandle(handle) { if (!supportsFixedFileOverwrite() || !handle) return; const db = await getFileHandleDb(); const tx = db.transaction(FILE_HANDLE_STORE_NAME, 'readwrite'); tx.objectStore(FILE_HANDLE_STORE_NAME).put(handle, FILE_HANDLE_KEY_ZIP); await idbTransactionToPromise(tx); cachedZipFileHandle = handle; } async function clearStoredZipFileHandle() { cachedZipFileHandle = null; if (!supportsFixedFileOverwrite()) return; try { const db = await getFileHandleDb(); const tx = db.transaction(FILE_HANDLE_STORE_NAME, 'readwrite'); tx.objectStore(FILE_HANDLE_STORE_NAME).delete(FILE_HANDLE_KEY_ZIP); await idbTransactionToPromise(tx); } catch (_) {} } async function ensureFileHandlePermission(handle, requestWrite = false) { if (!handle) return false; const options = { mode: 'readwrite' }; try { let state = await handle.queryPermission(options); if (state === 'granted') return true; if (!requestWrite) return false; state = await handle.requestPermission(options); return state === 'granted'; } catch (_) { return false; } } async function pickAndRememberZipFileHandle(suggestedName = 'chatgpt_backup.zip') { if (!supportsFixedFileOverwrite()) { throw new Error('当前浏览器不支持固定文件覆盖模式。'); } const handle = await window.showSaveFilePicker({ suggestedName, types: [{ description: 'ZIP 文件', accept: { 'application/zip': ['.zip'] } }] }); const granted = await ensureFileHandlePermission(handle, true); if (!granted) { throw new Error('未授予固定文件写入权限。'); } await setStoredZipFileHandle(handle); downloadSettings.handleName = handle.name || ''; saveDownloadSettings(); return handle; } function buildDefaultExportZipFilename(mode, selectionType, workspaceId, date, appendDate = true) { if (selectionType === 'selected') { return mode === 'team' ? `chatgpt_team_selected_${workspaceId}${appendDate ? `_${date}` : ''}.zip` : mode === 'project' ? `chatgpt_project_selected${appendDate ? `_${date}` : ''}.zip` : `chatgpt_personal_selected${appendDate ? `_${date}` : ''}.zip`; } return mode === 'team' ? `chatgpt_team_backup_${workspaceId}${appendDate ? `_${date}` : ''}.zip` : mode === 'project' ? `chatgpt_project_backup${appendDate ? `_${date}` : ''}.zip` : `chatgpt_personal_backup${appendDate ? `_${date}` : ''}.zip`; } function buildExportZipFilename(mode, selectionType, workspaceId, overrideSettings = null) { const normalizedMode = normalizeExportMode(mode); const normalizedSelectionType = selectionType === 'selected' ? 'selected' : 'full'; const date = new Date().toISOString().slice(0, 10); const settings = overrideSettings ? normalizeFilenameSettings(overrideSettings) : filenameSettings; if (!settings.template) { return buildDefaultExportZipFilename( normalizedMode, normalizedSelectionType, workspaceId, date, settings.appendDate ); } const typeValue = normalizedSelectionType === 'selected' ? 'selected' : 'backup'; const workspaceValue = typeof workspaceId === 'string' ? workspaceId.trim() : ''; const hasDatePlaceholder = /\{date\}/i.test(settings.template); let baseName = settings.template .replace(/\{mode\}/gi, normalizedMode) .replace(/\{type\}/gi, typeValue) .replace(/\{workspace\}/gi, workspaceValue) .replace(/\{date\}/gi, date) .trim(); if (settings.appendDate && !hasDatePlaceholder) { baseName = `${baseName}_${date}`; } baseName = sanitizeFilename(baseName) .replace(/\.zip$/i, '') .replace(/_+/g, '_') .replace(/^[-_. ]+|[-_. ]+$/g, ''); if (!baseName) { baseName = `chatgpt_export_${date}`; } return `${baseName}.zip`; } function normalizeScheduleEntries(entries) { if (!Array.isArray(entries)) return []; const map = new Map(); entries.forEach(item => { const id = typeof item?.id === 'string' ? item.id.trim() : ''; if (!id || map.has(id)) return; map.set(id, { id, title: typeof item?.title === 'string' && item.title.trim() ? item.title.trim() : 'Untitled Conversation', projectId: typeof item?.projectId === 'string' && item.projectId.trim() ? item.projectId.trim() : null, projectTitle: typeof item?.projectTitle === 'string' && item.projectTitle.trim() ? item.projectTitle.trim() : null, is_archived: Boolean(item?.is_archived), create_time: normalizeEpochSeconds(item?.create_time || 0), update_time: normalizeEpochSeconds(item?.update_time || item?.create_time || 0) }); }); return Array.from(map.values()); } function normalizeExportMode(mode) { if (TEAM_ONLY_MODE) return 'team'; return ['personal', 'project', 'team'].includes(mode) ? mode : 'personal'; } function getModeLabel(mode) { return mode === 'team' ? '团队空间' : mode === 'project' ? '项目空间' : '个人空间'; } function normalizeAutoExportState(input = {}) { const mode = normalizeExportMode(input.mode); const intervalRaw = Number(input.intervalMinutes); const intervalMinutes = Number.isFinite(intervalRaw) ? Math.max(AUTO_EXPORT_MIN_MINUTES, Math.floor(intervalRaw)) : 60; const workspaceId = typeof input.workspaceId === 'string' ? input.workspaceId.trim() : ''; const nextRunAt = Number(input.nextRunAt); const lastRunAt = Number(input.lastRunAt); const lastError = typeof input.lastError === 'string' ? input.lastError.trim() : ''; const selectionType = input.selectionType === 'selected' ? 'selected' : 'all'; const selectedModeRaw = ['personal', 'project', 'team'].includes(input.selectedMode) ? input.selectedMode : ''; let selectedEntries = normalizeScheduleEntries(input.selectedEntries); const selectedMode = TEAM_ONLY_MODE ? 'team' : selectedModeRaw; const selectedWorkspaceId = typeof input.selectedWorkspaceId === 'string' ? input.selectedWorkspaceId.trim() : ''; if (TEAM_ONLY_MODE && selectedModeRaw && selectedModeRaw !== 'team') { selectedEntries = []; } return { enabled: Boolean(input.enabled), mode, intervalMinutes, workspaceId, nextRunAt: Number.isFinite(nextRunAt) && nextRunAt > 0 ? nextRunAt : null, lastRunAt: Number.isFinite(lastRunAt) && lastRunAt > 0 ? lastRunAt : null, lastError, selectionType, selectedEntries, selectedMode, selectedWorkspaceId }; } function loadAutoExportState() { try { const raw = localStorage.getItem(AUTO_EXPORT_STORAGE_KEY); if (!raw) { return normalizeAutoExportState(); } const parsed = JSON.parse(raw); return normalizeAutoExportState(parsed); } catch (_) { return normalizeAutoExportState(); } } function saveAutoExportState() { try { localStorage.setItem(AUTO_EXPORT_STORAGE_KEY, JSON.stringify(autoExportState)); } catch (_) {} } function getIntervalExportState() { return { ...autoExportState, timerArmed: Boolean(autoExportTimer) }; } function getAutoExportStatusText() { const errorHint = autoExportState.lastError ? ` 最近错误:${autoExportState.lastError}` : ''; if (!autoExportState.enabled) { return `未启动定时导出。${errorHint}`; } const modeLabel = getModeLabel(autoExportState.mode); const selectionHint = autoExportState.selectionType === 'selected' ? `仅已选 ${autoExportState.selectedEntries.length} 条` : '全部对话'; const workspaceHint = autoExportState.mode === 'team' ? `,Workspace: ${autoExportState.workspaceId || '未配置'}` : ''; const nextRun = autoExportState.nextRunAt ? new Date(autoExportState.nextRunAt).toLocaleString() : '未知'; const lastRun = autoExportState.lastRunAt ? new Date(autoExportState.lastRunAt).toLocaleString() : '暂无'; return `运行中:每 ${autoExportState.intervalMinutes} 分钟导出一次(${modeLabel}${workspaceHint},${selectionHint})。下次:${nextRun},上次:${lastRun}。${errorHint}`; } function clearAutoExportTimer() { if (autoExportTimer) { clearTimeout(autoExportTimer); autoExportTimer = null; } } function scheduleNextAutoExport(delayMs = null) { clearAutoExportTimer(); if (!autoExportState.enabled) return; const intervalMs = autoExportState.intervalMinutes * 60 * 1000; const waitMs = delayMs == null ? intervalMs : Math.max(1000, Math.floor(delayMs)); autoExportState.nextRunAt = Date.now() + waitMs; saveAutoExportState(); autoExportTimer = setTimeout(runAutoExportTick, waitMs); } async function runAutoExportTick(source = 'interval') { if (!autoExportState.enabled) return; if (autoExportTaskRunning || exportInProgress) { scheduleNextAutoExport(autoExportState.intervalMinutes * 60 * 1000); return; } autoExportTaskRunning = true; try { let workspaceId = null; if (autoExportState.mode === 'team') { workspaceId = autoExportState.workspaceId || resolveWorkspaceId(null) || ''; if (!workspaceId) { throw new Error('团队空间定时导出失败:缺少 Workspace ID。'); } if (!autoExportState.workspaceId) { autoExportState.workspaceId = workspaceId; } } let exported = false; if (autoExportState.selectionType === 'selected') { const selectedEntries = normalizeScheduleEntries(autoExportState.selectedEntries); if (selectedEntries.length === 0) { throw new Error('定时导出已设置为“仅已选对话”,但未配置对话列表。'); } exported = await exportConversations({ mode: autoExportState.mode, workspaceId, conversationEntries: selectedEntries, exportType: 'selected', notifyMode: SCHEDULE_NOTIFY_MODE }); } else { exported = await startScheduledExport({ mode: autoExportState.mode, workspaceId, autoConfirm: true, source, notifyMode: SCHEDULE_NOTIFY_MODE }); } if (exported) { autoExportState.lastRunAt = Date.now(); autoExportState.lastError = ''; } else { autoExportState.lastError = '任务未执行(可能被取消或并发导出跳过)。'; } } catch (err) { autoExportState.lastError = err?.message || String(err); console.error('[ChatGPT Exporter] 定时导出执行失败:', err); } finally { autoExportTaskRunning = false; if (autoExportState.enabled) { scheduleNextAutoExport(autoExportState.intervalMinutes * 60 * 1000); } saveAutoExportState(); } } function startIntervalExport(options = {}) { const runNow = Boolean(options.runNow); const merged = normalizeAutoExportState({ ...autoExportState, ...options, enabled: true }); if (merged.mode === 'team' && !merged.workspaceId) { const detectedIds = detectAllWorkspaceIds(); if (detectedIds.length === 1) { merged.workspaceId = detectedIds[0]; } } if (merged.mode === 'team' && !merged.workspaceId) { throw new Error('团队空间定时导出需要 Team Workspace ID。'); } if (merged.selectionType === 'selected') { if (!Array.isArray(merged.selectedEntries) || merged.selectedEntries.length === 0) { throw new Error('定时导出选择了“仅已选对话”,但尚未选择任何对话。'); } const expectedWorkspaceId = merged.mode === 'team' ? merged.workspaceId : ''; const selectedWorkspaceId = merged.mode === 'team' ? (merged.selectedWorkspaceId || '') : ''; if (merged.selectedMode !== merged.mode || selectedWorkspaceId !== expectedWorkspaceId) { throw new Error('已选定时对话与当前空间设置不匹配,请重新选择定时对话。'); } } autoExportState = merged; autoExportState.lastError = ''; scheduleNextAutoExport(); if (runNow) { setTimeout(() => { runAutoExportTick('interval-start'); }, 50); } console.log('[ChatGPT Exporter] 已启动定时导出:', getIntervalExportState()); return getIntervalExportState(); } function stopIntervalExport() { clearAutoExportTimer(); autoExportState.enabled = false; autoExportState.nextRunAt = null; saveAutoExportState(); console.log('[ChatGPT Exporter] 已停止定时导出。'); return getIntervalExportState(); } function resumeIntervalExportIfNeeded() { if (!autoExportState.enabled) return; const intervalMs = autoExportState.intervalMinutes * 60 * 1000; const remaining = autoExportState.nextRunAt ? autoExportState.nextRunAt - Date.now() : intervalMs; scheduleNextAutoExport(Math.max(1000, remaining)); } function ensureGreatBallThemeStyles() { if (document.getElementById('chatgpt-exporter-greatball-theme')) return; const style = document.createElement('style'); style.id = 'chatgpt-exporter-greatball-theme'; style.textContent = ` #export-dialog-overlay.cge-theme-greatball-overlay { backdrop-filter: blur(3px); background: radial-gradient(circle at 12% 12%, rgba(112, 173, 255, 0.18), transparent 44%), radial-gradient(circle at 88% 88%, rgba(105, 163, 255, 0.12), transparent 42%), linear-gradient(180deg, rgba(5, 12, 24, 0.86), rgba(5, 12, 24, 0.74)) !important; } #export-dialog.cge-theme-greatball-dialog { position: relative; overflow: hidden; isolation: isolate; border: 4px solid #214b7e !important; border-radius: 16px !important; background: linear-gradient(180deg, #3f6ba3 0%, #325b90 48%, #2a4f80 100%) !important; box-shadow: 0 20px 42px rgba(0, 0, 0, 0.45), inset 0 0 0 2px rgba(160, 204, 255, 0.72), inset 0 1px 0 rgba(230, 243, 255, 0.2) !important; color: #edf6ff !important; font-family: "Trebuchet MS", "Verdana", "Noto Sans SC", sans-serif !important; } #export-dialog.cge-theme-greatball-dialog::before { content: ""; position: absolute; inset: 10px; border: 1px solid rgba(169, 210, 255, 0.3); border-radius: 12px; pointer-events: none; z-index: 0; } #export-dialog.cge-theme-greatball-dialog > * { position: relative; z-index: 1; } #export-dialog.cge-theme-greatball-dialog h2 { margin: 0 0 16px 0 !important; padding: 10px 14px !important; border: 2px solid #7eb1ea !important; border-radius: 11px !important; background: linear-gradient(180deg, #496fa3 0%, #3a6093 100%) !important; color: #f7fbff !important; font-size: 18px !important; font-weight: 900 !important; text-shadow: 0 1px 0 rgba(10, 24, 42, 0.42); box-shadow: inset 0 1px 0 rgba(215, 235, 255, 0.28); } #export-dialog.cge-theme-greatball-dialog strong, #export-dialog.cge-theme-greatball-dialog code { color: #f5fbff !important; } #export-dialog.cge-theme-greatball-dialog [style*="color: #666"] { color: #cfe3ff !important; } #export-dialog.cge-theme-greatball-dialog code { background: rgba(17, 37, 64, 0.58) !important; border: 1px solid rgba(141, 181, 228, 0.42); border-radius: 6px; } #export-dialog.cge-theme-greatball-dialog .cge-space-grid { display: grid; gap: 16px; } #export-dialog.cge-theme-greatball-dialog .cge-space-card { border: 2px solid #5486be !important; border-radius: 14px !important; background: linear-gradient(180deg, rgba(21, 46, 80, 0.92), rgba(18, 40, 69, 0.96)) !important; box-shadow: inset 0 0 0 2px rgba(12, 31, 54, 0.86), inset 0 1px 0 rgba(179, 211, 250, 0.16) !important; } #export-dialog.cge-theme-greatball-dialog .cge-space-card p { color: #d2e5ff !important; } #export-dialog.cge-theme-greatball-dialog .cge-space-actions { display: flex; flex-wrap: wrap; gap: 8px; } #export-dialog.cge-theme-greatball-dialog .cge-team-footer { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } #export-dialog.cge-theme-greatball-dialog button { border: 2px solid #5180b3 !important; border-radius: 11px !important; background: linear-gradient(180deg, #f4fbff 0%, #d9e9fb 100%) !important; color: #16385d !important; font-weight: 900 !important; box-shadow: 0 2px 0 #2f517a, inset 0 1px 0 rgba(255, 255, 255, 0.75) !important; transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease; } #export-dialog.cge-theme-greatball-dialog button:hover:not(:disabled) { transform: translateY(-1px); filter: saturate(1.06); } #export-dialog.cge-theme-greatball-dialog button:active:not(:disabled) { transform: translateY(1px); } #export-dialog.cge-theme-greatball-dialog button:disabled { opacity: 0.62; cursor: not-allowed; } #export-dialog.cge-theme-greatball-dialog #select-team-btn, #export-dialog.cge-theme-greatball-dialog #start-team-export-btn, #export-dialog.cge-theme-greatball-dialog #schedule-start-btn, #export-dialog.cge-theme-greatball-dialog #schedule-pick-btn, #export-dialog.cge-theme-greatball-dialog #schedule-run-now-btn, #export-dialog.cge-theme-greatball-dialog #export-selected-btn { border-color: #1a4f80 !important; background: linear-gradient(180deg, #6cdaff 0%, #308fd5 100%) !important; color: #f7fcff !important; } #export-dialog.cge-theme-greatball-dialog input, #export-dialog.cge-theme-greatball-dialog select { border: 2px solid #6e9acd !important; border-radius: 10px !important; background: linear-gradient(180deg, #f7fcff, #e2efff) !important; color: #123252 !important; font-weight: 700; } #export-dialog.cge-theme-greatball-dialog input:focus, #export-dialog.cge-theme-greatball-dialog select:focus { outline: none; border-color: #8db8eb !important; box-shadow: 0 0 0 3px rgba(120, 170, 230, 0.25) !important; } #export-dialog.cge-theme-greatball-dialog .cge-picker-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap; } #export-dialog.cge-theme-greatball-dialog .cge-picker-row-search input { flex: 1 1 260px; } #export-dialog.cge-theme-greatball-dialog .cge-picker-row-search select { flex: 0 1 auto; } #export-dialog.cge-theme-greatball-dialog .cge-picker-row-filter select, #export-dialog.cge-theme-greatball-dialog .cge-picker-row-filter input, #export-dialog.cge-theme-greatball-dialog .cge-picker-row-filter button { flex: 1 1 140px; min-width: 120px; } #export-dialog.cge-theme-greatball-dialog .cge-picker-footer { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 16px; } #export-dialog.cge-theme-greatball-dialog .cge-picker-footer-left, #export-dialog.cge-theme-greatball-dialog .cge-picker-footer-right { display: flex; flex-wrap: wrap; gap: 8px; } #export-dialog.cge-theme-greatball-dialog #conv-list { border: 2px solid #5d8abd !important; border-radius: 10px !important; background: linear-gradient(180deg, rgba(225, 238, 255, 0.95), rgba(204, 224, 249, 0.92)) !important; } #export-dialog.cge-theme-greatball-dialog #conv-list label { border: 1px solid rgba(56, 98, 146, 0.38) !important; border-radius: 10px !important; background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(231, 242, 255, 0.92)) !important; } #export-dialog.cge-theme-greatball-dialog .cge-tag-project { background: #d8ecff !important; color: #0f4c84 !important; border: 1px solid rgba(15, 76, 132, 0.3); } #export-dialog.cge-theme-greatball-dialog .cge-tag-archived { background: #ffe6cf !important; color: #8f520f !important; border: 1px solid rgba(143, 82, 15, 0.3); } #export-dialog.cge-theme-greatball-dialog[data-cge-dialog="picker"] #conv-list { max-height: min(48vh, 420px) !important; } .cge-ball-btn { position: relative !important; border: none !important; border-radius: 50% !important; overflow: hidden !important; cursor: pointer !important; user-select: none !important; transition: transform 0.12s ease, filter 0.12s ease, box-shadow 0.12s ease; } .cge-ball-btn::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 4px; margin-top: -2px; background: #0f1f35; z-index: 2; } .cge-ball-btn::after { content: ""; position: absolute; width: 16px; height: 16px; left: 50%; top: 50%; margin: -8px 0 0 -8px; border-radius: 50%; background: radial-gradient(circle at 32% 28%, #fff, #d8e1ee 62%, #9aa8bb 100%); border: 2px solid #1b2940; z-index: 3; } .cge-ball-btn .cge-ball-label { position: absolute; left: 50%; top: 73%; transform: translate(-50%, -50%); z-index: 4; color: #0d2a46; font-weight: 900; font-size: 13px; line-height: 1; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6); pointer-events: none; white-space: nowrap; } #gpt-rescue-btn.cge-theme-greatball-fab, #gpt-rescue-btn.cge-ball-great { position: fixed !important; right: 18px !important; bottom: 122px !important; width: 56px !important; height: 56px !important; padding: 0 !important; border: 2px solid #11406e !important; background: radial-gradient(circle at 24% 24%, #ff6a70 0 8px, transparent 9px), radial-gradient(circle at 76% 24%, #ff6a70 0 8px, transparent 9px), radial-gradient(circle at 30% 14%, rgba(255, 255, 255, 0.34) 0 10px, transparent 11px), linear-gradient(180deg, #4cb9ff 0 50%, #f6faff 50% 100%) !important; box-shadow: 0 8px 16px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.7) !important; z-index: 99997 !important; } #gpt-rescue-btn.cge-theme-greatball-fab[data-export-state="busy"] { filter: saturate(1.12) brightness(1.02); } #gpt-rescue-btn.cge-theme-greatball-fab[data-export-state="done"] { background: linear-gradient(180deg, #5dd9ad, #2f9d76) !important; } #gpt-rescue-btn.cge-theme-greatball-fab[data-export-state="error"] { background: linear-gradient(180deg, #ff8f98, #dc4a57) !important; } @media (max-width: 900px) { #export-dialog.cge-theme-greatball-dialog { padding: 16px !important; } #gpt-rescue-btn.cge-theme-greatball-fab, #gpt-rescue-btn.cge-ball-great { right: 10px !important; bottom: 108px !important; width: 52px !important; height: 52px !important; } .cge-ball-btn .cge-ball-label { font-size: 12px; } } `; document.head.appendChild(style); } /** * [新增] 从Cookie中获取 oai-device-id * @returns {string|null} - 返回设备ID或null */ function getOaiDeviceId() { const cookieString = document.cookie; const match = cookieString.match(/oai-did=([^;]+)/); return match ? match[1] : null; } function generateUniqueFilename(convData) { const convId = convData.conversation_id || ''; const shortId = convId.includes('-') ? convId.split('-').pop() : (convId || Date.now().toString(36)); let baseName = convData.title; if (!baseName || baseName.trim().toLowerCase() === 'new chat') { baseName = 'Untitled Conversation'; } return `${sanitizeFilename(baseName)}_${shortId}.json`; } function generateMarkdownFilename(convData) { const jsonName = generateUniqueFilename(convData); return jsonName.endsWith('.json') ? `${jsonName.slice(0, -5)}.md` : `${jsonName}.md`; } function cleanMessageContent(text) { if (!text) return ''; return text .replace(/\uE200cite(?:\uE202turn\d+(?:search|view)\d+)+\uE201/gi, '') .replace(/cite(?:turn\d+(?:search|view)\d+)+/gi, '') .trim(); } function processContentReferences(text, contentReferences) { if (!text || !Array.isArray(contentReferences) || contentReferences.length === 0) { return { text, footnotes: [] }; } const references = contentReferences.filter(ref => ref && typeof ref.matched_text === 'string' && ref.matched_text.length > 0); if (references.length === 0) { return { text, footnotes: [] }; } const getReferenceInfo = (ref) => { const item = Array.isArray(ref.items) ? ref.items[0] : null; const url = item?.url || (Array.isArray(ref.safe_urls) ? ref.safe_urls[0] : '') || ''; const title = item?.title || ''; let label = item?.attribution || ''; if (!label && typeof ref.alt === 'string') { const match = ref.alt.match(/\[([^\]]+)\]\([^)]+\)/); if (match) label = match[1]; } if (!label) label = title || url; return { url, title, label }; }; const footnotes = []; const footnoteIndexByKey = new Map(); const citationRefs = references .filter(ref => ref.type === 'grouped_webpages') .sort((a, b) => { const aIdx = Number.isFinite(a.start_idx) ? a.start_idx : Number.MAX_SAFE_INTEGER; const bIdx = Number.isFinite(b.start_idx) ? b.start_idx : Number.MAX_SAFE_INTEGER; return aIdx - bIdx; }); citationRefs.forEach(ref => { const info = getReferenceInfo(ref); if (!info.url) return; const key = `${info.url}|${info.title}`; if (footnoteIndexByKey.has(key)) return; const index = footnotes.length + 1; footnoteIndexByKey.set(key, index); footnotes.push({ index, url: info.url, title: info.title, label: info.label }); }); const sortedByReplacement = references .slice() .sort((a, b) => { const aIdx = Number.isFinite(a.start_idx) ? a.start_idx : -1; const bIdx = Number.isFinite(b.start_idx) ? b.start_idx : -1; if (aIdx !== -1 || bIdx !== -1) { return bIdx - aIdx; } return (b.matched_text?.length || 0) - (a.matched_text?.length || 0); }); let output = text; sortedByReplacement.forEach(ref => { if (!ref?.matched_text || ref.type === 'sources_footnote') return; let replacement = ''; if (ref.type === 'grouped_webpages') { const info = getReferenceInfo(ref); if (info.url) { const key = `${info.url}|${info.title}`; const index = footnoteIndexByKey.get(key); replacement = index ? `([${info.label}][${index}])` : (ref.alt || ''); } else { replacement = ref.alt || ''; } } else { replacement = ref.alt || ''; } if (Number.isFinite(ref.start_idx) && Number.isFinite(ref.end_idx)) { if (output.slice(ref.start_idx, ref.end_idx) === ref.matched_text) { output = output.slice(0, ref.start_idx) + replacement + output.slice(ref.end_idx); return; } } output = output.split(ref.matched_text).join(replacement); }); return { text: output, footnotes }; } function extractConversationMessages(convData) { const mapping = convData?.mapping; if (!mapping) return []; const messages = []; const mappingKeys = Object.keys(mapping); const rootId = mapping['client-created-root'] ? 'client-created-root' : mappingKeys.find(id => !mapping[id]?.parent) || mappingKeys[0]; const visited = new Set(); const traverse = (nodeId) => { if (!nodeId || visited.has(nodeId)) return; visited.add(nodeId); const node = mapping[nodeId]; if (!node) return; const msg = node.message; if (msg) { const author = msg.author?.role; const isHidden = msg.metadata?.is_visually_hidden_from_conversation || msg.metadata?.is_contextual_answers_system_message; if (author && author !== 'system' && !isHidden) { const content = msg.content; if (content?.content_type === 'text' && Array.isArray(content.parts)) { const rawText = content.parts .map(part => typeof part === 'string' ? part : (part?.text ?? '')) .filter(Boolean) .join('\n'); const contentReferences = msg.metadata?.content_references || []; let processedText = rawText; let footnotes = []; if (Array.isArray(contentReferences) && contentReferences.length > 0) { const processed = processContentReferences(rawText, contentReferences); processedText = processed.text; footnotes = processed.footnotes; } const cleaned = cleanMessageContent(processedText); if (cleaned) { messages.push({ role: author, content: cleaned, create_time: msg.create_time || null, footnotes }); } } } } if (Array.isArray(node.children)) { node.children.forEach(childId => traverse(childId)); } }; if (rootId) { traverse(rootId); } else { mappingKeys.forEach(traverse); } return messages; } function convertConversationToMarkdown(convData) { const messages = extractConversationMessages(convData); if (messages.length === 0) { return '# Conversation\nNo visible user or assistant messages were exported.\n'; } const mdLines = []; messages.forEach(msg => { const roleLabel = msg.role === 'user' ? '# User' : '# Assistant'; mdLines.push(roleLabel); mdLines.push(msg.content); if (Array.isArray(msg.footnotes) && msg.footnotes.length > 0) { mdLines.push(''); msg.footnotes .slice() .sort((a, b) => a.index - b.index) .forEach(note => { if (!note.url) return; const title = note.title ? ` "${note.title}"` : ''; mdLines.push(`[${note.index}]: ${note.url}${title}`); }); } mdLines.push(''); }); return mdLines.join('\n').trim() + '\n'; } function downloadFile(blob, filename) { const a = document.createElement('a'); const blobUrl = URL.createObjectURL(blob); a.href = blobUrl; a.download = filename; a.rel = 'noopener'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { if (a.parentNode) { a.parentNode.removeChild(a); } URL.revokeObjectURL(blobUrl); }, 60000); } async function saveExportArchive(blob, filename, options = {}) { const { notifyMode = DEFAULT_NOTIFY_MODE } = options; const fallbackToBrowser = (message, switchMode = false) => { if (switchMode) { downloadSettings.mode = 'browser'; saveDownloadSettings(); } if (message) { notifyUser(message, { mode: notifyMode, level: 'info', duration: 3200 }); } downloadFile(blob, filename); }; if (downloadSettings.mode !== 'file-handle') { downloadFile(blob, filename); return; } if (!supportsFixedFileOverwrite()) { fallbackToBrowser('固定覆盖模式不可用,已回退为普通下载。', true); return; } const handle = await getStoredZipFileHandle(); if (!handle) { fallbackToBrowser('未找到固定保存文件,已回退为普通下载。请在“文件命名”里重新选择。', true); return; } const granted = await ensureFileHandlePermission(handle, true); if (!granted) { fallbackToBrowser('固定文件写入权限不可用,本次已回退为普通下载。'); return; } try { const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); if (handle.name && downloadSettings.handleName !== handle.name) { downloadSettings.handleName = handle.name; saveDownloadSettings(); } } catch (err) { const errName = err?.name || ''; if (errName === 'NotFoundError' || errName === 'InvalidStateError') { await clearStoredZipFileHandle(); downloadSettings.handleName = ''; saveDownloadSettings(); fallbackToBrowser('固定保存文件已失效,已回退为普通下载。请重新选择固定文件。', true); return; } fallbackToBrowser( `固定覆盖写入失败(${err?.message || errName || '未知错误'}),已回退为普通下载。`, false ); return; } } // --- 导出流程核心逻辑 --- function getExportButton() { let btn = document.getElementById('gpt-rescue-btn'); if (!btn) { addBtn(); btn = document.getElementById('gpt-rescue-btn'); } return btn; } function setExportButtonState(btn, options = {}) { if (!btn) return; const { label = '导出', title = '打开导出面板', state = 'idle' } = options; const labelEl = btn.querySelector('.cge-ball-label'); if (labelEl) { labelEl.textContent = label; } else { btn.textContent = label; } btn.title = title; btn.setAttribute('aria-label', title); btn.dataset.exportState = state; } async function exportConversations(options = {}) { const { mode = 'personal', workspaceId = null, conversationEntries = null, exportType = null, notifyMode = DEFAULT_NOTIFY_MODE } = options; if (exportInProgress) { console.warn('[ChatGPT Exporter] 已有导出任务进行中,本次请求已跳过。'); notifyUser('已有导出任务进行中,本次请求已跳过。', { mode: notifyMode, level: 'info' }); return false; } exportInProgress = true; let exportSucceeded = false; const btn = getExportButton(); btn.disabled = true; setExportButtonState(btn, { label: '准备', title: '正在准备导出...', state: 'busy' }); try { if (!await ensureAccessToken({ notifyMode })) { btn.disabled = false; setExportButtonState(btn, { label: '导出', title: '打开导出面板', state: 'idle' }); return false; } const zip = new JSZip(); if (Array.isArray(conversationEntries) && conversationEntries.length > 0) { for (let i = 0; i < conversationEntries.length; i++) { const entry = conversationEntries[i]; const label = entry?.title ? entry.title.slice(0, 12) : '对话'; setExportButtonState(btn, { label: '抓取', title: `正在导出: ${label} (${i + 1}/${conversationEntries.length})`, state: 'busy' }); const convData = await getConversation(entry.id, workspaceId); const target = entry?.projectTitle ? zip.folder(sanitizeFilename(entry.projectTitle)) : zip; target.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2)); target.file(generateMarkdownFilename(convData), convertConversationToMarkdown(convData)); await sleep(jitter()); } } else { setExportButtonState(btn, { label: '扫描', title: '正在获取项目外对话...', state: 'busy' }); const orphanIds = await collectIds(btn, workspaceId, null); for (let i = 0; i < orphanIds.length; i++) { setExportButtonState(btn, { label: '抓取', title: `根目录对话 (${i + 1}/${orphanIds.length})`, state: 'busy' }); const convData = await getConversation(orphanIds[i], workspaceId); zip.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2)); zip.file(generateMarkdownFilename(convData), convertConversationToMarkdown(convData)); await sleep(jitter()); } setExportButtonState(btn, { label: '项目', title: '正在获取项目列表...', state: 'busy' }); const projects = await getProjects(workspaceId); for (const project of projects) { const projectFolder = zip.folder(sanitizeFilename(project.title)); setExportButtonState(btn, { label: '项目', title: `项目: ${project.title}`, state: 'busy' }); const projectConvIds = await collectIds(btn, workspaceId, project.id); if (projectConvIds.length === 0) continue; for (let i = 0; i < projectConvIds.length; i++) { setExportButtonState(btn, { label: '抓取', title: `${project.title.substring(0, 12)} (${i + 1}/${projectConvIds.length})`, state: 'busy' }); const convData = await getConversation(projectConvIds[i], workspaceId); projectFolder.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2)); projectFolder.file(generateMarkdownFilename(convData), convertConversationToMarkdown(convData)); await sleep(jitter()); } } } setExportButtonState(btn, { label: '打包', title: '正在生成 ZIP 文件...', state: 'busy' }); const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" }); const selectionType = exportType || ((Array.isArray(conversationEntries) && conversationEntries.length > 0) ? 'selected' : 'full'); const filename = buildExportZipFilename(mode, selectionType, workspaceId); await saveExportArchive(blob, filename, { notifyMode }); notifyUser('导出完成。', { mode: notifyMode, level: 'success' }); setExportButtonState(btn, { label: '完成', title: '导出完成', state: 'done' }); exportSucceeded = true; } catch (e) { console.error('导出过程中发生严重错误:', e); notifyUser(`导出失败: ${e.message}。详情请查看控制台(F12 -> Console)。`, { mode: notifyMode, level: 'error', duration: 3800 }); setExportButtonState(btn, { label: '错误', title: `导出失败: ${e.message}`, state: 'error' }); } finally { exportInProgress = false; setTimeout(() => { btn.disabled = false; setExportButtonState(btn, { label: '导出', title: '打开导出面板', state: 'idle' }); }, 2500); } return exportSucceeded; } async function startExportProcess(mode, workspaceId, options = {}) { const normalizedMode = normalizeExportMode(mode); const resolvedWorkspaceId = normalizedMode === 'team' ? (workspaceId || resolveWorkspaceId(null) || '') : null; if (normalizedMode === 'team' && !resolvedWorkspaceId) { throw new Error('团队空间导出需要 Team Workspace ID。'); } return await exportConversations({ mode: normalizedMode, workspaceId: resolvedWorkspaceId, ...options }); } async function startProjectSpaceExportProcess(workspaceId = null, options = {}) { const { notifyMode = DEFAULT_NOTIFY_MODE } = options; try { const projectEntries = await listProjectSpaceConversations(workspaceId); if (projectEntries.length === 0) { notifyUser('未找到项目空间对话。', { mode: notifyMode, level: 'info' }); return false; } return await exportConversations({ mode: 'project', workspaceId, conversationEntries: projectEntries, exportType: 'full', notifyMode }); } catch (err) { console.error('导出项目空间失败:', err); notifyUser(`导出项目空间失败: ${err.message}`, { mode: notifyMode, level: 'error', duration: 3800 }); return false; } } async function startSelectiveExportProcess(mode, workspaceId, conversationEntries, options = {}) { return await exportConversations({ mode, workspaceId, conversationEntries, ...options }); } function startScheduledExport(options = {}) { const { mode = 'personal', workspaceId = null, autoConfirm = false, source = 'schedule', notifyMode = autoConfirm ? SCHEDULE_NOTIFY_MODE : DEFAULT_NOTIFY_MODE } = options; const normalizedMode = normalizeExportMode(mode); const proceed = async () => { if (normalizedMode === 'project') { return await startProjectSpaceExportProcess(workspaceId, { notifyMode }); } return await startExportProcess(normalizedMode, workspaceId, { notifyMode }); }; if (autoConfirm) { return proceed(); } const modeLabel = normalizedMode === 'team' ? '团队空间' : normalizedMode === 'project' ? '项目空间' : '个人空间'; if (confirm(`Chrome 扩展请求导出 ${modeLabel} 对话(来源: ${source})。是否开始?`)) { return proceed(); } return Promise.resolve(false); } // --- API 调用函数 --- async function getProjects(workspaceId) { if (!workspaceId) return []; const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'ChatGPT-Account-Id': workspaceId, 'oai-device-id': deviceId }; const r = await fetch(`/backend-api/gizmos/snorlax/sidebar`, { headers }); if (!r.ok) { console.warn(`获取项目(Gizmo)列表失败 (${r.status})`); return []; } const data = await r.json(); const projects = []; data.items?.forEach(item => { if (item?.gizmo?.id && item?.gizmo?.display?.name) { projects.push({ id: item.gizmo.id, title: item.gizmo.display.name }); } }); return projects; } function resolveWorkspaceId(workspaceId) { if (workspaceId) return workspaceId; const match = document.cookie.match(/(?:^|; )_account=([^;]+)/); if (match?.[1]) return match[1]; const detectedIds = detectAllWorkspaceIds(); return detectedIds.length > 0 ? detectedIds[0] : null; } async function getProjectSpaces(workspaceId, options = {}) { const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId }; const resolvedWorkspaceId = resolveWorkspaceId(workspaceId); if (resolvedWorkspaceId) { headers['ChatGPT-Account-Id'] = resolvedWorkspaceId; } const query = new URLSearchParams(); if (options.conversationsPerGizmo !== undefined) { query.set('conversations_per_gizmo', String(options.conversationsPerGizmo)); } if (options.ownedOnly !== undefined) { query.set('owned_only', options.ownedOnly ? 'true' : 'false'); } const url = query.toString() ? `/backend-api/gizmos/snorlax/sidebar?${query.toString()}` : '/backend-api/gizmos/snorlax/sidebar'; const r = await fetch(url, { headers }); if (!r.ok) { throw new Error(`获取项目空间列表失败 (${r.status})`); } const data = await r.json(); const projects = []; data.items?.forEach(item => { const rawGizmo = item?.gizmo?.gizmo || item?.gizmo || item; const display = rawGizmo?.display || item?.gizmo?.display || item?.display; const id = rawGizmo?.id || item?.gizmo?.id || item?.id; const title = display?.name || rawGizmo?.name || 'Untitled Project'; if (!id) return; projects.push({ id, title, conversations: item?.conversations?.items || [] }); }); return projects; } async function collectIds(btn, workspaceId, gizmoId) { const all = new Set(); const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId }; if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; } if (gizmoId) { let cursor = '0'; do { const r = await fetch(`/backend-api/gizmos/${gizmoId}/conversations?cursor=${cursor}`, { headers }); if (!r.ok) throw new Error(`列举项目对话列表失败 (${r.status})`); const j = await r.json(); j.items?.forEach(it => all.add(it.id)); cursor = j.cursor; await sleep(jitter()); } while (cursor); } else { for (const is_archived of [false, true]) { let offset = 0, has_more = true, page = 0; do { setExportButtonState(btn, { label: '扫描', title: `项目外对话 (${is_archived ? 'Archived' : 'Active'} p${++page})`, state: 'busy' }); const r = await fetch(`/backend-api/conversations?offset=${offset}&limit=${PAGE_LIMIT}&order=updated${is_archived ? '&is_archived=true' : ''}`, { headers }); if (!r.ok) throw new Error(`列举项目外对话列表失败 (${r.status})`); const j = await r.json(); if (j.items && j.items.length > 0) { j.items.forEach(it => all.add(it.id)); has_more = j.items.length === PAGE_LIMIT; offset += j.items.length; } else { has_more = false; } await sleep(jitter()); } while (has_more); } } return Array.from(all); } function upsertConversationEntry(map, item, extra = {}) { if (!item?.id) return; const create_time = normalizeEpochSeconds(item.create_time || 0); const update_time = normalizeEpochSeconds(item.update_time || item.create_time || 0); const entry = { id: item.id, title: item.title || 'Untitled Conversation', create_time, update_time, is_archived: item.is_archived ?? extra.is_archived ?? false, projectId: extra.projectId || null, projectTitle: extra.projectTitle || null }; const existing = map.get(entry.id); if (!existing) { map.set(entry.id, entry); return; } if (!existing.projectTitle && entry.projectTitle) { existing.projectTitle = entry.projectTitle; existing.projectId = entry.projectId; } if (!existing.create_time && entry.create_time) { existing.create_time = entry.create_time; } existing.is_archived = existing.is_archived || entry.is_archived; if ((entry.update_time || 0) > (existing.update_time || 0)) { existing.update_time = entry.update_time; } if (existing.title === 'Untitled Conversation' && entry.title) { existing.title = entry.title; } } async function listConversations(workspaceId) { if (!await ensureAccessToken()) { throw new Error('无法获取 Access Token,请刷新页面或打开任意一个对话后再试。'); } const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId }; if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; } const map = new Map(); const addEntry = (item, extra = {}) => upsertConversationEntry(map, item, extra); for (const is_archived of [false, true]) { let offset = 0; let has_more = true; do { const r = await fetch(`/backend-api/conversations?offset=${offset}&limit=${PAGE_LIMIT}&order=updated${is_archived ? '&is_archived=true' : ''}`, { headers }); if (!r.ok) throw new Error(`列举对话列表失败 (${r.status})`); const j = await r.json(); if (j.items && j.items.length > 0) { j.items.forEach(it => addEntry(it, { is_archived })); has_more = j.items.length === PAGE_LIMIT; offset += j.items.length; } else { has_more = false; } await sleep(jitter()); } while (has_more); } if (workspaceId) { const projects = await getProjects(workspaceId); for (const project of projects) { let cursor = '0'; do { const r = await fetch(`/backend-api/gizmos/${project.id}/conversations?cursor=${cursor}`, { headers }); if (!r.ok) throw new Error(`列举项目对话列表失败 (${r.status})`); const j = await r.json(); j.items?.forEach(it => addEntry(it, { projectId: project.id, projectTitle: project.title })); cursor = j.cursor; await sleep(jitter()); } while (cursor); } } return Array.from(map.values()) .sort((a, b) => (b.update_time || 0) - (a.update_time || 0)); } async function listProjectSpaceConversations(workspaceId) { if (!await ensureAccessToken()) { throw new Error('无法获取 Access Token,请刷新页面或打开任意一个对话后再试。'); } const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId }; const resolvedWorkspaceId = resolveWorkspaceId(workspaceId); if (resolvedWorkspaceId) { headers['ChatGPT-Account-Id'] = resolvedWorkspaceId; } const map = new Map(); const projects = await getProjectSpaces(resolvedWorkspaceId, { conversationsPerGizmo: PROJECT_SIDEBAR_PREVIEW, ownedOnly: true }); for (const project of projects) { let cursor = '0'; let fetched = false; do { const r = await fetch(`/backend-api/gizmos/${project.id}/conversations?cursor=${cursor}`, { headers }); if (!r.ok) { if (!fetched && Array.isArray(project.conversations) && project.conversations.length > 0) { console.warn(`项目空间对话列表请求失败 (${r.status}),使用侧边栏返回的预览对话。`); project.conversations.forEach(item => upsertConversationEntry(map, item, { projectId: project.id, projectTitle: project.title })); cursor = null; break; } throw new Error(`列举项目空间对话列表失败 (${r.status})`); } const j = await r.json(); j.items?.forEach(item => upsertConversationEntry(map, item, { projectId: project.id, projectTitle: project.title })); cursor = j.cursor; fetched = true; await sleep(jitter()); } while (cursor); } return Array.from(map.values()) .sort((a, b) => (b.update_time || 0) - (a.update_time || 0)); } async function getConversation(id, workspaceId) { const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId }; if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; } const r = await fetch(`/backend-api/conversation/${id}`, { headers }); if (!r.ok) throw new Error(`获取对话详情失败 conv ${id} (${r.status})`); const j = await r.json(); j.__fetched_at = new Date().toISOString(); return j; } // --- UI 相关函数 --- // (UI部分无变动,此处省略以保持简洁) /** * [新增] 全面检测函数,返回所有找到的ID * @returns {string[]} - 返回包含所有唯一Workspace ID的数组 */ function detectAllWorkspaceIds() { const foundIds = new Set(capturedWorkspaceIds); // 从网络拦截的结果开始 // 扫描 __NEXT_DATA__ try { const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent); // 遍历所有账户信息 const accounts = data?.props?.pageProps?.user?.accounts; if (accounts) { Object.values(accounts).forEach(acc => { if (acc?.account?.id) { foundIds.add(acc.account.id); } }); } } catch (e) {} // 扫描 localStorage try { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && (key.includes('account') || key.includes('workspace'))) { const value = localStorage.getItem(key); if (value && /^[a-z0-9]{2,}-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.replace(/"/g, ''))) { const extractedId = value.match(/ws-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i); if(extractedId) foundIds.add(extractedId[0]); } else if (value && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.replace(/"/g, ''))) { foundIds.add(value.replace(/"/g, '')); } } } } catch(e) {} console.log('🔍 检测到以下 Workspace IDs:', Array.from(foundIds)); return Array.from(foundIds); } function showConversationPicker(options = {}) { const { mode: requestedMode = 'personal', workspaceId = null, action = 'export', initialSelectedIds = [], onConfirm = null, onBack = null } = options; const mode = normalizeExportMode(requestedMode); const isScheduleAction = action === 'schedule'; const selectedActionLabel = isScheduleAction ? '保存定时选择' : '导出选中'; ensureGreatBallThemeStyles(); const existing = document.getElementById('export-dialog-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'export-dialog-overlay'; overlay.classList.add('cge-theme-greatball-overlay'); Object.assign(overlay.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: '99998', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '5vh 16px 3vh', boxSizing: 'border-box', overflowY: 'auto' }); const dialog = document.createElement('div'); dialog.id = 'export-dialog'; dialog.classList.add('cge-theme-greatball-dialog'); dialog.setAttribute('data-cge-dialog', 'picker'); Object.assign(dialog.style, { background: '#fff', padding: '24px', borderRadius: '12px', boxShadow: '0 5px 15px rgba(0,0,0,.3)', width: 'min(92vw, 880px)', fontFamily: 'sans-serif', color: '#333', boxSizing: 'border-box' }); const closeDialog = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; const state = { list: [], filtered: [], selected: new Set(Array.isArray(initialSelectedIds) ? initialSelectedIds : []), query: '', scope: mode === 'project' ? 'project' : 'all', scopeLocked: mode === 'project', archived: 'all', timeField: 'update', loading: true, pageSize: 100, visibleCount: 100, startDate: '', endDate: '' }; const renderBase = () => { const modeLabel = mode === 'team' ? '团队空间' : mode === 'project' ? '项目空间' : '个人空间'; const workspaceLabel = workspaceId ? `(${workspaceId})` : ''; dialog.innerHTML = ` <h2 style="margin-top:0; margin-bottom: 12px; font-size: 18px;">${isScheduleAction ? '选择定时导出对话' : '选择要导出的对话'}</h2> <div style="margin-bottom: 12px; color: #666; font-size: 12px;">空间:${modeLabel}${workspaceLabel}</div> <div class="cge-picker-row cge-picker-row-search" style="display: flex; gap: 8px; margin-bottom: 8px;"> <input id="conv-search" type="text" placeholder="搜索标题/项目名/ID" style="flex: 1; padding: 8px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;"> <select id="filter-scope" style="padding: 8px 28px 8px 8px; border-radius: 6px; border: 1px solid #ccc;"> <option value="all">全部范围</option> <option value="project">仅项目</option> <option value="root">仅项目外</option> </select> <select id="filter-archived" style="padding: 8px 28px 8px 8px; border-radius: 6px; border: 1px solid #ccc;"> <option value="all">全部状态</option> <option value="active">仅未归档</option> <option value="archived">仅已归档</option> </select> </div> <div class="cge-picker-row cge-picker-row-filter" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center;"> <select id="filter-time-field" style="padding: 8px 28px 8px 8px; border-radius: 6px; border: 1px solid #ccc;"> <option value="update">按更新时间</option> <option value="create">按创建时间</option> </select> <input id="filter-start-date" type="date" style="padding: 8px; border-radius: 6px; border: 1px solid #ccc;"> <span style="color: #666; font-size: 12px;">至</span> <input id="filter-end-date" type="date" style="padding: 8px; border-radius: 6px; border: 1px solid #ccc;"> <button id="clear-date-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">清空日期</button> </div> <div id="conv-status" style="margin-bottom: 8px; font-size: 12px; color: #666;">正在加载列表...</div> <div id="conv-list" style="max-height: 360px; overflow: auto; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; background: #fff;"></div> <div class="cge-picker-footer" style="display: flex; justify-content: space-between; align-items: center; margin-top: 16px;"> <div class="cge-picker-footer-left" style="display: flex; gap: 8px;"> <button id="select-all-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">全选</button> <button id="clear-all-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">清空</button> </div> <div class="cge-picker-footer-right" style="display: flex; gap: 8px;"> <button id="back-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">返回</button> <button id="export-selected-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;" disabled>${selectedActionLabel} (0)</button> </div> </div> `; const searchInput = dialog.querySelector('#conv-search'); const scopeSelect = dialog.querySelector('#filter-scope'); const archivedSelect = dialog.querySelector('#filter-archived'); const timeFieldSelect = dialog.querySelector('#filter-time-field'); const startDateInput = dialog.querySelector('#filter-start-date'); const endDateInput = dialog.querySelector('#filter-end-date'); const clearDateBtn = dialog.querySelector('#clear-date-btn'); const selectAllBtn = dialog.querySelector('#select-all-btn'); const clearAllBtn = dialog.querySelector('#clear-all-btn'); const backBtn = dialog.querySelector('#back-btn'); const exportBtn = dialog.querySelector('#export-selected-btn'); if (state.scopeLocked && scopeSelect) { scopeSelect.value = 'project'; scopeSelect.disabled = true; scopeSelect.style.opacity = '0.7'; scopeSelect.style.cursor = 'not-allowed'; scopeSelect.title = '项目空间仅包含项目对话'; } searchInput.oninput = (e) => { state.query = e.target.value || ''; applyFilters(); renderList(); }; scopeSelect.onchange = (e) => { state.scope = e.target.value; applyFilters(); renderList(); }; archivedSelect.onchange = (e) => { state.archived = e.target.value; applyFilters(); renderList(); }; timeFieldSelect.onchange = (e) => { state.timeField = e.target.value; applyFilters(); renderList(); }; startDateInput.onchange = (e) => { state.startDate = e.target.value || ''; applyFilters(); renderList(); }; endDateInput.onchange = (e) => { state.endDate = e.target.value || ''; applyFilters(); renderList(); }; clearDateBtn.onclick = () => { state.startDate = ''; state.endDate = ''; startDateInput.value = ''; endDateInput.value = ''; applyFilters(); renderList(); }; selectAllBtn.onclick = () => { state.filtered.forEach(item => state.selected.add(item.id)); renderList(); }; clearAllBtn.onclick = () => { state.selected.clear(); renderList(); }; backBtn.onclick = () => { closeDialog(); if (isScheduleAction) { if (typeof onBack === 'function') { onBack(); } else { showExportDialog(); } return; } showExportDialog(); }; exportBtn.onclick = async () => { if (state.selected.size === 0) return; const selectedList = state.list.filter(item => state.selected.has(item.id)); closeDialog(); if (isScheduleAction) { if (typeof onConfirm === 'function') { await onConfirm(selectedList); } else { showExportDialog(); } return; } await startSelectiveExportProcess(mode, workspaceId, selectedList); }; }; const applyFilters = () => { const query = state.query.trim().toLowerCase(); const startBound = parseDateInputToEpoch(state.startDate, false); const endBound = parseDateInputToEpoch(state.endDate, true); state.filtered = state.list.filter(item => { const text = `${item.title || ''} ${item.projectTitle || ''} ${item.id || ''}`.toLowerCase(); if (query && !text.includes(query)) return false; if (state.scope === 'project' && !item.projectTitle) return false; if (state.scope === 'root' && item.projectTitle) return false; if (state.archived === 'active' && item.is_archived) return false; if (state.archived === 'archived' && !item.is_archived) return false; if (startBound || endBound) { const sourceTime = state.timeField === 'create' ? item.create_time : item.update_time; const ts = normalizeEpochSeconds(sourceTime || 0); if (!ts) return false; if (startBound && ts < startBound) return false; if (endBound && ts > endBound) return false; } return true; }); state.visibleCount = state.pageSize; }; const renderList = () => { const statusEl = dialog.querySelector('#conv-status'); const listEl = dialog.querySelector('#conv-list'); const exportBtn = dialog.querySelector('#export-selected-btn'); const selectAllBtn = dialog.querySelector('#select-all-btn'); const clearAllBtn = dialog.querySelector('#clear-all-btn'); const controlsDisabled = state.loading; if (selectAllBtn) selectAllBtn.disabled = controlsDisabled; if (clearAllBtn) clearAllBtn.disabled = controlsDisabled; if (exportBtn) exportBtn.disabled = controlsDisabled || state.selected.size === 0; listEl.innerHTML = ''; if (state.loading) { statusEl.textContent = '正在加载列表...'; return; } const visibleCount = Math.min(state.visibleCount, state.filtered.length); statusEl.textContent = `共 ${state.list.length} 条,当前筛选 ${state.filtered.length} 条,显示 ${visibleCount} 条,已选 ${state.selected.size} 条`; exportBtn.textContent = `${selectedActionLabel} (${state.selected.size})`; if (state.filtered.length === 0) { const empty = document.createElement('div'); empty.textContent = '没有匹配的对话。'; empty.style.color = '#999'; empty.style.padding = '8px 4px'; listEl.appendChild(empty); return; } const visibleItems = state.filtered.slice(0, state.visibleCount); visibleItems.forEach(item => { const label = document.createElement('label'); Object.assign(label.style, { display: 'flex', gap: '8px', padding: '8px', border: '1px solid #e5e7eb', borderRadius: '6px', marginBottom: '8px', cursor: 'pointer', alignItems: 'flex-start' }); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = state.selected.has(item.id); checkbox.onchange = (e) => { if (e.target.checked) { state.selected.add(item.id); } else { state.selected.delete(item.id); } renderList(); }; const content = document.createElement('div'); content.style.flex = '1'; const title = document.createElement('div'); title.textContent = item.title || 'Untitled Conversation'; title.style.fontWeight = 'bold'; title.style.fontSize = '14px'; const meta = document.createElement('div'); meta.style.fontSize = '12px'; meta.style.color = '#666'; const timeLabelPrefix = state.timeField === 'create' ? '创建' : '更新'; const timeValue = state.timeField === 'create' ? item.create_time : item.update_time; const timeLabel = formatTimestamp(timeValue) || '未知'; meta.textContent = `${timeLabelPrefix}: ${timeLabel}`; const tags = document.createElement('div'); tags.style.marginTop = '6px'; tags.style.display = 'flex'; tags.style.gap = '6px'; tags.style.flexWrap = 'wrap'; if (item.projectTitle) { const projectTag = document.createElement('span'); projectTag.className = 'cge-tag-project'; projectTag.textContent = `项目: ${item.projectTitle}`; Object.assign(projectTag.style, { background: '#eef2ff', color: '#4338ca', padding: '2px 6px', borderRadius: '999px', fontSize: '11px' }); tags.appendChild(projectTag); } if (item.is_archived) { const archivedTag = document.createElement('span'); archivedTag.className = 'cge-tag-archived'; archivedTag.textContent = '已归档'; Object.assign(archivedTag.style, { background: '#fef3c7', color: '#92400e', padding: '2px 6px', borderRadius: '999px', fontSize: '11px' }); tags.appendChild(archivedTag); } content.appendChild(title); content.appendChild(meta); if (tags.childNodes.length > 0) content.appendChild(tags); label.appendChild(checkbox); label.appendChild(content); listEl.appendChild(label); }); if (state.filtered.length > state.visibleCount) { const loadMore = document.createElement('button'); loadMore.textContent = `加载更多(剩余 ${state.filtered.length - state.visibleCount} 条)`; Object.assign(loadMore.style, { width: '100%', padding: '8px 12px', border: '1px solid #ccc', borderRadius: '6px', background: '#fff', cursor: 'pointer' }); loadMore.onclick = () => { state.visibleCount = Math.min(state.visibleCount + state.pageSize, state.filtered.length); renderList(); }; listEl.appendChild(loadMore); } }; renderBase(); overlay.appendChild(dialog); document.body.appendChild(overlay); overlay.onclick = (e) => { if (e.target !== overlay) return; closeDialog(); if (isScheduleAction) { if (typeof onBack === 'function') { onBack(); } else { showExportDialog(); } } }; const listPromise = mode === 'project' ? listProjectSpaceConversations(workspaceId) : listConversations(workspaceId); listPromise .then(list => { state.list = list; const listIds = new Set(list.map(item => item.id)); state.selected = new Set(Array.from(state.selected).filter(id => listIds.has(id))); state.loading = false; applyFilters(); renderList(); }) .catch(err => { const statusEl = dialog.querySelector('#conv-status'); state.loading = false; state.list = []; state.filtered = []; statusEl.textContent = `加载失败: ${err.message}`; renderList(); }); } /** * [重构] 多步骤、用户主导的导出对话框 */ function showExportDialog() { ensureGreatBallThemeStyles(); if (document.getElementById('export-dialog-overlay')) return; const overlay = document.createElement('div'); overlay.id = 'export-dialog-overlay'; overlay.classList.add('cge-theme-greatball-overlay'); Object.assign(overlay.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: '99998', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '5vh 16px 3vh', boxSizing: 'border-box', overflowY: 'auto' }); const dialog = document.createElement('div'); dialog.id = 'export-dialog'; dialog.classList.add('cge-theme-greatball-dialog'); dialog.setAttribute('data-cge-dialog', 'main'); Object.assign(dialog.style, { background: '#fff', padding: '24px', borderRadius: '12px', boxShadow: '0 5px 15px rgba(0,0,0,.3)', width: 'min(92vw, 560px)', fontFamily: 'sans-serif', color: '#333', boxSizing: 'border-box' }); const closeDialog = () => document.body.removeChild(overlay); let pendingTeamAction = null; const renderStep = (step, action = null) => { pendingTeamAction = action; let html = ''; switch (step) { case 'team': { const detectedIds = detectAllWorkspaceIds(); html = `<h2 style="margin-top:0; margin-bottom: 20px; font-size: 18px;">导出团队空间</h2>`; if (detectedIds.length > 1) { html += `<div style="background: #eef2ff; border: 1px solid #818cf8; border-radius: 8px; padding: 12px; margin-bottom: 20px;"> <p style="margin: 0 0 12px 0; font-weight: bold; color: #4338ca;">🔎 检测到多个 Workspace,请选择一个:</p> <div id="workspace-id-list">`; detectedIds.forEach((id, index) => { html += `<label style="display: block; margin-bottom: 8px; padding: 8px; border-radius: 6px; cursor: pointer; border: 1px solid #ddd; background: #fff;"> <input type="radio" name="workspace_id" value="${id}" ${index === 0 ? 'checked' : ''}> <code style="margin-left: 8px; font-family: monospace; color: #555;">${id}</code> </label>`; }); html += `</div></div>`; } else if (detectedIds.length === 1) { html += `<div style="background: #f0fdf4; border: 1px solid #4ade80; border-radius: 8px; padding: 12px; margin-bottom: 20px;"> <p style="margin: 0 0 8px 0; font-weight: bold; color: #166534;">✅ 已自动检测到 Workspace ID:</p> <code id="workspace-id-code" style="background: #e0e7ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; color: #4338ca; word-break: break-all;">${detectedIds[0]}</code> </div>`; } else { html += `<div style="background: #fffbeb; border: 1px solid #facc15; border-radius: 8px; padding: 12px; margin-bottom: 20px;"> <p style="margin: 0; color: #92400e;">⚠️ 未能自动检测到 Workspace ID。</p> <p style="margin: 8px 0 0 0; font-size: 12px; color: #92400e;">请尝试刷新页面或打开一个团队对话,或在下方手动输入。</p> </div> <label for="team-id-input" style="display: block; margin-bottom: 8px; font-weight: bold;">手动输入 Team Workspace ID:</label> <input type="text" id="team-id-input" placeholder="粘贴您的 Workspace ID (ws-...)" style="width: 100%; padding: 8px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;">`; } let actionButtons = ''; if (pendingTeamAction === 'all') { actionButtons = `<button id="start-team-export-btn" style="padding: 10px 16px; border: none; border-radius: 8px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">导出全部 (ZIP)</button>`; } else if (pendingTeamAction === 'select') { actionButtons = `<button id="start-team-picker-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">选择对话导出</button>`; } else { actionButtons = `<button id="start-team-export-btn" style="padding: 10px 16px; border: none; border-radius: 8px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">导出全部 (ZIP)</button> <button id="start-team-picker-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">选择对话导出</button>`; } html += `<div class="cge-team-footer" style="display: flex; justify-content: space-between; align-items: center; margin-top: 24px;"> <button id="back-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">返回</button> <div class="cge-space-actions" style="display: flex; gap: 8px;"> ${actionButtons} </div> </div>`; break; } case 'initial': default: const scheduleWorkspaceValue = (autoExportState.workspaceId || '').replace(/"/g, '"'); const scheduleWorkspaceDisabled = ''; const scheduleSelectionType = autoExportState.selectionType === 'selected' ? 'selected' : 'all'; const filenameTemplateValue = escapeHtml(filenameSettings.template || ''); const filenameAppendDateChecked = filenameSettings.appendDate ? 'checked' : ''; const fixedOverwriteSupported = supportsFixedFileOverwrite(); const fixedOverwriteChecked = downloadSettings.mode === 'file-handle' ? 'checked' : ''; const fixedOverwriteDisabled = fixedOverwriteSupported ? '' : 'disabled'; const fixedFileName = escapeHtml(downloadSettings.handleName || '未设置'); const fixedOverwriteHint = fixedOverwriteSupported ? '开启后将直接写入同一个 ZIP 文件,避免浏览器自动改名为 (1)。' : '当前浏览器不支持固定文件覆盖模式,将使用普通下载。'; const filenamePreviewValue = escapeHtml(buildExportZipFilename( normalizeExportMode(autoExportState.mode), autoExportState.selectionType === 'selected' ? 'selected' : 'full', autoExportState.workspaceId || '' )); html = `<h2 style="margin-top:0; margin-bottom: 20px; font-size: 18px;">团队空间导出</h2> <div class="cge-space-grid" style="display: flex; flex-direction: column; gap: 16px;"> <div class="cge-space-card" style="padding: 16px; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb;"> <strong style="font-size: 16px;">团队空间</strong> <p style="margin: 4px 0 12px 0; color: #666;">导出团队空间下的对话,将自动检测ID。</p> <div class="cge-space-actions" style="display: flex; gap: 8px;"> <button id="select-team-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">导出全部</button> <button id="select-team-picker-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">选择对话导出</button> </div> </div> <div class="cge-space-card" style="padding: 16px; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb;"> <strong style="font-size: 16px;">文件命名</strong> <p style="margin: 4px 0 12px 0; color: #666;">自定义 ZIP 文件名。若要尽量保持同名文件,请关闭“自动追加日期”(部分浏览器仍可能自动改名为 (1))。</p> <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;"> <input id="filename-template-input" type="text" value="${filenameTemplateValue}" placeholder="例如 latest_chat_backup 或 backup_{mode}_{type}" style="flex: 1 1 320px; min-width: 220px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;" /> </div> <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;"> <label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #666;"> <input id="filename-append-date-checkbox" type="checkbox" ${filenameAppendDateChecked} /> 自动追加日期 </label> <button id="filename-save-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">保存命名设置</button> <button id="filename-reset-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">恢复默认</button> </div> <div style="font-size: 12px; color: #666; margin-bottom: 6px;">支持变量:<code>{mode}</code> <code>{type}</code> <code>{workspace}</code> <code>{date}</code></div> <div style="font-size: 12px; color: #666; margin-bottom: 8px;">预览:<code id="filename-preview">${filenamePreviewValue}</code></div> <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;"> <label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #666;"> <input id="download-overwrite-checkbox" type="checkbox" ${fixedOverwriteChecked} ${fixedOverwriteDisabled} /> 固定文件覆盖模式(避免 (1)) </label> <button id="download-pick-file-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;" ${fixedOverwriteDisabled}>选择固定保存文件</button> <button id="download-clear-file-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;" ${fixedOverwriteDisabled}>清除固定文件</button> </div> <div id="download-fixed-file-status" style="font-size: 12px; color: #666; margin-bottom: 4px;">当前固定文件:<code>${fixedFileName}</code></div> <div style="font-size: 12px; color: #666;">${fixedOverwriteHint}</div> </div> <div class="cge-space-card" style="padding: 16px; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb;"> <strong style="font-size: 16px;">定时导出</strong> <p style="margin: 4px 0 12px 0; color: #666;">每隔固定时间自动导出,启动后会立即执行一次,刷新页面后会自动恢复。</p> <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;"> <input id="schedule-interval-input" type="number" min="${AUTO_EXPORT_MIN_MINUTES}" value="${autoExportState.intervalMinutes}" style="flex: 1 1 120px; min-width: 120px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;" /> <select id="schedule-mode-select" disabled style="flex: 1 1 140px; min-width: 140px; padding: 8px; border-radius: 6px; border: 1px solid #ccc; opacity: 0.75; cursor: not-allowed;"> <option value="team" selected>团队空间</option> </select> <input id="schedule-workspace-input" type="text" placeholder="团队 Workspace ID (ws-...)" value="${scheduleWorkspaceValue}" ${scheduleWorkspaceDisabled} style="flex: 2 1 200px; min-width: 180px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;" /> </div> <div class="cge-picker-row" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;"> <select id="schedule-selection-type" style="flex: 1 1 180px; min-width: 180px; padding: 8px; border-radius: 6px; border: 1px solid #ccc;"> <option value="all" ${scheduleSelectionType === 'all' ? 'selected' : ''}>导出全部对话</option> <option value="selected" ${scheduleSelectionType === 'selected' ? 'selected' : ''}>仅导出已选对话</option> </select> <button id="schedule-pick-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">选择定时对话</button> </div> <div id="schedule-selection-summary" style="font-size: 12px; color: #666; margin-bottom: 8px;"></div> <div class="cge-space-actions" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px;"> <button id="schedule-start-btn" style="padding: 8px 12px; border: none; border-radius: 6px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">启动定时</button> <button id="schedule-stop-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">停止定时</button> <button id="schedule-run-now-btn" style="padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer;">立即执行一次</button> </div> <div id="schedule-status" style="font-size: 12px; color: #666;">${getAutoExportStatusText()}</div> </div> </div> <div class="cge-team-footer" style="display: flex; justify-content: flex-end; margin-top: 24px;"> <button id="cancel-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">取消</button> </div>`; break; } dialog.innerHTML = html; attachListeners(step); }; const attachListeners = (step) => { if (step === 'initial') { const startTeamFlow = (action) => { const detectedIds = detectAllWorkspaceIds(); if (detectedIds.length === 1) { const workspaceId = detectedIds[0]; closeDialog(); if (action === 'all') { startExportProcess('team', workspaceId); } else { showConversationPicker({ mode: 'team', workspaceId }); } return; } renderStep('team', action); }; document.getElementById('select-team-btn').onclick = () => startTeamFlow('all'); document.getElementById('select-team-picker-btn').onclick = () => startTeamFlow('select'); document.getElementById('cancel-btn').onclick = closeDialog; const scheduleModeSelect = document.getElementById('schedule-mode-select'); const scheduleIntervalInput = document.getElementById('schedule-interval-input'); const scheduleWorkspaceInput = document.getElementById('schedule-workspace-input'); const scheduleSelectionTypeSelect = document.getElementById('schedule-selection-type'); const schedulePickBtn = document.getElementById('schedule-pick-btn'); const scheduleSelectionSummaryEl = document.getElementById('schedule-selection-summary'); const scheduleStatusEl = document.getElementById('schedule-status'); const filenameTemplateInput = document.getElementById('filename-template-input'); const filenameAppendDateCheckbox = document.getElementById('filename-append-date-checkbox'); const filenamePreviewEl = document.getElementById('filename-preview'); const filenameSaveBtn = document.getElementById('filename-save-btn'); const filenameResetBtn = document.getElementById('filename-reset-btn'); const downloadOverwriteCheckbox = document.getElementById('download-overwrite-checkbox'); const downloadPickFileBtn = document.getElementById('download-pick-file-btn'); const downloadClearFileBtn = document.getElementById('download-clear-file-btn'); const downloadFixedFileStatusEl = document.getElementById('download-fixed-file-status'); if (autoExportState.mode !== 'team') { autoExportState.mode = 'team'; autoExportState.workspaceId = (autoExportState.workspaceId || '').trim(); saveAutoExportState(); } const resolveTeamWorkspaceIdFromInput = (rawWorkspaceId, showAlert = false) => { const directWorkspaceId = (rawWorkspaceId || '').trim(); if (directWorkspaceId) return directWorkspaceId; const detectedIds = detectAllWorkspaceIds(); if (detectedIds.length === 1) return detectedIds[0]; if (showAlert) { alert('团队空间需要 Team Workspace ID(可手动填写,或先打开一个团队对话后再试)。'); } return ''; }; const isSelectionContextMatched = (mode, workspaceId) => { const normalizedWorkspaceId = mode === 'team' ? (workspaceId || '') : ''; return autoExportState.selectedMode === mode && (autoExportState.selectedWorkspaceId || '') === normalizedWorkspaceId; }; const updateScheduleWorkspaceState = () => { const teamMode = scheduleModeSelect.value === 'team'; scheduleWorkspaceInput.disabled = !teamMode; scheduleWorkspaceInput.style.opacity = teamMode ? '1' : '0.7'; if (!teamMode) return; if ((scheduleWorkspaceInput.value || '').trim()) return; const detectedIds = detectAllWorkspaceIds(); if (detectedIds.length === 1) { scheduleWorkspaceInput.value = detectedIds[0]; } }; const getDraftFilenameSettings = () => normalizeFilenameSettings({ template: filenameTemplateInput?.value || '', appendDate: Boolean(filenameAppendDateCheckbox?.checked) }); const updateFilenamePreview = () => { if (!filenamePreviewEl) return; const mode = scheduleModeSelect?.value || normalizeExportMode(autoExportState.mode); const selectionType = scheduleSelectionTypeSelect?.value === 'selected' ? 'selected' : 'full'; const workspaceId = mode === 'team' ? (scheduleWorkspaceInput?.value || '').trim() : ''; filenamePreviewEl.textContent = buildExportZipFilename( mode, selectionType, workspaceId, getDraftFilenameSettings() ); }; const updateFixedFileStatus = () => { if (downloadFixedFileStatusEl) { const name = downloadSettings.handleName || '未设置'; downloadFixedFileStatusEl.innerHTML = `当前固定文件:<code>${escapeHtml(name)}</code>`; } }; const updateScheduleSelectionSummary = () => { const mode = scheduleModeSelect.value; const workspaceId = mode === 'team' ? scheduleWorkspaceInput.value.trim() : ''; const selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all'; schedulePickBtn.disabled = false; schedulePickBtn.style.opacity = '1'; schedulePickBtn.style.cursor = 'pointer'; schedulePickBtn.textContent = selectionType === 'selected' ? '选择定时对话' : '切换到已选并选择'; if (selectionType === 'all') { scheduleSelectionSummaryEl.textContent = '当前策略:导出全部对话。'; return; } const selectedCount = autoExportState.selectedEntries.length; if (selectedCount === 0) { scheduleSelectionSummaryEl.textContent = '当前策略:仅导出已选对话(未选择)。'; return; } if (!isSelectionContextMatched(mode, workspaceId)) { scheduleSelectionSummaryEl.textContent = `已保存 ${selectedCount} 条定时对话,但与当前空间设置不匹配,请重新选择。`; return; } scheduleSelectionSummaryEl.textContent = `已保存 ${selectedCount} 条定时对话。`; }; const updateScheduleStatus = () => { scheduleStatusEl.textContent = getAutoExportStatusText(); }; scheduleModeSelect.onchange = () => { updateScheduleWorkspaceState(); updateScheduleSelectionSummary(); updateFilenamePreview(); }; scheduleWorkspaceInput.oninput = () => { updateScheduleSelectionSummary(); updateFilenamePreview(); }; scheduleSelectionTypeSelect.onchange = () => { autoExportState.selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all'; saveAutoExportState(); updateScheduleSelectionSummary(); updateFilenamePreview(); }; if (filenameTemplateInput) { filenameTemplateInput.oninput = () => updateFilenamePreview(); } if (filenameAppendDateCheckbox) { filenameAppendDateCheckbox.onchange = () => updateFilenamePreview(); } if (filenameSaveBtn) { filenameSaveBtn.onclick = () => { filenameSettings = getDraftFilenameSettings(); saveFilenameSettings(); if (downloadOverwriteCheckbox) { downloadSettings.mode = downloadOverwriteCheckbox.checked ? 'file-handle' : 'browser'; saveDownloadSettings(); } updateFilenamePreview(); updateFixedFileStatus(); notifyUser('命名设置已保存。', { mode: DEFAULT_NOTIFY_MODE, level: 'success' }); }; } if (filenameResetBtn) { filenameResetBtn.onclick = () => { filenameSettings = normalizeFilenameSettings(); saveFilenameSettings(); if (filenameTemplateInput) filenameTemplateInput.value = filenameSettings.template; if (filenameAppendDateCheckbox) filenameAppendDateCheckbox.checked = filenameSettings.appendDate; updateFilenamePreview(); notifyUser('已恢复默认命名规则。', { mode: DEFAULT_NOTIFY_MODE, level: 'info' }); }; } if (downloadOverwriteCheckbox) { downloadOverwriteCheckbox.onchange = () => { if (downloadOverwriteCheckbox.checked && !supportsFixedFileOverwrite()) { downloadOverwriteCheckbox.checked = false; notifyUser('当前浏览器不支持固定文件覆盖模式。', { mode: DEFAULT_NOTIFY_MODE, level: 'error' }); return; } downloadSettings.mode = downloadOverwriteCheckbox.checked ? 'file-handle' : 'browser'; saveDownloadSettings(); updateFixedFileStatus(); }; } if (downloadPickFileBtn) { downloadPickFileBtn.onclick = async () => { try { const mode = scheduleModeSelect?.value || normalizeExportMode(autoExportState.mode); const selectionType = scheduleSelectionTypeSelect?.value === 'selected' ? 'selected' : 'full'; const workspaceId = mode === 'team' ? (scheduleWorkspaceInput?.value || '').trim() : ''; const suggestedName = buildExportZipFilename( mode, selectionType, workspaceId, getDraftFilenameSettings() ); await pickAndRememberZipFileHandle(suggestedName); downloadSettings.mode = 'file-handle'; saveDownloadSettings(); if (downloadOverwriteCheckbox) downloadOverwriteCheckbox.checked = true; updateFixedFileStatus(); notifyUser('固定保存文件已设置,后续将直接覆盖该文件。', { mode: DEFAULT_NOTIFY_MODE, level: 'success' }); } catch (err) { if (err?.name === 'AbortError') return; notifyUser(`选择固定文件失败: ${err?.message || err}`, { mode: DEFAULT_NOTIFY_MODE, level: 'error', duration: 3800 }); } }; } if (downloadClearFileBtn) { downloadClearFileBtn.onclick = async () => { await clearStoredZipFileHandle(); downloadSettings.handleName = ''; if (downloadSettings.mode === 'file-handle') { downloadSettings.mode = 'browser'; if (downloadOverwriteCheckbox) downloadOverwriteCheckbox.checked = false; } saveDownloadSettings(); updateFixedFileStatus(); notifyUser('已清除固定保存文件。', { mode: DEFAULT_NOTIFY_MODE, level: 'info' }); }; } updateScheduleWorkspaceState(); updateScheduleSelectionSummary(); updateScheduleStatus(); updateFilenamePreview(); updateFixedFileStatus(); schedulePickBtn.onclick = () => { if (scheduleSelectionTypeSelect.value !== 'selected') { scheduleSelectionTypeSelect.value = 'selected'; autoExportState.selectionType = 'selected'; saveAutoExportState(); } const mode = scheduleModeSelect.value; let workspaceId = mode === 'team' ? resolveTeamWorkspaceIdFromInput(scheduleWorkspaceInput.value, true) : ''; if (mode === 'team' && !workspaceId) return; if (mode === 'team') { scheduleWorkspaceInput.value = workspaceId; } const initialSelectedIds = isSelectionContextMatched(mode, workspaceId) ? autoExportState.selectedEntries.map(item => item.id) : []; closeDialog(); showConversationPicker({ mode, workspaceId: mode === 'team' ? workspaceId : null, action: 'schedule', initialSelectedIds, onConfirm: async (selectedList) => { autoExportState.selectionType = 'selected'; autoExportState.selectedEntries = normalizeScheduleEntries(selectedList); autoExportState.selectedMode = mode; autoExportState.selectedWorkspaceId = mode === 'team' ? workspaceId : ''; autoExportState.lastError = ''; saveAutoExportState(); showExportDialog(); }, onBack: () => showExportDialog() }); }; document.getElementById('schedule-start-btn').onclick = () => { const intervalMinutes = Math.max( AUTO_EXPORT_MIN_MINUTES, Math.floor(Number(scheduleIntervalInput.value || 0)) ); if (!Number.isFinite(intervalMinutes) || intervalMinutes < AUTO_EXPORT_MIN_MINUTES) { alert(`请输入有效的间隔分钟数(>= ${AUTO_EXPORT_MIN_MINUTES})。`); return; } const mode = scheduleModeSelect.value; const selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all'; const workspaceId = mode === 'team' ? resolveTeamWorkspaceIdFromInput(scheduleWorkspaceInput.value, true) : ''; if (mode === 'team' && !workspaceId) return; if (mode === 'team') { scheduleWorkspaceInput.value = workspaceId; } const contextMatched = isSelectionContextMatched(mode, workspaceId); const selectedEntries = contextMatched ? normalizeScheduleEntries(autoExportState.selectedEntries) : []; if (selectionType === 'selected' && selectedEntries.length === 0) { alert('当前“仅导出已选对话”没有有效选择,请先点击“选择定时对话”。'); updateScheduleSelectionSummary(); return; } try { const state = startIntervalExport({ mode, workspaceId, intervalMinutes, runNow: true, selectionType, selectedEntries: selectionType === 'selected' ? selectedEntries : autoExportState.selectedEntries, selectedMode: selectionType === 'selected' ? mode : autoExportState.selectedMode, selectedWorkspaceId: selectionType === 'selected' ? (mode === 'team' ? workspaceId : '') : autoExportState.selectedWorkspaceId }); scheduleIntervalInput.value = String(state.intervalMinutes); scheduleModeSelect.value = state.mode; scheduleWorkspaceInput.value = state.workspaceId || ''; scheduleSelectionTypeSelect.value = state.selectionType || 'all'; updateScheduleWorkspaceState(); updateScheduleSelectionSummary(); updateScheduleStatus(); } catch (err) { alert(err?.message || '启动定时导出失败。'); } }; document.getElementById('schedule-stop-btn').onclick = () => { stopIntervalExport(); updateScheduleSelectionSummary(); updateScheduleStatus(); }; document.getElementById('schedule-run-now-btn').onclick = async () => { const mode = scheduleModeSelect.value; const selectionType = scheduleSelectionTypeSelect.value === 'selected' ? 'selected' : 'all'; let workspaceId = mode === 'team' ? resolveTeamWorkspaceIdFromInput(scheduleWorkspaceInput.value, true) : null; if (mode === 'team' && !workspaceId) return; if (mode === 'team') { scheduleWorkspaceInput.value = workspaceId; } if (selectionType === 'selected') { const contextMatched = isSelectionContextMatched(mode, workspaceId || ''); const selectedEntries = contextMatched ? normalizeScheduleEntries(autoExportState.selectedEntries) : []; if (selectedEntries.length === 0) { alert('当前“仅导出已选对话”没有有效选择,请先点击“选择定时对话”。'); updateScheduleSelectionSummary(); return; } closeDialog(); const exported = await exportConversations({ mode, workspaceId, conversationEntries: selectedEntries, exportType: 'selected', notifyMode: SCHEDULE_NOTIFY_MODE }); if (exported) { autoExportState.lastRunAt = Date.now(); autoExportState.lastError = ''; } else { autoExportState.lastError = '立即执行未完成导出。'; } saveAutoExportState(); return; } closeDialog(); await startScheduledExport({ mode, workspaceId, autoConfirm: true, source: 'manual-interval' }); }; } else if (step === 'team') { document.getElementById('back-btn').onclick = () => renderStep('initial'); const resolveWorkspaceId = () => { let workspaceId = ''; const radioChecked = document.querySelector('input[name="workspace_id"]:checked'); const codeEl = document.getElementById('workspace-id-code'); const inputEl = document.getElementById('team-id-input'); if (radioChecked) { workspaceId = radioChecked.value; } else if (codeEl) { workspaceId = codeEl.textContent; } else if (inputEl) { workspaceId = inputEl.value.trim(); } if (!workspaceId) { alert('请选择或输入一个有效的 Team Workspace ID!'); return; } return workspaceId; }; const exportAllBtn = document.getElementById('start-team-export-btn'); const pickerBtn = document.getElementById('start-team-picker-btn'); if (exportAllBtn) exportAllBtn.onclick = () => { const workspaceId = resolveWorkspaceId(); if (!workspaceId) return; closeDialog(); startExportProcess('team', workspaceId); }; if (pickerBtn) pickerBtn.onclick = () => { const workspaceId = resolveWorkspaceId(); if (!workspaceId) return; closeDialog(); showConversationPicker({ mode: 'team', workspaceId }); }; } }; overlay.appendChild(dialog); document.body.appendChild(overlay); overlay.onclick = (e) => { if (e.target === overlay) closeDialog(); }; renderStep('initial'); } function addBtn() { ensureGreatBallThemeStyles(); const oldRail = document.getElementById('cge-poke-rail'); if (oldRail) oldRail.remove(); const oldMasterBtn = document.getElementById('cge-master-btn'); if (oldMasterBtn) oldMasterBtn.remove(); const oldPokeBtn = document.getElementById('cge-poke-btn'); if (oldPokeBtn) oldPokeBtn.remove(); let b = document.getElementById('gpt-rescue-btn'); if (!b) { b = document.createElement('button'); b.id = 'gpt-rescue-btn'; b.type = 'button'; b.className = 'cge-ball-btn cge-ball-great cge-theme-greatball-fab'; b.innerHTML = '<span class="cge-ball-label">导出</span>'; } else { if (!b.querySelector('.cge-ball-label')) { b.innerHTML = '<span class="cge-ball-label">导出</span>'; } b.classList.remove('cge-ball-master', 'cge-ball-ultra', 'cge-ball-poke'); b.classList.add('cge-ball-btn', 'cge-ball-great', 'cge-theme-greatball-fab'); } if (!b.parentElement || b.parentElement !== document.body) { document.body.appendChild(b); } b.style.display = ''; b.disabled = false; setExportButtonState(b, { label: '导出', title: '打开导出面板', state: 'idle' }); b.onclick = showExportDialog; } // --- 脚本启动 --- setTimeout(addBtn, 2000); resumeIntervalExportIfNeeded(); window.ChatGPTExporter = window.ChatGPTExporter || {}; Object.assign(window.ChatGPTExporter, { showDialog: showExportDialog, startManualExport: (mode = 'team', workspaceId = null) => { const normalizedMode = normalizeExportMode(mode); if (normalizedMode === 'project') { return startProjectSpaceExportProcess(workspaceId); } return startExportProcess(normalizedMode, workspaceId); }, startScheduledExport, startIntervalExport, stopIntervalExport, getIntervalExportState }); document.documentElement.setAttribute('data-chatgpt-exporter-ready', '1'); window.dispatchEvent(new CustomEvent('CHATGPT_EXPORTER_READY')); window.addEventListener('message', (event) => { if (event.source !== window) return; const data = event.data || {}; if (data?.type !== 'CHATGPT_EXPORTER_COMMAND') return; const api = window.ChatGPTExporter; if (!api) return; try { switch (data.action) { case 'START_SCHEDULED_EXPORT': api.startScheduledExport(data.payload || {}); break; case 'START_INTERVAL_EXPORT': api.startIntervalExport(data.payload || {}); break; case 'STOP_INTERVAL_EXPORT': api.stopIntervalExport(); break; case 'OPEN_DIALOG': api.showDialog(); break; case 'START_MANUAL_EXPORT': api.startManualExport(data.payload?.mode, data.payload?.workspaceId); break; default: console.warn('[ChatGPT Exporter] 未知命令:', data.action); } } catch (err) { console.error('[ChatGPT Exporter] 处理命令失败:', err); } }); })(); // ===== END 1.js ===== // ===== BEGIN 2.js ===== // ==UserScript== // @name NotebookLM Chat // @namespace local.notebooklm // @version 0.4.4 // @description 在 ChatGPT 页面调用 NotebookLM 聊天,支持背景框与发送时自动拼接 // @author you // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://notebooklm.google.com/* // @run-at document-idle // @grant GM_xmlhttpRequest // @connect notebooklm.google.com // @connect accounts.google.com // ==/UserScript== (function () { "use strict"; const NOTEBOOKLM_HOME_URL = "https://notebooklm.google.com/"; const BATCHEXECUTE_URL = "https://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute"; const QUERY_URL = "https://notebooklm.google.com/_/LabsTailwindUi/data/google.internal.labs.tailwind.orchestration.v1.LabsTailwindOrchestrationService/GenerateFreeFormStreamed"; const RPC = { LIST_NOTEBOOKS: "wXbhsf", GET_NOTEBOOK: "rLM1Ne", }; const DEFAULT_BL = "boq_labs-tailwind-frontend_20251221.14_p0"; const STORAGE_KEY = "tm_notebooklm_chat_clipboard_state_v1"; const TOKEN_CACHE_TTL_MS = 60 * 1000; const CONTEXT_BLOCK_TITLE = "【NotebookLM 背景信息】"; const QUESTION_BLOCK_TITLE = "【用户问题】"; const ASK_SEND_BUTTON_TITLE = "先查询 NotebookLM 背景,再自动拼接并发送"; const DEFAULT_NOTEBOOK_SYSTEM_PROMPT = ""; const DEFAULT_MERGE_INSTRUCTION_PROMPT = [ "以下是 NotebookLM 返回的背景资料,请把它作为上下文参考:", "笔记本:{{notebook}}", "NotebookLM 提问:{{question}}", "NotebookLM 返回:", "{{answer}}", "", "请基于以上背景继续回答我的问题。", ].join("\n"); const state = { panelOpen: false, reqid: 100000, notebooks: [], sources: [], selectedNotebookId: "", sourceSelectionByNotebook: {}, conversationByNotebook: {}, historyByConversation: {}, lastResult: null, notebookSystemPrompt: DEFAULT_NOTEBOOK_SYSTEM_PROMPT, mergeInstructionPrompt: DEFAULT_MERGE_INSTRUCTION_PROMPT, chatContextText: "", chatContextLayout: null, chatContextMinimized: false, chatContextFabPos: null, }; const tokenCache = { csrfToken: "", sessionId: "", bl: DEFAULT_BL, fetchedAt: 0, }; const ui = { root: null, toggle: null, notebookSelect: null, reloadBtn: null, sourceList: null, sourceCount: null, selectAllBtn: null, clearAllBtn: null, notebookSystemPromptInput: null, mergeInstructionPromptInput: null, newConversationCheckbox: null, askBtn: null, chatContextWrap: null, chatContextFab: null, askSendFloatingBtn: null, chatContextTextarea: null, status: null, }; let chatContextObserver = null; let chatContextResizeObserver = null; let chatContextResizeObservedEl = null; let chatContextResizePersistTimer = 0; let chatContextApplyingLayout = false; let chatContextWindowResizeBound = false; let chatContextFabJustDragged = false; let chatAutoAskInFlight = false; let askSendFloatingPositionBound = false; function loadPersistedState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const saved = JSON.parse(raw); if (saved && typeof saved === "object") { state.panelOpen = saved.panelOpen !== false; state.notebookSystemPrompt = typeof saved.notebookSystemPrompt === "string" ? saved.notebookSystemPrompt : DEFAULT_NOTEBOOK_SYSTEM_PROMPT; state.mergeInstructionPrompt = (() => { if (typeof saved.mergeInstructionPrompt !== "string") { return DEFAULT_MERGE_INSTRUCTION_PROMPT; } const legacyValue = "请结合以下背景信息回答用户问题。"; if (saved.mergeInstructionPrompt.trim() === legacyValue) { return DEFAULT_MERGE_INSTRUCTION_PROMPT; } return saved.mergeInstructionPrompt; })(); state.chatContextText = saved.chatContextText || ""; state.chatContextLayout = saved.chatContextLayout && typeof saved.chatContextLayout === "object" ? saved.chatContextLayout : null; state.chatContextMinimized = saved.chatContextMinimized === true; state.chatContextFabPos = saved.chatContextFabPos && typeof saved.chatContextFabPos === "object" ? saved.chatContextFabPos : null; state.selectedNotebookId = saved.selectedNotebookId || ""; state.sourceSelectionByNotebook = saved.sourceSelectionByNotebook || {}; // On ChatGPT, keep panel collapsed after refresh. Open only when user clicks. if (isChatGPTPage()) { state.panelOpen = false; } } } catch (_) { // ignore storage errors } } function persistState() { try { localStorage.setItem( STORAGE_KEY, JSON.stringify({ panelOpen: state.panelOpen, notebookSystemPrompt: state.notebookSystemPrompt, mergeInstructionPrompt: state.mergeInstructionPrompt, chatContextText: state.chatContextText, chatContextLayout: state.chatContextLayout, chatContextMinimized: state.chatContextMinimized, chatContextFabPos: state.chatContextFabPos, selectedNotebookId: state.selectedNotebookId, sourceSelectionByNotebook: state.sourceSelectionByNotebook, }) ); } catch (_) { // ignore storage errors } } function injectStyles() { const style = document.createElement("style"); style.textContent = ` #tm-nblm-toggle{ position:fixed; right:20px; bottom:20px; z-index:12030; width:60px; height:60px; border:3px solid #111827; border-radius:999px; padding:0; display:flex; align-items:center; justify-content:center; background:linear-gradient(180deg,#ef4444 0 47%, #111827 47% 53%, #f8fafc 53% 100%); color:transparent; font-size:0; cursor:pointer; user-select:none; overflow:hidden; box-shadow:0 12px 28px rgba(17,24,39,.35); transition:transform .2s ease, box-shadow .2s ease, filter .2s ease; } #tm-nblm-toggle::before{ content:""; position:absolute; left:50%; top:50%; width:20px; height:20px; transform:translate(-50%,-50%); border-radius:999px; border:3px solid #111827; background:#ffffff; } #tm-nblm-toggle::after{ content:""; position:absolute; left:50%; top:50%; width:8px; height:8px; transform:translate(-50%,-50%); border-radius:999px; background:#ef4444; } #tm-nblm-toggle:hover{ transform:translateY(-1px); filter:saturate(108%); box-shadow:0 16px 32px rgba(17,24,39,.44); } #tm-nblm-toggle:active{ transform:translateY(0) scale(.97); } #tm-nblm-root{ position:fixed; right:20px; bottom:90px; z-index:12020; width:min(430px, calc(100vw - 24px)); max-height:min(78vh, 760px); display:flex; flex-direction:column; gap:10px; overflow:hidden; border:3px solid #111827; border-radius:20px; background:linear-gradient(165deg,#ef4444 0%,#dc2626 68%,#b91c1c 100%); box-shadow:0 20px 44px rgba(127,29,29,.42), inset 0 1px 0 rgba(255,255,255,.25); transform-origin:right bottom; transition:opacity .18s ease, transform .18s ease; font:13px/1.45 "Trebuchet MS","Noto Sans SC","Microsoft YaHei",sans-serif; color:#111827; } #tm-nblm-root.tm-hide{ opacity:0; transform:translateY(10px) scale(.97); pointer-events:none; } #tm-nblm-root, #tm-nblm-root *{ box-sizing:border-box; } #tm-nblm-root button, #tm-nblm-root select, #tm-nblm-root textarea, #tm-nblm-root input{ color:#101623; } #tm-nblm-head{ display:flex; align-items:center; justify-content:space-between; padding:10px 12px; border-bottom:3px solid #111827; background:linear-gradient(180deg,#991b1b,#7f1d1d); } #tm-nblm-title{ color:#fde047; font-weight:800; font-size:14px; letter-spacing:.4px; text-shadow:0 1px 0 rgba(0,0,0,.35); } #tm-nblm-close{ border:2px solid #111827; border-radius:999px; background:#fef3c7; color:#7c2d12; padding:5px 11px; cursor:pointer; font-weight:700; } #tm-nblm-body{ padding:10px 12px 12px; display:flex; flex-direction:column; gap:10px; overflow:auto; background-image: radial-gradient(rgba(15,23,42,.08) 1px, transparent 1px), linear-gradient(180deg,#fee2e2 0%,#fef3c7 52%,#dbeafe 100%); background-size:8px 8px, auto; } #tm-nblm-row{ display:flex; gap:8px; align-items:center; } #tm-nblm-row select, #tm-nblm-row button, #tm-nblm-body textarea, #tm-nblm-body input[type="text"]{ font:inherit; } #tm-nblm-notebook{ flex:1; min-width:0; border:2px solid #111827; border-radius:10px; padding:8px; background:#ecfccb; color:#1f2937; appearance:auto; } #tm-nblm-reload{ border:2px solid #111827; border-radius:10px; background:#e0f2fe; color:#0c4a6e; font-weight:700; padding:8px 10px; cursor:pointer; } #tm-nblm-source-wrap{ border:2px solid #111827; border-radius:12px; padding:8px; background:#d9f99d; display:flex; flex-direction:column; gap:6px; } #tm-nblm-source-top{ display:flex; align-items:center; justify-content:space-between; gap:8px; } #tm-nblm-source-count{ color:#14532d; font-size:12px; font-weight:700; } #tm-nblm-source-actions{ display:flex; gap:6px; } #tm-nblm-source-actions button{ border:2px solid #111827; border-radius:999px; background:#fef3c7; color:#7c2d12; font-weight:700; padding:4px 8px; cursor:pointer; } #tm-nblm-source-list{ max-height:160px; overflow:auto; display:flex; flex-direction:column; gap:4px; } .tm-nblm-source-item{ display:grid; grid-template-columns:auto 1fr; gap:8px; align-items:center; border-radius:10px; padding:6px; background:#f7fee7; border:2px solid #a3e635; } .tm-nblm-source-item:hover{ background:#ecfccb; } .tm-nblm-source-text{ min-width:0; display:flex; flex-direction:column; } .tm-nblm-source-title{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#14532d; } .tm-nblm-source-id{ color:#4d7c0f; font-size:11px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #tm-nblm-config-wrap{ border:2px solid #111827; border-radius:12px; padding:8px; background:#dbeafe; display:flex; flex-direction:column; gap:8px; } .tm-nblm-config-item{ display:flex; flex-direction:column; gap:6px; } .tm-nblm-config-item label{ color:#1e3a8a; font-size:12px; font-weight:700; } .tm-nblm-config-item textarea{ width:100%; min-height:58px; max-height:140px; resize:vertical; border:2px solid #111827; border-radius:10px; padding:8px; background:#eff6ff; color:#111827; } .tm-nblm-config-item textarea::placeholder{ color:#6b7280; opacity:1; } #tm-nblm-options{ display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap; } #tm-nblm-options label{ display:flex; align-items:center; gap:6px; color:#7f1d1d; font-weight:700; } #tm-nblm-actions{ display:flex; gap:8px; flex-wrap:wrap; } #tm-nblm-actions button{ border:2px solid #111827; border-radius:10px; padding:8px 10px; cursor:pointer; color:#fff; } #tm-nblm-ask{ background:linear-gradient(180deg,#fde047,#f59e0b); color:#3f2400; font-weight:800; box-shadow:0 6px 14px rgba(146,64,14,.3); } #tm-nblm-ask:hover:not(:disabled){ filter:brightness(1.03); } #tm-nblm-ask:active:not(:disabled){ transform:translateY(1px); } #tm-nblm-actions button:disabled{ opacity:.55; cursor:not-allowed; } #tm-nblm-status{ font-size:12px; color:#7f1d1d; font-weight:700; } #tm-nblm-chat-context-wrap, #tm-nblm-chat-context-fab{ display:none !important; } #tm-nblm-ask-send-float{ position:fixed; display:none; align-items:center; justify-content:center; width:var(--tm-nblm-ask-size, 40px); height:var(--tm-nblm-ask-size, 40px); border:2px solid #111827; border-radius:999px; padding:0; background: linear-gradient(180deg,#ef4444 0 47%, #111827 47% 53%, #f8fafc 53% 100%); color:#f8fafc; text-shadow:0 1px 0 rgba(0,0,0,.45); font:800 12px/1 "Trebuchet MS","Noto Sans SC","Microsoft YaHei",sans-serif; letter-spacing:.1px; box-shadow:0 8px 20px rgba(17,24,39,.36); white-space:nowrap; backdrop-filter:saturate(120%) blur(4px); -webkit-backdrop-filter:saturate(120%) blur(4px); z-index:12010; transform:translateZ(0); pointer-events:auto; transition:box-shadow .2s ease, transform .2s ease, opacity .2s ease; cursor:pointer; overflow:hidden; } #tm-nblm-ask-send-float.tm-show{ display:inline-flex; } body.tm-nblm-panel-open #tm-nblm-ask-send-float{ opacity:0; pointer-events:none; transform:translateY(8px) scale(.94); } #tm-nblm-ask-send-float::after{ content:""; position:absolute; inset:-2px; border-radius:inherit; border:1px solid rgba(255,255,255,.45); opacity:0; transform:scale(.93); transition:opacity .2s ease, transform .24s ease; pointer-events:none; } #tm-nblm-ask-send-float:hover:not(:disabled):not(.tm-loading){ transform:translateY(-1px); box-shadow: 0 14px 28px rgba(17,24,39,.4), inset 0 1px 0 rgba(255,255,255,.42), inset 0 -12px 20px rgba(0,0,0,.25); filter:saturate(108%); } #tm-nblm-ask-send-float:hover:not(:disabled):not(.tm-loading)::after{ opacity:1; transform:scale(1); } #tm-nblm-ask-send-float:active:not(:disabled):not(.tm-loading){ transform:translateY(0) scale(.97); } #tm-nblm-ask-send-float.tm-loading{ font-size:0; color:transparent; pointer-events:none; background: linear-gradient(180deg,#f87171 0 47%, #111827 47% 53%, #ffffff 53% 100%); background-size:180% 180%; animation:tm-nblm-ask-pulse 1.1s ease-in-out infinite, tm-nblm-ask-sheen 1.8s ease-in-out infinite; } #tm-nblm-ask-send-float.tm-loading::before{ content:""; width:var(--tm-nblm-spinner-size, 16px); height:var(--tm-nblm-spinner-size, 16px); border:var(--tm-nblm-spinner-border, 2px) solid transparent; border-top-color:#fff; border-right-color:rgba(255,255,255,.92); border-radius:999px; animation:tm-nblm-ask-spin .75s linear infinite; filter:drop-shadow(0 0 4px rgba(255,255,255,.35)); } #tm-nblm-ask-send-float.tm-loading::after{ opacity:1; transform:scale(1); animation:tm-nblm-ask-ring 1.3s ease-out infinite; } #tm-nblm-ask-send-float:disabled{ opacity:.6; cursor:not-allowed; } @keyframes tm-nblm-ask-spin{ from{ transform:rotate(0deg); } to{ transform:rotate(360deg); } } @keyframes tm-nblm-ask-pulse{ 0%,100%{ box-shadow: 0 10px 22px rgba(17,24,39,.34), inset 0 1px 0 rgba(255,255,255,.34), inset 0 -10px 16px rgba(0,0,0,.28); } 50%{ box-shadow: 0 14px 28px rgba(127,29,29,.46), inset 0 1px 0 rgba(255,255,255,.45), inset 0 -12px 18px rgba(0,0,0,.34); } } @keyframes tm-nblm-ask-sheen{ 0%{ background-position:0% 50%; } 50%{ background-position:100% 50%; } 100%{ background-position:0% 50%; } } @keyframes tm-nblm-ask-ring{ 0%{ opacity:.45; transform:scale(.88); } 70%{ opacity:0; transform:scale(1.22); } 100%{ opacity:0; transform:scale(1.22); } } #tm-nblm-chat-context{ width:100%; flex:1 1 auto; min-height:74px; height:100%; resize:none; border:1px solid #cfd4dc; border-radius:8px; padding:8px; box-sizing:border-box; font:12px/1.45 "Noto Sans SC","Microsoft YaHei",sans-serif; background:#fff; color:#111827; cursor:text; } #tm-nblm-chat-context::placeholder{ color:#6b7280; } `; document.head.appendChild(style); } function createUI() { ui.toggle = document.createElement("button"); ui.toggle.id = "tm-nblm-toggle"; ui.toggle.textContent = "NotebookLM"; ui.toggle.setAttribute("aria-label", "NotebookLM 控制面板"); ui.toggle.title = "NotebookLM 控制面板"; ui.root = document.createElement("div"); ui.root.id = "tm-nblm-root"; const head = document.createElement("div"); head.id = "tm-nblm-head"; const title = document.createElement("div"); title.id = "tm-nblm-title"; title.textContent = "控制面板"; const close = document.createElement("button"); close.id = "tm-nblm-close"; close.textContent = "收起"; head.appendChild(title); head.appendChild(close); const body = document.createElement("div"); body.id = "tm-nblm-body"; const row = document.createElement("div"); row.id = "tm-nblm-row"; ui.notebookSelect = document.createElement("select"); ui.notebookSelect.id = "tm-nblm-notebook"; ui.reloadBtn = document.createElement("button"); ui.reloadBtn.id = "tm-nblm-reload"; ui.reloadBtn.textContent = "刷新"; row.appendChild(ui.notebookSelect); row.appendChild(ui.reloadBtn); const sourceWrap = document.createElement("div"); sourceWrap.id = "tm-nblm-source-wrap"; const sourceTop = document.createElement("div"); sourceTop.id = "tm-nblm-source-top"; ui.sourceCount = document.createElement("div"); ui.sourceCount.id = "tm-nblm-source-count"; ui.sourceCount.textContent = "来源: 0"; const sourceActions = document.createElement("div"); sourceActions.id = "tm-nblm-source-actions"; ui.selectAllBtn = document.createElement("button"); ui.selectAllBtn.textContent = "全选"; ui.clearAllBtn = document.createElement("button"); ui.clearAllBtn.textContent = "全不选"; sourceActions.appendChild(ui.selectAllBtn); sourceActions.appendChild(ui.clearAllBtn); sourceTop.appendChild(ui.sourceCount); sourceTop.appendChild(sourceActions); ui.sourceList = document.createElement("div"); ui.sourceList.id = "tm-nblm-source-list"; sourceWrap.appendChild(sourceTop); sourceWrap.appendChild(ui.sourceList); const configWrap = document.createElement("div"); configWrap.id = "tm-nblm-config-wrap"; const notebookPromptItem = document.createElement("div"); notebookPromptItem.className = "tm-nblm-config-item"; const notebookPromptLabel = document.createElement("label"); notebookPromptLabel.htmlFor = "tm-nblm-notebook-system-prompt"; notebookPromptLabel.textContent = "NotebookLM 系统提示词(可选)"; ui.notebookSystemPromptInput = document.createElement("textarea"); ui.notebookSystemPromptInput.id = "tm-nblm-notebook-system-prompt"; ui.notebookSystemPromptInput.placeholder = "例如:请只基于已选来源回答,不确定时明确说明。"; ui.notebookSystemPromptInput.value = state.notebookSystemPrompt || ""; notebookPromptItem.appendChild(notebookPromptLabel); notebookPromptItem.appendChild(ui.notebookSystemPromptInput); const mergePromptItem = document.createElement("div"); mergePromptItem.className = "tm-nblm-config-item"; const mergePromptLabel = document.createElement("label"); mergePromptLabel.htmlFor = "tm-nblm-merge-instruction-prompt"; mergePromptLabel.textContent = "拼接提示词模板(用于背景内容)"; ui.mergeInstructionPromptInput = document.createElement("textarea"); ui.mergeInstructionPromptInput.id = "tm-nblm-merge-instruction-prompt"; ui.mergeInstructionPromptInput.placeholder = "支持占位符:{{notebook}} {{question}} {{answer}}"; ui.mergeInstructionPromptInput.value = state.mergeInstructionPrompt || ""; mergePromptItem.appendChild(mergePromptLabel); mergePromptItem.appendChild(ui.mergeInstructionPromptInput); configWrap.appendChild(notebookPromptItem); configWrap.appendChild(mergePromptItem); const options = document.createElement("div"); options.id = "tm-nblm-options"; const left = document.createElement("div"); left.style.display = "flex"; left.style.gap = "12px"; left.style.flexWrap = "wrap"; const newConversationLabel = document.createElement("label"); ui.newConversationCheckbox = document.createElement("input"); ui.newConversationCheckbox.type = "checkbox"; newConversationLabel.appendChild(ui.newConversationCheckbox); newConversationLabel.appendChild(document.createTextNode("每次新会话")); left.appendChild(newConversationLabel); options.appendChild(left); ui.status = document.createElement("div"); ui.status.id = "tm-nblm-status"; ui.status.textContent = "准备就绪"; body.appendChild(row); body.appendChild(sourceWrap); body.appendChild(configWrap); body.appendChild(options); body.appendChild(ui.status); ui.root.appendChild(head); ui.root.appendChild(body); document.body.appendChild(ui.toggle); document.body.appendChild(ui.root); close.addEventListener("click", () => { state.panelOpen = false; syncPanelVisibility(); persistState(); }); ui.toggle.addEventListener("click", () => { state.panelOpen = !state.panelOpen; syncPanelVisibility(); persistState(); }); ui.reloadBtn.addEventListener("click", () => { void initializeData(true); }); ui.notebookSelect.addEventListener("change", () => { state.selectedNotebookId = ui.notebookSelect.value; persistState(); void onNotebookChanged(); }); ui.selectAllBtn.addEventListener("click", () => { setAllSourceSelection(true); }); ui.clearAllBtn.addEventListener("click", () => { setAllSourceSelection(false); }); ui.notebookSystemPromptInput.addEventListener("input", () => { state.notebookSystemPrompt = ui.notebookSystemPromptInput.value; persistState(); }); ui.mergeInstructionPromptInput.addEventListener("input", () => { state.mergeInstructionPrompt = ui.mergeInstructionPromptInput.value; persistState(); }); } function syncPanelVisibility() { if (!ui.root) return; const open = !!state.panelOpen; ui.root.classList.toggle("tm-hide", !open); document.body?.classList.toggle("tm-nblm-panel-open", open); } function ensurePanelUI() { const root = document.getElementById("tm-nblm-root"); const toggle = document.getElementById("tm-nblm-toggle"); const panelReady = root && toggle && root.querySelector("#tm-nblm-notebook") && root.querySelector("#tm-nblm-status"); if (panelReady) { ui.root = root; ui.toggle = toggle; ui.notebookSelect = root.querySelector("#tm-nblm-notebook"); ui.reloadBtn = root.querySelector("#tm-nblm-reload"); ui.status = root.querySelector("#tm-nblm-status"); return; } root?.remove(); toggle?.remove(); createUI(); syncPanelVisibility(); } function setStatus(text) { ui.status.textContent = text; } function findChatSendButtonInForm(formEl) { if (!(formEl instanceof HTMLFormElement)) return null; const buttons = formEl.querySelectorAll("button"); for (const btn of buttons) { if (!isLikelySendButton(btn)) continue; if (!isElementVisible(btn)) continue; return btn; } return null; } function findChatActionAnchorButtonInForm(formEl) { if (!(formEl instanceof HTMLFormElement)) return null; const sendBtn = findChatSendButtonInForm(formEl); if (sendBtn) return sendBtn; const buttons = [...formEl.querySelectorAll("button")].filter( (btn) => btn.id !== "tm-nblm-ask-send-float" && isElementVisible(btn) ); for (const btn of buttons) { const marker = [ btn.getAttribute("data-testid") || "", btn.getAttribute("aria-label") || "", btn.getAttribute("title") || "", ] .join(" ") .toLowerCase(); if ( marker.includes("voice") || marker.includes("speech") || marker.includes("microphone") || marker.includes("语音") || marker.includes("麦克风") ) { return btn; } } return buttons.length ? buttons[buttons.length - 1] : null; } function hideAskSendFloatingButton(btn) { if (!(btn instanceof HTMLButtonElement)) return; btn.classList.remove("tm-show"); } function getRightActionClusterRect(formEl, anchorRect) { if (!(formEl instanceof HTMLFormElement)) return null; if (!anchorRect) return null; const anchorCenterY = anchorRect.top + anchorRect.height / 2; const rowTolerance = Math.max(20, Math.round(anchorRect.height * 1.25)); const maxHorizontalSpan = 220; const clusterRects = []; for (const el of formEl.querySelectorAll("button")) { if (!(el instanceof HTMLButtonElement)) continue; if (el.id === "tm-nblm-ask-send-float") continue; if (!isElementVisible(el)) continue; const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) continue; const centerY = rect.top + rect.height / 2; if (Math.abs(centerY - anchorCenterY) > rowTolerance) continue; if (rect.left < anchorRect.left - maxHorizontalSpan) continue; if (rect.right > anchorRect.right + 24) continue; clusterRects.push(rect); } if (!clusterRects.length) return null; let left = Infinity; let right = -Infinity; for (const rect of clusterRects) { if (rect.left < left) left = rect.left; if (rect.right > right) right = rect.right; } if (!Number.isFinite(left) || !Number.isFinite(right)) return null; return { left, right }; } function positionAskSendFloatingButton(btn, anchorBtn, formEl) { if (!(btn instanceof HTMLButtonElement)) return; if (!(anchorBtn instanceof HTMLButtonElement)) { hideAskSendFloatingButton(btn); return; } const rect = anchorBtn.getBoundingClientRect(); const btnSize = Math.max(34, Math.min(44, Math.round(rect.height))); const gap = 8; const viewportPad = 8; if (rect.width <= 0 || rect.height <= 0) { hideAskSendFloatingButton(btn); return; } btn.style.setProperty("--tm-nblm-ask-size", `${btnSize}px`); btn.style.setProperty( "--tm-nblm-spinner-size", `${Math.max(12, Math.round(btnSize * 0.42))}px` ); btn.style.setProperty( "--tm-nblm-spinner-border", `${Math.max(2, Math.round(btnSize * 0.08))}px` ); const cluster = getRightActionClusterRect(formEl, rect); let left = (cluster ? cluster.left : rect.left) - btnSize - gap; let top = rect.top + (rect.height - btnSize) / 2; if (left < viewportPad) { left = (cluster ? cluster.right : rect.right) + gap; } left = Math.max(viewportPad, Math.min(left, window.innerWidth - btnSize - viewportPad)); top = Math.max(viewportPad, Math.min(top, window.innerHeight - btnSize - viewportPad)); btn.style.left = `${Math.round(left)}px`; btn.style.top = `${Math.round(top)}px`; btn.classList.add("tm-show"); } function ensureAskSendFloatingButton() { if (!isChatGPTPage()) return null; let btn = document.getElementById("tm-nblm-ask-send-float"); if (!btn) { btn = document.createElement("button"); btn.id = "tm-nblm-ask-send-float"; btn.type = "button"; btn.textContent = "查"; btn.setAttribute("aria-label", "查背景并发送"); btn.title = ASK_SEND_BUTTON_TITLE; btn.addEventListener("click", () => { if (chatAutoAskInFlight) return; const inputEl = findChatInputElement(); if (!inputEl) { setStatus("未找到 ChatGPT 输入框"); return; } const formEl = inputEl.closest("form"); if (!(formEl instanceof HTMLFormElement)) { setStatus("未找到发送表单"); return; } const questionText = extractQuestionTextFromChatInput(readInputText(inputEl)); if (!questionText) { setStatus("请先在 ChatGPT 输入框中输入问题"); return; } void autoAskNotebookForChatSubmit(formEl, inputEl, questionText); }); } if (btn.parentElement !== document.body) { document.body.appendChild(btn); } const inputEl = findChatInputElement(); const formEl = inputEl?.closest("form") || document.querySelector("form"); if (!(formEl instanceof HTMLFormElement)) { hideAskSendFloatingButton(btn); return null; } const anchorBtn = findChatActionAnchorButtonInForm(formEl); if (!(anchorBtn instanceof HTMLButtonElement)) { hideAskSendFloatingButton(btn); return null; } positionAskSendFloatingButton(btn, anchorBtn, formEl); ui.askSendFloatingBtn = btn; return btn; } function setAskSendFloatingLoading(loading) { const btn = ui.askSendFloatingBtn || document.getElementById("tm-nblm-ask-send-float"); if (!(btn instanceof HTMLButtonElement)) return; const on = !!loading; btn.classList.toggle("tm-loading", on); btn.setAttribute("aria-busy", on ? "true" : "false"); btn.title = on ? "NotebookLM 查询中..." : ASK_SEND_BUTTON_TITLE; } function setBusy(busy) { if (ui.askBtn) ui.askBtn.disabled = busy; ui.reloadBtn.disabled = busy; ui.notebookSelect.disabled = busy; if (ui.askSendFloatingBtn) { ui.askSendFloatingBtn.disabled = busy; } } function bindAskSendFloatingButtonPosition() { if (askSendFloatingPositionBound) return; askSendFloatingPositionBound = true; let timer = 0; const schedule = () => { if (timer) return; timer = window.setTimeout(() => { timer = 0; ensureAskSendFloatingButton(); }, 80); }; window.addEventListener("resize", schedule); window.addEventListener("scroll", schedule, true); } function getDefaultChatContextLayout() { const width = Math.max(360, Math.min(920, Math.floor(window.innerWidth * 0.62))); const height = 220; const x = Math.max(12, Math.floor((window.innerWidth - width) / 2)); const y = Math.max(12, window.innerHeight - height - 24); return { x, y, width, height, borderRadius: 10 }; } function normalizeChatContextLayout(rawLayout) { const defaults = getDefaultChatContextLayout(); const base = rawLayout && typeof rawLayout === "object" ? rawLayout : defaults; const minWidth = 320; const minHeight = 140; const maxWidth = Math.max(minWidth, window.innerWidth - 12); const maxHeight = Math.max(minHeight, window.innerHeight - 12); const widthRaw = Number(base.width); const heightRaw = Number(base.height); const xRaw = Number(base.x); const yRaw = Number(base.y); const borderRadiusRaw = Number(base.borderRadius); const width = Math.max( minWidth, Math.min(maxWidth, Number.isFinite(widthRaw) ? widthRaw : defaults.width) ); const height = Math.max( minHeight, Math.min(maxHeight, Number.isFinite(heightRaw) ? heightRaw : defaults.height) ); const maxX = Math.max(0, window.innerWidth - width); const maxY = Math.max(0, window.innerHeight - height); const x = Math.max(0, Math.min(maxX, Number.isFinite(xRaw) ? xRaw : defaults.x)); const y = Math.max(0, Math.min(maxY, Number.isFinite(yRaw) ? yRaw : defaults.y)); const borderRadius = Math.max( 0, Math.min(28, Number.isFinite(borderRadiusRaw) ? borderRadiusRaw : defaults.borderRadius) ); return { x, y, width, height, borderRadius }; } function applyChatContextLayout(wrapEl, layout) { if (!wrapEl || !layout) return; chatContextApplyingLayout = true; wrapEl.style.left = `${Math.round(layout.x)}px`; wrapEl.style.top = `${Math.round(layout.y)}px`; wrapEl.style.width = `${Math.round(layout.width)}px`; wrapEl.style.height = `${Math.round(layout.height)}px`; wrapEl.style.borderRadius = `${Math.round(layout.borderRadius)}px`; chatContextApplyingLayout = false; } function setChatContextLayout(nextLayout, persist = false) { state.chatContextLayout = normalizeChatContextLayout(nextLayout); if (ui.chatContextWrap) { applyChatContextLayout(ui.chatContextWrap, state.chatContextLayout); } updateChatContextFabPosition(); if (persist) persistState(); } function updateChatContextFabPosition() { if (!ui.chatContextFab) return; const anchor = state.chatContextFabPos && typeof state.chatContextFabPos === "object" ? state.chatContextFabPos : state.chatContextLayout || getDefaultChatContextLayout(); const left = Number(anchor?.x); const top = Number(anchor?.y); if (Number.isFinite(left)) { ui.chatContextFab.style.left = `${Math.round(left)}px`; } if (Number.isFinite(top)) { ui.chatContextFab.style.top = `${Math.round(top)}px`; } } function setChatContextMinimized(minimized, persist = false) { state.chatContextMinimized = !!minimized; if (state.chatContextMinimized && !state.chatContextFabPos) { const rect = ui.chatContextWrap?.getBoundingClientRect(); const base = state.chatContextLayout || getDefaultChatContextLayout(); state.chatContextFabPos = { x: Math.round(rect?.left ?? base.x), y: Math.round(rect?.top ?? base.y), }; } if (ui.chatContextWrap) { ui.chatContextWrap.classList.toggle("tm-minimized", state.chatContextMinimized); } if (ui.chatContextFab) { updateChatContextFabPosition(); ui.chatContextFab.classList.toggle("tm-show", state.chatContextMinimized); } if (persist) persistState(); } function bindChatContextFabDrag(fabEl) { if (!fabEl || fabEl.dataset.tmFabDragBound) return; fabEl.dataset.tmFabDragBound = "1"; fabEl.addEventListener("mousedown", (event) => { if (event.button !== 0) return; event.preventDefault(); const rect = fabEl.getBoundingClientRect(); const startX = event.clientX; const startY = event.clientY; const startLeft = rect.left; const startTop = rect.top; let moved = false; const prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; const onMove = (moveEvent) => { const dx = moveEvent.clientX - startX; const dy = moveEvent.clientY - startY; if (!moved && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) { moved = true; } const left = Math.round(startLeft + dx); const top = Math.round(startTop + dy); fabEl.style.left = `${left}px`; fabEl.style.top = `${top}px`; }; const onUp = () => { document.body.style.userSelect = prevUserSelect; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); if (!moved) return; chatContextFabJustDragged = true; window.setTimeout(() => { chatContextFabJustDragged = false; }, 90); const left = Number.parseInt(fabEl.style.left, 10); const top = Number.parseInt(fabEl.style.top, 10); if (Number.isFinite(left) && Number.isFinite(top)) { state.chatContextFabPos = { x: left, y: top }; persistState(); } }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }); } function isPointNearBorder(clientX, clientY, rect, edge = 16) { const nearLeft = clientX - rect.left <= edge; const nearRight = rect.right - clientX <= edge; const nearTop = clientY - rect.top <= edge; const nearBottom = rect.bottom - clientY <= edge; return nearLeft || nearRight || nearTop || nearBottom; } function bindChatContextDrag(wrapEl) { if (!wrapEl || wrapEl.dataset.tmDragBound) return; wrapEl.dataset.tmDragBound = "1"; const dragEdge = 24; const resizeEdge = 6; wrapEl.addEventListener("dblclick", (event) => { const rect = wrapEl.getBoundingClientRect(); if (!isPointNearBorder(event.clientX, event.clientY, rect, dragEdge)) return; setChatContextMinimized(true, true); event.preventDefault(); }); wrapEl.addEventListener("mousedown", (event) => { if (event.button !== 0) return; const rect = wrapEl.getBoundingClientRect(); if (!isPointNearBorder(event.clientX, event.clientY, rect, dragEdge)) return; const resizeDirection = getChatContextResizeDirection( event.clientX, event.clientY, rect, resizeEdge ); if (resizeDirection) return; const startX = event.clientX; const startY = event.clientY; const startLeft = rect.left; const startTop = rect.top; const prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; const onMove = (moveEvent) => { const next = { ...(state.chatContextLayout || getDefaultChatContextLayout()), x: startLeft + (moveEvent.clientX - startX), y: startTop + (moveEvent.clientY - startY), }; setChatContextLayout(next, false); }; const onUp = () => { document.body.style.userSelect = prevUserSelect; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); persistState(); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); event.preventDefault(); event.stopPropagation(); }, true); } function getChatContextResizeDirection(clientX, clientY, rect, edge = 16) { const nearLeft = clientX - rect.left <= edge; const nearRight = rect.right - clientX <= edge; const nearTop = clientY - rect.top <= edge; const nearBottom = rect.bottom - clientY <= edge; if (nearTop && nearLeft) return "nw"; if (nearTop && nearRight) return "ne"; if (nearBottom && nearLeft) return "sw"; if (nearBottom && nearRight) return "se"; if (nearTop) return "n"; if (nearBottom) return "s"; if (nearLeft) return "w"; if (nearRight) return "e"; return ""; } function resizeCursorForDirection(direction) { if (!direction) return "default"; if (direction === "n" || direction === "s") return "ns-resize"; if (direction === "e" || direction === "w") return "ew-resize"; if (direction === "ne" || direction === "sw") return "nesw-resize"; if (direction === "nw" || direction === "se") return "nwse-resize"; return "default"; } function setChatContextResizeCursor(wrapEl, cursor) { if (wrapEl) wrapEl.style.cursor = cursor; if (ui.chatContextTextarea) { ui.chatContextTextarea.style.cursor = cursor === "default" ? "text" : cursor; } } function bindChatContextWindowLikeResize(wrapEl) { if (!wrapEl || wrapEl.dataset.tmWindowResizeBound) return; wrapEl.dataset.tmWindowResizeBound = "1"; const resizeEdge = 6; const dragEdge = 24; let resizing = false; wrapEl.addEventListener("mousemove", (event) => { if (resizing) return; const rect = wrapEl.getBoundingClientRect(); const direction = getChatContextResizeDirection( event.clientX, event.clientY, rect, resizeEdge ); if (direction) { setChatContextResizeCursor(wrapEl, resizeCursorForDirection(direction)); } else if (isPointNearBorder(event.clientX, event.clientY, rect, dragEdge)) { setChatContextResizeCursor(wrapEl, "move"); } else { setChatContextResizeCursor(wrapEl, "default"); } }); wrapEl.addEventListener("mouseleave", () => { if (!resizing) setChatContextResizeCursor(wrapEl, "default"); }); wrapEl.addEventListener( "mousedown", (event) => { if (event.button !== 0) return; const rect = wrapEl.getBoundingClientRect(); const direction = getChatContextResizeDirection( event.clientX, event.clientY, rect, resizeEdge ); if (!direction) return; event.preventDefault(); event.stopImmediatePropagation(); resizing = true; setChatContextResizeCursor(wrapEl, resizeCursorForDirection(direction)); const startX = event.clientX; const startY = event.clientY; const startLayout = state.chatContextLayout || getDefaultChatContextLayout(); const base = { x: startLayout.x, y: startLayout.y, width: startLayout.width, height: startLayout.height, borderRadius: startLayout.borderRadius, }; const prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = "none"; const onMove = (moveEvent) => { const dx = moveEvent.clientX - startX; const dy = moveEvent.clientY - startY; const next = { ...base }; if (direction.includes("e")) next.width = base.width + dx; if (direction.includes("s")) next.height = base.height + dy; if (direction.includes("w")) { next.width = base.width - dx; next.x = base.x + dx; } if (direction.includes("n")) { next.height = base.height - dy; next.y = base.y + dy; } setChatContextLayout(next, false); }; const onUp = () => { resizing = false; document.body.style.userSelect = prevUserSelect; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); persistState(); setChatContextResizeCursor(wrapEl, "default"); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); event.stopPropagation(); }, true ); } function bindChatContextResizeObserver(wrapEl) { if (!wrapEl) return; if (!chatContextResizeObserver) { chatContextResizeObserver = new ResizeObserver((entries) => { if (chatContextApplyingLayout) return; const entry = entries[0]; if (!entry) return; const rect = entry.contentRect; const next = { ...(state.chatContextLayout || getDefaultChatContextLayout()), width: Math.round(rect.width), height: Math.round(rect.height), }; setChatContextLayout(next, false); if (chatContextResizePersistTimer) { clearTimeout(chatContextResizePersistTimer); } chatContextResizePersistTimer = window.setTimeout(() => { persistState(); }, 180); }); } if (chatContextResizeObservedEl === wrapEl) return; if (chatContextResizeObservedEl) { chatContextResizeObserver.unobserve(chatContextResizeObservedEl); } chatContextResizeObserver.observe(wrapEl); chatContextResizeObservedEl = wrapEl; } function bindChatContextWindowResize() { if (chatContextWindowResizeBound) return; chatContextWindowResizeBound = true; window.addEventListener("resize", () => { if (!ui.chatContextWrap) return; setChatContextLayout(state.chatContextLayout || getDefaultChatContextLayout(), true); }); } function nextReqId() { state.reqid += 100000; return state.reqid; } function extractByRegex(text, regex) { const m = text.match(regex); return m ? m[1] : ""; } function isHttpOk(status) { return status >= 200 && status < 300; } function makeHttpError(message, status = 0, body = "") { const err = new Error(message); err.status = status; err.body = body; return err; } function httpRequest({ method, url, headers = {}, data = "", timeoutMs = 30000 }) { if (typeof GM_xmlhttpRequest === "function") { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url, headers, data, timeout: timeoutMs, responseType: "text", withCredentials: true, anonymous: false, onload: (resp) => resolve(resp), ontimeout: () => reject(makeHttpError(`请求超时: ${url}`)), onerror: () => reject(makeHttpError(`请求失败: ${url}`)), onabort: () => reject(makeHttpError(`请求中断: ${url}`)), }); }); } return fetch(url, { method, credentials: "include", headers, body: data || undefined, }).then(async (resp) => ({ status: resp.status, responseText: await resp.text(), finalUrl: resp.url, })); } function parsePageTokensFromHtml(html) { const csrfToken = extractByRegex(html, /"SNlM0e"\s*:\s*"([^"]+)"/); const sessionId = extractByRegex(html, /"FdrFJe"\s*:\s*"([^"]+)"/) || extractByRegex(html, /"f\.sid"\s*:\s*"([^"]+)"/); const bl = extractByRegex(html, /(boq_labs-tailwind-frontend_[0-9.]+_p\d+)/) || DEFAULT_BL; return { csrfToken, sessionId, bl }; } async function getPageTokens(forceRefresh = false) { const freshEnough = Date.now() - tokenCache.fetchedAt < TOKEN_CACHE_TTL_MS; if (!forceRefresh && tokenCache.sessionId && freshEnough) { return { csrfToken: tokenCache.csrfToken, sessionId: tokenCache.sessionId, bl: tokenCache.bl || DEFAULT_BL, }; } if (location.hostname === "notebooklm.google.com") { const localHtml = document.documentElement?.outerHTML || document.documentElement?.innerHTML || ""; const localTokens = parsePageTokensFromHtml(localHtml); if (localTokens.sessionId) { tokenCache.csrfToken = localTokens.csrfToken || ""; tokenCache.sessionId = localTokens.sessionId; tokenCache.bl = localTokens.bl || DEFAULT_BL; tokenCache.fetchedAt = Date.now(); return { csrfToken: tokenCache.csrfToken, sessionId: tokenCache.sessionId, bl: tokenCache.bl || DEFAULT_BL, }; } } const resp = await httpRequest({ method: "GET", url: NOTEBOOKLM_HOME_URL, }); const status = Number(resp?.status || 0); const html = String(resp?.responseText || ""); const finalUrl = String(resp?.finalUrl || "").toLowerCase(); if (!isHttpOk(status)) { throw makeHttpError(`加载 NotebookLM 页面失败: HTTP ${status}`, status, html); } const tokens = parsePageTokensFromHtml(html); if (tokens.sessionId) { tokenCache.csrfToken = tokens.csrfToken || ""; tokenCache.sessionId = tokens.sessionId; tokenCache.bl = tokens.bl || DEFAULT_BL; tokenCache.fetchedAt = Date.now(); return { csrfToken: tokenCache.csrfToken, sessionId: tokenCache.sessionId, bl: tokenCache.bl || DEFAULT_BL, }; } const looksLikeGoogleLoginPage = finalUrl.includes("accounts.google.com") || /ServiceLogin|signin\/v2\/identifier|identifierId/i.test(html); if (looksLikeGoogleLoginPage) { throw makeHttpError( "NotebookLM 未登录。请先在浏览器打开 https://notebooklm.google.com/ 完成登录。", status, html ); } throw makeHttpError( "未读取到 NotebookLM 会话参数(f.sid)。请确认已登录,且浏览器未拦截跨站 Cookie。", status, html ); } function stripAntiXssi(text) { return text.replace(/^\)\]\}'\r?\n/, ""); } function parseChunkedResponse(raw) { const cleaned = stripAntiXssi(raw).trim(); if (!cleaned) return []; const lines = cleaned.split("\n"); const chunks = []; let i = 0; while (i < lines.length) { const line = lines[i].trim(); if (!line) { i += 1; continue; } if (/^\d+$/.test(line)) { i += 1; if (i < lines.length) { try { chunks.push(JSON.parse(lines[i])); } catch (_) { // ignore malformed chunk } } i += 1; } else { try { chunks.push(JSON.parse(line)); } catch (_) { // ignore malformed line } i += 1; } } return chunks; } function extractRpcResult(chunks, rpcId) { for (const chunk of chunks) { if (!Array.isArray(chunk)) continue; const items = chunk.length > 0 && Array.isArray(chunk[0]) ? chunk : [chunk]; for (const item of items) { if (!Array.isArray(item) || item.length < 3) continue; if (item[0] === "er" && item[1] === rpcId) { throw new Error(`RPC 错误: ${item[2] ?? "unknown"}`); } if (item[0] === "wrb.fr" && item[1] === rpcId) { const payload = item[2]; if (typeof payload === "string") { try { return JSON.parse(payload); } catch (_) { return payload; } } return payload; } } } return null; } async function rpcCall(rpcId, params, sourcePath = "/") { async function runWithTokens(forceRefresh) { const { csrfToken, sessionId } = await getPageTokens(forceRefresh); const query = new URLSearchParams({ rpcids: rpcId, "source-path": sourcePath, "f.sid": sessionId, rt: "c", }); const rpcRequest = [[[rpcId, JSON.stringify(params), null, "generic"]]]; const bodyParts = [`f.req=${encodeURIComponent(JSON.stringify(rpcRequest))}`]; if (csrfToken) bodyParts.push(`at=${encodeURIComponent(csrfToken)}`); const body = `${bodyParts.join("&")}&`; const resp = await httpRequest({ method: "POST", url: `${BATCHEXECUTE_URL}?${query.toString()}`, headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", "x-same-domain": "1", }, data: body, }); const status = Number(resp?.status || 0); const text = String(resp?.responseText || ""); if (!isHttpOk(status)) { throw makeHttpError(`RPC HTTP ${status}`, status, text); } const chunks = parseChunkedResponse(text); const result = extractRpcResult(chunks, rpcId); if (result === null || result === undefined) { throw makeHttpError(`RPC ${rpcId} 返回空数据`, status, text); } return result; } try { return await runWithTokens(false); } catch (err) { const status = Number(err?.status || 0); if (![400, 401, 403].includes(status)) throw err; return await runWithTokens(true); } } function parseNotebook(raw) { if (!Array.isArray(raw)) return null; const id = typeof raw[2] === "string" ? raw[2] : ""; if (!id) return null; const rawTitle = typeof raw[0] === "string" ? raw[0] : ""; const title = rawTitle.replace("thought\n", "").trim() || "(未命名笔记本)"; return { id, title }; } function parseSource(raw) { if (!Array.isArray(raw) || raw.length === 0) return null; const sourceId = Array.isArray(raw[0]) ? raw[0][0] : raw[0]; if (typeof sourceId !== "string" || !sourceId) return null; const title = typeof raw[1] === "string" && raw[1].trim() ? raw[1] : "未命名来源"; return { id: sourceId, title }; } async function fetchSourcesForNotebook(notebookId) { const result = await rpcCall( RPC.GET_NOTEBOOK, [notebookId, null, [2], null, 0], `/notebook/${notebookId}` ); let rawSources = []; if ( Array.isArray(result) && Array.isArray(result[0]) && Array.isArray(result[0][1]) ) { rawSources = result[0][1]; } return rawSources.map(parseSource).filter(Boolean); } async function loadNotebooks() { const result = await rpcCall(RPC.LIST_NOTEBOOKS, [null, 1, null, [2]], "/"); const rawList = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result; const notebooks = Array.isArray(rawList) ? rawList.map(parseNotebook).filter(Boolean) : []; state.notebooks = notebooks; renderNotebookOptions(); } async function loadSources(notebookId) { state.sources = await fetchSourcesForNotebook(notebookId); const selected = new Set(state.sourceSelectionByNotebook[notebookId] || []); const availableIds = new Set(state.sources.map((s) => s.id)); const filtered = [...selected].filter((id) => availableIds.has(id)); const effective = filtered.length > 0 ? filtered : state.sources.map((s) => s.id); state.sourceSelectionByNotebook[notebookId] = effective; persistState(); renderSources(); } async function validateSelectedSourceIds(notebookId, selectedSourceIds) { const liveSources = await fetchSourcesForNotebook(notebookId); state.sources = liveSources; const availableIds = new Set(liveSources.map((s) => s.id)); const filtered = (selectedSourceIds || []).filter((id) => availableIds.has(id)); state.sourceSelectionByNotebook[notebookId] = filtered; persistState(); renderSources(); return filtered; } function renderNotebookOptions() { ui.notebookSelect.replaceChildren(); if (!state.notebooks.length) { const option = document.createElement("option"); option.value = ""; option.textContent = "没有可用笔记本"; ui.notebookSelect.appendChild(option); ui.notebookSelect.value = ""; state.selectedNotebookId = ""; return; } for (const nb of state.notebooks) { const option = document.createElement("option"); option.value = nb.id; option.textContent = nb.title; ui.notebookSelect.appendChild(option); } const exists = state.notebooks.some((nb) => nb.id === state.selectedNotebookId); if (!exists) state.selectedNotebookId = state.notebooks[0].id; ui.notebookSelect.value = state.selectedNotebookId; } function renderSources() { const notebookId = state.selectedNotebookId; const selected = new Set(state.sourceSelectionByNotebook[notebookId] || []); ui.sourceList.replaceChildren(); for (const src of state.sources) { const item = document.createElement("label"); item.className = "tm-nblm-source-item"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = src.id; cb.checked = selected.has(src.id); cb.addEventListener("change", () => { const set = new Set(state.sourceSelectionByNotebook[notebookId] || []); if (cb.checked) set.add(src.id); else set.delete(src.id); state.sourceSelectionByNotebook[notebookId] = [...set]; persistState(); updateSourceCount(); }); const text = document.createElement("span"); text.className = "tm-nblm-source-text"; const title = document.createElement("span"); title.className = "tm-nblm-source-title"; title.textContent = src.title; const sid = document.createElement("span"); sid.className = "tm-nblm-source-id"; sid.textContent = src.id; text.appendChild(title); text.appendChild(sid); item.appendChild(cb); item.appendChild(text); ui.sourceList.appendChild(item); } updateSourceCount(); } function updateSourceCount() { const notebookId = state.selectedNotebookId; const selected = state.sourceSelectionByNotebook[notebookId] || []; ui.sourceCount.textContent = `来源: 已选 ${selected.length} / 总计 ${state.sources.length}`; } function setAllSourceSelection(selectAll) { const notebookId = state.selectedNotebookId; if (!notebookId) return; state.sourceSelectionByNotebook[notebookId] = selectAll ? state.sources.map((s) => s.id) : []; persistState(); renderSources(); } function buildConversationHistory(conversationId) { const turns = state.historyByConversation[conversationId]; if (!Array.isArray(turns) || turns.length === 0) return null; const history = []; for (const turn of turns) { history.push([turn.answer, null, 2]); history.push([turn.query, null, 1]); } return history; } async function askNotebook({ notebookId, question, sourceIds, forceNewConversation }) { const sourceArray = sourceIds.map((id) => [[id]]); let conversationId = null; if (!forceNewConversation) { conversationId = state.conversationByNotebook[notebookId] || null; } if (!conversationId) conversationId = crypto.randomUUID(); const conversationHistory = forceNewConversation || !state.historyByConversation[conversationId] ? null : buildConversationHistory(conversationId); async function runWithTokens(forceRefresh) { const { csrfToken, sessionId, bl } = await getPageTokens(forceRefresh); const params = [ sourceArray, question, conversationHistory, [2, null, [1]], conversationId, ]; const fReq = JSON.stringify([null, JSON.stringify(params)]); const bodyParts = [`f.req=${encodeURIComponent(fReq)}`]; if (csrfToken) bodyParts.push(`at=${encodeURIComponent(csrfToken)}`); const body = `${bodyParts.join("&")}&`; const query = new URLSearchParams({ hl: "en", _reqid: String(nextReqId()), rt: "c", "f.sid": sessionId, bl: bl || DEFAULT_BL, }); const resp = await httpRequest({ method: "POST", url: `${QUERY_URL}?${query.toString()}`, headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", "x-same-domain": "1", }, data: body, }); const status = Number(resp?.status || 0); const rawText = String(resp?.responseText || ""); if (!isHttpOk(status)) { throw makeHttpError(`提问请求失败: HTTP ${status}`, status, rawText); } const answer = parseAskResponse(rawText); return { answer, conversationId, rawText }; } try { return await runWithTokens(false); } catch (err) { const status = Number(err?.status || 0); if (![400, 401, 403].includes(status)) throw err; return await runWithTokens(true); } } function isLikelyNotebookThinkingText(text) { const normalized = String(text || "").trim().toLowerCase(); if (!normalized) return false; if ( /^\*\*(synthesizing|analyzing|integrating|reviewing|planning|drafting)\b/.test( normalized ) ) { return true; } if ( /^i'?m now (integrating|synthesizing|analyzing|reviewing|combining)\b/.test( normalized ) || /^let me (integrate|synthesize|analyze|review|combine)\b/.test(normalized) ) { return true; } return false; } function parseAskResponse(rawText) { const cleaned = stripAntiXssi(rawText); const lines = cleaned.trim().split("\n"); let lastAnswerAnyText = ""; let lastAnswerNonThinkingText = ""; function processChunk(jsonStr) { let data; try { data = JSON.parse(jsonStr); } catch (_) { return; } if (!Array.isArray(data)) return; for (const item of data) { if (!Array.isArray(item) || item.length < 3 || item[0] !== "wrb.fr") continue; if (typeof item[2] !== "string") continue; let inner; try { inner = JSON.parse(item[2]); } catch (_) { continue; } if (!Array.isArray(inner) || !Array.isArray(inner[0])) continue; const first = inner[0]; const text = typeof first[0] === "string" ? first[0] : ""; if (!text) continue; let isAnswer = false; if (text.length > 20 && Array.isArray(first[4])) { const tail = first[4][first[4].length - 1]; if (tail === 1) isAnswer = true; } if (!isAnswer) continue; const isThinking = isLikelyNotebookThinkingText(text); lastAnswerAnyText = text; if (!isThinking) { lastAnswerNonThinkingText = text; } } } let i = 0; while (i < lines.length) { const line = lines[i].trim(); if (!line) { i += 1; continue; } if (/^\d+$/.test(line)) { i += 1; if (i < lines.length) processChunk(lines[i]); i += 1; } else { processChunk(line); i += 1; } } return lastAnswerNonThinkingText || lastAnswerAnyText || "(no answer text extracted)"; } function isChatGPTPage() { const host = location.hostname; return host === "chat.openai.com" || host === "chatgpt.com" || host.endsWith(".chatgpt.com"); } function isElementVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); return style.display !== "none" && style.visibility !== "hidden"; } function readInputText(inputEl) { if ( inputEl instanceof HTMLTextAreaElement || inputEl instanceof HTMLInputElement ) { return inputEl.value || ""; } return inputEl.innerText || inputEl.textContent || ""; } function findChatInputElement() { const selectors = [ "#prompt-textarea", 'textarea[data-testid="prompt-textarea"]', 'div[data-testid="prompt-textarea"]', 'div[contenteditable="true"][id="prompt-textarea"]', 'div[contenteditable="true"]', ]; for (const selector of selectors) { const nodes = document.querySelectorAll(selector); for (const node of nodes) { if (!isElementVisible(node)) continue; if (selector === 'div[contenteditable="true"]' && !node.closest("form")) continue; return node; } } return null; } function findChatInputElementInForm(formEl) { if (!(formEl instanceof HTMLFormElement)) return null; const selectors = [ "#prompt-textarea", 'textarea[data-testid="prompt-textarea"]', 'div[data-testid="prompt-textarea"]', 'div[contenteditable="true"][id="prompt-textarea"]', 'div[contenteditable="true"]', ]; for (const selector of selectors) { const nodes = formEl.querySelectorAll(selector); for (const node of nodes) { if (!isElementVisible(node)) continue; return node; } } return null; } function ensureChatContextDock() { if (!isChatGPTPage()) return null; let wrap = document.getElementById("tm-nblm-chat-context-wrap"); let textarea = wrap?.querySelector("#tm-nblm-chat-context") || null; let fab = document.getElementById("tm-nblm-chat-context-fab"); if (!wrap) { const inputEl = findChatInputElement(); if (!inputEl) return null; wrap = document.createElement("div"); wrap.id = "tm-nblm-chat-context-wrap"; } if (!textarea) { textarea = document.createElement("textarea"); textarea.id = "tm-nblm-chat-context"; textarea.readOnly = true; textarea.placeholder = "NotebookLM 查询结果将显示在这里"; wrap.appendChild(textarea); } const oldDragBar = wrap.querySelector("#tm-nblm-chat-context-drag"); if (oldDragBar) { oldDragBar.remove(); } if (!fab) { fab = document.createElement("button"); fab.id = "tm-nblm-chat-context-fab"; fab.type = "button"; fab.textContent = "N"; fab.title = "打开 NotebookLM 背景框"; fab.addEventListener("click", (event) => { if (chatContextFabJustDragged) { event.preventDefault(); event.stopPropagation(); return; } if (state.chatContextFabPos) { const next = { ...(state.chatContextLayout || getDefaultChatContextLayout()) }; const x = Number(state.chatContextFabPos.x); const y = Number(state.chatContextFabPos.y); if (Number.isFinite(x)) next.x = x; if (Number.isFinite(y)) next.y = y; setChatContextLayout(next, false); } setChatContextMinimized(false, true); }); } if (wrap.parentElement !== document.body) { document.body.appendChild(wrap); } if (fab.parentElement !== document.body) { document.body.appendChild(fab); } ui.chatContextWrap = wrap; ui.chatContextFab = fab; bindChatContextFabDrag(fab); if (!state.chatContextLayout) { state.chatContextLayout = getDefaultChatContextLayout(); } setChatContextLayout(state.chatContextLayout, false); if (textarea) { if (textarea.value !== state.chatContextText) { textarea.value = state.chatContextText || ""; } } bindChatContextWindowLikeResize(wrap); bindChatContextDrag(wrap); bindChatContextWindowResize(); setChatContextMinimized(state.chatContextMinimized, false); ui.chatContextTextarea = textarea; return wrap; } function setChatContextText(text) { state.chatContextText = text || ""; const wrap = ensureChatContextDock(); if (!wrap || !ui.chatContextTextarea) return false; if (ui.chatContextTextarea.value !== state.chatContextText) { ui.chatContextTextarea.value = state.chatContextText; } persistState(); return true; } function getChatContextText() { return (ui.chatContextTextarea?.value ?? state.chatContextText ?? "").trim(); } function setInputText(inputEl, text) { if ( inputEl instanceof HTMLTextAreaElement || inputEl instanceof HTMLInputElement ) { const proto = Object.getPrototypeOf(inputEl); const descriptor = Object.getOwnPropertyDescriptor(proto, "value"); if (descriptor?.set) descriptor.set.call(inputEl, text); else inputEl.value = text; inputEl.dispatchEvent(new Event("input", { bubbles: true })); inputEl.dispatchEvent(new Event("change", { bubbles: true })); inputEl.focus(); inputEl.setSelectionRange?.(text.length, text.length); return; } inputEl.focus(); const selection = window.getSelection(); if (selection) { const range = document.createRange(); range.selectNodeContents(inputEl); selection.removeAllRanges(); selection.addRange(range); } let inserted = false; try { inserted = document.execCommand("insertText", false, text); } catch (_) { inserted = false; } if (!inserted) { inputEl.textContent = text; } inputEl.dispatchEvent( new InputEvent("input", { bubbles: true, cancelable: true, data: text, inputType: "insertText", }) ); inputEl.dispatchEvent(new Event("change", { bubbles: true })); } function buildNotebookQuestion(questionText) { const finalQuestion = String(questionText || "").trim(); if (!finalQuestion) return ""; const systemPrompt = String(state.notebookSystemPrompt || "").trim(); if (!systemPrompt) return finalQuestion; return [ "请优先遵循以下系统提示词,再回答用户问题:", "", "【系统提示词】", systemPrompt, "", "【用户问题】", finalQuestion, ].join("\n"); } function renderMergePromptTemplate(template, data) { const rawTemplate = String(template || "").trim(); if (!rawTemplate) { return String(data?.answer || "").trim(); } return rawTemplate.replace(/{{\s*(\w+)\s*}}/g, (_, key) => { const value = data && Object.prototype.hasOwnProperty.call(data, key) ? data[key] : ""; return String(value ?? ""); }); } function buildMergedPrompt(contextText, questionText) { return [ CONTEXT_BLOCK_TITLE, contextText, "", QUESTION_BLOCK_TITLE, questionText ].join("\n"); } function isMergedPrompt(text) { if (!text) return false; return text.includes(CONTEXT_BLOCK_TITLE) && text.includes(QUESTION_BLOCK_TITLE); } function extractQuestionTextFromChatInput(rawText) { const text = (rawText || "").trim(); if (!text) return ""; if (!isMergedPrompt(text)) return text; const idx = text.indexOf(QUESTION_BLOCK_TITLE); if (idx < 0) return text; return text.slice(idx + QUESTION_BLOCK_TITLE.length).trim(); } function getQuestionFromChatInput() { if (!isChatGPTPage()) return ""; const inputEl = findChatInputElement(); if (!inputEl) return ""; return extractQuestionTextFromChatInput(readInputText(inputEl)); } function sleep(ms) { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } function pickCurrentChatInput(formEl, fallbackInputEl) { const candidates = [ findChatInputElementInForm(formEl), findChatInputElement(), fallbackInputEl, ]; for (const el of candidates) { if (!(el instanceof Element)) continue; if (!el.isConnected) continue; if (!isElementVisible(el)) continue; return el; } return null; } function isMergedPromptAppliedOnInput(inputEl, expectedQuestionText = "") { if (!(inputEl instanceof Element)) return false; const raw = readInputText(inputEl); if (!isMergedPrompt(raw)) return false; const expected = String(expectedQuestionText || "").trim(); if (!expected) return true; const extracted = extractQuestionTextFromChatInput(raw); return extracted === expected; } async function applyMergedPromptWithRetry( formEl, fallbackInputEl, mergedText, expectedQuestionText ) { const totalAttempts = 4; for (let attempt = 0; attempt < totalAttempts; attempt += 1) { const inputEl = pickCurrentChatInput(formEl, fallbackInputEl); if (!inputEl) { await sleep(80); continue; } setInputText(inputEl, mergedText); await sleep(attempt === 0 ? 80 : 140); const latestInput = pickCurrentChatInput(formEl, inputEl) || inputEl; if (isMergedPromptAppliedOnInput(latestInput, expectedQuestionText)) { return { ok: true, inputEl: latestInput }; } } return { ok: false, inputEl: pickCurrentChatInput(formEl, fallbackInputEl), }; } function rememberAskResult({ notebookId, sourceIds, question, answer, conversationId, rawResponse, }) { const notebook = state.notebooks.find((n) => n.id === notebookId); state.conversationByNotebook[notebookId] = conversationId; if (!state.historyByConversation[conversationId]) { state.historyByConversation[conversationId] = []; } state.historyByConversation[conversationId].push({ query: question, answer, }); state.lastResult = { timestamp: new Date().toISOString(), notebookId, notebookTitle: notebook?.title || "", sourceIds, question, answer, conversationId, rawResponse, }; state.lastResult.chatgptPrompt = buildChatGPTContextPrompt(state.lastResult); return state.lastResult; } function submitChatForm(formEl) { if (!(formEl instanceof HTMLFormElement)) return; if (typeof formEl.requestSubmit === "function") { formEl.requestSubmit(); return; } const submitBtn = formEl.querySelector('button[type="submit"]'); if (submitBtn instanceof HTMLButtonElement) { submitBtn.click(); return; } formEl.submit(); } async function autoAskNotebookForChatSubmit(formEl, inputEl, questionText) { const notebookId = state.selectedNotebookId; let sourceIds = notebookId ? state.sourceSelectionByNotebook[notebookId] || [] : []; if (!notebookId || !questionText) { if (!notebookId) setStatus("请先选择笔记本"); else if (!questionText) setStatus("请先在 ChatGPT 输入框中输入问题"); return; } chatAutoAskInFlight = true; setAskSendFloatingLoading(true); setBusy(true); setStatus("正在自动向 NotebookLM 提问..."); try { sourceIds = await validateSelectedSourceIds(notebookId, sourceIds); if (!sourceIds.length) { setStatus("已移除失效来源,请至少选择一个来源"); return; } const notebookQuestion = buildNotebookQuestion(questionText); const result = await askNotebook({ notebookId, question: notebookQuestion, sourceIds, forceNewConversation: ui.newConversationCheckbox.checked, }); rememberAskResult({ notebookId, sourceIds, question: questionText, answer: result.answer, conversationId: result.conversationId, rawResponse: result.rawText, }); const injected = injectPromptToChatInput(state.lastResult.chatgptPrompt); const finalContext = getChatContextText() || state.lastResult.chatgptPrompt || ""; const merged = buildMergedPrompt(finalContext, questionText); const mergeResult = await applyMergedPromptWithRetry( formEl, inputEl, merged, questionText ); if (!mergeResult.ok) { setStatus("自动提问完成,但拼接未生效,已取消发送"); return; } await sleep(120); const submitForm = mergeResult.inputEl?.closest("form") || formEl; submitChatForm(submitForm); setStatus(injected.ok ? "自动提问完成,已拼接背景并发送" : `自动提问完成,但${injected.message}`); } catch (err) { console.error("[TM NotebookLM]", err); setStatus(`自动提问失败: ${err.message || err}`); } finally { chatAutoAskInFlight = false; setAskSendFloatingLoading(false); setBusy(false); } } function isLikelySendButton(btn) { if (!(btn instanceof HTMLButtonElement)) return false; if ((btn.getAttribute("type") || "").toLowerCase() === "submit") return true; const testid = (btn.getAttribute("data-testid") || "").toLowerCase(); const aria = (btn.getAttribute("aria-label") || "").toLowerCase(); const title = (btn.getAttribute("title") || "").toLowerCase(); if (testid.includes("send")) return true; if (aria.includes("send") || aria.includes("发送")) return true; if (title.includes("send") || title.includes("发送")) return true; return false; } function startChatContextObserver() { if (!isChatGPTPage() || chatContextObserver) return; let scheduled = false; chatContextObserver = new MutationObserver(() => { if (scheduled) return; scheduled = true; window.setTimeout(() => { scheduled = false; ensurePanelUI(); const dockMissing = !ui.chatContextWrap || !ui.chatContextWrap.isConnected; const fabMissing = !ui.chatContextFab || !ui.chatContextFab.isConnected; ensureAskSendFloatingButton(); if (dockMissing || fabMissing) { ensureChatContextDock(); } }, 250); }); chatContextObserver.observe(document.body, { childList: true, subtree: true }); } function buildChatGPTContextPrompt(result) { const template = String(state.mergeInstructionPrompt || ""); const notebookName = result.notebookTitle || result.notebookId || ""; let rendered = renderMergePromptTemplate(template, { notebook: notebookName, notebookTitle: result.notebookTitle || "", notebookId: result.notebookId || "", question: result.question || "", answer: result.answer || "", context: result.answer || "", }).trim(); const hasAnswerPlaceholder = /{{\s*(answer|context)\s*}}/.test(template); if (!hasAnswerPlaceholder && result.answer) { rendered = [rendered, result.answer].filter(Boolean).join("\n\n").trim(); } if (!rendered) return result.answer || ""; if (rendered.includes("{{") || rendered.includes("}}")) { return result.answer || ""; } return rendered; } function injectPromptToChatInput(promptText) { if (!isChatGPTPage()) { return { ok: false, message: "当前页面不是 ChatGPT,未写入背景框" }; } const ok = setChatContextText(promptText); if (!ok) { return { ok: false, message: "未找到背景框挂载位置" }; } return { ok: true, message: "已写入背景框,发送时会自动拼接到问题" }; } async function handleAsk() { const notebookId = state.selectedNotebookId; const question = getQuestionFromChatInput(); const notebookQuestion = buildNotebookQuestion(question); if (!notebookId) { setStatus("请先选择笔记本"); return; } if (!question) { setStatus("请先在 ChatGPT 输入框中输入问题"); return; } let sourceIds = state.sourceSelectionByNotebook[notebookId] || []; setBusy(true); setStatus("正在提问..."); try { sourceIds = await validateSelectedSourceIds(notebookId, sourceIds); if (!sourceIds.length) { setStatus("已移除失效来源,请至少选择一个来源"); return; } const result = await askNotebook({ notebookId, question: notebookQuestion, sourceIds, forceNewConversation: ui.newConversationCheckbox.checked, }); rememberAskResult({ notebookId, sourceIds, question, answer: result.answer, conversationId: result.conversationId, rawResponse: result.rawText, }); const statusParts = ["提问完成"]; const injected = injectPromptToChatInput(state.lastResult.chatgptPrompt); if (injected.ok) statusParts.push("已写入背景框"); else statusParts.push(injected.message); setStatus(statusParts.join(",")); } catch (err) { console.error("[TM NotebookLM]", err); setStatus(`提问失败: ${err.message || err}`); } finally { setBusy(false); } } async function onNotebookChanged() { const notebookId = state.selectedNotebookId; if (!notebookId) { state.sources = []; renderSources(); return; } setStatus("加载来源..."); try { await loadSources(notebookId); setStatus("来源已加载"); } catch (err) { console.error("[TM NotebookLM]", err); state.sources = []; renderSources(); setStatus(`加载来源失败: ${err.message || err}`); } } async function initializeData(forceRefresh = false) { if (forceRefresh) { state.notebooks = []; state.sources = []; renderNotebookOptions(); renderSources(); } setBusy(true); setStatus("加载笔记本..."); try { await loadNotebooks(); if (!state.selectedNotebookId && state.notebooks.length > 0) { state.selectedNotebookId = state.notebooks[0].id; } ui.notebookSelect.value = state.selectedNotebookId; await onNotebookChanged(); setStatus("准备就绪"); } catch (err) { console.error("[TM NotebookLM]", err); setStatus(`初始化失败: ${err.message || err}`); } finally { setBusy(false); persistState(); } } function mount() { loadPersistedState(); injectStyles(); ensurePanelUI(); if (isChatGPTPage()) { ensureAskSendFloatingButton(); bindAskSendFloatingButtonPosition(); ensureChatContextDock(); startChatContextObserver(); } syncPanelVisibility(); void initializeData(false); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", mount, { once: true }); } else { mount(); } })(); // ===== END 2.js ===== // ===== BEGIN 3.js ===== // ==UserScript== // @name Thriller // @namespace http://tampermonkey.net/merged_tools // @version 4.5.2_1.2.0_Merged // @description Thriller // @author Moe // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js // @run-at document-idle // @grant none // ==/UserScript== // ===================== 0. 闂備礁鎲$敮鐐存櫠濡も偓閿曘垽鍩勯崘銊х獮闁哄鐗勯崝宥呪枍韫囨洍鍋撳▓鍨灀闁稿鎹囬弻娑滅疀閺囥劌濮曠紓浣介哺閻熲晛鐣峰Ο琛℃瀻闁逛即娼ф慨銈夋⒑閸濆嫬鏆旈柛搴㈠絻閿曘垽鏁撻悩铏珫?===================== const THRILLER_EXPIRES_AT = '3036-01-31T00:00:00+09:00'; // 失效后是否弹窗提示(true/false) const THRILLER_EXPIRE_ALERT = false; (function thrillerExpiryGuard() { function parseExpires(s) { if (!s) return NaN; // 支持只写日期:'YYYY-MM-DD' -> 默认当天 00:00:00 失效 // 如果你想“到当天结束才失效”,把 00:00:00 改成 23:59:59.999 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { return new Date(`${s}T00:00:00+09:00`).getTime(); // return new Date(`${s}T23:59:59.999+09:00`).getTime(); // ← 当天结束才失效 } const t = new Date(s).getTime(); return t; } const expiresMs = parseExpires(THRILLER_EXPIRES_AT); const expired = Number.isFinite(expiresMs) && Date.now() >= expiresMs; if (expired) { window.__THRILLER_DISABLED__ = true; const msg = `[Thriller] 已到期,脚本已禁用(expiresAt=${THRILLER_EXPIRES_AT})`; console.warn(msg); if (THRILLER_EXPIRE_ALERT) { if (!window.__THRILLER_EXPIRE_ALERTED__) { window.__THRILLER_EXPIRE_ALERTED__ = true; alert('Thriller 脚本已到期,已自动禁用。'); } } } else { window.__THRILLER_DISABLED__ = false; } })(); /** * ============================================================================== * 婵犵妲呴崹顏堝焵椤掑啯鐝柛瀣ㄥ劚閳藉骞橀姘闂備焦瀵х粙鎺楊敄瀹稿獘tGPT 濠电偞鍨堕幐鎾磻閹剧粯鈷戞繛鍡樺劤閺嬫稒銇勯敂钘夌祷閻?(v4.5.2 闂備礁鎲¢崹鐔煎磻閹剧粯鐓曢柡宥冨妿婢а囨煙娓氬灝濡界€垫澘瀚濂稿炊瑜滈弳? * 闂備礁鎲″濠氬窗閺囥垹绀傛慨妞诲亾闁轰礁绉撮~婵嬵敆婢跺﹥顔撻梻?JSON闂備焦瀵х粙鎴︽嚐椤栫倛鍥箵閹规娅g槐鎺懳熸导娆戠闂佽娴烽弫鎼併€佹繝鍥ㄥ剨濞寸姴顑嗛弲顒勬倵濞戞凹娓?濠电偞鍨堕幐鎼佸箹椤愩儯浜归柛娑橈功閻熷湱鎲告惔銊ョ;闁规崘绉ぐ鎺濇晩闁芥ê顦弸娆撴煟? * ============================================================================== */ (() => { 'use strict'; if (window.__THRILLER_DISABLED__) return; console.log('>>> [Module 1] Loading: ChatGPT Revive...'); // ===================== 1. 闂備礁鎼粔鍫曗€﹂崼銏㈢处濡わ絽鍟惌妤呮煕鐏炲墽鈯曟繛?===================== // [设置] 是否默认开启遮罩 (true: 开启, false: 关闭) - 代码内开关 const ENABLE_OVERLAY_DEFAULT = true; const SKIPPED_CONTENT_TYPES = new Set(['thoughts', 'reasoning_recap', 'model_editable_context']); // ===================== 2. 闂備胶顭堢换鍫ュ礉瀹€鍕剳妞ゆ帒瀚崑鎰版煠閸濄儺鏆柛?===================== let loadedMessages = []; let lastThreadTitle = ''; let lastThreadId = 'chat_history'; // ===================== 3. IndexedDB 闂備浇妗ㄩ懗鑸垫櫠濡も偓閻e灚鎷呯憴鍕妳?===================== const IDB_NAME = 'resu_immortal_db'; const IDB_VER = 2; const STORE_THREADS = 'threads'; const STORE_MSG = 'messages'; const STORE_SETTINGS = 'settings'; const KEY_URL_MAP = 'resu_url_mappings'; const KEY_SESSION_MAP = 'resu_session_mappings'; let idb = null; let dbQueue = Promise.resolve(); function withDbLock(fn) { dbQueue = dbQueue.then(fn, fn); return dbQueue; } function idbReq(req) { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function idbOpen() { if (idb) return idb; idb = await new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VER); req.onerror = () => reject(req.error); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE_THREADS)) { const st = db.createObjectStore(STORE_THREADS, { keyPath: 'threadId' }); st.createIndex('updatedAt', 'updatedAt', { unique: false }); } if (!db.objectStoreNames.contains(STORE_MSG)) { const sm = db.createObjectStore(STORE_MSG, { keyPath: 'id', autoIncrement: true }); sm.createIndex('threadId', 'threadId', { unique: false }); sm.createIndex('thread_seq', ['threadId', 'seq'], { unique: true }); } if (!db.objectStoreNames.contains(STORE_SETTINGS)) { db.createObjectStore(STORE_SETTINGS, { keyPath: 'key' }); } }; req.onsuccess = () => resolve(req.result); }); return idb; } async function settingsGet(key) { const db = await idbOpen(); const tx = db.transaction([STORE_SETTINGS], 'readonly'); const st = tx.objectStore(STORE_SETTINGS); const row = await idbReq(st.get(key)); await new Promise((resolve) => { tx.oncomplete = resolve; }); return row ? row.value : null; } async function settingsSet(key, value) { const db = await idbOpen(); const tx = db.transaction([STORE_SETTINGS], 'readwrite'); const st = tx.objectStore(STORE_SETTINGS); st.put({ key, value }); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); } async function ensureThread(threadId, titleFromImport) { const db = await idbOpen(); const tx = db.transaction([STORE_THREADS], 'readwrite'); const st = tx.objectStore(STORE_THREADS); const now = Date.now(); const cur = await idbReq(st.get(threadId)); let next; if (cur) { const hasTitle = cur.title && String(cur.title).trim(); next = { ...cur, title: hasTitle ? cur.title : (titleFromImport || threadId), updatedAt: now }; } else { next = { threadId, title: titleFromImport || threadId, createdAt: now, updatedAt: now, nextSeq: 0 }; } st.put(next); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return next; } async function bumpSeq(threadId) { const db = await idbOpen(); const tx = db.transaction([STORE_THREADS], 'readwrite'); const st = tx.objectStore(STORE_THREADS); const thr = await idbReq(st.get(threadId)); if (!thr) throw new Error('thread not found: ' + threadId); const seq = thr.nextSeq || 0; thr.nextSeq = seq + 1; thr.updatedAt = Date.now(); st.put(thr); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return seq; } async function addMsg(threadId, role, content, ts, source, extra = null) { return withDbLock(async () => { const db = await idbOpen(); await ensureThread(threadId); const seq = await bumpSeq(threadId); const tx = db.transaction([STORE_MSG], 'readwrite'); const sm = tx.objectStore(STORE_MSG); const payload = { threadId, seq, role, content, ts: ts || Date.now(), source: source || 'live', }; if (extra && typeof extra === 'object') { if (typeof extra.sender === 'string' && extra.sender.trim()) payload.sender = extra.sender.trim(); if (typeof extra.thinking === 'string' && extra.thinking.trim()) payload.thinking = extra.thinking.trim(); } const id = await idbReq(sm.add(payload)); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); return id; }); } async function clearThread(threadId) { return withDbLock(async () => { const db = await idbOpen(); const tx = db.transaction([STORE_MSG], 'readwrite'); const sm = tx.objectStore(STORE_MSG); const idx = sm.index('threadId'); await new Promise((resolve, reject) => { const range = IDBKeyRange.only(threadId); const curReq = idx.openCursor(range); curReq.onsuccess = () => { const c = curReq.result; if (!c) return resolve(); c.delete(); c.continue(); }; curReq.onerror = () => reject(curReq.error); }); await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }); const tx2 = db.transaction([STORE_THREADS], 'readwrite'); const st = tx2.objectStore(STORE_THREADS); const thr = await idbReq(st.get(threadId)); if (thr) { thr.nextSeq = 0; thr.updatedAt = Date.now(); st.put(thr); } await new Promise((resolve, reject) => { tx2.oncomplete = resolve; tx2.onerror = () => reject(tx2.error); tx2.onabort = () => reject(tx2.error); }); }); } async function getFullThreadMessages(threadId) { const db = await idbOpen(); const tx = db.transaction([STORE_MSG], 'readonly'); const sm = tx.objectStore(STORE_MSG); const idx = sm.index('thread_seq'); const range = IDBKeyRange.bound([threadId, 0], [threadId, Infinity]); const req = idx.getAll(range); const msgs = await idbReq(req); await new Promise((resolve) => { tx.oncomplete = resolve; }); msgs.sort((a, b) => a.seq - b.seq); return msgs; } // ===================== 4. 闂佽娴烽弫鎼佸储瑜斿畷鐢割敇閵忥紕鍔甸梺鍝勫缁绘帞鏁? ===================== function scrapeNewMessagesFromPage() { const scraped = []; const articles = document.querySelectorAll('article:not([data-resu-injected="true"])'); for (const art of articles) { const userEl = art.querySelector('[data-message-author-role="user"]'); const assistEl = art.querySelector('[data-message-author-role="assistant"]'); let role = ''; if (userEl) role = 'user'; else if (assistEl) role = 'assistant'; else continue; let content = ''; if (role === 'user') { const textDiv = art.querySelector('.whitespace-pre-wrap'); content = textDiv ? textDiv.innerText : art.innerText; } else { const mdDiv = art.querySelector('.markdown'); if (mdDiv) { const clone = mdDiv.cloneNode(true); const details = clone.querySelectorAll('details'); details.forEach(d => d.remove()); content = clone.innerText; } else { if (art.querySelector('.text-red-500')) continue; content = art.innerText; } } content = content.trim(); content = content.replace(/^(?:闁诲骸婀遍…鍫濐嚕閸洖鐒垫い鎺嶈兌缁犳岸鏌嶈閸撴瑩宕滈悜绯穙ught for|Thinking)\s*\d+\s*(?:s|缂傚倷绀侀ˇ鐗堢箾婵垁conds?)\s*(\n+)?/i, ''); const pollutionPatterns = [ /Is this conversation helpful so far\?/i, /这个回答有帮助吗?/i, /Was this response better or worse/i ]; let isTrash = false; for (const p of pollutionPatterns) { if (p.test(content)) { if (content.length < 100) isTrash = true; else content = content.replace(p, ''); } } if (isTrash) continue; content = content.replace(/Copy code|复制代码/gi, ''); if (role === 'assistant') { content = content.replace(/^(ChatGPT|Assistant)([::]|\\s)+/i, ''); } if (role === 'user') { content = content.replace(/^(You|你说|你)([::]|\\s)+/i, ''); } content = content.replace(/\[\d+\]/g, ''); content = content.trim(); if (content) { scraped.push({ role, content }); } } return scraped; } async function exportThread(threadId) { const db = await idbOpen(); await ensureThread(threadId); const txT = db.transaction([STORE_THREADS], 'readonly'); const st = txT.objectStore(STORE_THREADS); const thr = await idbReq(st.get(threadId)); await new Promise((resolve) => { txT.oncomplete = resolve; }); setStatus('⏳ 正在抓取网页新对话…'); const newMessages = scrapeNewMessagesFromPage(); const finalMessages = [...loadedMessages, ...newMessages]; return { title: thr?.title || threadId, exported_at: new Date().toISOString(), total_messages: finalMessages.length, messages: finalMessages.map(m => ({ role: m.role, content: m.content })) }; } // ===================== 5. 闂備礁鎼崐绋棵洪敐鍛瀻闁靛繆鈧磭绐為梺鑽ゅ枑婢瑰棗顫?===================== const FS = { supported: typeof window.showDirectoryPicker === 'function', dirHandle: null, fileList: [] }; async function ensureDirPermission(dirHandle, mode = 'readwrite') { try { const q = await dirHandle.queryPermission({ mode }); if (q === 'granted') return true; const r = await dirHandle.requestPermission({ mode }); return r === 'granted'; } catch { return false; } } async function pickLibraryFolder() { if (!FS.supported) { setStatus('⚠️ 当前浏览器不支持文件夹访问'); return; } try { const dir = await window.showDirectoryPicker(); const ok = await ensureDirPermission(dir, 'readwrite'); if (!ok) { setStatus('⚠️ 权限未授权'); return; } FS.dirHandle = dir; await settingsSet('libraryDir', dir); setStatus('✅ 已选择库文件夹'); await refreshLibraryList(); } catch { setStatus('(已取消选择)'); } } async function loadSavedFolderIfAny() { if (!FS.supported) return; const saved = await settingsGet('libraryDir'); if (!saved) return; FS.dirHandle = saved; const ok = await ensureDirPermission(saved, 'readwrite'); if (!ok) { setStatus('⚠️ 需重新授权文件夹'); return; } await refreshLibraryList(); } async function refreshLibraryList() { if (!FS.dirHandle) { setStatus('⚠️ 未选择文件夹'); return; } const ok = await ensureDirPermission(FS.dirHandle, 'read'); if (!ok) { setStatus('⚠️ 无读取权限'); return; } const list = []; try { for await (const [name, handle] of FS.dirHandle.entries()) { if (handle.kind === 'file' && name.toLowerCase().endsWith('.json')) list.push({ name, handle }); } } catch { setStatus('❌ 遍历文件夹失败'); return; } list.sort((a, b) => a.name.localeCompare(b.name)); FS.fileList = list; updateLibraryMenu(); setStatus(`✅ 库文件夹:${list.length} 个 JSON`); } function updateLibraryMenu() { const sel = $p('#resu-lib-select'); sel.innerHTML = ''; if (!FS.fileList.length) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '(闂備礁鎼崐绋棵洪敐鍛瀻闁靛繆鈧磭绐為梻渚囧亝缁嬫牜绮堢€n剛纾?'; sel.appendChild(opt); return; } for (const f of FS.fileList) { const opt = document.createElement('option'); opt.value = f.name; opt.textContent = f.name; sel.appendChild(opt); } } async function writeJsonToFolderIfPossible(filename, jsonString) { if (!FS.supported || !FS.dirHandle) return false; const ok = await ensureDirPermission(FS.dirHandle, 'readwrite'); if (!ok) return false; try { const fh = await FS.dirHandle.getFileHandle(filename, { create: true }); const w = await fh.createWritable(); await w.write(jsonString); await w.close(); return true; } catch { return false; } } function downloadJson(obj, filename) { const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } // ===================== 6. JSON 闂佽崵鍠愰悷杈╁緤妤e啯鍊甸柦妯侯樈閸熷懘鏌曟径鍫濆姎鐎?===================== function extractTitleFromJson(raw) { try { if (!raw || typeof raw !== 'object') return ''; const candidates = [ raw.title, raw.conversation_title, raw.thread_title, raw.name, raw?.thread?.title, raw?.meta?.title, raw?.metadata?.title, ]; for (const v of candidates) if (typeof v === 'string' && v.trim()) return v.trim(); return ''; } catch { return ''; } } function safeFileName(name) { let s = (name || '').toString().trim(); if (!s) return 'chat_history'; s = s.replace(/[\\/:\*?"<>|]/g, ' '); s = s.replace(/[\u0000-\u001F\u007F]/g, ' '); s = s.replace(/\s+/g, ' ').trim(); s = s.replace(/[. ]+$/g, ''); if (!s) s = 'chat_history'; if (s.length > 120) s = s.slice(0, 120).trim(); return s || 'chat_history'; } function fnv1a32(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0; } return ('0000000' + h.toString(16)).slice(-8); } function parseJsonToMessages(raw) { if (!raw) return []; if (Array.isArray(raw) && raw.length > 0 && raw[0] && raw[0].mapping) { return parseChatGptConversation(raw[0]); } if (raw && typeof raw === 'object' && raw.mapping) { return parseChatGptConversation(raw); } const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.messages) ? raw.messages : []); const out = []; for (const item of arr) { const normalized = normalizeGenericMessage(item); if (normalized) out.push(normalized); } return out; } function normalizeGenericMessage(item) { if (!item || typeof item !== 'object') return null; const roleRaw = typeof item.author === 'string' ? item.author : item.author?.role; const role = normalizeMessageRole(item.role || roleRaw || ''); if (!role) return null; const sender = deriveSenderForGeneric(item, role); const content = extractTextForGeneric(item, role).trim(); if (!content) return null; if (role === 'assistant' && isNonAnswerAssistantContent(content)) return null; return { role, sender, content }; } function deriveSenderForGeneric(item, role) { if (typeof item.sender === 'string' && item.sender.trim()) return item.sender.trim(); if (typeof item.model === 'string' && item.model.trim() && role === 'assistant') { const model = item.model.trim(); const gptMatch = model.match(/gpt-(.+)/i); if (gptMatch) return `GPT-${gptMatch[1]}`; return model; } if (role === 'assistant') return 'assistant'; return 'user'; } function extractThinkingForGeneric(item) { if (typeof item.thinking === 'string' && item.thinking.trim()) return item.thinking.trim(); if (!Array.isArray(item.content)) return ''; const chunks = []; for (const part of item.content) { if (!part || typeof part !== 'object') continue; if (part.type === 'think' && typeof part.think === 'string' && part.think.trim()) { chunks.push(part.think.trim()); } } return chunks.join('\n\n').trim(); } function extractTextForGeneric(item, role) { if (typeof item.content === 'string') return item.content; if (typeof item.text === 'string') return item.text; if (Array.isArray(item.content)) { const chunks = []; for (const part of item.content) { if (typeof part === 'string') { if (part.trim()) chunks.push(part.trim()); continue; } if (!part || typeof part !== 'object') continue; if (part.type === 'text' && typeof part.text === 'string') chunks.push(part.text); else if (typeof part.text === 'string') chunks.push(part.text); else if (typeof part.value === 'string') chunks.push(part.value); } return chunks.join('\n\n'); } if (item.content && typeof item.content === 'object') { if (item.content.content_type) { return formatMessageText({ author: { role }, content: item.content, metadata: item.metadata || {}, }); } if (typeof item.content.text === 'string') return item.content.text; if (Array.isArray(item.content.parts)) return extractTextFromParts(item.content.parts); } if (Array.isArray(item.parts)) return extractTextFromParts(item.parts); return ''; } function parseChatGptConversation(conv) { const mapping = conv?.mapping || {}; const path = resolveConversationPath(conv, mapping); const out = []; for (const id of path) { const node = mapping[id]; const message = node?.message; if (!message) continue; if (shouldSkipMessage(message)) continue; if (!hasRenderablePayload(message)) continue; const role = normalizeMessageRole(message.author?.role || ''); if (!role) continue; if (role === 'assistant' && shouldSkipAssistantInternalMessage(message)) continue; const model = message.metadata?.model_slug || ''; let sender = role; if (role === 'assistant') { const gptMatch = model.match(/gpt-(.+)/i); sender = gptMatch ? `GPT-${gptMatch[1]}` : (model || 'assistant'); } const content = formatMessageText(message).trim(); if (!content) continue; if (role === 'assistant' && isNonAnswerAssistantContent(content)) continue; const ts = parseChatGptTimestamp(message.create_time || conv.create_time) || Date.now(); out.push({ role, sender, content, ts }); } out.sort((a, b) => a.ts - b.ts); return out.map(({ ts, ...rest }) => rest); } function resolveConversationPath(conv, mapping) { const current = conv?.current_node; const path = []; const seen = new Set(); let nodeId = current; while (nodeId && mapping[nodeId] && !seen.has(nodeId)) { seen.add(nodeId); path.push(nodeId); nodeId = mapping[nodeId].parent; } if (path.length > 0) { path.reverse(); return path; } const ids = Object.keys(mapping); ids.sort((a, b) => { const ta = Number(mapping[a]?.message?.create_time || 0); const tb = Number(mapping[b]?.message?.create_time || 0); return ta - tb; }); return ids; } function normalizeMessageRole(role) { const value = String(role || '').toLowerCase().trim(); if (value === 'user') return 'user'; if (value === 'assistant') return 'assistant'; return ''; } function shouldSkipAssistantInternalMessage(messageData) { if (!messageData || messageData.author?.role !== 'assistant') return false; const recipient = String(messageData.recipient || '').trim().toLowerCase(); if (recipient && recipient !== 'all') return true; const channel = String(messageData.channel || '').trim().toLowerCase(); if (channel === 'commentary') return true; const contentType = String(messageData.content?.content_type || '').toLowerCase(); if (contentType === 'execution_output') return true; if (contentType === 'code' && (recipient && recipient !== 'all')) return true; const reasoningStatus = String(messageData.metadata?.reasoning_status || '').toLowerCase(); if (reasoningStatus === 'is_reasoning' && contentType === 'code') return true; return false; } function isNonAnswerAssistantContent(messageText) { const text = String(messageText || '').trim(); if (!text) return false; if (/^Execution Output:/i.test(text)) return true; if (looksLikeToolCallJson(text)) return true; if (looksLikeSandboxPythonScript(text)) return true; if (looksLikeSandboxExecutionTuple(text)) return true; return false; } function stripOuterCodeFence(text) { const source = String(text || '').trim(); const m = source.match(/^```[a-z0-9_-]*\n([\s\S]*?)\n```$/i); return m ? m[1].trim() : source; } function looksLikeToolCallJson(text) { const source = stripOuterCodeFence(text); if (!(source.startsWith('{') && source.endsWith('}'))) return false; let parsed = null; try { parsed = JSON.parse(source); } catch { return false; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; const keys = Object.keys(parsed).map((k) => String(k || '').toLowerCase()); const toolKeys = new Set([ 'image_query', 'search_query', 'open', 'click', 'find', 'screenshot', 'sports', 'finance', 'weather', 'time', 'response_length', 'tool_uses', 'shell_command', 'parallel', ]); return keys.some((k) => toolKeys.has(k) || k.endsWith('_query') || k.endsWith('_queries')); } function looksLikeSandboxPythonScript(text) { const source = stripOuterCodeFence(text); if (!/\/mnt\/data/i.test(source)) return false; if (/^from pathlib import Path\b/m.test(source)) return true; if (/\b(base|path)\s*=\s*Path\(\s*['"]\/mnt\/data['"]\s*\)/i.test(source)) return true; if (/\bwrite_text\(/i.test(source) && /\bPath\(/.test(source)) return true; return false; } function looksLikeSandboxExecutionTuple(text) { const source = stripOuterCodeFence(text); return /^\(\s*['"]\/mnt\/data\/[\s\S]*\)\s*$/i.test(source); } function parseChatGptTimestamp(value) { if (typeof value !== 'number' || Number.isNaN(value)) return null; return value * 1000; } function shouldSkipMessage(messageData) { if (!messageData) return true; if (messageData.author?.role === 'system') return true; if (messageData.metadata?.is_visually_hidden_from_conversation === true) return true; const contentType = messageData.content?.content_type; return SKIPPED_CONTENT_TYPES.has(contentType); } function hasRenderablePayload(messageData) { const content = messageData?.content; if (!content) return false; const contentType = content.content_type; if (contentType === 'code' || contentType === 'execution_output') { return typeof content.text === 'string' && content.text.trim().length > 0; } if (Array.isArray(content.parts)) { return extractTextFromParts(content.parts).trim().length > 0; } if (typeof content.text === 'string' && content.text.trim().length > 0) { return true; } if (Array.isArray(messageData.metadata?.content_references)) { return messageData.metadata.content_references.some((ref) => { return buildReferenceReplacement(ref).trim().length > 0; }); } return false; } function formatMessageText(messageData) { const content = messageData.content || {}; const contentType = content.content_type; const isText = contentType === 'text' || contentType === 'multimodal_text'; let messageText = ''; if (isText && Array.isArray(content.parts)) { messageText = extractTextFromParts(content.parts); } else if (contentType === 'code') { messageText = `\`\`\`${content.language || ''}\n${content.text || ''}\n\`\`\``; } else if (contentType === 'execution_output') { messageText = `Execution Output:\n> ${content.text || ''}`; } else if (Array.isArray(content.parts)) { messageText = extractTextFromParts(content.parts); } else if (typeof content.text === 'string') { messageText = content.text; } else if (Array.isArray(messageData.metadata?.content_references)) { messageText = messageData.metadata.content_references .map((reference) => buildReferenceReplacement(reference)) .filter(Boolean) .join('\n'); } if (messageData.author?.role === 'assistant') { messageText = processAssistantMessage(messageData, messageText); } return String(messageText || '').trim(); } function processAssistantMessage(messageData, messageText) { let result = typeof messageText === 'string' ? messageText : ''; result = processContentReferences(messageData, result); return result; } function extractTextFromParts(parts) { const segments = []; for (const part of parts) { if (typeof part === 'string') { segments.push(part); continue; } if (!part || typeof part !== 'object') continue; const objectPartText = extractObjectPartText(part); if (objectPartText) segments.push(objectPartText); } return joinTextSegments(segments).trim(); } function joinTextSegments(segments) { let out = ''; for (const seg of segments) { const text = String(seg ?? ''); if (!text) continue; if (!out) { out = text; continue; } if (/[ \t\n]$/.test(out) || /^[ \t\n]/.test(text)) { out += text; } else { out += '\n' + text; } } return out; } function extractObjectPartText(part) { if (typeof part.text === 'string') return part.text; if (typeof part.value === 'string') return part.value; if (Array.isArray(part.parts)) return extractTextFromParts(part.parts); const contentType = String(part.content_type || part.type || '').toLowerCase(); const imageUrl = extractFirstUrl([part.url, part.image_url, part.thumbnail_url, part.content_url, part.href]); if (contentType.includes('image')) { if (imageUrl) return ``; const pointer = part.asset_pointer || part.file_id || part.id || ''; return pointer ? `[Image: ${String(pointer)}]` : '[Image]'; } if (contentType.includes('audio')) return '[Audio]'; if (contentType.includes('video')) return '[Video]'; if (contentType.includes('file')) { const fileLabel = part.filename || part.name || part.file_id || part.id || 'file'; return `[File: ${String(fileLabel)}]`; } if (imageUrl) return imageUrl; return ''; } function processContentReferences(messageData, messageText) { const references = messageData.metadata?.content_references; if (!Array.isArray(references) || references.length === 0) return messageText; let result = messageText; const sortedReferences = [...references].sort((a, b) => { const aStart = typeof a?.start_idx === 'number' ? a.start_idx : -1; const bStart = typeof b?.start_idx === 'number' ? b.start_idx : -1; return bStart - aStart; }); for (const reference of sortedReferences) { const matchedText = typeof reference?.matched_text === 'string' ? reference.matched_text : ''; const start = reference?.start_idx; const end = reference?.end_idx; const replacement = buildReferenceReplacement(reference); if (!replacement) { if (matchedText && matchedText.trim().length > 0 && result.includes(matchedText)) { result = result.replace(matchedText, ''); continue; } if ( typeof start === 'number' && typeof end === 'number' && start >= 0 && end >= start && start <= result.length && end <= result.length ) { result = result.slice(0, start) + result.slice(end); } continue; } if (matchedText && matchedText.trim().length > 0 && result.includes(matchedText)) { result = result.replace(matchedText, replacement); continue; } if ( typeof start === 'number' && typeof end === 'number' && start >= 0 && end >= start && start <= result.length && end <= result.length ) { result = result.slice(0, start) + replacement + result.slice(end); continue; } if (matchedText && result.includes(matchedText)) { result = result.replace(matchedText, replacement); } } return result; } function buildReferenceReplacement(reference) { if (!reference || typeof reference !== 'object') return ''; const type = String(reference.type || '').toLowerCase(); const hasImageRef = type === 'image_v2' || (Array.isArray(reference.refs) && reference.refs.some((item) => item?.ref_type === 'image')); if (hasImageRef) return buildImageReferenceReplacement(reference); if (type === 'grouped_webpages') return ''; if (type === 'sources_footnote') return ''; return ''; } function buildImageReferenceReplacement(reference) { const markdownImages = []; const images = Array.isArray(reference.images) ? reference.images : []; for (const image of images.slice(0, 8)) { const src = image?.thumbnail_url || image?.content_url || image?.url; if (!src) continue; const title = sanitizeMarkdownLabel(image?.title || 'image'); const link = extractFirstUrl([image?.url, image?.content_url]); if (link && link !== src) markdownImages.push(`[](${link})`); else markdownImages.push(``); } if (markdownImages.length > 0) return `\n${markdownImages.join('\n')}\n`; if (typeof reference.alt === 'string' && reference.alt.trim()) return reference.alt.trim(); if (typeof reference.prompt_text === 'string' && reference.prompt_text.trim()) { const promptText = reference.prompt_text.replace(/^(\s*##\s*Images:\s*)/i, '').trim(); if (promptText) return `\n${promptText}\n`; } return ''; } function buildGroupedWebpagesReplacement(reference) { const links = []; const items = Array.isArray(reference.items) ? reference.items : []; const fallbackItems = Array.isArray(reference.fallback_items) ? reference.fallback_items : []; for (const item of [...items, ...fallbackItems]) { const url = extractFirstUrl([item?.url]); if (!url) continue; const title = sanitizeMarkdownLabel(item?.title || item?.attribution || 'source'); links.push(`[${title}](${url})`); if (links.length >= 5) break; } if (links.length === 1) return ` (${links[0]})`; if (links.length > 1) return ` (${links.join(' | ')})`; if (typeof reference.alt === 'string' && reference.alt.trim()) return reference.alt.trim(); return ''; } function buildSourcesFootnoteReplacement(reference) { const sources = Array.isArray(reference.sources) ? reference.sources : []; if (sources.length === 0) return ''; const lines = []; for (const source of sources.slice(0, 8)) { const url = extractFirstUrl([source?.url]); if (!url) continue; const title = sanitizeMarkdownLabel(source?.title || source?.attribution || 'source'); lines.push(`- [${title}](${url})`); } if (lines.length === 0) return ''; return `\n\nSources:\n${lines.join('\n')}\n`; } function sanitizeMarkdownLabel(value) { return String(value || '') .replace(/[\[\]\r\n]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function extractFirstUrl(candidates) { for (const candidate of candidates) { if (typeof candidate !== 'string') continue; const url = candidate.trim(); if (/^(https?:\/\/|sandbox:|blob:|data:image\/)/i.test(url)) return url; } return ''; } // ===================== 7. DOM 闂備胶鎳撻悘婵堢矓瀹曞洨绀婇柡鍐ㄧ墕閸愨偓缂備礁顑堝▔鏇熶繆?===================== const sleep = (ms) => new Promise(r => setTimeout(r, ms)); async function renderLoadedMessagesOnPage(messages, timeoutMs = 10000) { const start = Date.now(); let lastErr = null; while (Date.now() - start < timeoutMs) { if (canRenderVisualHistory()) { try { renderVisualHistory(messages); return; } catch (err) { lastErr = err; } } await sleep(120); } if (lastErr) throw lastErr; throw new Error('未找到可渲染区域'); } // ===================== 8. 婵犵數鍋為幐绋款嚕閸洘鍋傞悗锝庝憾閸ゆ洟鏌¢崶鈺佹瀾闁?===================== function findHistoryHostNode() { const firstArticle = document.querySelector('main article[data-turn-id], main article'); if (firstArticle?.parentElement) return firstArticle.parentElement; return ( document.querySelector('main #thread') || document.querySelector('main') || document.body ); } function ensureVisualHistoryCanvas() { const host = findHistoryHostNode(); if (!host) return null; let canvas = document.getElementById('resu-lc-thread'); if (!canvas) { canvas = document.createElement('section'); canvas.id = 'resu-lc-thread'; canvas.className = 'resu-lc-thread'; canvas.setAttribute('data-resu-injected', 'true'); if (host.firstChild) host.insertBefore(canvas, host.firstChild); else host.appendChild(canvas); } return canvas; } function canRenderVisualHistory() { return !!findHistoryHostNode(); } function ensureKatexStylesheet() { if (document.getElementById('resu-katex-css')) return; const style = document.createElement('style'); style.id = 'resu-katex-css'; style.textContent = ` .resu-lc-math-block math { display: block; overflow-x: auto; } .resu-lc-markdown math { font-size: 1.04em; } `; document.head.appendChild(style); } function renderVisualHistory(messages) { ensureKatexStylesheet(); const canvas = ensureVisualHistoryCanvas(); if (!canvas) { console.warn('[Resu] render failed: mount not found'); return; } const normalized = Array.isArray(messages) ? messages : []; canvas.innerHTML = ''; const frag = document.createDocumentFragment(); for (const raw of normalized) { const role = normalizeMessageRole(raw?.role || raw?.author || ''); if (!role) continue; const content = String(raw?.content || '').trim(); const sender = (typeof raw?.sender === 'string' && raw.sender.trim()) ? raw.sender.trim() : (role === 'assistant' ? 'assistant' : 'anonymous'); if (!content) continue; if (role === 'assistant' && isNonAnswerAssistantContent(content)) continue; if (role === 'user') { frag.appendChild(createUserTurnNode(content)); continue; } frag.appendChild(createAssistantTurnNode({ role, sender, content })); } canvas.appendChild(frag); } function createUserTurnNode(content) { const turn = document.createElement('article'); turn.className = 'resu-lc-turn resu-lc-turn-user group'; turn.setAttribute('data-resu-injected', 'true'); turn.innerHTML = ` <div class="resu-lc-msg-shell"> <div class="resu-lc-msg-inner"> <div class="resu-lc-msg-main"> <div class="resu-lc-text-message" dir="auto"> <div class="resu-lc-markdown">${renderMarkdownMessage(content || '')}</div> </div> </div> </div> </div> `; bindTurnActions(turn); return turn; } function createAssistantTurnNode(msg) { const turn = document.createElement('article'); turn.className = 'resu-lc-turn resu-lc-turn-assistant group'; turn.setAttribute('data-resu-injected', 'true'); turn.innerHTML = ` <div class="resu-lc-msg-shell"> <div class="resu-lc-msg-inner"> <div class="resu-lc-msg-main resu-lc-agent-turn"> <div class="resu-lc-text-message" dir="auto"> <div class="resu-lc-markdown">${renderMarkdownMessage(msg.content || '')}</div> </div> </div> </div> </div> `; bindTurnActions(turn); return turn; } function renderCopyIconSvg() { return ` <svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="none" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="icon-md-heavy" aria-hidden="true"> <path fill="currentColor" fill-rule="evenodd" d="M7 5a3 3 0 0 1 3-3h9a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-2v2a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3h2zm2 2h5a3 3 0 0 1 3 3v5h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1zM5 9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-9a1 1 0 0 0-1-1z" clip-rule="evenodd"></path> </svg> `; } function bindTurnActions(root) { const codeButtons = root.querySelectorAll('.resu-copy-code'); codeButtons.forEach((btn) => { btn.addEventListener('click', async () => { const encoded = btn.getAttribute('data-resu-code') || ''; let decoded = ''; try { decoded = decodeURIComponent(encoded); } catch { decoded = encoded; } await copyTextToClipboard(decoded); btn.classList.add('is-copied'); const label = btn.querySelector('.resu-copy-code-live'); if (label) label.textContent = 'Copied'; setTimeout(() => { btn.classList.remove('is-copied'); const resetLabel = btn.querySelector('.resu-copy-code-live'); if (resetLabel) resetLabel.textContent = 'Copy code'; }, 1300); }); }); } async function copyTextToClipboard(text) { const value = String(text || ''); if (!value) return; try { if (navigator?.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; } } catch {} try { const ta = document.createElement('textarea'); ta.value = value; ta.setAttribute('readonly', 'readonly'); ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); ta.remove(); } catch {} } function renderMarkdownMessage(markdownText) { const text = String(markdownText || '').replace(/\r\n?/g, '\n'); if (!text.trim()) return ''; const codeBlocks = []; const withCodePlaceholders = text.replace(/```([^\n`]*)\n([\s\S]*?)```/g, (_m, lang, code) => { const token = `@@RESU_CODE_${codeBlocks.length}@@`; codeBlocks.push({ lang: String(lang || '').trim(), code: String(code || '') }); return `\n${token}\n`; }); const lines = withCodePlaceholders.split('\n'); const html = []; let i = 0; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); if (!trimmed) { i++; continue; } const codeToken = trimmed.match(/^@@RESU_CODE_(\d+)@@$/); if (codeToken) { const idx = Number(codeToken[1]); const block = codeBlocks[idx]; const langRaw = String(block?.lang || 'unknown').trim() || 'unknown'; const lang = escapeHtml(langRaw); const code = escapeHtml(block?.code || ''); const encoded = escapeAttribute(encodeURIComponent(block?.code || '')); html.push(` <div class="resu-code-root relative w-full rounded-md bg-gray-900 text-xs text-white/80"> <div class="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200 dark:bg-gray-700"> <span class="">${lang}</span> <div class="flex items-center justify-center gap-4"> <button type="button" class="resu-copy-code ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white" data-resu-code="${encoded}" aria-label="Copy code"> ${renderCopyIconSvg()} <span class="relative"> <span class="invisible">Copy code</span> <span class="resu-copy-code-live absolute inset-0 flex items-center">Copy code</span> </span> </button> </div> </div> <div class="overflow-y-auto p-4"> <code class="hljs language-${lang} !whitespace-pre resu-code-content">${code}</code> </div> <div class="resu-code-float absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150 pointer-events-none opacity-0"> <button type="button" class="resu-copy-code cursor-pointer flex items-center justify-center rounded p-1.5 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white" data-resu-code="${encoded}" aria-label="Copy code"> ${renderCopyIconSvg()} </button> </div> </div> `); i++; continue; } const gallery = []; let j = i; while (j < lines.length) { const parsed = parseImageMarkdownLine(lines[j].trim()); if (!parsed) break; gallery.push(parsed); j++; } if (gallery.length > 0) { html.push(renderImageGallery(gallery)); i = j; continue; } if (trimmed === '\\[') { const mathLines = []; let k = i + 1; while (k < lines.length && lines[k].trim() !== '\\]') { mathLines.push(lines[k]); k++; } if (k < lines.length && lines[k].trim() === '\\]') { html.push(renderMathBlock(mathLines.join('\n'))); i = k + 1; continue; } } if (trimmed === '$$') { const mathLines = []; let k = i + 1; while (k < lines.length && lines[k].trim() !== '$$') { mathLines.push(lines[k]); k++; } if (k < lines.length && lines[k].trim() === '$$') { html.push(renderMathBlock(mathLines.join('\n'))); i = k + 1; continue; } } const singleBlockMath = trimmed.match(/^\\\[(.+)\\\]$/); if (singleBlockMath) { html.push(renderMathBlock(singleBlockMath[1])); i++; continue; } const heading = trimmed.match(/^(#{1,6})\s+(.+)$/); if (heading) { const level = Math.max(1, Math.min(6, heading[1].length)); html.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`); i++; continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { html.push('<hr />'); i++; continue; } if (trimmed.startsWith('>')) { const quoteLines = []; while (i < lines.length && lines[i].trim().startsWith('>')) { quoteLines.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html.push(`<blockquote>${quoteLines.map((q) => renderInlineMarkdown(q)).join('<br />')}</blockquote>`); continue; } if (isTableHeader(lines, i)) { const { tableHtml, nextIndex } = consumeTable(lines, i); html.push(tableHtml); i = nextIndex; continue; } if (/^[-*+]\s+/.test(trimmed)) { const { listHtml, nextIndex } = consumeList(lines, i, 'ul'); html.push(listHtml); i = nextIndex; continue; } if (/^\d+\.\s+/.test(trimmed)) { const { listHtml, nextIndex } = consumeList(lines, i, 'ol'); html.push(listHtml); i = nextIndex; continue; } const paraLines = []; while (i < lines.length && !isBlockStart(lines[i])) { if (lines[i].trim()) paraLines.push(lines[i]); i++; } html.push(`<p>${paraLines.map((p) => renderInlineMarkdown(p)).join('<br />')}</p>`); } return html.join('\n'); } function renderMathBlock(expression) { const mathHtml = renderMathExpression(expression, true); return `<div class="resu-lc-math-block">${mathHtml}</div>`; } function renderMathExpression(expression, displayMode = false) { const expr = String(expression || '').trim(); if (!expr) return ''; const katexLib = window?.katex; if (katexLib && typeof katexLib.renderToString === 'function') { try { return katexLib.renderToString(expr, { throwOnError: false, strict: 'ignore', displayMode, // Use MathML to avoid relying on external KaTeX CSS blocked by CSP. output: 'mathml', }); } catch {} } return `<code class="resu-lc-math-raw">${escapeHtml(expr)}</code>`; } function parseImageMarkdownLine(line) { if (!line) return null; const linked = line.match(/^\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)$/); if (linked) { const alt = linked[1] || 'image'; const src = safeUrl(linked[2]); const href = safeUrl(linked[3]); if (!src) return null; return { alt, src, href: href || src }; } const plain = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); if (plain) { const alt = plain[1] || 'image'; const src = safeUrl(plain[2]); if (!src) return null; return { alt, src, href: src }; } return null; } function renderImageGallery(images) { const items = images.map((img) => { const alt = escapeHtml(img.alt || 'image'); const src = escapeAttribute(img.src); const href = escapeAttribute(img.href || img.src); return `<a href="${href}" target="_blank" rel="noopener noreferrer"><img src="${src}" alt="${alt}" loading="lazy" /></a>`; }); return `<div class="resu-lc-gallery">${items.join('')}</div>`; } function consumeList(lines, startIndex, listType) { const isOrdered = listType === 'ol'; const chunks = []; let i = startIndex; while (i < lines.length) { const trimmed = lines[i].trim(); if (!trimmed) break; const marker = isOrdered ? /^\d+\.\s+(.+)$/ : /^[-*+]\s+(.+)$/; const m = trimmed.match(marker); if (!m) break; chunks.push(`<li>${renderInlineMarkdown(m[1])}</li>`); i++; } return { listHtml: `<${listType}>${chunks.join('')}</${listType}>`, nextIndex: i }; } function isTableHeader(lines, index) { if (index + 1 >= lines.length) return false; const header = lines[index].trim(); const sep = lines[index + 1].trim(); if (!header.includes('|')) return false; return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(sep); } function splitTableRow(line) { const raw = line.trim().replace(/^\|/, '').replace(/\|$/, ''); return raw.split('|').map((v) => v.trim()); } function consumeTable(lines, startIndex) { const headCells = splitTableRow(lines[startIndex]); let i = startIndex + 2; const bodyRows = []; while (i < lines.length) { const line = lines[i].trim(); if (!line || !line.includes('|')) break; bodyRows.push(splitTableRow(line)); i++; } const headHtml = `<tr>${headCells.map((cell) => `<th>${renderInlineMarkdown(cell)}</th>`).join('')}</tr>`; const bodyHtml = bodyRows .map((row) => `<tr>${row.map((cell) => `<td>${renderInlineMarkdown(cell)}</td>`).join('')}</tr>`) .join(''); return { tableHtml: `<div class="resu-lc-table-wrap"><table><thead>${headHtml}</thead><tbody>${bodyHtml}</tbody></table></div>`, nextIndex: i, }; } function isBlockStart(line) { const trimmed = line.trim(); if (!trimmed) return true; if (/^@@RESU_CODE_(\d+)@@$/.test(trimmed)) return true; if (/^(#{1,6})\s+/.test(trimmed)) return true; if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) return true; if (trimmed === '\\[' || trimmed === '$$' || /^\\\[(.+)\\\]$/.test(trimmed)) return true; if (trimmed.startsWith('>')) return true; if (/^[-*+]\s+/.test(trimmed)) return true; if (/^\d+\.\s+/.test(trimmed)) return true; if (parseImageMarkdownLine(trimmed)) return true; return false; } function renderInlineMarkdown(text) { const source = String(text || ''); const mathTokens = []; let prepared = source.replace(/\\\(([\s\S]+?)\\\)/g, (_m, expr) => { const token = `@@RESU_MATH_${mathTokens.length}@@`; mathTokens.push(String(expr || '').trim()); return token; }); prepared = prepared.replace(/(^|[^\\])\$([^\n$]+?)\$/g, (_m, prefix, expr) => { const token = `@@RESU_MATH_${mathTokens.length}@@`; mathTokens.push(String(expr || '').trim()); return `${prefix}${token}`; }); let out = escapeHtml(prepared); out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => { const src = safeUrl(url); if (!src) return escapeHtml(_m); return `<img src="${escapeAttribute(src)}" alt="${alt || 'image'}" loading="lazy" />`; }); out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => { const href = safeUrl(url); if (!href) return escapeHtml(_m); return `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">${label}</a>`; }); out = out.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`); out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>'); out = out.replace(/~~([^~]+)~~/g, '<del>$1</del>'); out = out.replace(/@@RESU_MATH_(\d+)@@/g, (_m, idxText) => { const idx = Number(idxText); if (!Number.isFinite(idx) || idx < 0 || idx >= mathTokens.length) return ''; return renderMathExpression(mathTokens[idx], false); }); return out; } function safeUrl(value) { const url = String(value || '').trim(); if (/^(https?:\/\/|sandbox:|blob:|data:image\/)/i.test(url)) return url; if (url.startsWith('/')) return url; return ''; } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function escapeAttribute(value) { return escapeHtml(value).replace(/`/g, '`'); } // ===================== 9. 闂備礁缍婇弲鎻掝渻閹烘梻涓嶆繛鍡樻尭缁€宀勬煛瀹ュ繐顩柣鐔村灪缁绘盯骞橀搹顐㈡闂?===================== function getConversationIdFromPath(pathname = window.location.pathname) { const text = String(pathname || ''); const m = text.match(/\/c\/([^/?#]+)/i); return m ? m[1] : ''; } function normalizeConversationPath(pathname = window.location.pathname) { const conversationId = getConversationIdFromPath(pathname); return conversationId ? `/c/${conversationId}` : ''; } async function bindUrlToThread(threadId) { const currentPath = normalizeConversationPath(window.location.pathname); const conversationId = getConversationIdFromPath(currentPath); if (!conversationId) { setStatus('⚠️ 当前页面没有会话ID,无法绑定'); return; } const savedByPath = await settingsGet(KEY_URL_MAP) || {}; savedByPath[currentPath] = threadId; await settingsSet(KEY_URL_MAP, savedByPath); const savedBySession = await settingsGet(KEY_SESSION_MAP) || {}; savedBySession[conversationId] = threadId; await settingsSet(KEY_SESSION_MAP, savedBySession); console.log(`[Resu] Bound Session ${conversationId} (${currentPath}) to Thread ${threadId}`); setStatus('✅ 已绑定会话ID,刷新可自动恢复'); } async function checkAndAutoRender() { const currentPath = normalizeConversationPath(window.location.pathname); const conversationId = getConversationIdFromPath(currentPath); if (!conversationId) return; const savedByPath = await settingsGet(KEY_URL_MAP) || {}; const savedBySession = await settingsGet(KEY_SESSION_MAP) || {}; const threadId = savedByPath[currentPath] || savedBySession[conversationId]; if (!threadId) return; // 兼容旧数据:如果只命中了 session 映射,补写路径映射,便于后续快速恢复 if (!savedByPath[currentPath]) { savedByPath[currentPath] = threadId; await settingsSet(KEY_URL_MAP, savedByPath); } if (document.getElementById('resu-lc-thread')) return; console.log(`[Resu] Found auto-restore for session ${conversationId} -> ${threadId}`); setStatus('⏳ 正在自动恢复历史...'); try { const msgs = await getFullThreadMessages(threadId); if (msgs && msgs.length > 0) { loadedMessages = msgs; lastThreadId = threadId; let retries = 0; const autoTimer = setInterval(() => { retries++; if (canRenderVisualHistory()) { clearInterval(autoTimer); renderVisualHistory(msgs); setStatus(`✅ 自动恢复成功 (${msgs.length}条)`); } else if (retries > 60) { clearInterval(autoTimer); setStatus('⚠️ 自动恢复等待中 (未找到模板)'); } }, 250); } } catch (e) { console.error(e); setStatus('❌ 自动恢复出错'); } } let lastUrl = location.href; new MutationObserver(() => { const url = location.href; if (url !== lastUrl) { lastUrl = url; setTimeout(checkAndAutoRender, 1500); } }).observe(document, { subtree: true, childList: true }); // ===================== 10. 本地渲染主流程 ===================== async function renderOnlyFlow(messages) { showOverlay('正在渲染…', '正在将存档渲染到当前页面...'); await renderLoadedMessagesOnPage(messages, 10000); try { await bindUrlToThread(lastThreadId); } catch (e) { console.warn('[Resu] 会话ID绑定失败(不影响渲染):', e); } return { totalParts: 1 }; } // ===================== 11. 闂備礁婀遍搹搴ㄥ储妤e喛缍栭柟鐑樺焾濞尖晠鏌涘┑鍕姕闁哄懏鎮傞獮?===================== async function seedFromLoadedJson(threadId, msgs, titleFromImport) { await idbOpen(); await ensureThread(threadId, titleFromImport); await clearThread(threadId); for (const m of msgs) { await addMsg(threadId, m.role, m.content, Date.now(), 'base', { sender: m.sender || '', }); } } async function loadJsonObjectToState(raw, inferredName) { const msgs = parseJsonToMessages(raw); if (!msgs.length) { setStatus('❌ 解析失败(无消息)'); loadedMessages = []; $p('#resu-preview').value = ''; disableActions(); return false; } const importedTitle = extractTitleFromJson(raw) || inferredName || 'chat_history'; const base = safeFileName(importedTitle); const hint = `${inferredName || ''}|${raw?.exported_at || ''}|${msgs.length}|${JSON.stringify(raw)?.length || 0}`; const threadId = `${base}__${fnv1a32(hint)}`; loadedMessages = msgs; lastThreadTitle = importedTitle; lastThreadId = threadId; const jsonStr = JSON.stringify(msgs); $p('#resu-preview').value = `已载入:${loadedMessages.length} 条消息\n` + `标题:${importedTitle}\n` + `模式:本地渲染(不发送消息) + 自动恢复\n` + `压缩后体积:${jsonStr.length} 字符\n`; setStatus('⏳ 写入库…'); await seedFromLoadedJson(threadId, loadedMessages, importedTitle); setStatus(`✅ 已载入:${loadedMessages.length} 条`); enableActions(); return true; } // ===================== 12. UI 构建 (重构版) ===================== // 注入样式 const styleEl = document.createElement('style'); styleEl.textContent = ` .resu-wrapper { --resu-poke-red: #6942d6; --resu-poke-red-dark: #3e277f; --resu-poke-yellow: #ff63d4; --resu-poke-yellow-dark: #ca43aa; --resu-poke-blue: #88d9ff; --resu-poke-blue-dark: #3f77c7; --resu-poke-ink: #130f24; --resu-poke-cream: #edf7ff; --resu-poke-border: #090b14; font-family: "Trebuchet MS", "Segoe UI", Tahoma, sans-serif; } .resu-toggle-btn { position: fixed; top: 50%; right: 14px; transform: translateY(-50%); width: 60px; height: 60px; border: 4px solid var(--resu-poke-border); border-radius: 999px; cursor: pointer; z-index: 999990; display: flex; align-items: center; justify-content: center; background: radial-gradient(circle at 27% 21%, #ff63d4 0 12px, transparent 13px), radial-gradient(circle at 73% 21%, #ff63d4 0 12px, transparent 13px), linear-gradient(180deg, var(--resu-poke-red) 0 46%, var(--resu-poke-border) 46% 53%, var(--resu-poke-cream) 53% 100%); box-shadow: 0 10px 20px rgba(0,0,0,0.38), inset 0 0 0 2px rgba(255,255,255,0.22); transition: transform 0.18s ease, filter 0.2s ease; overflow: hidden; } .resu-toggle-btn::before { content: ''; position: absolute; width: 20px; height: 20px; border-radius: 50%; border: 3px solid var(--resu-poke-border); background: #dff4ff; top: 50%; left: 50%; transform: translate(-50%, -50%); box-shadow: inset 0 0 0 3px #a8d8ef; } .resu-toggle-btn::after { content: 'M'; position: absolute; top: 8px; left: 50%; transform: translateX(-50%); color: #dee3ff; font-size: 15px; font-weight: 900; letter-spacing: 0.5px; text-shadow: 0 1px 0 rgba(0,0,0,0.45); } .resu-toggle-btn svg { display: none; } .resu-toggle-btn:hover { transform: translateY(-50%) scale(1.06); filter: saturate(1.08); } .resu-panel { position: fixed; top: 10px; right: 10px; width: min(356px, calc(100vw - 20px)); color: var(--resu-poke-ink); z-index: 999999; border: 4px solid var(--resu-poke-border); border-radius: 10px; background: linear-gradient(180deg, var(--resu-poke-red) 0 56px, #160f31 56px 63px, var(--resu-poke-cream) 63px 100%); box-shadow: 0 16px 44px rgba(0,0,0,0.4), inset 0 0 0 2px rgba(255,255,255,0.24); display: none; flex-direction: column; padding: 10px 12px 12px; animation: resuSlideIn 0.28s cubic-bezier(0.22, 1, 0.36, 1); overflow: hidden; isolation: isolate; gap: 6px; } .resu-panel::before { content: ''; position: absolute; inset: 5px; border: 2px solid rgba(255,255,255,0.58); border-radius: 6px; pointer-events: none; opacity: 0.45; } .resu-panel::after { content: ''; position: absolute; left: 12px; right: 12px; top: 61px; height: 1px; background: rgba(255,255,255,0.35); pointer-events: none; } @keyframes resuSlideIn { from { opacity: 0; transform: translateX(20px) scale(0.97); } to { opacity: 1; transform: translateX(0) scale(1); } } .resu-header { display: flex; justify-content: space-between; align-items: center; margin: -10px -12px 4px -12px; padding: 7px 10px 8px; border-bottom: 2px solid rgba(255,255,255,0.2); background: linear-gradient(180deg, rgba(255,255,255,0.09) 0%, rgba(255,255,255,0) 100%); color: #fff; } .resu-title { display: inline-flex; align-items: center; gap: 7px; font-weight: 900; font-size: 14px; color: #f6f8ff; letter-spacing: 0.25px; text-transform: none; text-shadow: 0 2px 0 rgba(0,0,0,0.3); } .resu-title::before { content: ''; width: 14px; height: 14px; border-radius: 50%; border: 2px solid #1d103b; background: linear-gradient(180deg, #a78cff 0 48%, #1d103b 48% 58%, #dcecff 58% 100%); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.35); flex: 0 0 14px; } .resu-close { border: 3px solid var(--resu-poke-border); background: #dff2ff; color: var(--resu-poke-red-dark); cursor: pointer; width: 30px; height: 30px; border-radius: 999px; font-weight: 900; line-height: 1; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 2px 0 rgba(0,0,0,0.28); } .resu-close:hover { background: #cce7fb; } .resu-section-label { color: #f7f1ff; font-size: 10px; margin: 4px 0 4px 0; font-weight: 900; text-transform: uppercase; letter-spacing: 0.9px; background: linear-gradient(180deg, #7f62e3 0%, #4f36a8 100%); border: 2px solid #261453; border-radius: 6px; padding: 4px 8px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.3); } .resu-btn { border: 3px solid #120d26; border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 900; padding: 9px 12px; display: flex; align-items: center; justify-content: center; gap: 6px; transition: transform 0.14s ease, box-shadow 0.14s ease, filter 0.2s ease; box-shadow: inset 0 1px 0 rgba(255,255,255,0.35), 0 3px 0 rgba(0,0,0,0.25); text-shadow: 0 1px 0 rgba(0,0,0,0.26); } .resu-btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; } .resu-btn:hover:not(:disabled) { transform: translateY(-1px); filter: saturate(1.08) brightness(1.02); } .resu-btn:active:not(:disabled) { transform: translateY(1px); box-shadow: inset 0 1px 0 rgba(255,255,255,0.25), 0 1px 0 rgba(0,0,0,0.28); } .resu-btn-primary { background: linear-gradient(180deg, var(--resu-poke-yellow) 0%, var(--resu-poke-yellow-dark) 100%); color: #fff; width: 100%; margin-top: 0; padding: 10px; font-size: 13px; } .resu-btn-secondary { background: linear-gradient(180deg, #7a59e3 0%, var(--resu-poke-red-dark) 100%); color: #fff; } .resu-btn-danger { background: linear-gradient(180deg, #9245d6 0%, #6c2fa8 100%); color: #fff; } .resu-btn-export { background: linear-gradient(180deg, var(--resu-poke-blue) 0%, var(--resu-poke-blue-dark) 100%); color: #fff; } .resu-row { display: flex; gap: 8px; align-items: stretch; } .resu-row-tight { margin-top: 10px; } .resu-card .resu-row:first-child { margin-bottom: 8px; } .resu-row > .resu-btn { min-height: 44px; } .resu-btn-fill { flex: 1; } .resu-btn-block { width: 100%; } .resu-btn-icon { width: 48px; flex: 0 0 48px; padding: 0; font-size: 15px; } .resu-panel-body { display: flex; flex-direction: column; gap: 10px; margin-top: 2px; } .resu-card { border: 2px solid #2b1c59; border-radius: 8px; background: linear-gradient(180deg, rgba(255,255,255,0.32) 0%, rgba(255,255,255,0.18) 100%); padding: 9px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.3); } .resu-card-status { padding: 6px 8px 8px; } .resu-divider { height: 2px; border-radius: 2px; background: linear-gradient(90deg, rgba(80,55,170,0.1) 0%, rgba(80,55,170,0.85) 18%, rgba(80,55,170,0.85) 82%, rgba(80,55,170,0.1) 100%); margin: 1px 4px 0; } .resu-select { width: 100%; background: linear-gradient(180deg, #ffffff 0%, #e9f4ff 100%); color: #151734; border: 3px solid #2e205e; padding: 8px 10px; border-radius: 8px; outline: none; margin: 8px 0; box-shadow: inset 0 1px 0 rgba(255,255,255,0.92), 0 2px 0 rgba(0,0,0,0.15); font-weight: 700; min-height: 44px; } #resu-status { font-size: 11px; color: #2f2b66; text-align: left; margin-top: 0; height: 20px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; border: 2px solid #2b1c59; border-radius: 6px; background: linear-gradient(180deg, #f7fcff 0%, #dcecff 100%); padding: 0 8px; } .resu-debug { margin-top: 6px; } #resu-preview { width: 100%; height: 100px; background: #1a1431; border: 3px solid #2f2259; color: #c5eeff; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; font-size: 10px; padding: 8px; border-radius: 8px; resize: none; outline: none; box-shadow: inset 0 0 0 1px rgba(168,216,239,0.25); } details > summary { color: #2f2b66; font-size: 11px; cursor: pointer; margin-top: 4px; list-style: none; font-weight: 800; border: 2px solid #2b1c59; border-radius: 6px; background: linear-gradient(180deg, #f7fbff 0%, #ddeeff 100%); padding: 4px 8px; display: inline-block; } .resu-debug > summary { margin-top: 0; } .resu-panel > * { position: relative; z-index: 1; } details > summary:hover { color: #fff; background: linear-gradient(180deg, #7e63e3 0%, #5037aa 100%); } .resu-lc-thread { --resu-lc-text: #ececf1; --resu-lc-text-secondary: #a1a1aa; --resu-lc-border: #3f3f46; --resu-lc-surface: #171717; --resu-lc-surface-hover: #262626; padding: 6px 0 26px; color: var(--resu-lc-text); } .resu-lc-turn { width: 100%; margin: 0; color: inherit; padding: 0.2rem 0; } .resu-lc-msg-shell { width: 100%; border: 0; background: transparent; } .resu-lc-msg-inner { position: relative; margin: 0 auto; display: flex; width: 100%; padding: 0.08rem 1rem; justify-content: center; } @media (min-width: 640px) { .resu-lc-msg-inner { padding: 0.08rem 1.5rem; } } @media (min-width: 1024px) { .resu-lc-msg-inner { padding: 0.08rem 4rem; } } .resu-lc-msg-main { position: relative; display: flex; width: 100%; max-width: 48rem; min-width: 0; flex-direction: column; gap: 0.15rem; } .resu-lc-turn-user .resu-lc-msg-main { width: 100%; align-items: flex-end; } .resu-lc-turn-assistant .resu-lc-msg-main { width: 100%; align-items: flex-start; } .resu-lc-sender { display: none; } .resu-lc-text-message { display: flex; min-height: 18px; flex-direction: column; align-items: stretch; width: 100%; gap: 0.4rem; overflow: visible; } .resu-lc-turn-user .resu-lc-text-message { align-items: flex-end; } .resu-lc-turn-assistant .resu-lc-text-message { align-items: flex-start; } .resu-lc-markdown { width: 100%; max-width: 100%; word-break: break-word; line-height: 1.62; font-size: 0.99rem; color: var(--resu-lc-text); border: 0; border-radius: 0; padding: 0; box-shadow: none; background: transparent; } .resu-lc-turn-assistant .resu-lc-markdown { width: 100%; background: transparent; border: 0; border-radius: 0; padding: 0; box-shadow: none; margin-right: auto; } .resu-lc-turn-user .resu-lc-markdown { width: fit-content; max-width: min(100%, var(--resu-user-chat-width, 70%)); background: #323232d9; color: #ececec; border: 0; border-radius: 18px; padding: 0.62rem 1rem; box-shadow: none; margin-left: auto; } .resu-lc-markdown p { margin: 0 0 0.5rem 0; white-space: pre-wrap; } .resu-lc-markdown p:last-child { margin-bottom: 0; } .resu-lc-markdown h1, .resu-lc-markdown h2, .resu-lc-markdown h3, .resu-lc-markdown h4, .resu-lc-markdown h5, .resu-lc-markdown h6 { margin: 1rem 0 0.5rem 0; color: #f8fafc; line-height: 1.28; font-weight: 700; } .resu-lc-markdown h1 { font-size: 1.35rem; } .resu-lc-markdown h2 { font-size: 1.2rem; } .resu-lc-markdown h3 { font-size: 1.08rem; } .resu-lc-markdown ul, .resu-lc-markdown ol { margin: 0 0 0.65rem 1.2rem; } .resu-lc-markdown li { margin: 0.2rem 0; } .resu-lc-markdown img { display: block; max-width: min(420px, 100%); height: auto; border-radius: 0.75rem; border: 1px solid #3f3f46; margin: 0.2rem 0; } .resu-lc-markdown a { color: #8ab4ff; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(138,180,255,0.5); } .resu-lc-markdown a:hover { color: #b6d1ff; } .resu-lc-markdown code:not(.resu-code-content) { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; background: #202123; border: 1px solid #3f3f46; border-radius: 0.38rem; padding: 0.06rem 0.3rem; font-size: 0.9em; color: #e5e7eb; } .resu-lc-markdown pre { width: 100%; margin: 0 0 0.75rem 0; padding: 0; border: 0; background: transparent; overflow: visible; } .resu-code-root { margin: 0 0 0.75rem 0; } .resu-code-root .icon-md-heavy { stroke-width: 2; width: 1.125rem; height: 1.125rem; } .resu-code-content { display: block; white-space: pre !important; font-size: calc(0.85 * var(--markdown-font-size, 1rem)); line-height: 1.5; color: inherit; background: transparent !important; border: 0 !important; padding: 0 !important; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; } .resu-code-root:hover .resu-code-float, .resu-code-root:focus-within .resu-code-float { pointer-events: auto; opacity: 1; } .resu-copy-code.is-copied .resu-copy-code-live { color: #d1fae5; } .resu-lc-math-block { width: 100%; overflow-x: auto; margin: 0 0 0.75rem 0; padding: 0.2rem 0; } .resu-lc-math-block .katex-display { margin: 0; padding: 0; } .resu-lc-markdown .katex { color: #ececf1; font-size: 1.04em; } .resu-lc-math-raw { display: inline-block; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; background: #202123; border: 1px solid #3f3f46; border-radius: 0.3rem; padding: 0.12rem 0.3rem; color: #e5e7eb; } .resu-lc-markdown blockquote { margin: 0 0 0.7rem 0; border-left: 2px solid #4b5563; padding: 0.15rem 0 0.15rem 0.75rem; color: #d4d4d8; background: rgba(24,24,27,0.55); border-radius: 0 0.45rem 0.45rem 0; } .resu-lc-markdown hr { border: 0; border-top: 1px solid #3f3f46; margin: 0.8rem 0; } .resu-lc-table-wrap { width: 100%; overflow-x: auto; margin: 0 0 0.75rem 0; border: 1px solid #3f3f46; border-radius: 0.6rem; } .resu-lc-table-wrap table { width: 100%; border-collapse: collapse; font-size: 0.83rem; } .resu-lc-table-wrap th, .resu-lc-table-wrap td { border-bottom: 1px solid #3f3f46; padding: 0.45rem 0.6rem; text-align: left; vertical-align: top; } .resu-lc-table-wrap th { background: #23252b; color: #f3f4f6; font-weight: 700; } .resu-lc-table-wrap tr:last-child td { border-bottom: 0; } .resu-lc-gallery { display: flex; flex-wrap: wrap; gap: 0.55rem; margin: 0 0 0.75rem 0; } .resu-lc-gallery a { display: block; width: min(320px, 100%); border-radius: 0.75rem; overflow: hidden; border: 1px solid #3f3f46; background: #18181b; line-height: 0; } .resu-lc-gallery img { display: block; width: 100%; height: auto; max-height: 320px; object-fit: cover; margin: 0; } @media (max-width: 560px) { .resu-panel { top: 8px; right: 8px; left: 8px; width: auto; } .resu-toggle-btn { right: 8px; width: 56px; height: 56px; } } `; document.head.appendChild(styleEl); const wrapper = document.createElement('div'); wrapper.className = 'resu-wrapper'; wrapper.innerHTML = ` <!-- 悬浮开关 --> <div id="resu-toggle" class="resu-toggle-btn" title="打开控制面板"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> </div> <!-- 主面板 --> <div id="resu-panel" class="resu-panel"> <div class="resu-header"> <div class="resu-title">控制面板</div> <button id="resu-close" class="resu-close" title="收起">✕</button> </div> <div class="resu-panel-body"> <div class="resu-card"> <div class="resu-row"> <button id="resu-lib-pick" class="resu-btn resu-btn-secondary resu-btn-fill">📁 绑定库目录</button> <button id="resu-lib-refresh" class="resu-btn resu-btn-secondary resu-btn-icon" title="刷新文件列表">🔄</button> </div> <select id="resu-lib-select" class="resu-select"><option value="">(未绑定或文件夹为空)</option></select> <button id="resu-lib-load" class="resu-btn resu-btn-secondary resu-btn-block">📥 读取选中存档</button> </div> <div class="resu-divider"></div> <div class="resu-card"> <button id="resu-send" disabled class="resu-btn resu-btn-primary resu-btn-block">🧩 渲染聊天记录</button> <div class="resu-row resu-row-tight"> <button id="resu-export" disabled class="resu-btn resu-btn-export resu-btn-fill">💾 备份当前对话</button> <button id="resu-clear" disabled class="resu-btn resu-btn-danger resu-btn-fill">🗑️ 清除缓存</button> </div> </div> <div class="resu-card resu-card-status"> <div id="resu-status">等待操作...</div> <details class="resu-debug"> <summary>调试控制台</summary> <textarea id="resu-preview" readonly></textarea> </details> </div> </div> </div> `; document.body.appendChild(wrapper); const $p = (sel) => wrapper.querySelector(sel); const setStatus = (msg) => { const el = $p('#resu-status'); if (!el) return; el.textContent = msg; el.title = msg; }; // UI 交互逻辑 const toggleBtn = $p('#resu-toggle'); const mainPanel = $p('#resu-panel'); const closeBtn = $p('#resu-close'); toggleBtn.addEventListener('click', () => { mainPanel.style.display = 'flex'; toggleBtn.style.display = 'none'; }); closeBtn.addEventListener('click', () => { mainPanel.style.display = 'none'; toggleBtn.style.display = 'flex'; }); function enableActions() { $p('#resu-send').disabled = false; $p('#resu-export').disabled = false; $p('#resu-clear').disabled = false; } function disableActions() { $p('#resu-send').disabled = true; $p('#resu-export').disabled = true; $p('#resu-clear').disabled = true; } // ===================== 13. 闂傚倷绶¢崜锕傚窗濮樿泛纾?===================== const overlayState = { prevOverflow: null, enabled: true, runtime: null }; function overlayOn() { return (overlayState.runtime ?? overlayState.enabled) === true; } function ensureOverlay() { let ov = document.getElementById('resu-overlay'); const hasValidNodes = ov && ov.querySelector('#resu-ov-title') && ov.querySelector('#resu-ov-sub') && ov.querySelector('#resu-spin'); if (hasValidNodes) return ov; if (ov) ov.remove(); ov = document.createElement('div'); ov.id = 'resu-overlay'; ov.style.cssText = `position:fixed; inset:0; background:rgba(0,0,0,0.95); z-index:1000000; display:none; align-items:center; justify-content:center; backdrop-filter: blur(4px);`; ov.innerHTML = ` <div style="width:min(500px,90vw); background:#161227; border:1px solid #4a3398; border-radius:16px; padding:24px; box-shadow:0 20px 50px rgba(0,0,0,0.8); color:#fff; text-align:center;"> <div id="resu-spin" style="margin:0 auto 16px; width:32px; height:32px; border:4px solid rgba(255,255,255,.14); border-top-color:#7b55e8; border-radius:50%; animation:resuSpin 1s linear infinite;"></div> <div id="resu-ov-title" style="font-weight:700; font-size:16px; margin-bottom:8px;">正在处理</div> <div id="resu-ov-sub" style="color:#b7a7ef; font-size:13px;">请稍候...</div> </div> `; const style = document.createElement('style'); style.textContent = `@keyframes resuSpin{from{transform:rotate(0)}to{transform:rotate(360deg)}}`; ov.appendChild(style); document.body.appendChild(ov); return ov; } function getOverlayParts() { try { let ov = ensureOverlay(); if (!ov) return null; let titleEl = ov.querySelector('#resu-ov-title'); let subEl = ov.querySelector('#resu-ov-sub'); let spinEl = ov.querySelector('#resu-spin'); if (!titleEl || !subEl || !spinEl) { ov.remove(); ov = ensureOverlay(); if (!ov) return null; titleEl = ov.querySelector('#resu-ov-title'); subEl = ov.querySelector('#resu-ov-sub'); spinEl = ov.querySelector('#resu-spin'); if (!titleEl || !subEl || !spinEl) return null; } return { ov, titleEl, subEl, spinEl }; } catch (e) { console.warn('[Resu] overlay init failed:', e); return null; } } function lockScroll() { if (overlayState.prevOverflow == null) { overlayState.prevOverflow = document.documentElement.style.overflow; document.documentElement.style.overflow = 'hidden'; } } function unlockScroll() { if (overlayState.prevOverflow != null) { document.documentElement.style.overflow = overlayState.prevOverflow; overlayState.prevOverflow = null; } } function showOverlay(title, sub) { if (!overlayOn()) return; const parts = getOverlayParts(); if (!parts) return; const { ov, titleEl, subEl, spinEl } = parts; ov.style.display = 'flex'; lockScroll(); titleEl.textContent = title || '正在渲染…'; subEl.textContent = sub || '请稍候'; spinEl.style.display = 'block'; spinEl.style.animation = 'resuSpin 1s linear infinite'; spinEl.style.borderColor = 'rgba(255,255,255,.1)'; spinEl.style.borderTopColor = '#7b55e8'; } function showOverlaySuccess(title, sub) { if (!overlayOn()) return; const parts = getOverlayParts(); if (!parts) return; const { ov, titleEl, subEl, spinEl } = parts; ov.style.display = 'flex'; lockScroll(); titleEl.textContent = title || '✅ 操作成功'; subEl.textContent = sub || '已完成'; spinEl.style.display = 'block'; spinEl.style.animation = 'none'; spinEl.style.borderColor = 'rgba(136,217,255,.35)'; spinEl.style.borderTopColor = '#88d9ff'; } function showOverlayFail(title, sub) { if (!overlayOn()) return; const parts = getOverlayParts(); if (!parts) return; const { ov, titleEl, subEl, spinEl } = parts; ov.style.display = 'flex'; lockScroll(); titleEl.textContent = title || '⚠️ 操作失败'; subEl.textContent = sub || '已取消'; spinEl.style.display = 'none'; } function hideOverlay() { const ov = document.getElementById('resu-overlay'); if (ov) ov.style.display = 'none'; unlockScroll(); } // ===================== 14. 闂備礁鎲$敮妤冩崲閸岀儑缍栭柟鐗堟緲缁€宀勬煛瀹ュ繐顩柣鐔村灩闇夋繝濠傚枤閸庡孩绻涢崱鎰伄缂佽鲸甯¢獮瀣攽閸喐鐦?===================== async function loadJsonFromLibrarySelected() { if (!FS.dirHandle || !FS.fileList.length) { setStatus('⚠️ 请先点击刷新按钮'); return; } const name = $p('#resu-lib-select').value; const item = FS.fileList.find(x => x.name === name); if (!item) { setStatus('⚠️ 请选择一个有效文件'); return; } try { const file = await item.handle.getFile(); const text = await file.text(); const raw = JSON.parse(text); const inferredName = file.name.replace(/\.json$/i, ''); await loadJsonObjectToState(raw, inferredName); } catch (e) { console.error(e); setStatus('❌ 载入失败,文件损坏?'); } } $p('#resu-lib-pick').addEventListener('click', async () => { await pickLibraryFolder(); }); $p('#resu-lib-refresh').addEventListener('click', async () => { await refreshLibraryList(); }); $p('#resu-lib-load').addEventListener('click', async () => { await loadJsonFromLibrarySelected(); }); $p('#resu-export').addEventListener('click', async () => { try { if (!lastThreadId) return; const data = await exportThread(lastThreadId); const filename = `${safeFileName(lastThreadTitle)}.json`; const jsonString = JSON.stringify(data, null, 2); const wrote = await writeJsonToFolderIfPossible(filename, jsonString); if (wrote) setStatus(`✅ 已导出到库:${filename}`); else { downloadJson(data, filename); setStatus('✅ 已下载文件'); } } catch (e) { setStatus('❌ 导出失败'); } }); $p('#resu-clear').addEventListener('click', async () => { try { if (!lastThreadId) return; if (!confirm('确定要清空当前线程的缓存吗?')) return; await clearThread(lastThreadId); setStatus('🧹 缓存已清空'); } catch (e) { setStatus('❌ 清空失败'); } }); $p('#resu-send').addEventListener('click', async () => { if (!loadedMessages.length) { setStatus('⚠️ 请先读取一个存档'); return; } try { await renderOnlyFlow(loadedMessages); showOverlaySuccess('✅ 渲染成功', '已绑定会话ID,刷新可自动恢复'); setTimeout(() => { hideOverlay(); setStatus('✅ 渲染完成(仅本地)'); }, 1200); } catch (e) { console.error(e); const msg = String(e?.message || e || '未知错误'); showOverlayFail('❌ 渲染失败', msg); setTimeout(() => { hideOverlay(); setStatus(`❌ 渲染失败:${msg}`); }, 2200); } }); // ===================== 15. 初始化 ===================== (async () => { disableActions(); // [设置] 强制默认值 overlayState.enabled = ENABLE_OVERLAY_DEFAULT; if (FS.supported) await loadSavedFolderIfAny(); else setStatus('就绪 (无文件夹支持)'); setTimeout(checkAndAutoRender, 1000); })().catch(() => setStatus('⚠️ 初始化出错')); })(); // ===== END 3.js ===== // ===== MERGED UI OVERRIDES (STYLE ONLY) ===== (() => { 'use strict'; const STYLE_ID = 'merged-unified-pokeball-ui'; function injectStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = ` :root { --merged-ball-right: 12px; --merged-ball-bottom: 14px; --merged-ball-size: 48px; --merged-ball-gap: 2px; --merged-ball-border: #111827; --merged-ui-bg1: #fff8e7; --merged-ui-bg2: #f1e3c2; --merged-ui-card: #fffdf7; --merged-ui-border: #262626; --merged-ui-text: #1c1c1c; --merged-ui-muted: #4b4b4b; --merged-ui-input-bg: #ffffff; --merged-ui-input-text: #1a1a1a; --merged-ui-btn-main: #ee1515; --merged-ui-btn-main-2: #b30f0f; --merged-ui-btn-text: #ffffff; --merged-ui-red: #ee1515; --merged-ui-red-2: #b30f0f; --merged-ui-focus: rgba(238, 21, 21, 0.24); --merged-ui-radius-sm: 10px; --merged-ui-radius-md: 14px; } html body #tm-nblm-toggle, html body #resu-toggle.resu-toggle-btn, html body #gpt-rescue-btn.cge-theme-greatball-fab, html body #gpt-rescue-btn.cge-ball-great { position: fixed !important; left: auto !important; top: auto !important; right: var(--merged-ball-right) !important; width: var(--merged-ball-size) !important; height: var(--merged-ball-size) !important; margin: 0 !important; padding: 0 !important; box-sizing: border-box !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; border: 2px solid var(--merged-ball-border) !important; border-radius: 999px !important; box-shadow: 0 5px 12px rgba(17, 24, 39, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.62) !important; } html body #tm-nblm-toggle { bottom: var(--merged-ball-bottom) !important; z-index: 12030 !important; background: linear-gradient(180deg, #ef4444 0 47%, #111827 47% 53%, #f8fafc 53% 100%) !important; } html body #tm-nblm-toggle::before { width: 16px !important; height: 16px !important; border-width: 2px !important; } html body #tm-nblm-toggle::after { width: 6px !important; height: 6px !important; } html body #resu-toggle.resu-toggle-btn { transform: none !important; bottom: calc(var(--merged-ball-bottom) + var(--merged-ball-size) + var(--merged-ball-gap)) !important; z-index: 12020 !important; background: radial-gradient(circle at 27% 21%, #ffd84a 0 8px, transparent 9px), radial-gradient(circle at 73% 21%, #ffd84a 0 8px, transparent 9px), linear-gradient(180deg, #2f3442 0 47%, #111827 47% 53%, #f8fafc 53% 100%) !important; } html body #resu-toggle.resu-toggle-btn::before { width: 16px !important; height: 16px !important; border-width: 2px !important; } html body #resu-toggle.resu-toggle-btn::after { top: 6px !important; font-size: 10px !important; color: #ffe89a !important; } html body #resu-toggle.resu-toggle-btn:hover { transform: none !important; } html body #gpt-rescue-btn.cge-theme-greatball-fab, html body #gpt-rescue-btn.cge-ball-great { bottom: calc(var(--merged-ball-bottom) + var(--merged-ball-size) + var(--merged-ball-gap) + var(--merged-ball-size) + var(--merged-ball-gap)) !important; z-index: 12010 !important; } html body #gpt-rescue-btn .cge-ball-label { font-size: 10px !important; top: 75% !important; } html body #tm-nblm-toggle:hover, html body #gpt-rescue-btn.cge-theme-greatball-fab:hover, html body #gpt-rescue-btn.cge-ball-great:hover { transform: none !important; } /* Unified non-pokeball UI theme */ html body #export-dialog.cge-theme-greatball-dialog, html body #tm-nblm-root, html body .resu-panel { border-color: var(--merged-ui-border) !important; color: var(--merged-ui-text) !important; font-family: "Trebuchet MS", "Verdana", "Noto Sans SC", sans-serif !important; } html body #export-dialog.cge-theme-greatball-dialog { background: linear-gradient(180deg, var(--merged-ui-bg1) 0%, var(--merged-ui-bg2) 100%) !important; } html body #export-dialog.cge-theme-greatball-dialog h2 { background: linear-gradient(180deg, var(--merged-ui-red), var(--merged-ui-red-2)) !important; border-color: var(--merged-ui-border) !important; color: #fffdf8 !important; } html body #export-dialog.cge-theme-greatball-dialog .cge-space-card { background: var(--merged-ui-card) !important; border-color: var(--merged-ui-border) !important; } html body #export-dialog.cge-theme-greatball-dialog p, html body #export-dialog.cge-theme-greatball-dialog [style*="color: #666"] { color: var(--merged-ui-muted) !important; } html body #export-dialog.cge-theme-greatball-dialog input, html body #export-dialog.cge-theme-greatball-dialog select { background: var(--merged-ui-input-bg) !important; color: var(--merged-ui-input-text) !important; border-color: var(--merged-ui-border) !important; } html body #export-dialog.cge-theme-greatball-dialog #select-team-btn, html body #export-dialog.cge-theme-greatball-dialog #start-team-export-btn, html body #export-dialog.cge-theme-greatball-dialog #schedule-start-btn, html body #export-dialog.cge-theme-greatball-dialog #schedule-pick-btn, html body #export-dialog.cge-theme-greatball-dialog #schedule-run-now-btn, html body #export-dialog.cge-theme-greatball-dialog #export-selected-btn { background: linear-gradient(180deg, var(--merged-ui-btn-main), var(--merged-ui-btn-main-2)) !important; border-color: #6f0d0d !important; color: var(--merged-ui-btn-text) !important; text-shadow: none !important; } html body #export-dialog.cge-theme-greatball-dialog #conv-list label, html body #export-dialog.cge-theme-greatball-dialog #conv-list [style*="color: #666"] { color: var(--merged-ui-input-text) !important; } html body #tm-nblm-root { background: linear-gradient(180deg, var(--merged-ui-bg1) 0%, var(--merged-ui-bg2) 100%) !important; box-shadow: 0 14px 30px rgba(30, 24, 14, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.8) !important; } html body #tm-nblm-head { background: linear-gradient(180deg, var(--merged-ui-red), var(--merged-ui-red-2)) !important; border-bottom-color: var(--merged-ui-border) !important; } html body #tm-nblm-title { color: #fffdf8 !important; text-shadow: none !important; } html body #tm-nblm-body { background-image: radial-gradient(rgba(32, 32, 32, 0.05) 1px, transparent 1px), linear-gradient(180deg, #fff8e7 0%, #f3e6c8 100%) !important; background-size: 8px 8px, auto !important; } html body #tm-nblm-source-wrap, html body #tm-nblm-config-wrap, html body .tm-nblm-source-item { background: var(--merged-ui-card) !important; border-color: var(--merged-ui-border) !important; } html body #tm-nblm-source-count, html body .tm-nblm-source-title, html body .tm-nblm-source-id, html body .tm-nblm-config-item label, html body #tm-nblm-options label, html body #tm-nblm-status { color: var(--merged-ui-muted) !important; } html body #tm-nblm-root select, html body #tm-nblm-root textarea, html body #tm-nblm-root input[type="text"], html body #tm-nblm-root button:not(#tm-nblm-toggle):not(#tm-nblm-ask-send-float) { border-color: var(--merged-ui-border) !important; } html body #tm-nblm-notebook, html body #tm-nblm-reload, html body #tm-nblm-body textarea, html body #tm-nblm-body input[type="text"] { background: var(--merged-ui-input-bg) !important; color: var(--merged-ui-input-text) !important; } html body #tm-nblm-ask { background: linear-gradient(180deg, var(--merged-ui-btn-main), var(--merged-ui-btn-main-2)) !important; color: var(--merged-ui-btn-text) !important; border-color: #6f0d0d !important; text-shadow: none !important; } html body .resu-panel { background: linear-gradient(180deg, var(--merged-ui-red) 0 56px, #202020 56px 63px, var(--merged-ui-bg1) 63px 100%) !important; box-shadow: 0 16px 32px rgba(30, 24, 14, 0.32), inset 0 0 0 2px rgba(255, 255, 255, 0.38) !important; } html body .resu-header { background: linear-gradient(180deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0) 100%) !important; border-bottom-color: rgba(255, 255, 255, 0.38) !important; } html body .resu-title { color: #fffdf8 !important; } html body .resu-card { background: var(--merged-ui-card) !important; border-color: var(--merged-ui-border) !important; } html body .resu-select, html body .resu-preview, html body #resu-preview { background: var(--merged-ui-input-bg) !important; color: var(--merged-ui-input-text) !important; border-color: var(--merged-ui-border) !important; } html body .resu-btn-primary, html body .resu-btn-secondary, html body .resu-btn-export, html body .resu-btn-danger { background: linear-gradient(180deg, var(--merged-ui-btn-main), var(--merged-ui-btn-main-2)) !important; border-color: #6f0d0d !important; color: var(--merged-ui-btn-text) !important; text-shadow: none !important; } html body #resu-status, html body .resu-debug > summary { color: var(--merged-ui-input-text) !important; background: var(--merged-ui-input-bg) !important; border-color: var(--merged-ui-border) !important; } /* UI polish: spacing, feedback, readability */ html body #export-dialog.cge-theme-greatball-dialog { border-radius: var(--merged-ui-radius-md) !important; box-shadow: 0 22px 44px rgba(20, 16, 10, 0.28) !important; } html body #export-dialog.cge-theme-greatball-dialog .cge-space-card { border-radius: var(--merged-ui-radius-sm) !important; } html body #export-dialog.cge-theme-greatball-dialog button { min-height: 36px !important; border-radius: 9px !important; font-weight: 800 !important; } html body #export-dialog.cge-theme-greatball-dialog #conv-list { scrollbar-width: thin; scrollbar-color: #8c8c8c #f6f0df; } html body #export-dialog.cge-theme-greatball-dialog #conv-list label { border-radius: 9px !important; transition: background-color 0.14s ease, border-color 0.14s ease !important; } html body #export-dialog.cge-theme-greatball-dialog #conv-list label:hover { background: #fff4d8 !important; border-color: #b79a63 !important; } html body #export-dialog.cge-theme-greatball-dialog input[type="checkbox"] { accent-color: var(--merged-ui-red); } html body #export-dialog.cge-theme-greatball-dialog #conv-status, html body #export-dialog.cge-theme-greatball-dialog #schedule-status, html body #export-dialog.cge-theme-greatball-dialog #schedule-selection-summary, html body #export-dialog.cge-theme-greatball-dialog #filename-preview, html body #export-dialog.cge-theme-greatball-dialog #download-fixed-file-status { color: var(--merged-ui-muted) !important; } html body #tm-nblm-root { border-radius: 18px !important; } html body #tm-nblm-body { scrollbar-width: thin; scrollbar-color: #8c8c8c #f6f0df; } html body #tm-nblm-root button:not(#tm-nblm-toggle):not(#tm-nblm-ask-send-float) { min-height: 34px !important; border-radius: 9px !important; font-weight: 800 !important; } html body #tm-nblm-root input[type="checkbox"] { accent-color: var(--merged-ui-red); } html body #tm-nblm-source-list { scrollbar-width: thin; scrollbar-color: #8c8c8c #f6f0df; } html body .resu-panel { border-radius: 12px !important; } html body .resu-panel .resu-btn { min-height: 40px !important; border-width: 2px !important; } html body .resu-panel .resu-panel-body { scrollbar-width: thin; scrollbar-color: #8c8c8c #f6f0df; } /* Alignment audit: component size + text baseline + row alignment */ html body #export-dialog.cge-theme-greatball-dialog { text-align: left !important; } html body #export-dialog.cge-theme-greatball-dialog h2 { line-height: 1.2 !important; } html body #export-dialog.cge-theme-greatball-dialog button, html body #export-dialog.cge-theme-greatball-dialog select, html body #export-dialog.cge-theme-greatball-dialog input:not([type="checkbox"]):not([type="radio"]) { box-sizing: border-box !important; min-height: 36px !important; line-height: 1.2 !important; padding-top: 7px !important; padding-bottom: 7px !important; } html body #export-dialog.cge-theme-greatball-dialog button { display: inline-flex !important; align-items: center !important; justify-content: center !important; text-align: center !important; vertical-align: middle !important; } html body #export-dialog.cge-theme-greatball-dialog .cge-picker-row, html body #export-dialog.cge-theme-greatball-dialog .cge-picker-footer, html body #export-dialog.cge-theme-greatball-dialog .cge-space-actions, html body #export-dialog.cge-theme-greatball-dialog .cge-team-footer { align-items: center !important; } html body #export-dialog.cge-theme-greatball-dialog .cge-picker-row span { line-height: 1.2 !important; align-self: center !important; } html body #export-dialog.cge-theme-greatball-dialog .cge-picker-row label { display: inline-flex !important; align-items: center !important; gap: 6px !important; line-height: 1.2 !important; } html body #export-dialog.cge-theme-greatball-dialog #workspace-id-list label { display: flex !important; align-items: center !important; min-height: 38px !important; } html body #export-dialog.cge-theme-greatball-dialog #workspace-id-list input[type="radio"] { margin: 0 !important; } html body #export-dialog.cge-theme-greatball-dialog #conv-list label { align-items: flex-start !important; gap: 10px !important; padding: 9px 10px !important; } html body #export-dialog.cge-theme-greatball-dialog #conv-list label input[type="checkbox"] { width: 16px !important; height: 16px !important; margin-top: 2px !important; flex: 0 0 16px !important; } html body #export-dialog.cge-theme-greatball-dialog #conv-list label > div { min-width: 0 !important; } html body #tm-nblm-root { text-align: left !important; } html body #tm-nblm-row, html body #tm-nblm-source-top, html body #tm-nblm-source-actions, html body #tm-nblm-options { align-items: center !important; } html body #tm-nblm-root button:not(#tm-nblm-toggle):not(#tm-nblm-ask-send-float) { box-sizing: border-box !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; line-height: 1.2 !important; text-align: center !important; } html body #tm-nblm-notebook, html body #tm-nblm-reload, html body #tm-nblm-root textarea, html body #tm-nblm-root input[type="text"] { box-sizing: border-box !important; line-height: 1.25 !important; } html body #tm-nblm-notebook, html body #tm-nblm-reload { min-height: 34px !important; } html body #tm-nblm-source-actions button { min-height: 30px !important; padding: 4px 10px !important; } html body .tm-nblm-source-item { align-items: flex-start !important; padding: 7px 8px !important; } html body .tm-nblm-source-item input[type="checkbox"] { width: 16px !important; height: 16px !important; margin-top: 2px !important; flex: 0 0 16px !important; } html body #tm-nblm-options label { line-height: 1.2 !important; } html body .resu-panel { text-align: left !important; } html body .resu-panel .resu-header { min-height: 42px !important; } html body .resu-panel .resu-btn { box-sizing: border-box !important; display: flex !important; align-items: center !important; justify-content: center !important; text-align: center !important; line-height: 1.2 !important; } html body .resu-panel .resu-title, html body .resu-panel .resu-close { line-height: 1 !important; } html body .resu-panel .resu-select, html body .resu-panel #resu-preview { box-sizing: border-box !important; line-height: 1.3 !important; } html body .resu-panel #resu-status { min-height: 24px !important; height: auto !important; display: flex !important; align-items: center !important; line-height: 1.2 !important; padding-top: 2px !important; padding-bottom: 2px !important; } html body .resu-panel details > summary { display: inline-flex !important; align-items: center !important; min-height: 30px !important; line-height: 1.2 !important; } html body #export-dialog.cge-theme-greatball-dialog button:focus-visible, html body #export-dialog.cge-theme-greatball-dialog input:focus-visible, html body #export-dialog.cge-theme-greatball-dialog select:focus-visible, html body #tm-nblm-root button:focus-visible, html body #tm-nblm-root input:focus-visible, html body #tm-nblm-root textarea:focus-visible, html body #tm-nblm-root select:focus-visible, html body .resu-panel button:focus-visible, html body .resu-panel select:focus-visible, html body .resu-panel textarea:focus-visible { outline: none !important; box-shadow: 0 0 0 3px var(--merged-ui-focus) !important; } @media (prefers-reduced-motion: reduce) { html body #export-dialog.cge-theme-greatball-dialog *, html body #tm-nblm-root *, html body .resu-panel *, html body #tm-nblm-toggle, html body #resu-toggle.resu-toggle-btn, html body #gpt-rescue-btn.cge-theme-greatball-fab, html body #gpt-rescue-btn.cge-ball-great { animation: none !important; transition: none !important; } } @media (max-width: 900px) { :root { --merged-ball-right: 8px; --merged-ball-bottom: 10px; --merged-ball-size: 44px; --merged-ball-gap: 2px; } html body #tm-nblm-toggle::before, html body #resu-toggle.resu-toggle-btn::before { width: 14px !important; height: 14px !important; } html body #gpt-rescue-btn .cge-ball-label { font-size: 9px !important; } } `; document.head.appendChild(style); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', injectStyle, { once: true }); return; } injectStyle(); })();