ChatGPT 助手

支持降智检测(IP 使用情况风险判断)以及跳转 ChatGPT 镜像站的语音功能到 LiveKit Meet 而不是镜像站的 LiveKit Meet。

// ==UserScript==
// @name         ChatGPT 助手
// @author       Hmjz100
// @namespace    github.com/hmjz100
// @version      1.0.3
// @description  支持降智检测(IP 使用情况风险判断)以及跳转 ChatGPT 镜像站的语音功能到 LiveKit Meet 而不是镜像站的 LiveKit Meet。
// @icon         data:image/webp;base64,UklGRnICAABXRUJQVlA4WAoAAAAQAAAAHwAAHwAAQUxQSI8AAAABd6A2kg06/T5EcqGIzkREILWfPrI5RTitte1pXprvniGwKHzbgMMgjMAkHAaB9HhUevJ9U+T/6T6i/wzctm0kde/27hVA1LJgHQAoiCX2Hvw3S62UnAc0rkOEtyHir7C+QPMbSg48lz0vKU1fl0ZUNyHRH8Pf4Tt0/Wb4Ow5/53QI3XzKKRVk8v/IxeB/BgBWUDggvAEAABALAJ0BKiAAIAA+aSqQRaQioZv6rABABoS0gAnAHJxnoP3rMm/kBkKFPL/yvok/1/878zXyp/g/cC/jv9A/x35fdjN7Hf7ANdIJ5yn1lcylRWD9msH+g4lCVG3DwlB27AAA/v/b7x/k//Og4rBakFx39Od09+fJ1x4C+DLOrn57IHc1Tex5U/5cB8l2f3fZBiD+5/7/IowL/9h145ee+E8Tn9Mx//ya2jMrsF0MWumdxn7zRJ7f7BEicBZVfKYEwPy/e9nUZPOkR9QyW1vCwpKLT4HeJVe7ayZ4dcQRfzMR0TeGbvw8U5I+jHciOCqPs43iwvLybOhARP2A/54Jx8B2XKJ3NgHNzVi3W2HkTpisnFaSV2NcPAhURRCHCp9wAwR12+PxPlPateeyuKpswYl1DEzNt/SkXzDPkSq/LyuOSsHuqlcmY8ch6wMlzG7JlrkvSIL3kuB6BS6sByZZXCNqJoa/4vqVhG8NdvpsjyX6hpz5DqxYMDrvqnl1//oedvi9KU2f/8I6YzFjiLYx5bH31D5eTMZBT8kqCpoNVnHwfQz5KqJRnU9pk4JVJRwRO0gt0GsehqDyZAAAAA==
// @license      MIT
// @match        *://chatgpt.com/*
// @match        *://chat.openai.com/*
// @match        *://*.oaifree.com/*
// @match        *://chat.rawchat.top/*
// @match        *://chat.rawchat.cc/*
// @match        *://chat.sharedchat.cn/*
// @match        *://chat.chatgptplus.cn/*
// @match        *://free.share-ai.top/*
// @match        *://gpt.github.cn.com/*
// @match        *://share.github.cn.com/*
// @match        *://go.gptdie.com/*
// @match        *://chat.gptdie.com/*
// @match        *://free.xyhelper.cn/*
// @match        *://*.xyhelper.com.cn/*
// @match        *://chat.freegpts.org/*
// @match        *://go.gptdsb.com/*
// @match        *://chat.gptdsb.com/*
// @match        *://www.opkfc.com/*
// @match        *://chatgpt.dairoot.cn/*
// @match        *://web.tu-zi.com/*
// @match        *://share.tu-zi.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_openInTab
// @grant        unsafeWindow
// @run-at       document-start
// @require      https://unpkg.com/jquery@3.6.3/dist/jquery.min.js
// ==/UserScript==

(function () {
	'use strict';
	// 非常有意思的不知道是什么模型的网站
	// chatgptchatapp.com

	// 监听 fetch 请求的响应
	const originalFetch = window.fetch;
	unsafeWindow.fetch = async function (url, options) {
		try {
			const response = await originalFetch(url, options);
			// 检测是否是目标 API 请求
			if (url.includes('sentinel/chat-requirements') && options.method === 'POST') {
				let res = await response.clone().text();
				try {
					res = JSON.parse(res);
				} catch (e) { }
				console.log('风险数据: \n', res);

				const difficulty = res?.proofofwork?.difficulty ? res?.proofofwork?.difficulty : 'N/A';
				updateDifficultyIndicator(difficulty);
			}
			return response;
		} catch (error) {
			throw error;
		}
	};

	// 更新难易度指示器
	function updateDifficultyIndicator(difficulty) {
		const result = difficulty === 'N/A' ? {
			color: '#888', secondaryColor: '#666', textColor: '#888', level: '未知', detail: '服务器未提供 PoW 难度值,可能是因为当前站点不是镜像站点\n(即使网站的 UI 界面与官方相似)'
		} : (() => {
			const hexValue = difficulty.replace('0x', '').replace(/^0+/, '');
			const hexLength = hexValue.length;
			const decimalValue = parseInt(hexValue, 16);  // 转换为十进制数值
			const percentage = Math.min(decimalValue / 0x00FFFF * 100, 100).toFixed(2); // 精确到小数点后两位
			console.log(difficulty, hexLength, decimalValue, percentage)
			if (hexLength <= 2) return { color: '#F44336', secondaryColor: '#d32f2f', level: '困难', detail: `PoW:${difficulty} (${percentage}%),高级模型以及功能可能无法正常使用。` };
			if (hexLength === 3) return { color: '#FFC107', secondaryColor: '#ffa000', level: '中等', detail: `PoW:${difficulty} (${percentage}%),可能会影响部分高级功能。` };
			if (hexLength === 4) return { color: '#8BC34A', secondaryColor: '#689f38', level: '简单', detail: `PoW:${difficulty} (${percentage}%),可正常使用 ChatGPT。` };
			return { color: '#4CAF50', secondaryColor: '#388e3c', level: '极易', detail: `PoW:${difficulty} (${percentage}%),可舒适使用 ChatGPT。` };
		})();

		// 更新 UI
		$('#ChatGPTPow-Button svg defs linearGradient stop[offset="0%"]').css('stop-color', result.color);
		$('#ChatGPTPow-Button svg defs linearGradient stop[offset="100%"]').css('stop-color', result.secondaryColor);
		$('#ChatGPTPow-Button span').text(result.level);
		$('#ChatGPTPow-Button').attr('title', result.detail);
		$('#ChatGPTPow-Button')[0].style.background = `linear-gradient(140.91deg, ${result.color} 12.61%, ${result.secondaryColor} 76.89%)`;
	}

	// 隐藏原来的按钮
	waitForKeyElements('div:not(#ChatGPTVoice-On-LiveKitMeet-Button, #immersive-translate-popup) svg.icon[width="25"][height="25"], div#voiceButton svg, #of-custom-floating-ball svg, div > div#livekit', function (element) {
		element.parent().hide();
	});

	waitForKeyElements("body main div.mb-7.text-center, div.btn-voice, body main div.flex-shrink-0 svg", function (element) {
		if (element.hasClass("voice")) return;

		let checkOpacity = function () {
			if (element.hasClass("mb-7")) {
				if (element.find("h1.result-streaming").css('opacity') == '0') {
					let clone = element.clone(true);
					clone.addClass("voice")
					element.replaceWith(clone);

					clone.css({
						'cursor': 'pointer',
						'user-select': 'none',
						'-webkit-user-select': 'none',
						'-ms-user-select': 'none',
						'-moz-user-select': 'none'
					});

					clone.on('click touchend', handleVoiceClick);
				} else {
					setTimeout(checkOpacity, 100);
				}
			} else {
				element.addClass("voice")

				element.css({
					'cursor': 'pointer',
					'user-select': 'none',
					'-webkit-user-select': 'none',
					'-ms-user-select': 'none',
					'-moz-user-select': 'none'
				});
				element.on('click touchend', handleVoiceClick);
			}
		};

		// 初次调用检查函数
		checkOpacity();
	});

	let html = $(`<div id="ChatGPTVoice-On-LiveKitMeet">
		<div id="ChatGPTVoice-On-LiveKitMeet-Button">
			<svg width="25" height="25" class="icon" fill="none" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
				<path d="M18 12C18 15.3137 15.3137 18 12 18M12 18C8.68629 18 6 15.3137 6 12M12 18V21M12 21H15M12 21H9M15 6V12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12V6C9 4.34315 10.3431 3 12 3C13.6569 3 15 4.34315 15 6Z" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
			</svg>
			<span>语音</span>
		</div>
		<div id="ChatGPTPow-Button" style="background: linear-gradient(140.91deg, #2ecc71 12.61%, #3498db 76.89%);" title="等待站点请求中...">
			<svg width="25" height="25" class="icon" fill="none" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" style="transition: all 0.3s ease;">
                <defs>
                    <linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
                        <stop offset="0%" style="stop-color:#3498db;stop-opacity:1"></stop>
                        <stop offset="100%" style="stop-color:#2ecc71;stop-opacity:1"></stop>
                    </linearGradient>
                    <filter id="glow">
                        <feGaussianBlur stdDeviation="2" result="coloredBlur"></feGaussianBlur>
                        <feMerge>
                            <feMergeNode in="coloredBlur"></feMergeNode>
                            <feMergeNode in="SourceGraphic"></feMergeNode>
                        </feMerge>
                    </filter>
                </defs>
                <g id="icon-group" filter="url(#glow)">
                    <circle cx="32" cy="32" r="28" fill="url(#gradient)" stroke="#fff" stroke-width="2"></circle>
                    <circle cx="32" cy="32" r="20" fill="none" stroke="#fff" stroke-width="2" stroke-dasharray="100">
                        <animateTransform attributeName="transform" attributeType="XML" type="rotate" from="0 32 32" to="360 32 32" dur="8s" repeatCount="indefinite"></animateTransform>
                    </circle>
                    <circle cx="32" cy="32" r="12" fill="none" stroke="#fff" stroke-width="2">
                        <animate attributeName="r" values="12;14;12" dur="2s" repeatCount="indefinite"></animate>
                    </circle>
                    <circle id="center-dot" cx="32" cy="32" r="4" fill="#fff">
                        <animate attributeName="r" values="4;6;4" dur="2s" repeatCount="indefinite"></animate>
                    </circle>
                </g>
            </svg>
			<span>状态</span>
		</div>
		<style id="ChatGPTVoice-On-LiveKitMeet-Style">
			div#ChatGPTVoice-On-LiveKitMeet-Button, div#ChatGPTPow-Button {
				border-top-left-radius: 34px;
				border-bottom-left-radius: 34px;
				background: linear-gradient(140.91deg, #7367F0 12.61%, #574AB8 76.89%);
				height: 34px;
				width: 80px;
				margin: 1px;
				display: flex !important;
				align-items: center;
				position: fixed;
				right: -35px;
				top: calc(80% - 34px);
				cursor: pointer;
				padding-left: 7px;
				z-index: 114514;
				opacity: 0.75;
				transition: right 0.3s, opacity 0.3s !important;
			}
			div#ChatGPTVoice-On-LiveKitMeet-Button:hover, 
			div#ChatGPTVoice-On-LiveKitMeet-Button.is-dragging,
			div#ChatGPTPow-Button:hover,
			div#ChatGPTPow-Button.is-dragging {
				right: -5px;
				opacity: 1;
			}
			div#ChatGPTVoice-On-LiveKitMeet-Button span,
			div#ChatGPTPow-Button span {
				color:#ffffff;
				font-size:15px;
				margin-left:3px;
				white-space: nowrap;
			}
		</style>
	</div>`)

	let button = html.find('#ChatGPTVoice-On-LiveKitMeet-Button');
	let isDragging = false;
	let offsetY = 0;
	let dragStartTime;
	button.css('z-index', 114514 + 1)

	// 从 GM 获取按钮位置
	if (GM_getValue('buttonTop')) {
		button.css('top', GM_getValue('buttonTop') + 'px');
	} else {
		button.css('top', 'calc(30% - 34px)');
	}

	// 点击事件处理
	button.on('click touchend', handleVoiceClick);

	// 鼠标按下事件
	button.on('mousedown touchstart', function (e) {
		e.preventDefault();
		dragStartTime = Date.now(); // 记录拖动开始时间
		offsetY = e.clientY - button.offset().top;
	});

	// 鼠标移动事件
	$(document).on('mousemove touchmove', function (e) {
		if (offsetY !== undefined) {
			let newTop = e.clientY - offsetY;
			const buttonHeight = button.outerHeight();
			const windowHeight = $(window).height();

			// 限制按钮位置
			if (newTop < 0) newTop = 0;
			if (newTop + buttonHeight > windowHeight) newTop = windowHeight - buttonHeight;

			// 判断是否拖动
			if (isDragging || (Date.now() - dragStartTime > 100)) { // 如果已经拖动或拖动时间超过100ms
				isDragging = true;
				button.addClass('is-dragging');
				button.css('top', newTop + 'px');
				GM_setValue('buttonTop', newTop);
			}
		}
	});

	// 鼠标抬起事件
	$(document).on('mouseup touchend', function () {
		if (isDragging) {
			setTimeout(function () {
				isDragging = false;
				button.removeClass('is-dragging');
			}, 100)
		}
		offsetY = undefined; // 重置 offsetY
	});

	setInterval(function () {
		if (!$('#ChatGPTVoice-On-LiveKitMeet-Button').length || !$('#ChatGPTVoice-On-LiveKitMeet-Style').length) {
			$('#ChatGPTVoice-On-LiveKitMeet').remove()
			$('body').append(html);
		}
	}, 500)

	// 绑定点击事件到新创建的按钮
	async function handleVoiceClick(event) {
		if (!event?.currentTarget || isDragging) return;
		let element = $(event.currentTarget);
		if (element.attr('data-clicked') === 'true') return;
		element.attr('data-clicked', 'true');

		// 异步获取语音链接
		await goVoice(element).catch(function (error) {
			alert('获取语音对话(会议)链接错误: \n' + error.message);
			console.error(error);
			element.removeAttr('data-clicked');
		});
	};

	async function goVoice(element) {
		// 定义不同服务器的配置
		let servers = {
			"new.oaifree.com": {
				apiPath: "/api/voice/link",
				apiType: "POST",
				url: "wss://webrtc.oaifree.com",
				model: new URL(location.href).searchParams.get('model'),
				mode: [['标准语音', '高级语音'], ['std', 'adv']],
				getToken: data => new URL(data.url).searchParams.get('token'),
				getHash: data => new URL(data.url).hash
			},
			"chat.rawchat.top": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"chat.rawchat.cc": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"chat.sharedchat.cn": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"chat.chatgptplus.cn": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"free.share-ai.top": {
				apiPath: "/frontend-api/getVoice",
				apiType: "GET",
				url: data => (data.data.voiceServerUrl || data.data.url),
				getToken: data => data.data.token,
				getHash: data => data.data.e2ee_key
			},
			"gpt.github.cn.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"share.github.cn.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"go.gptdie.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"chat.gptdie.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"free.xyhelper.cn": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"free.xyhelper.com.cn": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"chat.freegpts.org": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"go.gptdsb.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"chat.gptdsb.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"www.opkfc.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"chatgpt.dairoot.cn": {
				apiPath: "/api/livekit",
				apiType: "GET",
				url: data => new URL(data.data).searchParams.get('liveKitUrl'),
				getToken: data => new URL(data.data).searchParams.get('token'),
				getHash: data => new URL(data.data).hash
			},
			"web.tu-zi.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
			"share.tu-zi.com": {
				apiPath: "/backend-api/voice_token",
				apiType: "GET",
				url: data => data.url,
				getToken: data => data.token,
				getHash: data => data.e2ee_key
			},
		};

		// 获取当前服务器的域名
		let host = location.hostname;

		// 获取服务器配置
		let config = servers[host];
		if (!config) {
			throw new Error(`未支持当前站点: ${host}`);
		}

		let extra = {
			method: config.apiType,
			headers: { 'Content-Type': 'application/json' }
		}

		if (config.model !== undefined && config.mode !== undefined && config.apiType === 'POST') {
			let model = config.model;
			let mode = config.mode;

			let modeChoice;
			if (mode && mode.length) {
				let modeOptions = mode[0]
					.map((name, index) => `(${index + 1}) ${name}`)
					.join(" ");
				let userChoice = prompt(`请选择语音模式: (不输入则使用${mode[0][0]})\n${modeOptions}`);

				let choiceIndex = parseInt(userChoice) - 1;
				if (choiceIndex >= 0 && choiceIndex < mode[1].length) {
					modeChoice = mode[1][choiceIndex];
				} else if (userChoice === null) {
					return element.removeAttr('data-clicked');
				} else {
					modeChoice = mode[1][0];
				}
			}

			if (!model) {
				let userInput = prompt("请输入模型名称: (不输入则使用默认模型)");
				if (userInput === null) {
					return element.removeAttr('data-clicked');
				}
				model = userInput;
			}

			extra.body = JSON.stringify({ model, mode: modeChoice });
		}

		// 发送请求到语音API
		let response = await unsafeWindow.fetch(config.apiPath, extra);

		// 解析返回的JSON数据
		let data = await response.json();
		console.log('服务数据: \n', data);

		// 检查返回的模式,如果是高级模式,修改颜色
		if (data.mode === "advanced") {
			element.css('color', '#f00');
		}

		// 检查是否有url或者token,否则抛出错误
		function hasUrl(obj) {
			if (obj && typeof obj === 'object') {
				if ('url' in obj) return true; // 如果当前对象包含 url 属性,返回 true
				if ('data' in obj) return true; // 如果当前对象包含 url 属性,返回 true
				return Object.values(obj).some(hasUrl); // 递归检查嵌套的对象
			}
			return false;
		}

		if (data && !hasUrl(data)) {
			throw new Error(data.detail || '语音服务未返回所需数据');
		}

		// 获取url、token、hash
		let url = typeof config.url === 'function' ? config.url(data) : config.url;
		let token = config.getToken ? config.getToken(data) : null;
		let hash = config.getHash ? config.getHash(data) : null;

		// 打印日志方便调试
		console.log('会议数据: \n', { token, hash, url });

		// 检查是否有url或者token,否则抛出错误
		if (!url || !token || !hash) throw new Error(data.detail || '语音服务未返回数据');

		// 构建 meetUrl
		let meetUrl = new URL('https://meet.livekit.io/custom');
		if (url) meetUrl.searchParams.set('liveKitUrl', url);
		if (token) meetUrl.searchParams.set('token', token);
		if (hash) meetUrl.hash = hash;

		// 打开新页面
		GM_openInTab(meetUrl.href, { active: true, insert: true, setParent: true })
		element.removeAttr('data-clicked');
	}

	function waitForKeyElements(selectorTxt, actionFunction, bWaitOnce, iframeSelector) {
		function findInShadowRoots(root, selector) {
			let elements = $(root).find(selector).toArray();
			$(root).find('*').each(function () {
				let shadowRoot = this.shadowRoot;
				if (shadowRoot) {
					elements = elements.concat(findInShadowRoots(shadowRoot, selector));
				}
			});
			return elements;
		}
		var targetElements;
		if (iframeSelector) {
			targetElements = $(iframeSelector).contents();
		} else {
			targetElements = $(document);
		}
		let allElements = findInShadowRoots(targetElements, selectorTxt);
		if (allElements.length > 0) {
			allElements.forEach(function (element) {
				var jThis = $(element);
				var uniqueIdentifier = 'alreadyFound';
				var alreadyFound = jThis.data(uniqueIdentifier) || false;
				if (!alreadyFound) {
					var cancelFound = actionFunction(jThis);
					if (cancelFound) {
						return false;
					} else {
						jThis.data(uniqueIdentifier, true);
					}
				}
			});
		}
		var controlObj = waitForKeyElements.controlObj || {};
		var controlKey = selectorTxt.replace(/[^\w]/g, "_");
		var timeControl = controlObj[controlKey];
		if (allElements.length > 0 && bWaitOnce && timeControl) {
			clearInterval(timeControl);
			delete controlObj[controlKey];
		} else {
			if (!timeControl) {
				timeControl = setInterval(function () {
					waitForKeyElements(selectorTxt, actionFunction, bWaitOnce, iframeSelector);
				}, 1000);
				controlObj[controlKey] = timeControl;
			}
		}
		waitForKeyElements.controlObj = controlObj;
	}
})();