多家大模型网页同时回答

只需输入一次问题,就能自动同时给浏览器打开的各家大模型网页提问。免去手动拷贝粘贴到其他网页、并苦苦等待的麻烦。支持范围:Deepseek, ChatGPT(官网版、zchat原生界面版),Kimi,通义千问,豆包

Verzia zo dňa 02.06.2025. Pozri najnovšiu verziu.

// ==UserScript==
// @name         多家大模型网页同时回答
// @namespace    http://tampermonkey.net/
// @version      1.1.9
// @description  只需输入一次问题,就能自动同时给浏览器打开的各家大模型网页提问。免去手动拷贝粘贴到其他网页、并苦苦等待的麻烦。支持范围:Deepseek, ChatGPT(官网版、zchat原生界面版),Kimi,通义千问,豆包
// @author       interest2
// @match        https://www.kimi.com/*
// @match        https://chat.deepseek.com/*
// @match        https://www.tongyi.com/*
// @match        https://chatgpt.com/*
// @match        https://www.doubao.com/*
// @match        https://chat.zchat.tech/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @connect      localhost
// @connect      www.ratetend.com
// @license      GPL-3.0-only
// ==/UserScript==

(function () {
    'use strict';
    console.log("ai script, start");

    let newFollow = false; // 新开一家大模型网页,是否自动同步之前其他大模型的问题,默认不同步

    const T = "tool-";
    const QUEUE = "tool-queue";
    const LEN = "len";
    const LAST_Q = "lastQ";
    const UID_KEY = "uid";
    const SPLIT_CHAR = ",,,";
    // const DOMAIN = "https://www.ratetend.com:5001";
    const DOMAIN = "http://localhost:8002";

    const MAX_QUEUE = 3;
    const checkGap = 100;
    let maxRetries = 100; // 最多尝试10秒
    const MAX_PLAIN = 50; // lastQuestion原文存储的极限长度
    const HASH_LEN = 16;

    let MAIN_SITE = 0;
    let site = 0;
    let url = window.location.href;

    const keywords = {
        "kimi": 0,
        "deepseek": 1,
        "tongyi": 2,
        "chatgpt": 3,
        "doubao": 4,
        "zchat": 5
    };
    // 根据当前网址关键词,设置site值
    for (const keyword in keywords) {
        if (url.indexOf(keyword) > -1) {
            site = keywords[keyword];
            break;
        }
    }

    // 各家大模型的网址(新对话,历史对话的前缀)
    const webSites = {
        0: ["https://www.kimi.com/", "chat/"],
        1: ["https://chat.deepseek.com/", "a/chat/s/"],
        2: ["https://www.tongyi.com/", "?sessionId="],
        3: ["https://chatgpt.com/", "c/"],
        4: ["https://www.doubao.com/chat", "/"],
        5: ["https://chat.zchat.tech/", "c/"]
    };
    const newSites = Object.fromEntries(
        Object.entries(webSites).map(([key, [baseUrl]]) => [key, baseUrl])
    );
    const historySites = Object.fromEntries(
        Object.entries(webSites).map(([key, [baseUrl, suffix]]) => [key, baseUrl + suffix])
    );


    let pat0 = "[0-9a-z]{20}";
    let pat1 = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
    let pat2 = "[0-9a-f]{32}";
    let pat3 = "[0-9a-f]{16}";

    const pattern ={
        0: pat0,
        1: pat1,
        2: pat2,
        3: pat1,
        4: pat3,
        5: pat1
    }

    // 从url提取各大模型网站给一场对话设置的唯一标识
    function getChatId(){
        let url = getUrl();
        if(isEmpty(url)){
            return "";
        }
        if(site === 4 && url.indexOf("local") > -1){
            return "";
        }
        const regex = new RegExp(pattern[site]);
        let ret = url.match(regex);
        if(isEmpty(ret)){
            return "";
        }
        return ret[0];
    }

    function getUrl(){
        return window.location.href;
    }

    // // 用于辅助判断的情形:该网页此前位于历史对话(hasChatId为true),而现在手动打开了新对话
    // let hasChatId = false;
    // 给receive方法整体加锁
    let receiveLock = false;
    // 给发送环节加锁。能否不要这个锁?不能,因为send环节是异步轮询,receive解锁时send未必轮询结束
    let sendLock = false;

    // setInterval(function(){
    //     masterCheckNew();
    //     receiveNew();
    // }, 3000);

    setTimeout(function(){
        setInterval(function(){
            masterCheckNew();
            receiveNew();
        }, 1000);

    }, 100);

    // SSE事件
    const sseUrl = DOMAIN + "/create?sourceId=" + site;

    if (typeof(EventSource) === "undefined") {
        console.error("This browser does not support Server-Sent Events.");
        return;
    }

    const source = new EventSource(sseUrl);

    source.onopen = () => {
        console.log("[SSE] Connection established.");
    };

    source.onmessage = (event) => {
        console.log("[SSE] Default message:", event.data);
    };

    source.addEventListener("broadcast", (event) => {
        console.log("[SSE] Broadcast:", event.data);
        receiveNew();
    });

    source.onerror = (err) => {
        console.error("[SSE] Connection error:", err);
    };

    function isEqual(latestQ, lastQ){
        if(latestQ.length > MAX_PLAIN){
            if(lastQ.length === HASH_LEN){
                return dHash(latestQ) === lastQ;
            }else{
                return false;
            }
        }else{
            return latestQ === lastQ;
        }
    }

    function getQuesOrHash(ques){
        return ques.length > MAX_PLAIN ? dHash(ques) : ques;
    }

    // 发送端
    function masterCheckNew(){
        if(sendLock){
            return;
        }
        let masterId = getChatId();
        if(isEmpty(masterId)){
            return;
        }

        let questions = getQuestionList();
        let lenNext = questions.length;
        if(lenNext > 0){
            let len = hgetS(T + masterId, LEN) || 0;
            // console.log("lenNext: "+lenNext+", len: "+len);
            if(lenNext - len === 1){
                let lastestQ = questions[lenNext - 1].textContent;
                let lastQuestion = hgetS(T + masterId, LAST_Q);

                if(!isEmpty(lastQuestion) && isEqual(lastestQ, lastQuestion)){
                    return;
                }
                masterReq(masterId, lastestQ);
                hsetS(T + masterId, LEN, lenNext);
            }
        }
    };

    function masterReq(masterId, lastestQ){
        let uid = hgetS(T + masterId, UID_KEY);
        if(isEmpty(uid)){
            uid = guid();
            hsetS(T + masterId, UID_KEY, uid);
        }

        let msg = {
            uid: uid,
            question: lastestQ
        };
        console.log(msg);
        setGV("msg", msg);
        hsetS(T + masterId, LAST_Q, getQuesOrHash(lastestQ));

        let uidJson = getGV(uid);
        // 若json非空,则其中一定有首次提问的主节点的信息;
        // 故json若空则必为首次,只有首次会走如下逻辑
        if(isEmpty(uidJson)){
            uidJson = {};
            uidJson[site] = masterId;
            console.log("master print uidJson: "+JSON.stringify(uidJson));
            setGV(uid, uidJson);

            // 存储管理(删除与添加)
            dequeue();
            enqueue(masterId);
        }

        let remoteUrl = DOMAIN + "/masterQ";
        GM_xmlhttpRequest({
            method: "POST",
            url: remoteUrl,
            data: null,
            headers: {
                "Content-Type": "application/json"
            },
            onload: function(response) {
                console.log(response.responseText);
            },
            onerror: function(error) {
                console.error('请求失败:', error);
            }
        });
    }

    function receiveNew(){
        // console.log(new Date()+" receiveNew start");
        if(receiveLock){
            return;
        }

        let msg = getGV("msg");
        if(isEmpty(msg)){
            return;
        }

        receiveLock = true;

        if(sendLock){
            receiveLock = false;
            return;
        }

        let curSlaveId = getChatId();

        let question = msg.question;
        let sameQuestion = false;
        if(!isEmpty(curSlaveId)){
            let lastQuestion = hgetS(T + curSlaveId, LAST_Q);

            sameQuestion = !isEmpty(lastQuestion) && isEqual(question, lastQuestion);
            console.log("question: "+question+", lastQuestion: "+lastQuestion);
            if(sameQuestion){
                receiveLock = false;
                return;
            }
        }

        let questionBeforeJump = getS("questionBeforeJump");

        // 如果是经跳转而来,无需处理主节点信息,直接从缓存取对话内容
        if(!isEmpty(questionBeforeJump)){
            console.log("questionBeforeJump: " + questionBeforeJump);
            let splits = questionBeforeJump.split(SPLIT_CHAR);
            let cachedQuestion = splits[0];
            let cachedUid = splits[1];

            let cachedSlaveId = "";
            if(!isEmpty(curSlaveId)){
                cachedSlaveId = splits[2];
                if(curSlaveId !== cachedSlaveId){
                    receiveLock = false;
                    return;
                }
                hsetS(T + curSlaveId, LAST_Q, getQuesOrHash(cachedQuestion));
            }

            // 清空跳转用的缓存
            setS("questionBeforeJump", "");
            console.log("h1 send");
            abstractSend(cachedQuestion, cachedSlaveId);

            if(isEmpty(curSlaveId)){
                setUid(cachedUid, cachedQuestion);
            }
            receiveLock = false;
            return;
        }


        let uid = msg.uid;

        let targetUrl = "";
        let slaveIdFlag = false;
        let slaveId = "";
        let uidJson = getGV(uid);
        let lastQuestionOfComingSlaveId = "";

        // 来者消息的uid,是否关联了从节点的chatId?
        if(!isEmpty(uidJson)){
            console.log("uidJson " + JSON.stringify(uidJson));
            slaveId = uidJson[site];
            if(!isEmpty(slaveId)){
                lastQuestionOfComingSlaveId = hgetS(T + slaveId, LAST_Q);
                console.log("lastQuestionOfComingSlaveId "+lastQuestionOfComingSlaveId);

                if(isEqual(question, lastQuestionOfComingSlaveId)){
                    receiveLock = false;
                    return;
                }
                slaveIdFlag = true;
            }
          
        }

        let curIdFlag = !isEmpty(curSlaveId);
        // 从节点已进行过来者的uid对应的对话
        if(slaveIdFlag){
            // 当前页面有chatId
            if(curIdFlag){
                // chatId相同则对话,不同则跳转
                if(curSlaveId === slaveId){
                    console.log(new Date() + " h2 send");
                    hsetS(T + curSlaveId, LAST_Q, getQuesOrHash(question));
                    abstractSend(question, curSlaveId);
                }else{
                    targetUrl = historySites[site] + slaveId;
                }
            // 当前页面是空白,需跳转
            }else{
                targetUrl = historySites[site] + slaveId;
            }
        // 对从节点而言是新对话
        }else{
            // 当前页面有chatId,则跳转空白页
            if(curIdFlag){
                targetUrl = newSites[site];
            // 当前页面已经是空白页
            }else{
                console.log("h3 send");
                abstractSend(question, "");
                setUid(uid, question);
            }
        }
        receiveLock = false;
        if(!isEmpty(targetUrl)){
            setS("questionBeforeJump", question + SPLIT_CHAR + uid + SPLIT_CHAR + slaveId);
            window.location.href = targetUrl;
        }
    }

    function setUid(uid, question){
        let intervalId;
        let lastUrl = getUrl();
        let count = 0;
        let waitTime = 15000;
        if(site === 3){
            waitTime *= 2;
        }

        console.log("ready to setUid");
        intervalId = setInterval(function() {
            count ++;
            if(count > waitTime / checkGap){
                console.log("setUid超时");
                clearInterval(intervalId);
            }
            let chatId = getChatId();
            if (!isEmpty(chatId)) {

                let uidJson = getGV(uid);
                if(!isEmpty(uidJson)){
                    if(isEmpty(uidJson[site])){
                        uidJson[site] = chatId;
                    }
                }else{
                    uidJson = {};
                    uidJson[site] = chatId;
                }
                hsetS(T + chatId, LAST_Q, getQuesOrHash(question));
                hsetS(T + chatId, LEN, 1);

                console.log("slave print uidJson: "+JSON.stringify(uidJson));
                setGV(uid, uidJson);
                setS("uid-" + uid, JSON.stringify(uidJson));

                sendLock = false;
                console.log("setUid finish");
                hsetS(T + chatId, UID_KEY, uid);

                // 存储管理(删除与添加)
                dequeue();
                enqueue(chatId);

                clearInterval(intervalId);
            }
        }, checkGap);
    }

    // ① 检查textArea存在 ② 检查sendBtn存在 ③ 检查问题列表长度是否加一
    function abstractSend(content, chatId){
        let intervalId;
        let count = 0;

        sendLock = true;

        intervalId = setInterval(function() {
            count ++;
            if(count > 5000 / checkGap){
                clearInterval(intervalId);
            }
            const textarea = getTextArea(site);
            if (!isEmpty(textarea)) {
                clearInterval(intervalId);
                sendContent(textarea, content, chatId);
            }
        }, checkGap);
    }

    function sendContent(textarea, content, chatId){
        textarea.focus();
        document.execCommand('insertText', false, content);
        clickAndCheckLen(chatId);
    }

    function clickAndCheckLen(chatId) {
        let tryCount = 0;

        const checkBtnInterval = setInterval(() => {
            let quesFlag = false;
            if(isEmpty(chatId)){
               quesFlag = true;
            }else{
                let len = getQuestionList().length;
                if(len > 0){
                    quesFlag = true;
                }
            }

            let sendBtn = getBtn(site);
            if (quesFlag && !isEmpty(sendBtn)) {
                clearInterval(checkBtnInterval);
                setTimeout(function(){
                    // sendBtn存在不一定立即可以点击,最好延迟一下
                    sendBtn.click();
                }, 200);
                checkQuesList(chatId);
            } else {
                tryCount++;
                if (tryCount > maxRetries) {
                    clearInterval(checkBtnInterval);
                    console.log("tryCount "+tryCount + ", quesFlag "+quesFlag+", sendBtn "+isEmpty(sendBtn));
                    console.warn("sendBtn或问题列表未找到,超时");
                    return;
                }
            }
        }, checkGap);
    }

    function checkQuesList(chatId) {
        let tryCount = 0;
        let cachedLen = hgetS(T + chatId, LEN);
        let newChatFlag = isEmpty(chatId) || isEmpty(cachedLen) || cachedLen === 0;

        const checkInterval = setInterval(() => {
            tryCount++;

            // 定时器:检查问题列表长度大于上次,则停止,并设置sendLock
            // 注意,若是chat首个问题,则只要求len=1
            let len = getQuestionList().length;
            let questionDisplayFlag = false;
            if(newChatFlag){
                if(len === 1){
                    questionDisplayFlag = true;
                }
            }else{
                if(len > cachedLen){
                    questionDisplayFlag = true;
                }
            }

            if (questionDisplayFlag) {
                clearInterval(checkInterval);
                if(!isEmpty(chatId)){
                    hsetS(T + chatId, LEN, len);
                    sendLock = false; // 解锁(如果chatId空,有setUid方法负责解锁)
                }
            } else if (tryCount > maxRetries) {
                console.log("tryCount "+tryCount + ", len "+len+", cachedLen "+cachedLen+", newChatFlag "+newChatFlag);
                clearInterval(checkInterval);
                console.warn("问题列表长度未符合判据,超时");
                sendLock = false;
                let areaContent = getTextArea(site).textContent;
                if(!isEmpty(areaContent)){
                    location.reload();
                }
            }
        }, checkGap);
    }

    function getQuestionList(){
        let questions = [];
        if(site == 0){
            questions = document.getElementsByClassName("user-content");
        }else if(site === 1){
            let scrollable = document.getElementsByClassName("scrollable")[1];
            if(!isEmpty(scrollable)){
                let list = scrollable.firstElementChild.firstElementChild.children
                let elementsArray = Array.from(list);
                questions = elementsArray.filter((item, index) => index % 2 === 0);
            }
        }else if(site === 2){
            questions = document.querySelectorAll('[class^="bubble-"]');
        }else if([3, 5].includes(site)){
            questions = document.querySelectorAll('[data-message-author-role="user"]');
        }else if(site === 4){
            let list = document.querySelectorAll('[data-testid="message_text_content"]');
            let elementsArray = Array.from(list);
            questions = elementsArray.filter((item, index) => index % 2 === 0);
        }
        return questions;
    }

	function getTextArea(site){
        if(site == 0){
            return document.getElementsByClassName('chat-input-editor')[0];
        }else if(site === 1){
            return document.getElementById('chat-input');
        }else if([2, 4].includes(site)){
            return document.getElementsByTagName('textarea')[0];
        }else if([3, 5].includes(site)){
            return document.getElementById('prompt-textarea');
        }
	}
	function getBtn(site){
        if(site == 0){
            return document.getElementsByClassName('send-button')[0];
        }else if(site === 1){
            var btns = document.querySelectorAll('[role="button"]');
            return btns[btns.length - 1];
        }else if(site === 2){
            return document.querySelectorAll('[class^="operateBtn-"], [class*=" operateBtn-"]')[0];
        }else if([3, 5].includes(site)){
            return document.getElementById('composer-submit-button');
        }else if(site === 4){
            return document.getElementById('flow-end-msg-send');
        }
	}

    // 队列头部添加元素
    function enqueue(element) {
        let queue = JSON.parse(localStorage.getItem(QUEUE) || "[]");
        if (queue.length > 0 && queue[0] === element) {
            return;
        }
        queue.unshift(element);
        localStorage.setItem(QUEUE, JSON.stringify(queue));
    }

    // 当队列长度超过阈值,删除队尾元素
    function dequeue() {
        let queue = JSON.parse(localStorage.getItem(QUEUE) || "[]");
        let len = queue.length;
        if(len > MAX_QUEUE){

            let chatIdKey = T + queue[len - 1];
            let valJson = JSON.parse(getS(chatIdKey));
            if(!isEmpty(valJson)){
                let uid = valJson.uid;
                localStorage.removeItem("uid-" + uid);
                GM_deleteValue(uid);
            }

            localStorage.removeItem(chatIdKey);
            queue.pop();
            localStorage.setItem(QUEUE, JSON.stringify(queue));
        }
    }

    // localStorage读写json(hashMap)
    function hgetS(key, jsonKey){
        let json = localStorage.getItem(key);
        if(isEmpty(json)){
            return "";
        }
        json = JSON.parse(json);
        return json[jsonKey];
    }
    function hsetS(key, jsonKey, val){
        let json = JSON.parse(localStorage.getItem(key) || "{}");
        json[jsonKey] = val;
        localStorage.setItem(key, JSON.stringify(json));
    }

    function getS(key){
        return localStorage.getItem(key);
    }
    function setS(key, val){
        localStorage.setItem(key, val);
    }

    // 油猴设置、读取共享存储
    function setGV(key, value){
        GM_setValue(key, value);
    }
    function getGV(key){
        return GM_getValue(key);
    }

    function isEmpty(item){
        if(item===null || item===undefined || item.length===0 || item === "null"){
            return true;
        }else{
            return false;
        }
    }

    // 自定义哈希
    function dHash(str, length = HASH_LEN) {
        let hash = 5381;
        for (let i = 0; i < str.length; i++) {
            hash = (hash * 33) ^ str.charCodeAt(i);
        }

        const chars = '0123456789abcdefghijklmnopqrstuvwxyz';
        let result = '';
        let h = hash >>> 0; // 转为无符号整数

        // 简单的伪随机数生成器(带种子)
        function pseudoRandom(seed) {
            let value = seed;
            return () => {
                value = (value * 1664525 + 1013904223) >>> 0; // 常见的 LCG 参数
                return value / 4294967296; // 返回 [0,1) 的浮点数
            };
        }

        const rand = pseudoRandom(hash); // 使用 hash 作为种子

        for (let i = 0; i < length; i++) {
            if (h > 0) {
                result += chars[h % chars.length];
                h = Math.floor(h / chars.length);
            } else {
                // 使用伪随机数生成字符
                const randomIndex = Math.floor(rand() * chars.length);
                result += chars[randomIndex];
            }
        }

        return result;
    }

    function guid() {
        return 'xxxxxxxx-xxxx-4xxx-yxxxx'.replace(/[xy]/g, function (c) {
            var r = Math.random() * 16 | 0,
                v = c == 'x' ? r : (r & 0x3 | 0x8);
            return v.toString(16);
        });
    }

})();
长期地址
遇到问题?请前往 GitHub 提 Issues。