Greasy Fork is available in English.
拒绝复制粘贴!一键将你的问题分发给 ChatGPT、Claude、Gemini、豆包、Kimi 等所有 AI 模型。在任意 AI 网站提问,脚本会自动将问题同步到其他已打开的 AI 标签页。助你快速横向对比模型效果,效率提升 10 倍。
当前为
// ==UserScript==
// @name AI 对话助手(一键同步多模型)
// @name:zh-CN AI 对话助手(一键同步多模型)
// @name:en AI Chat Assistant (One-click Sync Multi-Model)
// @namespace https://github.com/YHangbin
// @version 1.0
// @description 拒绝复制粘贴!一键将你的问题分发给 ChatGPT、Claude、Gemini、豆包、Kimi 等所有 AI 模型。在任意 AI 网站提问,脚本会自动将问题同步到其他已打开的 AI 标签页。助你快速横向对比模型效果,效率提升 10 倍。
// @description:en Do not copy and paste! Sync your questions to ChatGPT, Claude, Gemini, Doubao, Kimi and other AI models with one click.
// @author Gemini 2.5 Pro & User
// @match https://doubao.com/chat/*
// @match https://www.doubao.com/chat/*
// @match https://chat.qwen.ai/*
// @match https://tongyi.com/*
// @match https://www.tongyi.com/*
// @match https://aistudio.google.com/*
// @match https://gemini.google.com/*
// @match https://chatgpt.com/*
// @match https://yuanbao.tencent.com/*
// @match https://chat.deepseek.com/*
// @match https://kimi.com/*
// @match https://www.kimi.com/*
// @match https://claude.ai/*
// @match https://grok.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
/*
* =================================================================================================
* --- 功能简介与使用说明 ---
*
* 【AI 对话助手(一键同步多模型)】
*
* 核心目标:拒绝复制粘贴,实现“一处提问,多处同步”。
*
* 核心亮点:
* 1. 光速同步: 在一个页面输入,所有模型即刻响应。
* 2. 双向互通: 不分“主次”,任何一个聊天窗口都可以作为控制台。
* 3. 原生体验: 非弹窗式设计,使用网站原生的输入框,保留所有富文本功能。
* 4. 广泛支持: 适配 ChatGPT, Claude, Gemini, 豆包, Kimi, 通义千问, DeepSeek, Grok 等。
*
* 简易使用说明:
* 1. 打开面板: 点击页面右下角的悬浮按钮。
* 2. 选择目标:
* - 灰色 (点击启动): 标签页未打开,点击自动打开。
* - 蓝色边框 (待发送): 标签页已打开,点击即可选中。
* - 蓝色填充 (已选中): 问题将同步发送给这些模型。
* 3. 发送问题: 在当前输入框正常提问,脚本自动分发。
* 4. 管理模型: 点击齿轮图标,自定义显示哪些常用模型。
* =================================================================================================
*/
(function () {
'use strict';
/**
* @class AITabSync
* @description Core application object for the AI Tab Sync userscript.
* Encapsulates all state, configuration, and logic.
*/
const AITabSync = {
// ===================================================================================
// --- 1. State Management ---
// ===================================================================================
state: {
thisSite: null,
visibleTargets: [],
selectedTargets: new Set(),
isLoggingEnabled: false,
isSubmitting: false,
isProcessingTask: false,
menuCommandId: null,
tooltipTimeoutId: null,
},
// ===================================================================================
// --- 2. Configuration ---
// ===================================================================================
config: {
SCRIPT_VERSION: '1.0',
KEYS: {
SHARED_QUERY: 'multi_sync_query_v1.0',
ACTIVE_TABS: 'multi_sync_active_tabs_v1.0',
LOGGING_ENABLED: 'multi_sync_logging_v1.0',
VISIBLE_TARGETS: 'multi_sync_visible_targets_v1.0',
},
TIMINGS: {
HEARTBEAT_INTERVAL: 5000,
STALE_THRESHOLD: 15000,
CLEANUP_INTERVAL: 10000,
SUBMIT_TIMEOUT: 20000, // Increased timeout for complex SPAs
HUMAN_LIKE_DELAY: 500,
FRESHNESS_THRESHOLD: 5000,
TOOLTIP_DELAY: 300,
},
DISPLAY_ORDER: ['AI_STUDIO', 'GEMINI', 'TONGYI', 'QWEN', 'YUANBAO', 'CHATGPT', 'CLAUDE', 'DOUBAO', 'DEEPSEEK', 'KIMI', 'GROK'],
SITES: {
GROK: {
id: 'GROK',
name: 'Grok',
host: 'grok.com',
url: 'https://grok.com/',
apiPaths: ['/rest/app-chat/conversations/'],
inputSelectors: ['div.tiptap.ProseMirror'],
queryExtractor: (body) => {
try {
return JSON.parse(body)?.message || '';
} catch (e) {
return '';
}
},
},
CLAUDE: {
id: 'CLAUDE',
name: 'Claude',
host: 'claude.ai',
url: 'https://claude.ai/new',
apiPaths: ['/api/organizations/', '/completion'],
inputSelectors: ['div[contenteditable="true"][role="textbox"]'],
queryExtractor: (body) => {
try {
return JSON.parse(body)?.prompt || '';
} catch (e) {
return '';
}
},
},
KIMI: {
id: 'KIMI',
name: 'Kimi',
host: 'kimi.com',
url: 'https://www.kimi.com/',
apiPaths: ['/apiv2/kimi.gateway.chat.v1.ChatService/Chat'],
inputSelectors: ['[data-lexical-editor="true"]'],
queryExtractor: (body) => {
try {
const firstBraceIndex = body.indexOf('{');
const lastBraceIndex = body.lastIndexOf('}');
if (firstBraceIndex === -1 || lastBraceIndex < firstBraceIndex) return '';
const jsonString = body.substring(firstBraceIndex, lastBraceIndex + 1);
return JSON.parse(jsonString)?.message?.blocks?.[0]?.text?.content || '';
} catch (e) {
return '';
}
},
},
GEMINI: {
id: 'GEMINI',
name: 'Gemini',
host: 'gemini.google.com',
url: 'https://gemini.google.com/app',
apiPaths: ['/StreamGenerate'],
inputSelectors: ['div.ql-editor[contenteditable="true"]'],
queryExtractor: (body) => {
try {
const params = new URLSearchParams(body);
const f_req = params.get('f.req');
if (!f_req) return '';
const outerArray = JSON.parse(f_req);
const innerJsonString = outerArray?.[1];
if (!innerJsonString) return '';
const innerArray = JSON.parse(innerJsonString);
const query = innerArray?.[0]?.[0];
return typeof query === 'string' ? query : '';
} catch (e) {
return '';
}
},
},
YUANBAO: {
id: 'YUANBAO',
name: '元宝',
host: 'yuanbao.tencent.com',
url: 'https://yuanbao.tencent.com/',
apiPaths: ['/api/chat/'],
inputSelectors: ['.ql-editor[contenteditable="true"]'],
queryExtractor: (body) => {
try {
return JSON.parse(body)?.prompt || '';
} catch (e) {
return '';
}
},
},
DEEPSEEK: {
id: 'DEEPSEEK',
name: 'DeepSeek',
host: 'chat.deepseek.com',
url: 'https://chat.deepseek.com/',
apiPaths: ['/api/v0/chat/completion'],
inputSelectors: ['textarea[placeholder="给 DeepSeek 发送消息 "]'],
queryExtractor: (body) => {
try {
return JSON.parse(body)?.prompt || '';
} catch (e) {
return '';
}
},
},
DOUBAO: {
id: 'DOUBAO',
name: '豆包',
host: 'doubao.com',
url: 'https://www.doubao.com/chat/',
apiPaths: ['/samantha/chat/completion'],
inputSelectors: ['textarea[data-testid="chat_input_input"]'],
queryExtractor: (body) => {
try {
const outerJson = JSON.parse(body);
const innerJsonString = outerJson?.messages?.[0]?.content;
if (!innerJsonString) return '';
const innerJson = JSON.parse(innerJsonString);
return innerJson?.text || '';
} catch (e) {
return '';
}
},
},
QWEN: {
id: 'QWEN',
name: 'Qwen',
host: 'chat.qwen.ai',
url: 'https://chat.qwen.ai/',
apiPaths: ['/api/v2/chat/completions'],
inputSelectors: ['textarea#chat-input', 'textarea[data-testid="yuntu-textarea"]'],
queryExtractor: (body) => {
try {
return JSON.parse(body)?.messages?.slice(-1)?.[0]?.content || '';
} catch (e) {
return '';
}
},
},
TONGYI: {
id: 'TONGYI',
name: '通义',
host: 'tongyi.com',
url: 'https://www.tongyi.com/',
apiPaths: ['/dialog/conversation'],
inputSelectors: ['div[class*="textareaWrap"] textarea', 'textarea[class*="ant-input"]'],
queryExtractor: (body) => {
try {
return JSON.parse(body)?.contents?.[0]?.content || '';
} catch (e) {
return '';
}
},
},
AI_STUDIO: {
id: 'AI_STUDIO',
name: 'AI Studio',
host: 'aistudio.google.com',
url: 'https://aistudio.google.com/prompts/new_chat',
apiPaths: ['/GenerateContent'],
inputSelectors: ['ms-autosize-textarea textarea'],
queryExtractor: (body) => {
try {
const json = JSON.parse(body);
const messages = json?.[1];
if (Array.isArray(messages)) {
for (let i = messages.length - 1; i >= 0; i--) {
const msgBlock = messages[i];
if (Array.isArray(msgBlock) && msgBlock[1] === 'user') {
return msgBlock[0]?.[0]?.[1] || '';
}
}
}
return '';
} catch (e) {
return '';
}
},
},
CHATGPT: {
id: 'CHATGPT',
name: 'ChatGPT',
host: 'chatgpt.com',
url: 'https://chatgpt.com/',
apiPaths: ['/backend-api/conversation', '/backend-api/f/conversation'],
inputSelectors: ['#prompt-textarea'],
queryExtractor: (body) => {
try {
const json = JSON.parse(body);
const lastMessage = json?.messages?.slice(-1)?.[0];
return lastMessage?.content?.parts?.[0] || '';
} catch (e) {
return '';
}
},
},
},
},
// ===================================================================================
// --- 3. Cached Elements ---
// ===================================================================================
elements: {
container: null,
fab: null,
chipsContainer: null,
settingsModal: null,
tooltip: null,
},
// ===================================================================================
// --- 4. Utility Methods ---
// ===================================================================================
utils: {
log(message, ...optionalParams) {
if (!AITabSync.state.isLoggingEnabled || typeof console === 'undefined') return;
console.log(`%c[AI Sync v${AITabSync.config.SCRIPT_VERSION}] ${message}`, 'color: #1976D2; font-weight: bold;', ...optionalParams);
},
waitFor(conditionFn, timeout, description) {
return new Promise((resolve, reject) => {
let result = conditionFn();
if (result) return resolve(result);
let timeoutId = null;
const observer = new MutationObserver(() => {
result = conditionFn();
if (result) {
if (timeoutId) clearTimeout(timeoutId);
observer.disconnect();
resolve(result);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
});
timeoutId = setTimeout(() => {
observer.disconnect();
const lastResult = conditionFn();
lastResult ? resolve(lastResult) : reject(new Error(`waitFor timed out after ${timeout}ms for: ${description}`));
}, timeout);
});
},
deepQuerySelector(selector, root = document) {
try {
const el = root.querySelector(selector);
if (el) return el;
} catch (e) {
/* ignore */
}
for (const host of root.querySelectorAll('*')) {
if (host.shadowRoot) {
const found = AITabSync.utils.deepQuerySelector(selector, host.shadowRoot);
if (found) return found;
}
}
return null;
},
getCurrentSiteInfo() {
const { SITES } = AITabSync.config;
const currentHost = window.location.hostname;
if (currentHost.includes('chatgpt.com')) return SITES.CHATGPT;
for (const siteKey in SITES) {
if (Object.prototype.hasOwnProperty.call(SITES, siteKey) && currentHost.includes(SITES[siteKey].host)) {
return SITES[siteKey];
}
}
return null;
},
simulateInput(element, value) {
element.focus();
const siteId = AITabSync.state.thisSite?.id;
// Handle complex rich-text editors with specific event-based methods first.
if (siteId === 'GROK') {
// Grok (Tiptap/ProseMirror) responds well to simulated paste events.
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', value);
const pasteEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true,
});
element.dispatchEvent(pasteEvent);
} else if (siteId === 'KIMI') {
// Kimi (Lexical) responds to 'beforeinput' events.
const beforeInputEvent = new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
composed: true,
inputType: 'insertText',
data: value,
});
element.dispatchEvent(beforeInputEvent);
} else if (element.isContentEditable || element.contentEditable === 'true') {
// Generic handler for other contentEditable elements (like Gemini, Claude).
if (siteId === 'CLAUDE') {
element.innerHTML = `<p>${value}</p>`; // Claude expects a paragraph.
} else {
element.textContent = value;
}
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
} else if (element.tagName === 'TEXTAREA') {
// Standard handler for <textarea> elements.
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
valueSetter.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
}
},
},
// ===================================================================================
// --- 5. UI Module ---
// ===================================================================================
ui: {
injectStyle() {
GM_addStyle(`
@keyframes ai-sync-halo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
:root {
--ai-sync-active-color: #0d6efd;
--ai-sync-theme-color: #0d6efd;
--ai-sync-offline-color: #6c757d;
}
#ai-sync-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 99998;
display: flex;
align-items: flex-end;
gap: 12px;
pointer-events: none;
}
#ai-sync-container.expanded {
pointer-events: auto;
}
#ai-sync-toggle-fab {
position: relative;
width: 36px;
height: 36px;
background-color: #fff;
border: 1px solid #e0e0e0;
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
color: #212121;
pointer-events: auto;
transition: all 0.2s;
}
#ai-sync-toggle-fab:hover {
border-color: #c0c0c0;
transform: scale(1.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
#ai-sync-toggle-fab:focus-visible {
outline: 2px solid var(--ai-sync-theme-color);
outline-offset: 2px;
}
#ai-sync-toggle-fab::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
width: calc(100% + 4px);
height: calc(100% + 4px);
border-radius: 50%;
background: conic-gradient(from 180deg, transparent 0%, transparent 70%, var(--ai-sync-theme-color) 100%);
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
#ai-sync-toggle-fab.sending::before {
opacity: 1;
animation: ai-sync-halo-spin 1.2s linear infinite;
}
.ai-sync-fab-badge {
position: absolute;
top: -2px;
right: -4px;
background-color: var(--ai-sync-theme-color);
color: white;
border-radius: 8px;
padding: 0 5px;
font-size: 11px;
font-weight: 600;
line-height: 16px;
min-width: 16px;
text-align: center;
border: 1px solid white;
}
#ai-sync-content-panel {
display: inline-block;
background-color: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(8px);
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 12px 16px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
opacity: 0;
transform: translateX(15px);
transition: none;
visibility: hidden;
}
#ai-sync-container.expanded #ai-sync-content-panel {
opacity: 1;
transform: translateX(0);
visibility: visible;
}
#ai-sync-panel-title-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
#ai-sync-panel-title {
font-weight: 600;
font-size: 15px;
color: #333;
}
#ai-sync-settings-btn {
all: unset;
cursor: pointer;
color: #757575;
padding: 2px;
border-radius: 4px;
line-height: 1;
}
#ai-sync-settings-btn:hover {
color: #212121;
background-color: #f0f0f0;
}
#ai-sync-chips-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 240px;
}
#ai-sync-chips-container > i {
flex-grow: 1;
}
.ai-sync-chip {
all: unset;
box-sizing: border-box;
cursor: pointer;
padding: 5px 10px;
border-radius: 16px;
font-size: 13px;
line-height: 1.4;
border: 1.5px solid var(--ai-sync-offline-color);
color: var(--ai-sync-offline-color);
background-color: #fff;
transition: all 0.2s ease;
user-select: none;
text-align: center;
flex-grow: 1;
}
.ai-sync-chip:not(.selected):hover {
background-color: #f5f5f5;
}
.ai-sync-chip:active {
transform: scale(0.96);
}
.ai-sync-chip.online {
border-color: var(--ai-sync-active-color);
color: var(--ai-sync-active-color);
}
.ai-sync-chip.selected {
background-color: var(--ai-sync-active-color);
border-color: var(--ai-sync-active-color);
color: white;
}
#ai-sync-settings-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.4);
z-index: 99999;
justify-content: center;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#ai-sync-settings-panel {
background-color: #fff;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15);
width: 250px;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-sync-settings-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #212121;
}
.ai-sync-settings-list {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
gap: 12px;
}
.ai-sync-settings-item label {
display: flex;
align-items: center;
cursor: pointer;
font-size: 14px;
color: #424242;
}
.ai-sync-settings-item input[type="checkbox"] {
margin-right: 8px;
accent-color: var(--ai-sync-theme-color);
}
#ai-sync-custom-tooltip {
display: none;
position: fixed;
background-color: rgba(249, 249, 249, 0.98);
backdrop-filter: blur(8px);
color: #212121;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
z-index: 100000;
pointer-events: none;
transform: translate(-50%, -100%);
white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
`);
},
createMainPanel() {
if (document.getElementById('ai-sync-container')) return;
const { elements } = AITabSync;
const svgNS = 'http://www.w3.org/2000/svg';
elements.container = document.createElement('div');
elements.container.id = 'ai-sync-container';
const panel = document.createElement('div');
panel.id = 'ai-sync-content-panel';
const titleWrapper = document.createElement('div');
titleWrapper.id = 'ai-sync-panel-title-wrapper';
const title = document.createElement('span');
title.id = 'ai-sync-panel-title';
title.textContent = '发送给:';
titleWrapper.appendChild(title);
const settingsBtn = document.createElement('button');
settingsBtn.id = 'ai-sync-settings-btn';
settingsBtn.title = '自定义常用模型';
const settingsSvg = document.createElementNS(svgNS, 'svg');
settingsSvg.setAttribute('width', '16');
settingsSvg.setAttribute('height', '16');
settingsSvg.setAttribute('fill', 'currentColor');
settingsSvg.setAttribute('viewBox', '0 0 16 16');
const settingsPath = document.createElementNS(svgNS, 'path');
settingsPath.setAttribute(
'd',
'M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311a1.464 1.464 0 0 1-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.169.311c-.698 1.283.705 2.686 1.987 1.987l.31.17a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.169-.311c.698-1.283-.705-2.686-1.987-1.987l-.31-.17a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z',
);
settingsSvg.appendChild(settingsPath);
settingsBtn.appendChild(settingsSvg);
titleWrapper.appendChild(settingsBtn);
panel.appendChild(titleWrapper);
elements.chipsContainer = this.buildChipsContainer();
panel.appendChild(elements.chipsContainer);
elements.fab = document.createElement('button');
elements.fab.id = 'ai-sync-toggle-fab';
elements.fab.title = 'AI 对话助手';
elements.fab.setAttribute('aria-label', 'AI 对话助手');
const fabSvg = document.createElementNS(svgNS, 'svg');
fabSvg.setAttribute('width', '20');
fabSvg.setAttribute('height', '20');
fabSvg.setAttribute('viewBox', '0 0 24 24');
fabSvg.setAttribute('fill', 'none');
fabSvg.setAttribute('stroke', 'currentColor');
fabSvg.setAttribute('stroke-width', '1.2');
fabSvg.setAttribute('stroke-linecap', 'round');
fabSvg.setAttribute('stroke-linejoin', 'round');
fabSvg.setAttribute('aria-hidden', 'true');
const fabRect = document.createElementNS(svgNS, 'rect');
fabRect.setAttribute('x', '9');
fabRect.setAttribute('y', '9');
fabRect.setAttribute('width', '13');
fabRect.setAttribute('height', '13');
fabRect.setAttribute('rx', '2');
fabRect.setAttribute('ry', '2');
const fabPath = document.createElementNS(svgNS, 'path');
fabPath.setAttribute('d', 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1');
fabSvg.appendChild(fabRect);
fabSvg.appendChild(fabPath);
elements.fab.appendChild(fabSvg);
elements.container.appendChild(panel);
elements.container.appendChild(elements.fab);
document.body.appendChild(elements.container);
},
createSettingsModal() {
if (document.getElementById('ai-sync-settings-overlay')) return;
const { config } = AITabSync;
const overlay = document.createElement('div');
overlay.id = 'ai-sync-settings-overlay';
const panel = document.createElement('div');
panel.id = 'ai-sync-settings-panel';
const title = document.createElement('h2');
title.className = 'ai-sync-settings-title';
title.textContent = '自定义常用模型';
panel.appendChild(title);
const list = document.createElement('div');
list.className = 'ai-sync-settings-list';
config.DISPLAY_ORDER.forEach((siteId) => {
const site = config.SITES[siteId];
if (!site) return;
const item = document.createElement('div');
item.className = 'ai-sync-settings-item';
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = siteId;
checkbox.checked = AITabSync.state.visibleTargets.includes(siteId);
label.appendChild(checkbox);
label.appendChild(document.createTextNode(` ${site.name}`));
item.appendChild(label);
list.appendChild(item);
});
panel.appendChild(list);
overlay.appendChild(panel);
document.body.appendChild(overlay);
AITabSync.elements.settingsModal = overlay;
},
createTooltip() {
if (document.getElementById('ai-sync-custom-tooltip')) return;
AITabSync.elements.tooltip = document.createElement('div');
AITabSync.elements.tooltip.id = 'ai-sync-custom-tooltip';
document.body.appendChild(AITabSync.elements.tooltip);
},
buildChipsContainer() {
const { config, state } = AITabSync;
const container = document.createElement('div');
container.id = 'ai-sync-chips-container';
const targetsToDisplay = config.DISPLAY_ORDER.filter((id) => state.visibleTargets.includes(id) && id !== state.thisSite.id);
targetsToDisplay.forEach((siteId) => {
const site = config.SITES[siteId];
if (!site) return;
const chip = document.createElement('button');
chip.className = 'ai-sync-chip';
chip.dataset.siteId = site.id;
chip.textContent = site.name;
container.appendChild(chip);
});
container.appendChild(document.createElement('i'));
container.appendChild(document.createElement('i'));
return container;
},
async rebuildChipsUI() {
const { elements } = AITabSync;
const oldContainer = elements.chipsContainer || document.getElementById('ai-sync-chips-container');
if (oldContainer && oldContainer.parentElement) {
const newContainer = this.buildChipsContainer();
oldContainer.parentElement.replaceChild(newContainer, oldContainer);
elements.chipsContainer = newContainer;
await this.updatePanelState();
}
},
async updatePanelState() {
const activeTabs = JSON.parse(await GM_getValue(AITabSync.config.KEYS.ACTIVE_TABS, '{}'));
document.querySelectorAll('.ai-sync-chip').forEach((chip) => {
const siteId = chip.dataset.siteId;
chip.classList.toggle('online', !!activeTabs[siteId]);
chip.classList.toggle('selected', AITabSync.state.selectedTargets.has(siteId));
});
this.updateFabBadge();
},
updateFabBadge() {
const { fab } = AITabSync.elements;
if (!fab) return;
const count = AITabSync.state.selectedTargets.size;
let badge = fab.querySelector('.ai-sync-fab-badge');
if (count > 0) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'ai-sync-fab-badge';
fab.appendChild(badge);
}
badge.textContent = count;
} else {
badge?.remove();
}
},
togglePanelVisibility() {
const { container } = AITabSync.elements;
if (!container) return;
container.classList.toggle('expanded');
if (container.classList.contains('expanded')) {
this.updatePanelState();
document.addEventListener('click', AITabSync.events.onClickOutside, true);
} else {
document.removeEventListener('click', AITabSync.events.onClickOutside, true);
}
},
updateMenuCommand() {
const { state } = AITabSync;
if (state.menuCommandId) GM_unregisterMenuCommand(state.menuCommandId);
const label = state.isLoggingEnabled ? '停用调试日志' : '启用调试日志';
state.menuCommandId = GM_registerMenuCommand(label, AITabSync.events.onToggleLogging);
},
},
// ===================================================================================
// --- 6. Event Handlers ---
// ===================================================================================
events: {
register() {
const { elements, ui } = AITabSync;
elements.fab.addEventListener('click', (e) => {
e.stopPropagation();
ui.togglePanelVisibility();
});
elements.container.addEventListener('click', this.onChipClick);
elements.container.querySelector('#ai-sync-settings-btn').addEventListener('click', () => {
if (elements.settingsModal) elements.settingsModal.style.display = 'flex';
});
elements.settingsModal.addEventListener('click', (e) => {
if (e.target === elements.settingsModal) elements.settingsModal.style.display = 'none';
});
elements.settingsModal.querySelector('.ai-sync-settings-list').addEventListener('change', this.onSettingsChange);
elements.container.addEventListener('mouseover', this.onChipMouseOver, true);
elements.container.addEventListener('mouseout', this.onChipMouseOut, true);
},
async onChipClick(event) {
if (!event.target.matches('.ai-sync-chip')) return;
const { config, state, ui, utils } = AITabSync;
const chip = event.target;
const siteId = chip.dataset.siteId;
const siteInfo = config.SITES[siteId];
if (!siteInfo) return;
if (state.selectedTargets.has(siteId)) {
state.selectedTargets.delete(siteId);
} else {
state.selectedTargets.add(siteId);
const activeTabs = JSON.parse(await GM_getValue(config.KEYS.ACTIVE_TABS, '{}'));
if (!activeTabs[siteId]) {
utils.log(`选择了离线目标 ${siteId},正在打开新标签页...`);
window.open(siteInfo.url, `ai_sync_window_for_${siteId}`);
}
}
ui.updatePanelState();
},
async onSettingsChange(event) {
if (event.target.type !== 'checkbox') return;
const { config } = AITabSync;
const list = event.currentTarget;
const checkboxes = list.querySelectorAll('input[type="checkbox"]:checked');
const newVisibleTargets = Array.from(checkboxes).map((cb) => cb.value);
await GM_setValue(config.KEYS.VISIBLE_TARGETS, newVisibleTargets);
},
onClickOutside(event) {
const { container } = AITabSync.elements;
if (container && !container.contains(event.target) && container.classList.contains('expanded')) {
AITabSync.ui.togglePanelVisibility();
}
},
onChipMouseOver(event) {
if (!event.target.matches('.ai-sync-chip')) return;
const { state, config, elements } = AITabSync;
const chip = event.target;
const siteId = chip.dataset.siteId;
let tooltipText = '';
if (state.selectedTargets.has(siteId)) tooltipText = '已选中 (点击取消)';
else if (chip.classList.contains('online')) tooltipText = '待发送 (点击选中)';
else tooltipText = '点击启动';
state.tooltipTimeoutId = setTimeout(() => {
elements.tooltip.textContent = tooltipText;
const chipRect = chip.getBoundingClientRect();
elements.tooltip.style.left = `${chipRect.left + chipRect.width / 2}px`;
elements.tooltip.style.top = `${chipRect.top - 10}px`;
elements.tooltip.style.display = 'block';
}, config.TIMINGS.TOOLTIP_DELAY);
},
onChipMouseOut(event) {
if (!event.target.matches('.ai-sync-chip')) return;
clearTimeout(AITabSync.state.tooltipTimeoutId);
AITabSync.elements.tooltip.style.display = 'none';
},
async onToggleLogging() {
const { state, ui } = AITabSync;
state.isLoggingEnabled = !state.isLoggingEnabled;
await GM_setValue(AITabSync.config.KEYS.LOGGING_ENABLED, state.isLoggingEnabled);
alert(`[AI Sync] 调试日志 ${state.isLoggingEnabled ? '已开启' : '已关闭'}。`);
ui.updateMenuCommand();
},
},
// ===================================================================================
// --- 7. Lifecycle & Background Tasks ---
// ===================================================================================
lifecycle: {
ensureWindowName() {
const { thisSite } = AITabSync.state;
if (!thisSite) return;
const expectedName = `ai_sync_window_for_${thisSite.id}`;
if (window.name !== expectedName) window.name = expectedName;
},
deployHistoryInterceptor() {
const { thisSite } = AITabSync.state;
if (!thisSite) return;
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
let lastUrl = location.href;
const handleUrlChange = () => {
setTimeout(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
this.ensureWindowName();
this.registerTabAsActive();
}
}, 100);
};
history.pushState = function (...args) {
originalPushState.apply(this, args);
handleUrlChange();
};
history.replaceState = function (...args) {
originalReplaceState.apply(this, args);
handleUrlChange();
};
window.addEventListener('popstate', handleUrlChange);
},
async registerTabAsActive() {
const { thisSite } = AITabSync.state;
if (!thisSite) return;
try {
const activeTabs = JSON.parse(await GM_getValue(AITabSync.config.KEYS.ACTIVE_TABS, '{}'));
activeTabs[thisSite.id] = { url: window.location.href, timestamp: Date.now() };
await GM_setValue(AITabSync.config.KEYS.ACTIVE_TABS, JSON.stringify(activeTabs));
} catch (e) {
AITabSync.utils.log('心跳注册失败:', e);
}
},
async unregisterTabAsInactive() {
const { thisSite } = AITabSync.state;
if (!thisSite) return;
// This is a fire-and-forget call, not guaranteed to complete on all browsers.
try {
const key = AITabSync.config.KEYS.ACTIVE_TABS;
const json = await GM_getValue(key, '{}');
const activeTabs = JSON.parse(json);
if (activeTabs[thisSite.id]) {
delete activeTabs[thisSite.id];
await GM_setValue(key, JSON.stringify(activeTabs));
AITabSync.utils.log(`Tab un-registered due to unload.`);
}
} catch (e) {
// This might happen if the page is torn down too quickly.
}
},
async cleanupStaleTabs() {
try {
const activeTabs = JSON.parse(await GM_getValue(AITabSync.config.KEYS.ACTIVE_TABS, '{}'));
const now = Date.now();
let hasChanged = false;
for (const siteId in activeTabs) {
if (Object.prototype.hasOwnProperty.call(activeTabs, siteId)) {
const tabInfo = activeTabs[siteId];
const isStale =
typeof tabInfo !== 'object' || tabInfo === null || typeof tabInfo.timestamp !== 'number' || now - tabInfo.timestamp > AITabSync.config.TIMINGS.STALE_THRESHOLD;
if (isStale) {
delete activeTabs[siteId];
hasChanged = true;
}
}
}
if (hasChanged) await GM_setValue(AITabSync.config.KEYS.ACTIVE_TABS, JSON.stringify(activeTabs));
} catch (e) {
AITabSync.utils.log('清理陈旧标签页时出错:', e);
}
},
},
// ===================================================================================
// --- 8. Communication Module ---
// ===================================================================================
comms: {
deployNetworkInterceptor() {
const { thisSite } = AITabSync.state;
if (!thisSite?.queryExtractor) {
AITabSync.utils.log(`[${thisSite.name}] 不支持作为发送方,跳过网络拦截器部署。`);
return;
}
const { send } = unsafeWindow.XMLHttpRequest.prototype;
if (!send._isHooked) {
const { open } = unsafeWindow.XMLHttpRequest.prototype;
unsafeWindow.XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._url = url;
return open.apply(this, [method, url, ...args]);
};
unsafeWindow.XMLHttpRequest.prototype.send = function (body) {
const site = AITabSync.utils.getCurrentSiteInfo();
if (site?.apiPaths.some((p) => this._url?.includes(p)) && body && typeof body === 'string' && !AITabSync.state.isSubmitting) {
const query = site.queryExtractor(body);
if (query) AITabSync.comms.handleQueryFound(query, site);
}
return send.apply(this, arguments);
};
unsafeWindow.XMLHttpRequest.prototype.send._isHooked = true;
}
const { fetch } = unsafeWindow;
if (!fetch._isHooked) {
unsafeWindow.fetch = async function (...args) {
const site = AITabSync.utils.getCurrentSiteInfo();
const url = args[0] instanceof Request ? args[0].url : args[0];
const config = args[1] || {};
if (site?.apiPaths.some((p) => url.includes(p)) && (config.method || 'GET').toUpperCase() === 'POST' && !AITabSync.state.isSubmitting) {
try {
// Ensure body is not FormData or Blob before decoding to avoid errors
if (config.body && typeof config.body !== 'string' && !(config.body instanceof FormData) && !(config.body instanceof Blob)) {
const body = config.body instanceof Uint8Array ? new TextDecoder().decode(config.body) : config.body;
if (typeof body === 'string') {
const query = site.queryExtractor(body);
if (query) AITabSync.comms.handleQueryFound(query, site);
}
} else if (typeof config.body === 'string') {
const query = site.queryExtractor(config.body);
if (query) AITabSync.comms.handleQueryFound(query, site);
}
} catch (e) {
AITabSync.utils.log(`[${site.name}] 处理 Fetch Body 出错:`, e);
}
}
return fetch.apply(this, args);
};
unsafeWindow.fetch._isHooked = true;
}
},
async handleQueryFound(query, sourceSite) {
const { utils, state, config, elements } = AITabSync;
utils.log(`成功拦截到问题: "${query}"`);
const targets = Array.from(state.selectedTargets);
if (targets.length === 0) {
utils.log('未选择任何同步目标,操作取消。');
return;
}
utils.log(`准备同步到: ${targets.join(', ')}`);
if (elements.fab) {
elements.fab.classList.add('sending');
setTimeout(() => elements.fab.classList.remove('sending'), 2000);
}
const message = { query, timestamp: Date.now(), sourceId: sourceSite.id, targetIds: targets };
await GM_setValue(config.KEYS.SHARED_QUERY, JSON.stringify(message));
utils.log('广播完成。');
},
async processSharedQuery(value) {
const { state, utils, config } = AITabSync;
if (state.isProcessingTask) return;
state.isProcessingTask = true;
try {
if (!value) return;
const data = JSON.parse(value);
if (!data.targetIds?.includes(state.thisSite.id) || Date.now() - data.timestamp >= config.TIMINGS.FRESHNESS_THRESHOLD) return;
const remainingTargets = data.targetIds.filter((id) => id !== state.thisSite.id);
if (remainingTargets.length > 0) {
await GM_setValue(config.KEYS.SHARED_QUERY, JSON.stringify({ ...data, targetIds: remainingTargets }));
} else {
await GM_deleteValue(config.KEYS.SHARED_QUERY);
}
await this.processSubmission(state.thisSite, data.query);
} catch (e) {
utils.log('处理共享任务失败:', e);
await GM_deleteValue(config.KEYS.SHARED_QUERY);
} finally {
state.isProcessingTask = false;
}
},
async processSubmission(site, query) {
const { utils, config, state } = AITabSync;
const inputArea = await utils.waitFor(
() => site.inputSelectors.map((s) => utils.deepQuerySelector(s)).find(Boolean),
config.TIMINGS.SUBMIT_TIMEOUT,
'输入框',
);
utils.simulateInput(inputArea, query);
await new Promise((resolve) => setTimeout(resolve, config.TIMINGS.HUMAN_LIKE_DELAY));
try {
state.isSubmitting = true;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const eventType = site.id === 'QWEN' ? 'keypress' : 'keydown';
const useModifierKey = site.id === 'AI_STUDIO'; // Check if the site requires a modifier key
const enterEvent = new KeyboardEvent(eventType, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
ctrlKey: useModifierKey && !isMac, // Use Ctrl if it's the target site and not a Mac
metaKey: useModifierKey && isMac, // Use Cmd if it's the target site and is a Mac
shiftKey: false,
});
inputArea.dispatchEvent(enterEvent);
utils.log(`[${site.name}] 成功分派 ${eventType} 事件。`);
setTimeout(() => (state.isSubmitting = false), 2000);
} catch (error) {
utils.log(`提交过程中发生错误 (Enter键模拟): ${error.message}`);
state.isSubmitting = false;
}
},
async initReceiver() {
const { utils, config } = AITabSync;
try {
await utils.waitFor(
() => AITabSync.state.thisSite.inputSelectors.map((s) => utils.deepQuerySelector(s)).find(Boolean),
config.TIMINGS.SUBMIT_TIMEOUT,
'UI就绪 (输入框出现)',
);
const value = await GM_getValue(config.KEYS.SHARED_QUERY);
if (value) this.processSharedQuery(value);
} catch (error) {
utils.log('等待UI就绪超时,可能无法处理初始同步任务。');
}
GM_addValueChangeListener(config.KEYS.SHARED_QUERY, (name, old_value, new_value, remote) => {
if (remote && new_value) {
try {
if (JSON.parse(new_value).sourceId !== AITabSync.state.thisSite.id) {
this.processSharedQuery(new_value);
}
} catch (e) {
utils.log('解析收到的广播任务时出错:', e);
}
}
});
},
},
// ===================================================================================
// --- 9. Main Application Logic ---
// ===================================================================================
main: {
async loadInitialState() {
const { state, config } = AITabSync;
state.isLoggingEnabled = await GM_getValue(config.KEYS.LOGGING_ENABLED, false);
state.visibleTargets = await GM_getValue(config.KEYS.VISIBLE_TARGETS, null);
if (state.visibleTargets === null) {
state.visibleTargets = [...config.DISPLAY_ORDER];
await GM_setValue(config.KEYS.VISIBLE_TARGETS, state.visibleTargets);
}
},
registerGMListeners() {
const { config, ui, state, utils } = AITabSync;
GM_addValueChangeListener(config.KEYS.LOGGING_ENABLED, (name, ov, nv) => {
state.isLoggingEnabled = nv;
ui.updateMenuCommand();
});
GM_addValueChangeListener(config.KEYS.ACTIVE_TABS, (name, ov, nv, remote) => {
if (remote) ui.updatePanelState();
});
GM_addValueChangeListener(config.KEYS.VISIBLE_TARGETS, (name, ov, nv) => {
const oldTargets = ov || config.DISPLAY_ORDER;
const newTargets = nv || [];
state.visibleTargets = newTargets;
const hiddenTargets = oldTargets.filter((id) => !newTargets.includes(id));
hiddenTargets.forEach((id) => {
if (state.selectedTargets.has(id)) {
state.selectedTargets.delete(id);
utils.log(`模型 "${config.SITES[id]?.name || id}" 已从菜单隐藏,同步选择已取消。`);
}
});
ui.rebuildChipsUI();
});
},
startBackgroundTasks() {
const { lifecycle, config, ui } = AITabSync;
lifecycle.registerTabAsActive();
lifecycle.cleanupStaleTabs();
setInterval(lifecycle.registerTabAsActive, config.TIMINGS.HEARTBEAT_INTERVAL);
setInterval(lifecycle.cleanupStaleTabs, config.TIMINGS.CLEANUP_INTERVAL);
setInterval(() => {
if (document.body && !document.getElementById('ai-sync-container')) {
ui.createMainPanel();
}
}, 2000);
},
initEarly() {
AITabSync.state.thisSite = AITabSync.utils.getCurrentSiteInfo();
if (!AITabSync.state.thisSite) return false;
AITabSync.comms.deployNetworkInterceptor();
return true;
},
async initDOMReady() {
const { state, ui, utils, lifecycle, comms } = AITabSync;
if (!state.thisSite) return;
try {
await utils.waitFor(() => document.body, 10000, 'document.body to be ready');
await this.loadInitialState();
utils.log(`脚本 v${AITabSync.config.SCRIPT_VERSION} 在 ${state.thisSite.name} 启动。`);
ui.injectStyle();
ui.createMainPanel();
ui.createSettingsModal();
ui.createTooltip();
AITabSync.events.register();
this.registerGMListeners();
this.startBackgroundTasks();
lifecycle.ensureWindowName();
lifecycle.deployHistoryInterceptor();
comms.initReceiver();
// Immediately send a heartbeat when the tab becomes visible.
// This counters browser throttling of setInterval in background tabs.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
AITabSync.utils.log('Tab became visible, sending immediate heartbeat.');
lifecycle.registerTabAsActive();
}
});
// Attempt to unregister the tab when the user closes it.
// This is a "best-effort" feature and may not always succeed.
window.addEventListener('beforeunload', lifecycle.unregisterTabAsInactive);
if (window.self === window.top) ui.updateMenuCommand();
} catch (error) {
utils.log('初始化过程中发生严重错误:', error);
}
},
},
};
// --- Script Execution ---
if (AITabSync.main.initEarly()) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => AITabSync.main.initDOMReady());
} else {
AITabSync.main.initDOMReady();
}
}
})();