AI Chat Bulk Manager

Bulk archive or delete ChatGPT and Gemini conversations

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         AI Chat Bulk Manager
// @name:zh-CN   AI Chat Bulk Manager
// @namespace    http://tampermonkey.net/
// @version      0.4
// @description  Bulk archive or delete ChatGPT and Gemini conversations
// @description:zh-CN 批量归档或删除 ChatGPT 和 Gemini 的历史会话
// @author       Luo Jiahao
// @homepageURL  https://github.com/learnerLj/ai-chat-bulk-manager
// @supportURL   https://github.com/learnerLj/ai-chat-bulk-manager/issues
// @match        https://chatgpt.com/*
// @match        https://gemini.google.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      chatgpt.com
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[AI Chat Bulk Manager]';
    const CONVERSATION_LINK_SELECTOR = 'a[href*="/c/"]';
    const GEMINI_BOOT_DELAY_MS = 3500;
    const BULK_DELETE_INTERVAL_MS = 100;
    const GEMINI_DELETE_POLL_MS = 150;
    const MESSAGES = {
        zh: {
            archiveSelected: '归档选中',
            deleteSelected: '删除选中',
            stop: '停止',
            ready: '就绪',
            stopping: '停止中',
            cancelled: '已取消',
            done: '完成',
            stopped: '已停止',
            noSelection: '未选择会话',
            archiveAction: '归档',
            deleteAction: '删除',
            detectedConversations: ({ count }) => `检测到 ${count} 条会话`,
            processing: ({ current, total }) => `正在处理 ${current}/${total}`,
            runningAction: ({ actionLabel, current, total }) => `正在${actionLabel} (${current}/${total})`,
            failed: ({ message }) => `失败:${message}`,
            confirmAction: ({ actionLabel, count }) => `确认${actionLabel}选中的 ${count} 条会话?`
        },
        en: {
            archiveSelected: 'Archive selected',
            deleteSelected: 'Delete selected',
            stop: 'Stop',
            ready: 'Ready',
            stopping: 'Stopping',
            cancelled: 'Cancelled',
            done: 'Done',
            stopped: 'Stopped',
            noSelection: 'No conversations selected',
            archiveAction: 'archive',
            deleteAction: 'delete',
            detectedConversations: ({ count }) => `Detected ${count} conversations`,
            processing: ({ current, total }) => `Processing ${current}/${total}`,
            runningAction: ({ actionLabel, current, total }) => `${actionLabel} (${current}/${total})`,
            failed: ({ message }) => `Failed: ${message}`,
            confirmAction: ({ actionLabel, count }) => `Confirm ${actionLabel} for ${count} selected conversations?`
        }
    };
    const GEMINI_SELECTORS = {
        conversation: [
            'gem-nav-list-item[data-test-id="conversation"]',
            'div[data-test-id="conversation"]',
            '.chat-history-list gem-nav-list-item',
            '.chat-history-list a.mat-mdc-list-item'
        ].join(', '),
        historyRoot: [
            '.chat-history',
            'bard-sidenav',
            'side-navigation',
            'mat-sidenav',
            '[role="navigation"]'
        ].join(', '),
        menuButton: [
            'button[data-test-id="actions-menu-button"]',
            'button[aria-label*="更多选项"]',
            'button[aria-label*="More options"]',
            'button[aria-label*="More"]',
            'button[aria-label*="更多"]',
            'button:has(mat-icon[data-mat-icon-name="more_vert"])',
            'button:has(mat-icon[fonticon="more_vert"])',
            'button:has(mat-icon)'
        ].join(', '),
        deleteItem: [
            'button[data-test-id="delete-button"]',
            '[role="menu"] button:has-text("删除")',
            '[role="menu"] button:has-text("Delete")',
            '.mat-mdc-menu-panel button:has-text("删除")',
            '.mat-mdc-menu-panel button:has-text("Delete")',
            'div[role="menu"] button:has(mat-icon[data-mat-icon-name="delete"])',
            'div[role="menu"] button:has(mat-icon[fonticon="delete"])',
            'div[role="menu"] [role="menuitem"]:has(mat-icon[data-mat-icon-name="delete"])',
            'div[role="menu"] [role="menuitem"]:has(mat-icon[fonticon="delete"])',
            'button:has(mat-icon[data-mat-icon-name="delete"])',
            'button:has(mat-icon[fonticon="delete"])'
        ].join(', '),
        confirmButton: [
            'mat-dialog-container gem-button[data-test-id="confirm-button"]',
            '.cdk-overlay-pane gem-button[data-test-id="confirm-button"]',
            'gem-button[data-test-id="confirm-button"]',
            'mat-dialog-container gem-button[data-test-id="confirm-button"] button',
            '.cdk-overlay-pane gem-button[data-test-id="confirm-button"] button',
            'gem-button[data-test-id="confirm-button"] button',
            'mat-dialog-container button[data-test-id="confirm-button"]',
            'mat-dialog-container button:has-text("Delete")',
            'mat-dialog-container button:has-text("删除")',
            '.cdk-overlay-pane button:has-text("Delete")',
            '.cdk-overlay-pane button:has-text("删除")',
            'button[data-test-id="confirm-button"]'
        ].join(', ')
    };

    const log = (...args) => console.log(LOG_PREFIX, ...args);
    const warn = (...args) => console.warn(LOG_PREFIX, ...args);
    const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    const getPreferredLanguage = () => {
        const languages = [
            document.documentElement.lang,
            ...Array.from(navigator.languages || []),
            navigator.language
        ];
        const hasChinese = languages.some(language => String(language || '').toLowerCase().startsWith('zh'));
        if (hasChinese) return 'zh';
        const hasEnglish = languages.some(language => String(language || '').toLowerCase().startsWith('en'));
        if (hasEnglish) return 'en';
        return 'zh';
    };

    const CURRENT_LANGUAGE = getPreferredLanguage();

    const formatMessage = (key, params = {}) => {
        const message = (MESSAGES[CURRENT_LANGUAGE] || MESSAGES.zh)[key] || MESSAGES.zh[key];
        return typeof message === 'function' ? message(params) : message;
    };

    const getText = (node) => (node && node.textContent ? node.textContent.trim() : '');

    const clickElement = (node) => {
        const nodeWindow = node.ownerDocument.defaultView || window;
        const mouseEventInit = { bubbles: true, cancelable: true, view: nodeWindow };
        node.dispatchEvent(new nodeWindow.MouseEvent('pointerdown', mouseEventInit));
        node.dispatchEvent(new nodeWindow.MouseEvent('mousedown', mouseEventInit));
        node.dispatchEvent(new nodeWindow.MouseEvent('pointerup', mouseEventInit));
        node.dispatchEvent(new nodeWindow.MouseEvent('mouseup', mouseEventInit));
        node.click();
    };

    const getConversationIdFromHref = (href) => {
        if (!href) return '';
        const match = href.match(/\/c\/([a-f0-9-]{36})/i);
        return match ? match[1] : '';
    };

    const queryTextSelector = (root, selector) => {
        const hasTextMatch = selector.match(/^(.*):has-text\("(.+)"\)$/);
        if (!hasTextMatch) return root.querySelector(selector);

        const [, baseSelector, expectedText] = hasTextMatch;
        return Array.from(root.querySelectorAll(baseSelector))
            .find(node => getText(node).includes(expectedText)) || null;
    };

    const queryFirst = (selector, root = document) => {
        const selectors = selector.split(',').map(item => item.trim()).filter(Boolean);
        for (const item of selectors) {
            try {
                const node = queryTextSelector(root, item);
                if (node) return node;
            } catch (error) {
                warn('Selector failed', item, error);
            }
        }
        return null;
    };

    const waitForElement = async (selector, root = document, timeoutMs = 1500) => {
        const startedAt = Date.now();
        while (Date.now() - startedAt < timeoutMs) {
            const node = queryFirst(selector, root);
            if (node) return node;
            await delay(100);
        }
        throw new Error(`Element not found: ${selector}`);
    };

    const isVisible = (node) => {
        if (!node || !node.isConnected) return false;
        const rect = node.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0;
    };

    const waitForNodeRemoval = async (node, timeoutMs = 8000) => {
        const startedAt = Date.now();
        while (Date.now() - startedAt < timeoutMs) {
            if (!node || !node.isConnected) return;
            await delay(150);
        }
        throw new Error('Conversation node was not removed after delete confirmation');
    };

    const sendRequest = (details) => {
        if (typeof GM_xmlhttpRequest === 'function') {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    ...details,
                    onload: resolve,
                    onerror: reject
                });
            });
        }

        return fetch(details.url, {
            method: details.method,
            headers: details.headers,
            body: details.data,
            credentials: 'include'
        }).then(async response => ({
            status: response.status,
            responseText: await response.text()
        }));
    };

    const addStyle = (css) => {
        if (typeof GM_addStyle === 'function') {
            GM_addStyle(css);
            return;
        }
        const style = document.createElement('style');
        style.textContent = css;
        (document.head || document.documentElement).appendChild(style);
    };

    class BaseAdapter {
        getConversations() { throw new Error('Not implemented'); }
        injectCheckbox(node, callback) { throw new Error('Not implemented'); }
        deleteConversation(node) { throw new Error('Not implemented'); }
        archiveConversation(node) { return this.deleteConversation(node); }
        getSidebarHeader() { throw new Error('Not implemented'); }
        getNodeId(node) { return getText(node); }
        findConversationById(id) {
            return this.getConversations().find(node => this.getNodeId(node) === id) || null;
        }
    }

    class OpenAIAdapter extends BaseAdapter {
        constructor() {
            super();
            this.accessToken = '';
            this.fetchAccessToken();
        }

        fetchAccessToken() {
            return sendRequest({
                method: 'GET',
                url: 'https://chatgpt.com/api/auth/session',
            }).then((res) => {
                try {
                    const data = JSON.parse(res.responseText);
                    this.accessToken = data.accessToken || '';
                    log('ChatGPT token loaded', Boolean(this.accessToken));
                } catch(e) {
                    console.error(LOG_PREFIX, 'Failed to parse ChatGPT token', e);
                }
            }).catch((error) => {
                console.error(LOG_PREFIX, 'Failed to load ChatGPT token', error);
            });
        }

        getConversations() {
            const links = Array.from(document.querySelectorAll(`nav ${CONVERSATION_LINK_SELECTOR}`))
                .filter(link => getConversationIdFromHref(link.getAttribute('href')));
            const seen = new Set();
            return links.filter(link => {
                const id = getConversationIdFromHref(link.getAttribute('href'));
                if (seen.has(id)) return false;
                seen.add(id);
                return true;
            });
        }

        getNodeId(node) {
            return getConversationIdFromHref(node.getAttribute('href'));
        }

        injectCheckbox(node, onSelectChange) {
            if (node.querySelector('.bulk-delete-checkbox')) return;
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.className = 'bulk-delete-checkbox';
            checkbox.addEventListener('click', (event) => event.stopPropagation());
            checkbox.addEventListener('change', (e) => onSelectChange(node, e.target.checked, e));
            node.insertBefore(checkbox, node.firstChild);
        }

        async deleteConversation(node) {
            const href = node.getAttribute('href');
            const id = getConversationIdFromHref(href);
            if (!id) return Promise.reject('No conversation ID found');
            if (!this.accessToken) {
                await this.fetchAccessToken();
            }
            if (!this.accessToken) return Promise.reject('No Access Token');

            const res = await sendRequest({
                method: 'PATCH',
                url: `https://chatgpt.com/backend-api/conversation/${id}`,
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${this.accessToken}`
                },
                data: JSON.stringify({ is_visible: false })
            });

            if (res.status >= 200 && res.status < 300) {
                node.remove();
                log('ChatGPT hidden', id, res.status);
                return;
            }

            throw new Error(`HTTP error ${res.status}`);
        }

        async archiveConversation(node) {
            const href = node.getAttribute('href');
            const id = getConversationIdFromHref(href);
            if (!id) return Promise.reject('No conversation ID found');
            if (!this.accessToken) {
                await this.fetchAccessToken();
            }
            if (!this.accessToken) return Promise.reject('No Access Token');

            const res = await sendRequest({
                method: 'PATCH',
                url: `https://chatgpt.com/backend-api/conversation/${id}`,
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${this.accessToken}`
                },
                data: JSON.stringify({ is_archived: true })
            });

            if (res.status >= 200 && res.status < 300) {
                node.remove();
                log('ChatGPT archived', id, res.status);
                return;
            }

            throw new Error(`HTTP error ${res.status}`);
        }

        getSidebarHeader() {
            const firstConversation = this.getConversations()[0];
            return firstConversation?.closest('ul') || document.querySelector('nav');
        }
    }

    class GoogleAdapter extends BaseAdapter {
        getConversations() {
            const nodes = Array.from(document.querySelectorAll(GEMINI_SELECTORS.conversation))
                .map(node => node.closest('gem-nav-list-item[data-test-id="conversation"]') || node)
                .filter(node => !node.closest('#bulk-controls-panel'));
            return Array.from(new Set(nodes));
        }

        getNodeId(node) {
            const href = node.querySelector('a[href^="/app/"]')?.getAttribute('href');
            return href || getText(node);
        }

        injectCheckbox(node, onSelectChange) {
            if (node.querySelector('.bulk-delete-checkbox')) return;
            node.classList.add('gemini-bulk-delete-row');
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.className = 'bulk-delete-checkbox';
            checkbox.addEventListener('click', (event) => event.stopPropagation());
            checkbox.addEventListener('change', (e) => onSelectChange(node, e.target.checked, e));
            const anchor = node.querySelector('a.mat-mdc-list-item') || node.firstChild;
            node.insertBefore(checkbox, anchor);
        }

        async deleteConversation(node) {
            const nodeId = this.getNodeId(node);
            node.scrollIntoView({ block: 'center' });
            node.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
            const menuBtn = await waitForElement(GEMINI_SELECTORS.menuButton, node, 1500);
            clickElement(menuBtn);

            const deleteItem = await waitForElement(GEMINI_SELECTORS.deleteItem, document, 1500);
            clickElement(deleteItem);

            const confirmBtn = await this.waitForConfirmButton();
            clickElement(confirmBtn);
            await this.waitForDeletedNode(node, nodeId);
            log('Gemini delete clicked', getText(node).slice(0, 80));
        }

        isConversationDeleted(node, nodeId) {
            return !node || !node.isConnected || Boolean(nodeId && !this.findConversationById(nodeId));
        }

        async waitForDeletedNode(node, nodeId) {
            const startedAt = Date.now();
            while (Date.now() - startedAt < 10000) {
                const confirmBtn = await this.findConfirmButton();
                const isDeleted = this.isConversationDeleted(node, nodeId);
                if (isDeleted && confirmBtn) {
                    const cancelBtn = await this.findCancelButton();
                    if (cancelBtn) {
                        clickElement(cancelBtn);
                        await delay(GEMINI_DELETE_POLL_MS);
                        if (!await this.findConfirmButton()) return;
                    }
                }
                if (isDeleted && !confirmBtn) return;
                if (confirmBtn) {
                    clickElement(confirmBtn);
                }
                await delay(GEMINI_DELETE_POLL_MS);
            }
            if (!this.isConversationDeleted(node, nodeId)) {
                await waitForNodeRemoval(node, 1);
            }
        }

        async findConfirmButton() {
            const confirmHosts = Array.from(document.querySelectorAll([
                'mat-dialog-container gem-button[data-test-id="confirm-button"]',
                '.cdk-overlay-pane gem-button[data-test-id="confirm-button"]'
            ].join(', '))).filter(isVisible);
            if (confirmHosts.length > 0) {
                const host = confirmHosts[confirmHosts.length - 1];
                return host.querySelector('button') || host;
            }

            const buttons = Array.from(document.querySelectorAll([
                'mat-dialog-container gem-button[data-test-id="confirm-button"] button',
                '.cdk-overlay-pane gem-button[data-test-id="confirm-button"] button',
                'mat-dialog-container button',
                '.cdk-overlay-pane button',
                'button[data-test-id="confirm-button"]'
            ].join(', '))).filter(isVisible);
            const confirmButtons = buttons.filter(button => {
                const text = getText(button);
                return text === '删除'
                    || text === 'Delete'
                    || button.matches('[data-test-id="confirm-button"]')
                    || Boolean(button.closest('gem-button[data-test-id="confirm-button"]'));
            });
            return confirmButtons.length > 0 ? confirmButtons[confirmButtons.length - 1] : null;
        }

        async findCancelButton() {
            const cancelHosts = Array.from(document.querySelectorAll([
                'mat-dialog-container gem-button[data-test-id="cancel-button"]',
                '.cdk-overlay-pane gem-button[data-test-id="cancel-button"]'
            ].join(', '))).filter(isVisible);
            if (cancelHosts.length > 0) {
                const host = cancelHosts[cancelHosts.length - 1];
                return host.querySelector('button') || host;
            }

            const buttons = Array.from(document.querySelectorAll([
                'mat-dialog-container button',
                '.cdk-overlay-pane button'
            ].join(', '))).filter(isVisible);
            return buttons.find(button => ['取消', 'Cancel'].includes(getText(button))) || null;
        }

        async waitForConfirmButton() {
            const startedAt = Date.now();
            while (Date.now() - startedAt < 3000) {
                const confirmButton = await this.findConfirmButton();
                if (confirmButton) {
                    return confirmButton;
                }
                await delay(100);
            }
            throw new Error(`Element not found: ${GEMINI_SELECTORS.confirmButton}`);
        }

        getSidebarHeader() {
            return queryFirst(GEMINI_SELECTORS.historyRoot) || document.querySelector('nav');
        }
    }

    const getAdapter = () => {
        const host = window.location.hostname;
        if (host.includes('chatgpt.com')) {
            return new OpenAIAdapter();
        } else if (host.includes('gemini.google.com')) {
            return new GoogleAdapter();
        }
        return null;
    };

    class BulkManager {
        constructor() {
            this.adapter = getAdapter();
            if (!this.adapter) return;
            this.selectedNodes = new Set();
            this.isDeleting = false;
            this.init();
        }

        init() {
            addStyle(`
                .bulk-delete-checkbox { width: 16px; height: 16px; margin: 4px 8px 4px 0; cursor: pointer; flex: 0 0 auto; }
                #bulk-controls-panel { padding: 8px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid rgba(0,0,0,0.16); background: rgba(255,255,255,0.92); color: #111; position: sticky; top: 0; z-index: 9999; font-size: 12px; }
                #bulk-controls-panel.bulk-controls-panel-gemini { margin: 4px 0 8px; border: 1px solid rgba(255,255,255,0.16); border-radius: 8px; background: rgba(32,32,32,0.96); color: #f2f2f2; box-sizing: border-box; max-width: 100%; }
                #bulk-controls-panel.bulk-controls-panel-chatgpt { margin: 4px 0 8px; border-radius: 8px; box-sizing: border-box; max-width: 100%; }
                .bulk-btn { padding: 4px 8px; cursor: pointer; border-radius: 4px; border: 1px solid #aaa; background: #fff; color: #111; }
                .bulk-btn:disabled { cursor: not-allowed; opacity: 0.5; }
                .bulk-status { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
                .gemini-bulk-delete-row { display: flex !important; align-items: center !important; }
            `);
            this.startObserver();
            this.injectPanel();
        }

        injectPanel() {
            if (document.getElementById('bulk-controls-panel')) return;
            const isGemini = window.location.hostname.includes('gemini.google.com');
            const isChatGPT = window.location.hostname.includes('chatgpt.com');
            const header = isGemini ? document.querySelector('.chat-history-list') : this.adapter.getSidebarHeader();
            if (!header) return;
            
            const panel = document.createElement('div');
            panel.id = 'bulk-controls-panel';
            if (isGemini) {
                panel.classList.add('bulk-controls-panel-gemini');
            } else if (isChatGPT) {
                panel.classList.add('bulk-controls-panel-chatgpt');
            }
            const selectAll = document.createElement('input');
            selectAll.type = 'checkbox';
            selectAll.id = 'bulk-select-all';
            const deleteBtn = document.createElement('button');
            deleteBtn.className = 'bulk-btn';
            deleteBtn.id = 'bulk-delete-btn';
            deleteBtn.textContent = `${formatMessage('deleteSelected')} (0)`;
            const archiveBtn = document.createElement('button');
            archiveBtn.className = 'bulk-btn';
            archiveBtn.id = 'bulk-archive-btn';
            archiveBtn.textContent = `${formatMessage('archiveSelected')} (0)`;
            const stopBtn = document.createElement('button');
            stopBtn.className = 'bulk-btn';
            stopBtn.id = 'bulk-stop-btn';
            stopBtn.textContent = formatMessage('stop');
            stopBtn.disabled = true;
            const status = document.createElement('span');
            status.className = 'bulk-status';
            status.id = 'bulk-status';
            status.textContent = formatMessage('ready');
            if (isChatGPT) {
                panel.append(selectAll, archiveBtn, deleteBtn, stopBtn, status);
            } else {
                panel.append(selectAll, deleteBtn, stopBtn, status);
            }
            
            if (isGemini) {
                header.parentElement.insertBefore(panel, header);
            } else if (isChatGPT && header.parentElement) {
                header.parentElement.insertBefore(panel, header);
            } else {
                header.insertBefore(panel, header.firstChild);
            }
            
            selectAll.addEventListener('change', (e) => {
                const list = this.adapter.getConversations();
                list.forEach(node => {
                    const cb = node.querySelector('.bulk-delete-checkbox');
                    if (cb) {
                        cb.checked = e.target.checked;
                        this.onSelectChange(node, e.target.checked);
                    }
                });
            });

            archiveBtn.addEventListener('click', () => this.runBulkArchive());
            deleteBtn.addEventListener('click', () => this.runBulkDelete());
            stopBtn.addEventListener('click', () => {
                this.stopRequested = true;
                this.setStatus(formatMessage('stopping'));
            });
        }

        onSelectChange(node, isSelected) {
            if (isSelected) {
                this.selectedNodes.add(node);
            } else {
                this.selectedNodes.delete(node);
            }
            this.refreshSelectedState();
        }

        refreshSelectedState() {
            const btn = document.getElementById('bulk-delete-btn');
            const archiveBtn = document.getElementById('bulk-archive-btn');
            if (btn) {
                btn.innerText = `${formatMessage('deleteSelected')} (${this.selectedNodes.size})`;
            }
            if (archiveBtn) {
                archiveBtn.innerText = `${formatMessage('archiveSelected')} (${this.selectedNodes.size})`;
            }
        }

        syncSelectedFromDom() {
            this.selectedNodes.clear();
            this.adapter.getConversations().forEach(node => {
                const checkbox = node.querySelector('.bulk-delete-checkbox');
                if (checkbox?.checked) {
                    this.selectedNodes.add(node);
                }
            });
            this.refreshSelectedState();
        }

        setStatus(message) {
            const status = document.getElementById('bulk-status');
            if (status) status.innerText = message;
        }

        scanAndInject() {
            const list = this.adapter.getConversations();
            list.forEach(node => this.adapter.injectCheckbox(node, (n, s, e) => this.onSelectChange(n, s, e)));
            this.injectPanel();
            this.setStatus(formatMessage('detectedConversations', { count: list.length }));
        }

        startObserver() {
            this.scanAndInject();
            this.observer = new MutationObserver(() => {
                window.clearTimeout(this.scanTimer);
                this.scanTimer = window.setTimeout(() => this.scanAndInject(), 150);
            });
            this.observer.observe(document.body, { childList: true, subtree: true });
        }

        async runBulkDelete() {
            this.syncSelectedFromDom();
            const actionLabel = formatMessage('deleteAction');
            if (this.selectedNodes.size === 0) {
                this.setStatus(formatMessage('noSelection'));
                return;
            }
            if (!window.confirm(formatMessage('confirmAction', { actionLabel, count: this.selectedNodes.size }))) {
                this.setStatus(formatMessage('cancelled'));
                return;
            }
            await this.runBulkAction('delete');
        }

        async runBulkArchive() {
            await this.runBulkAction('archive');
        }

        async runBulkAction(action) {
            this.syncSelectedFromDom();
            if (this.isDeleting || this.selectedNodes.size === 0) {
                this.setStatus(formatMessage('noSelection'));
                return;
            }
            this.isDeleting = true;
            this.stopRequested = false;
            const btn = document.getElementById('bulk-delete-btn');
            const archiveBtn = document.getElementById('bulk-archive-btn');
            const stopBtn = document.getElementById('bulk-stop-btn');
            const nodes = Array.from(this.selectedNodes)
                .map(node => ({ node, id: this.adapter.getNodeId(node) }));
            if (btn) btn.disabled = true;
            if (archiveBtn) archiveBtn.disabled = true;
            if (stopBtn) stopBtn.disabled = false;
            const actionLabel = action === 'archive' ? formatMessage('archiveAction') : formatMessage('deleteAction');
            
            for (let i = 0; i < nodes.length; i++) {
                if (this.stopRequested) break;
                const entry = nodes[i];
                const node = entry.node.isConnected ? entry.node : this.adapter.findConversationById(entry.id);
                if (!node) {
                    this.selectedNodes.delete(entry.node);
                    continue;
                }
                const activeBtn = action === 'archive' ? archiveBtn : btn;
                if (activeBtn) {
                    activeBtn.innerText = formatMessage('runningAction', {
                        actionLabel,
                        current: i + 1,
                        total: nodes.length
                    });
                }
                this.setStatus(formatMessage('processing', { current: i + 1, total: nodes.length }));
                try {
                    if (action === 'archive') {
                        await this.adapter.archiveConversation(node);
                    } else {
                        await this.adapter.deleteConversation(node);
                    }
                    this.selectedNodes.delete(node);
                } catch(e) {
                    console.error(LOG_PREFIX, `${actionLabel} failed`, e);
                    this.setStatus(formatMessage('failed', { message: e.message || e }));
                }
                await delay(BULK_DELETE_INTERVAL_MS);
            }
            
            this.refreshSelectedState();
            const selectAll = document.getElementById('bulk-select-all');
            if (selectAll) selectAll.checked = false;
            if (btn) btn.disabled = false;
            if (archiveBtn) archiveBtn.disabled = false;
            if (stopBtn) stopBtn.disabled = true;
            this.setStatus(this.stopRequested ? formatMessage('stopped') : formatMessage('done'));
            this.isDeleting = false;
        }
    }

    const boot = () => {
        if (window.__aiChatBulkManager) return;
        if (!document.body || !document.documentElement) {
            window.setTimeout(boot, 100);
            return;
        }
        window.__aiChatBulkManager = new BulkManager();
        log('initialized');
    };

    if (window.location.hostname.includes('gemini.google.com')) {
        window.setTimeout(boot, GEMINI_BOOT_DELAY_MS);
    } else {
        boot();
    }
})();