// ==UserScript==
// @name ChatGPT Voice On LiveKit Meet
// @namespace github.com/hmjz100
// @version 1.0.2
// @description 跳转 ChatGPT 镜像站的语音功能到 LiveKit Meet 而不是镜像站的 LiveKit Meet。始皇的镜像站甚至支持选择高级语音模式和选择模型
// @license MIT
// @author hmjz100
// @match https://new.oaifree.com/*
// @match https://chat.rawchat.top/*
// @match https://chat.sharedchat.cn/*
// @match https://chat.chatgptplus.cn/*
// @match https://gpt.github.cn.com/*
// @match https://free.xyhelper.cn/*
// @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==
// @require https://unpkg.com/jquery@3.6.3/dist/jquery.min.js
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_openInTab
// @grant unsafeWindow
// ==/UserScript==
(function () {
'use strict';
// 隐藏原来的按钮
waitForKeyElements('div:not(#ChatGPTVoice-On-LiveKitMeet-Button, #immersive-translate-popup) svg.icon[width="25"][height="25"], div#voiceButton svg, #of-custom-floating-ball svg', 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>
<style id="ChatGPTVoice-On-LiveKitMeet-Style">
div#ChatGPTVoice-On-LiveKitMeet-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(30% - 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 {
right: -5px;
opacity: 1;
}
div#ChatGPTVoice-On-LiveKitMeet-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;
// 从 GM 获取按钮位置
if (GM_getValue('buttonTop')) {
button.css('top', GM_getValue('buttonTop') + 'px');
}
// 点击事件处理
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.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
},
"gpt.github.cn.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
}
};
// 获取当前服务器的域名
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,否则抛出错误
if (!data.url) {
throw new Error(data.detail || 'No Data provided by server');
}
// 获取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;
}
})();