AI Chat 增强 — MCP 工具调用 + TTS 朗读 + 多站点适配
// ==UserScript==
// @name DS MCP Bridge
// @namespace https://github.com/calendar0917/ds-enhance
// @version 4.2.0
// @description AI Chat 增强 — MCP 工具调用 + TTS 朗读 + 多站点适配
// @author ds-enhance
// @match https://chat.deepseek.com/*
// @match https://chat.openai.com/*
// @match https://chatgpt.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @run-at document-start
// @license GPL-3.0
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_PREFIX = '[Bridge]';
const DEFAULT_MCP_URL = 'http://localhost:8024/mcp';
const TOOL_CALL_RE = /```mcp:([\w.-]+)\n([\s\S]*?)```/g;
const SYSTEM_HINT_START = '[系统指令]';
const SYSTEM_HINT_END = '[系统指令结束]';
const AUTO_SEND_KEY = 'auto_send';
const DEFAULT_AUTO_SEND = true;
// ═══════════════════════════════════════════════════════════════
// Module Toggles (top-level so XHR/fetch hooks can access)
// ═══════════════════════════════════════════════════════════════
const MODULE_DEFAULTS = { mcp: true, tts: true, ttsAutoPlay: false };
function getModuleEnabled(mod) { return GM_getValue('mod_' + mod, MODULE_DEFAULTS[mod]); }
function setModuleEnabled(mod, val) { GM_setValue('mod_' + mod, val); }
function getAutoSendEnabled() { return GM_getValue(AUTO_SEND_KEY, DEFAULT_AUTO_SEND); }
// ═══════════════════════════════════════════════════════════════
// Adapter Registry — Multi-site support
// ═══════════════════════════════════════════════════════════════
const ADAPTERS = {
deepseek: {
id: 'deepseek',
name: 'DeepSeek Chat',
match: (url) => /chat\.deepseek\.com/.test(url),
selectors: {
assistantMessages: '.ds-markdown--block, [class*="markdown"]',
inputBox: 'textarea, [contenteditable="true"][placeholder]',
},
getRequestPattern: () => /completion/,
},
chatgpt: {
id: 'chatgpt',
name: 'ChatGPT',
match: (url) => /chat\.openai\.com|chatgpt\.com/.test(url),
selectors: {
assistantMessages: '[data-message-author-role="assistant"]',
inputBox: 'textarea[id="prompt-textarea"], #prompt-textarea',
},
getRequestPattern: () => /backend-api\/conversation/,
},
};
function detectAdapter() {
const url = location.href;
for (const [id, adapter] of Object.entries(ADAPTERS)) {
if (adapter.match(url)) {
console.log(`${SCRIPT_PREFIX} Detected adapter: ${adapter.name}`);
return adapter;
}
}
console.log(`${SCRIPT_PREFIX} No adapter matched for: ${url}`);
return null;
}
const currentAdapter = detectAdapter();
// ═══════════════════════════════════════════════════════════════
// File Context (tool results only — native upload handled by DS)
// ═══════════════════════════════════════════════════════════════
const _toolFiles = [];
function addToolFileResult(filename, text, mimeType) {
_toolFiles.push({ filename, _textContent: text, mime_type: mimeType || 'text/plain' });
}
function injectToolFileContext(bodyStr) {
if (!_toolFiles.length || !bodyStr) return bodyStr;
try {
const parsed = JSON.parse(bodyStr);
let ctx = '\n\n[上传文件内容]\n';
for (const f of _toolFiles) {
ctx += `\n--- ${f.filename} ---\n${f._textContent}\n`;
}
if (parsed.prompt && typeof parsed.prompt === 'string') {
parsed.prompt += ctx;
} else if (parsed.messages?.length) {
const lastMsg = parsed.messages[parsed.messages.length - 1];
if (typeof lastMsg.content === 'string') lastMsg.content += ctx;
else if (Array.isArray(lastMsg.content)) {
const tp = lastMsg.content.find(p => p.type === 'text');
if (tp) tp.text += ctx;
}
}
console.log(`${SCRIPT_PREFIX} Injected ${_toolFiles.length} tool file context(s)`);
_toolFiles.length = 0;
return JSON.stringify(parsed);
} catch { return bodyStr; }
}
// ═══════════════════════════════════════════════════════════════
// MCP Client (GM_xmlhttpRequest to bypass CORS)
// ═══════════════════════════════════════════════════════════════
class MCPClient {
constructor(url) {
this.url = url;
this.sessionId = null;
this._nextId = 1;
this.connected = false;
}
_post(body) {
return new Promise((resolve, reject) => {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (this.sessionId) headers['Mcp-Session-Id'] = this.sessionId;
GM_xmlhttpRequest({
method: 'POST', url: this.url, headers,
data: JSON.stringify(body),
onload: (resp) => {
try {
const text = resp.responseText;
if (resp.responseHeaders?.includes('text/event-stream')) {
for (const line of text.split('\n')) {
if (line.startsWith('data: ')) { resolve(JSON.parse(line.slice(6))); return; }
}
reject(new Error('No data in SSE response'));
} else {
resolve(JSON.parse(text));
}
} catch (e) { reject(new Error(`Parse error: ${e.message}`)); }
},
onerror: (e) => reject(new Error(`Network error: ${e.error || 'connection refused'}`)),
ontimeout: () => reject(new Error('Request timed out')),
timeout: 30000,
});
});
}
async _rpc(method, params = {}) {
const id = this._nextId++;
const resp = await this._post({ jsonrpc: '2.0', id, method, params });
if (resp.error) throw new Error(`MCP error: ${resp.error.message}`);
return resp.result;
}
async initialize() {
try {
const result = await this._rpc('initialize', {
protocolVersion: '2025-03-26', capabilities: {},
clientInfo: { name: 'ds-mcp-bridge', version: '2.0.0' },
});
this.sessionId = result.sessionId;
this.connected = true;
await this._post({ jsonrpc: '2.0', method: 'notifications/initialized' });
console.log(`${SCRIPT_PREFIX} MCP connected: ${this.sessionId}`);
return true;
} catch (e) { console.error(`${SCRIPT_PREFIX} Init failed:`, e.message); this.connected = false; return false; }
}
async listTools() {
if (!this.connected) await this.initialize();
const result = await this._rpc('tools/list');
return result.tools || [];
}
async callTool(name, args = {}) {
if (!this.connected) await this.initialize();
return this._rpc('tools/call', { name, arguments: args });
}
async checkHealth() {
try {
const resp = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url: this.url.replace('/mcp', '/health'),
onload: (r) => resolve(JSON.parse(r.responseText)),
onerror: (e) => reject(e), timeout: 5000,
});
});
return resp.status === 'ok';
} catch { return false; }
}
}
// ═══════════════════════════════════════════════════════════════
// TTS Client — Text-to-Speech via server API
// ═══════════════════════════════════════════════════════════════
class TTSClient {
constructor() {
this.audio = null;
this.playing = false;
this.currentText = '';
}
getBaseUrl() {
const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
try { return new URL(mcpUrl).origin; }
catch { return mcpUrl.replace(/\/[^/]*$/, ''); }
}
async synthesize(text) {
const voice = GM_getValue('tts_voice', 'zh-CN-XiaoxiaoNeural');
const provider = GM_getValue('tts_provider', 'edge');
const body = { text, voice, provider };
if (provider === 'openai') {
body.api_key = GM_getValue('tts_api_key', '');
body.base_url = GM_getValue('tts_base_url', 'https://api.openai.com/v1');
body.model = GM_getValue('tts_model', 'tts-1');
}
const url = this.getBaseUrl() + '/api/tts';
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST', url,
headers: { 'Content-Type': 'application/json', 'Accept': 'audio/mpeg' },
data: JSON.stringify(body),
responseType: 'blob',
onload: (resp) => {
if (resp.status !== 200) {
try { const err = JSON.parse(resp.responseText); reject(new Error(err.error || 'TTS failed')); }
catch { reject(new Error('TTS HTTP ' + resp.status)); }
return;
}
resolve(URL.createObjectURL(resp.response));
},
onerror: () => reject(new Error('TTS 网络错误')),
ontimeout: () => reject(new Error('TTS 超时')),
timeout: 60000,
});
});
}
async play(text) {
this.stop();
this.currentText = text;
try {
const audioUrl = await this.synthesize(text);
this.audio = new Audio(audioUrl);
this.playing = true;
this.audio.onended = () => { this.playing = false; };
await this.audio.play();
} catch (e) {
console.warn(`${SCRIPT_PREFIX} TTS server failed, falling back to Web Speech:`, e.message);
this._fallbackPlay(text);
}
}
_fallbackPlay(text) {
if (!('speechSynthesis' in window)) { toast('TTS 不可用', 'error'); return; }
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = 'zh-CN';
u.rate = 1.0;
this.playing = true;
u.onend = () => { this.playing = false; };
window.speechSynthesis.speak(u);
}
stop() {
if (this.audio) { this.audio.pause(); this.audio.currentTime = 0; this.audio = null; this.playing = false; }
if (window.speechSynthesis) { window.speechSynthesis.cancel(); this.playing = false; }
}
}
const ttsClient = new TTSClient();
// ═══════════════════════════════════════════════════════════════
// Tool Registry & Hint Builder
// ═══════════════════════════════════════════════════════════════
let toolRegistry = [];
function buildToolHint() {
if (!toolRegistry.length) return '';
let hint = `${SYSTEM_HINT_START} 你拥有以下 MCP 工具。当用户的需求可以用工具完成时,你必须在回复中调用工具。`;
hint += ' 调用格式:用代码块写 ```mcp:工具名``` 后紧跟一个 JSON 代码块写参数。\n\n';
hint += '示例:\n```mcp:execute_command\n{"command": "ls -la"}\n```\n\n';
hint += '可用工具列表:\n';
toolRegistry.forEach(t => {
hint += `- ${t.name}: ${t.description || ''}`;
const req = t.inputSchema?.required;
if (req?.length) hint += ` (参数: ${req.join(', ')})`;
hint += '\n';
});
hint += '\n如果不需要工具就正常回答。需要工具时一定要调用。';
hint += '\n\n当收到用户发送的 <tool_result> 包裹的文本时,这是你之前调用的工具的执行结果。请基于结果继续回答用户的问题。';
hint += `\n${SYSTEM_HINT_END}`;
return hint;
}
function modifyRequest(bodyStr) {
if (!toolRegistry.length || !bodyStr) return bodyStr;
try {
const parsed = JSON.parse(bodyStr);
const hint = buildToolHint();
if (!hint) return bodyStr;
if (bodyStr.includes(`${SYSTEM_HINT_START} 你拥有以下 MCP 工具`)) return bodyStr;
if (parsed.prompt && typeof parsed.prompt === 'string') {
parsed.prompt = hint + '\n\n' + parsed.prompt;
return injectToolFileContext(JSON.stringify(parsed));
}
if (parsed.messages?.length) {
const lastMsg = parsed.messages[parsed.messages.length - 1];
const content = lastMsg?.content;
if (typeof content === 'string') {
lastMsg.content = hint + '\n\n' + content;
return injectToolFileContext(JSON.stringify(parsed));
}
if (Array.isArray(content)) {
const textPart = content.find(p => p.type === 'text');
if (textPart && !textPart.text.includes('[系统指令]')) {
textPart.text = hint + '\n\n' + textPart.text;
return injectToolFileContext(JSON.stringify(parsed));
}
}
}
} catch { /* not JSON */ }
return bodyStr;
}
// ═══════════════════════════════════════════════════════════════
// SSE Parsing — DeepSeek native format + OpenAI compatible
// ═══════════════════════════════════════════════════════════════
const executedCalls = new Set();
let _streamDebounce = null;
function checkForToolCalls(content) {
if (!content || !toolRegistry.length) return;
// Strategy 1: Match ```mcp:tool_name\n{...}\n```
const re = new RegExp(TOOL_CALL_RE.source, 'g');
let match;
while ((match = re.exec(content)) !== null) {
const toolName = match[1];
const rawArgs = match[2].trim();
let args = {};
try { args = JSON.parse(rawArgs); }
catch { args = { input: rawArgs }; }
const key = toolName + ':' + JSON.stringify(args);
if (executedCalls.has(key)) continue;
executedCalls.add(key);
console.log(`${SCRIPT_PREFIX} Tool call: ${toolName}`, args);
executeToolCall(toolName, args);
}
// Strategy 2: Match registered tool names directly
// Handles SSE token boundary truncation
for (const tool of toolRegistry) {
const name = tool.name;
const idx = content.indexOf(name);
if (idx === -1) continue;
const afterName = content.substring(idx + name.length);
const braceStart = afterName.indexOf('{');
if (braceStart === -1) continue;
const braceEnd = afterName.indexOf('}', braceStart);
if (braceEnd === -1) continue;
const jsonStr = afterName.substring(braceStart, braceEnd + 1);
let args = {};
try { args = JSON.parse(jsonStr); }
catch { args = { input: jsonStr }; }
const key = name + ':' + JSON.stringify(args);
if (executedCalls.has(key)) continue;
executedCalls.add(key);
console.log(`${SCRIPT_PREFIX} Tool call: ${name}`, args);
executeToolCall(name, args);
}
}
function parseSSEChunk(rawText) {
let content = '';
const lines = rawText.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const jsonStr = trimmed.slice(6).trim();
if (jsonStr === '[DONE]') continue;
try {
const obj = JSON.parse(jsonStr);
// DeepSeek native: {"p":"response/content","o":"SET","v":"text"}
const v = obj.v;
if (typeof v === 'string' && v.length > 0) {
const p = obj.p || '';
if (!p.includes('fragments') && !p.includes('status')) {
content += v;
}
continue;
}
// OpenAI streaming: choices[0].delta.content
const c = obj?.choices?.[0]?.delta?.content;
if (c) { content += c; continue; }
// OpenAI non-streaming: choices[0].message.content
const mc = obj?.choices?.[0]?.message?.content;
if (mc) { content += mc; continue; }
} catch { /* not JSON, skip */ }
}
return content;
}
// ═══════════════════════════════════════════════════════════════
// XHR Hook — SSE stream reading via progress events
// ═══════════════════════════════════════════════════════════════
const XHRProto = unsafeWindow.XMLHttpRequest.prototype;
const origOpen = XHRProto.open;
const origSend = XHRProto.send;
const xhrMeta = new WeakMap();
XHRProto.open = function (method, url, ...rest) {
xhrMeta.set(this, { url, method });
return origOpen.apply(this, [method, url, ...rest]);
};
XHRProto.send = function (body) {
const meta = xhrMeta.get(this);
if (!meta) return origSend.apply(this, [body]);
const isCompletion = meta.url.includes('completion');
if (isCompletion && getModuleEnabled('mcp')) {
if (body) body = modifyRequest(body);
let requestContent = '';
let requestLastParsed = 0;
this.addEventListener('progress', function () {
try {
const rt = this.responseText || '';
if (rt.length <= requestLastParsed) return;
const newPart = rt.substring(requestLastParsed);
// Only parse up to the last complete line to avoid dropping
// incomplete `data:` lines split across progress events
const lastNewline = newPart.lastIndexOf('\n');
if (lastNewline < 0) return; // no complete line yet
requestLastParsed += lastNewline + 1;
const completePart = newPart.substring(0, lastNewline + 1);
const newContent = parseSSEChunk(completePart);
if (newContent) requestContent += newContent;
if (_streamDebounce) clearTimeout(_streamDebounce);
_streamDebounce = setTimeout(() => {
if (requestContent) checkForToolCalls(requestContent);
}, 1000);
} catch { /* ignore */ }
});
this.addEventListener('load', function () {
try {
const rt = this.responseText || '';
if (rt.length > requestLastParsed) {
const newPart = rt.substring(requestLastParsed);
const finalContent = parseSSEChunk(newPart);
if (finalContent) requestContent += finalContent;
}
} catch { /* ignore */ }
if (_streamDebounce) clearTimeout(_streamDebounce);
checkForToolCalls(requestContent);
});
}
return origSend.apply(this, [body]);
};
// ── Hook fetch (backup) ──
const origFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async function (...args) {
const url = (typeof args[0] === 'string') ? args[0] : args[0]?.url;
if (url && url.includes('completion') && getModuleEnabled('mcp')) {
if (args[1]?.body) {
args[1].body = modifyRequest(args[1].body);
}
const response = await origFetch.apply(this, args);
const contentType = response.headers?.get('content-type') || '';
const clone = response.clone();
clone.text().then(text => {
const content = parseSSEChunk(text);
if (content) checkForToolCalls(content);
}).catch(() => { });
return response;
}
return origFetch.apply(this, args);
};
// ═══════════════════════════════════════════════════════════════
// Tool Execution & Result Injection
// ═══════════════════════════════════════════════════════════════
async function executeToolCall(toolName, args) {
const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
const client = new MCPClient(mcpUrl);
try {
toast(`调用工具: ${toolName}...`, 'info');
const result = await client.callTool(toolName, args);
const resultText = result?.content?.[0]?.text || '(no result)';
const isError = result?.isError;
toast(isError ? `${toolName} 失败` : `${toolName} 完成`, isError ? 'error' : 'success');
if (!isError && (toolName === 'read_file' || toolName === 'list_directory')) {
const filename = (args.path || args.filename || 'tool_result.txt').split('/').pop().split('\\').pop();
addToolFileResult(filename, resultText, 'text/plain');
toast(`📁 ${filename} 已添加到文件列表`, 'success');
injectResultToChat(`[工具 ${toolName} 的结果已作为文件添加,共 ${resultText.length} 字符。发送下条消息时会自动附带文件内容。]`);
} else {
injectResultToChat(isError ? `Error: ${resultText}` : resultText);
}
} catch (e) {
toast(`工具调用失败: ${e.message}`, 'error');
console.error(`${SCRIPT_PREFIX} Tool error:`, e);
injectResultToChat(`Error: ${e.message}`);
}
}
function injectResultToChat(resultText) {
setTimeout(async () => {
const wrappedText = `<tool_result>\n${resultText}\n</tool_result>`;
const input = findInputElement();
if (!input) {
toast('找不到聊天输入框', 'error');
return;
}
input.focus();
await sleep(200);
setInputValue(input, wrappedText);
await sleep(500);
if (!getAutoSendEnabled()) {
toast('工具结果已填入输入框', 'info');
return;
}
simulateEnter(input);
await sleep(300);
const sendBtn = findSendButton();
if (sendBtn) sendBtn.click();
toast('工具结果已发送', 'success');
}, 1500);
}
function findInputElement() {
for (const ta of document.querySelectorAll('textarea')) {
if (isVisible(ta)) return ta;
}
const editables = document.querySelectorAll('[contenteditable="true"]');
for (const el of editables) {
if (isVisible(el) && el.getAttribute('placeholder')) return el;
}
for (const el of editables) {
if (isVisible(el)) return el;
}
return null;
}
function findSendButton() {
const selectors = [
'button[aria-label*="send"]', 'button[aria-label*="Send"]',
'button[aria-label*="发送"]', 'button[aria-label*="Submit"]',
'button[type="submit"]', 'div[role="button"][aria-label*="send"]',
'div[role="button"][aria-label*="发送"]',
];
for (const sel of selectors) {
const btn = document.querySelector(sel);
if (btn && isVisible(btn)) return btn;
}
return null;
}
function isVisible(el) {
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function setInputValue(element, value) {
const isCE = element.contentEditable === 'true';
if (isCE) {
element.focus();
const sel = unsafeWindow.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
sel.removeAllRanges();
sel.addRange(range);
element.dispatchEvent(new InputEvent('beforeinput', {
bubbles: true, cancelable: true, inputType: 'insertText', data: value,
}));
try { document.execCommand('insertText', false, value); }
catch { element.textContent = value; }
range.selectNodeContents(element);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
} else {
const setter = Object.getOwnPropertyDescriptor(
unsafeWindow.HTMLTextAreaElement.prototype, 'value'
)?.set || Object.getOwnPropertyDescriptor(
unsafeWindow.HTMLInputElement.prototype, 'value'
)?.set;
if (setter) setter.call(element, value);
else element.value = value;
}
[
new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: value }),
new Event('change', { bubbles: true }),
new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Unidentified' }),
new KeyboardEvent('keyup', { bubbles: true, cancelable: true, key: 'Unidentified' }),
].forEach(e => element.dispatchEvent(e));
}
function simulateEnter(element) {
const init = {
key: 'Enter', code: 'Enter', keyCode: 13, which: 13,
bubbles: true, cancelable: true,
};
element.dispatchEvent(new KeyboardEvent('keydown', init));
element.dispatchEvent(new KeyboardEvent('keypress', init));
element.dispatchEvent(new KeyboardEvent('keyup', init));
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ═══════════════════════════════════════════════════════════════
// Toast
// ═══════════════════════════════════════════════════════════════
function toast(msg, type = 'info') {
if (!document.body) return;
const colors = { info: '#2a2a3e', success: '#0d3320', error: '#3d0f0f' };
const el = document.createElement('div');
el.style.cssText = `position:fixed;bottom:24px;right:24px;z-index:1000001;background:${colors[type]};color:#eee;padding:12px 22px;border-radius:10px;font-size:14px;box-shadow:0 4px 20px rgba(0,0,0,.5);font-family:system-ui;transition:opacity .3s;`;
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3500);
}
// ═══════════════════════════════════════════════════════════════
// CSS
// ═══════════════════════════════════════════════════════════════
const PANEL_CSS = `
#mcp-fab{position:fixed;z-index:999999;width:48px;height:48px;border-radius:50%;background:#16a34a;color:#fff;border:none;font-size:22px;cursor:grab;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 12px rgba(22,163,74,.4);user-select:none;-webkit-user-select:none;touch-action:none}
#mcp-fab:active{cursor:grabbing}
#mcp-fab:hover{transform:scale(1.1);box-shadow:0 4px 20px rgba(22,163,74,.6)}
#mcp-fab.disconnected{background:#dc2626;box-shadow:0 2px 12px rgba(220,38,38,.4)}
#mcp-fab.disconnected:hover{box-shadow:0 4px 20px rgba(220,38,38,.6)}
#mcp-panel{position:fixed;z-index:999998;width:460px;max-height:min(75vh, calc(100vh - 20px));background:#16161e;color:#eee;border:1px solid #333;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.6);font-family:system-ui;font-size:14px;display:none;flex-direction:column;overflow:hidden}
#mcp-panel.open{display:flex}
#mcp-panel .hd{padding:14px 18px;border-bottom:1px solid #2a2a3a;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
#mcp-panel .hd h3{margin:0;font-size:15px;font-weight:600}
#mcp-panel .hd .ver{font-size:11px;color:#666;margin-left:8px}
#mcp-panel .hd .cls{background:none;border:none;color:#888;font-size:20px;cursor:pointer;padding:0 4px}
#mcp-panel .hd .cls:hover{color:#fff}
#mcp-tabs{display:flex;border-bottom:1px solid #2a2a3a;overflow-x:auto;scrollbar-width:none;flex-shrink:0}
#mcp-tabs::-webkit-scrollbar{display:none}
#mcp-tabs button{flex:0 0 auto;padding:9px 14px;background:none;border:none;color:#888;font-size:12px;cursor:pointer;border-bottom:2px solid transparent;transition:color .15s,border-color .15s;white-space:nowrap}
#mcp-tabs button.active{color:#7aa2f7;border-bottom-color:#7aa2f7}
#mcp-tabs button:hover{color:#ccc}
.mcp-bd{flex:1;overflow-y:auto;padding:12px 14px}
.mcp-sec{display:none}.mcp-sec.active{display:block}
.mcp-btn{padding:6px 12px;border-radius:8px;border:1px solid #444;background:#222;color:#eee;font-size:12px;cursor:pointer;transition:background .15s}
.mcp-btn:hover{background:#333}
.mcp-btn.pri{background:#16a34a;border-color:#16a34a;color:#fff}
.mcp-btn.pri:hover{background:#15803d}
.mcp-input{width:100%;padding:8px 12px;border-radius:8px;border:1px solid #444;background:#1a1a28;color:#eee;font-size:13px;box-sizing:border-box;outline:none}
.mcp-input:focus{border-color:#7aa2f7}
.mcp-input::placeholder{color:#555}
.mcp-sel{width:100%;padding:7px 10px;border:1px solid #444;border-radius:8px;background:#1a1a28;color:#eee;font-size:13px;outline:none}
.mcp-sel option{background:#1a1a28}
.mcp-tool{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:8px;transition:background .1s;font-size:13px}
.mcp-tool:hover{background:#1e1e2e}
.mcp-tool .name{color:#7aa2f7;font-weight:500}
.mcp-tool .desc{color:#888;font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mcp-result{margin-top:10px;padding:10px;background:#1a1a28;border-radius:8px;font-size:12px;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow-y:auto;color:#aaa;font-family:monospace}
.mcp-result.error{color:#f87171}
.mcp-label{font-size:12px;color:#888;margin-bottom:4px;display:block}
.mcp-label-row{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.mcp-status{font-size:13px;padding:8px 0}
.mcp-status .ok{color:#4ade80}
.mcp-status .err{color:#f87171}
.ext-card{padding:10px 12px;border:1px solid #333;border-radius:10px;margin-bottom:8px;background:#1a1a28}
.ext-card-hd{display:flex;align-items:center;justify-content:space-between;gap:8px}
.ext-card-name{font-weight:600;color:#7aa2f7;font-size:14px}
.ext-card-transport{font-size:11px;color:#666;background:#222;padding:2px 6px;border-radius:4px}
.ext-card-status{font-size:12px;display:flex;align-items:center;gap:4px}
.ext-card-status .dot{width:7px;height:7px;border-radius:50%;display:inline-block}
.ext-card-status .dot-green{background:#4ade80}.ext-card-status .dot-red{background:#f87171}.ext-card-status .dot-gray{background:#666}
.ext-card-tools{font-size:11px;color:#888;margin-top:6px}
.ext-card-actions{display:flex;gap:6px;margin-top:8px}
.ext-card-actions .mcp-btn{font-size:11px;padding:4px 10px}
.ext-form-row{margin-bottom:8px}
.ext-form-row label{font-size:11px;color:#888;display:block;margin-bottom:3px}
.ext-form-row input{font-size:12px}
.ext-add-toggle{font-size:12px;color:#7aa2f7;cursor:pointer;border:none;background:none;padding:0;margin-top:6px}
.ext-add-toggle:hover{text-decoration:underline}
.ext-section{margin-top:10px;padding-top:10px;border-top:1px solid #2a2a3a}
/* TTS Button */
.mcp-tts-btn {
display:inline-flex;align-items:center;gap:4px;
padding:4px 8px;border-radius:6px;border:1px solid transparent;
background:transparent;color:#999;font-size:12px;cursor:pointer;
transition:background .15s,color .15s,border-color .15s;user-select:none;
white-space:nowrap;line-height:1;height:28px;box-sizing:border-box;
}
.mcp-tts-btn:hover { background:#2a2a3e;color:#e0e0e0;border-color:#555; }
.mcp-tts-btn.playing { color:#4ade80;background:#1a3a28;border-color:#16a34a; }
.mcp-tts-btn.paused { color:#fbbf24;background:#3a2a18;border-color:#d97706; }
/* Module toggles */
.mcp-toggle-row { display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #2a2a3a; }
.mcp-toggle-label { font-size:13px;color:#ccc; }
.mcp-toggle-desc { font-size:11px;color:#666;margin-top:2px; }
.mcp-switch { position:relative;width:36px;height:20px;flex-shrink:0; }
.mcp-switch input { opacity:0;width:0;height:0; }
.mcp-switch .slider { position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#333;border-radius:10px;transition:.2s; }
.mcp-switch .slider:before { position:absolute;content:"";height:16px;width:16px;left:2px;bottom:2px;background:#888;border-radius:50%;transition:.2s; }
.mcp-switch input:checked + .slider { background:#16a34a; }
.mcp-switch input:checked + .slider:before { transform:translateX(16px);background:#fff; }
.mcp-code-action {
display:inline-flex;align-items:center;justify-content:center;
height:24px;padding:0 8px;margin-left:4px;border-radius:5px;
border:1px solid #444;background:#222;color:#ddd;font-size:12px;
line-height:1;cursor:pointer;white-space:nowrap;box-sizing:border-box;
}
.mcp-code-action:hover { background:#333;color:#fff;border-color:#666; }
.mcp-code-action.resend { background:#14351f;border-color:#16a34a;color:#8df0ad; }
.mcp-code-action.resend:hover { background:#166534;color:#fff; }
pre.mcp-code-hidden, code.mcp-code-hidden, .mcp-code-hidden pre, .mcp-code-hidden code { display:none !important; }
.mcp-sys-fold {
margin:8px 0;padding:8px 10px;border:1px solid #333;border-radius:8px;
background:#15151f;color:#ccc;font-size:13px;line-height:1.55;
}
.mcp-sys-fold-hd { display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;user-select:none; }
.mcp-sys-fold-title { color:#7aa2f7;font-weight:600; }
.mcp-sys-fold-toggle { color:#999;font-size:12px; }
.mcp-sys-fold-body { display:none;margin-top:8px;white-space:pre-wrap;color:#999;font-size:12px; }
.mcp-sys-fold.open .mcp-sys-fold-body { display:block; }
`;
// ═══════════════════════════════════════════════════════════════
// FAB + Panel
// ═══════════════════════════════════════════════════════════════
function waitForDOM() {
return new Promise(resolve => {
if (document.body) resolve();
else new MutationObserver(() => { if (document.body) resolve(); })
.observe(document.documentElement, { childList: true });
});
}
waitForDOM().then(() => {
const style = document.createElement('style');
style.textContent = PANEL_CSS;
document.head.appendChild(style);
// FAB
const fab = document.createElement('button');
fab.id = 'mcp-fab';
fab.innerHTML = '⚙';
fab.title = 'DS MCP Bridge (可拖动)';
document.body.appendChild(fab);
// Panel
const panel = document.createElement('div');
panel.id = 'mcp-panel';
panel.innerHTML = `
<div class="hd">
<h3>DS MCP Bridge <span class="ver">v4.2.0</span></h3>
<button class="cls">×</button>
</div>
<div id="mcp-tabs">
<button class="active" data-tab="status">状态</button>
<button data-tab="test">测试</button>
<button data-tab="ext">MCP 服务器</button>
<button data-tab="settings">设置</button>
</div>
<div class="mcp-bd">
<div class="mcp-sec active" id="mcp-sec-status"></div>
<div class="mcp-sec" id="mcp-sec-test"></div>
<div class="mcp-sec" id="mcp-sec-ext"></div>
<div class="mcp-sec" id="mcp-sec-settings"></div>
</div>
`;
document.body.appendChild(panel);
// Close button
panel.querySelector('.cls').onclick = () => panel.classList.remove('open');
// ── Drag ──
let fabDragged = false, fabSX, fabSY, fabOX, fabOY;
const DRAG_TH = 5;
function posPanel() {
const r = fab.getBoundingClientRect();
const TOP_MARGIN = 50; // Never cover the top bar
// Horizontal: right-align with FAB, clamp to viewport
let l = r.right - 460;
if (l + 460 > window.innerWidth - 10) l = window.innerWidth - 470;
if (l < 10) l = 10;
panel.style.left = l + 'px';
// Try to place panel above the FAB
const gap = 10;
const spaceAbove = r.top - gap - TOP_MARGIN;
const maxH = Math.min(window.innerHeight * 0.75, window.innerHeight - 2 * TOP_MARGIN);
if (spaceAbove >= 200) {
// Enough space above FAB — open upward
panel.style.bottom = (window.innerHeight - r.top + gap) + 'px';
panel.style.top = 'auto';
} else {
// Not enough space above — pin to top margin, let content scroll
panel.style.top = TOP_MARGIN + 'px';
panel.style.bottom = 'auto';
}
panel.style.maxHeight = maxH + 'px';
}
fab.addEventListener('pointerdown', (e) => {
if (e.button) return;
fabDragged = false; fabSX = e.clientX; fabSY = e.clientY;
const r = fab.getBoundingClientRect();
fabOX = e.clientX - r.left; fabOY = e.clientY - r.top;
const mv = (e) => {
if (!fabDragged && Math.abs(e.clientX - fabSX) + Math.abs(e.clientY - fabSY) < DRAG_TH) return;
fabDragged = true;
fab.style.left = Math.max(0, Math.min(innerWidth - 48, e.clientX - fabOX)) + 'px';
fab.style.top = Math.max(0, Math.min(innerHeight - 48, e.clientY - fabOY)) + 'px';
fab.style.bottom = 'auto';
};
const up = () => {
document.removeEventListener('pointermove', mv);
document.removeEventListener('pointerup', up);
if (!fabDragged) { panel.classList.toggle('open'); if (panel.classList.contains('open')) { posPanel(); refreshStatus(); } }
else if (panel.classList.contains('open')) posPanel();
};
document.addEventListener('pointermove', mv);
document.addEventListener('pointerup', up);
e.preventDefault();
});
fab.style.right = '20px';
fab.style.left = 'auto';
fab.style.top = (innerHeight - 68) + 'px';
// ── Tabs ──
panel.querySelectorAll('#mcp-tabs button').forEach(btn => {
btn.onclick = () => {
panel.querySelectorAll('#mcp-tabs button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
panel.querySelectorAll('.mcp-sec').forEach(s => s.classList.remove('active'));
panel.querySelector(`#mcp-sec-${tab}`).classList.add('active');
};
});
// ── Shortcut ──
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
panel.classList.toggle('open');
if (panel.classList.contains('open')) { posPanel(); refreshStatus(); }
}
});
// ═══════════════════════════════════════════════════════════════
// Tab: Status
// ═══════════════════════════════════════════════════════════════
const secStatus = panel.querySelector('#mcp-sec-status');
async function refreshStatus() {
const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
secStatus.innerHTML = '<div class="mcp-status">连接中...</div>';
const client = new MCPClient(mcpUrl);
const healthy = await client.checkHealth();
if (!healthy) {
fab.classList.add('disconnected');
toolRegistry = [];
secStatus.innerHTML = `
<div class="mcp-status"><span class="err">未连接</span> — 服务器未运行</div>
<div style="font-size:12px;color:#666;margin-top:8px">
请先启动 MCP 服务器:<br>
<code style="color:#7aa2f7">cd server && python server.py</code>
</div>
<div style="margin-top:12px">
<button class="mcp-btn pri" id="mcp-retry">重试连接</button>
</div>
`;
secStatus.querySelector('#mcp-retry').onclick = refreshStatus;
return;
}
// Fetch health info for external server status
let healthInfo = null;
try {
const resp = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url: mcpUrl.replace('/mcp', '/health'),
onload: (r) => resolve(JSON.parse(r.responseText)),
onerror: (e) => reject(e), timeout: 5000,
});
});
healthInfo = resp;
} catch { }
const tools = await client.listTools();
toolRegistry = tools;
fab.classList.remove('disconnected');
// Separate builtin vs external tools
const extServers = healthInfo?.external_servers || [];
const extToolNames = new Set();
extServers.forEach(s => s.tools?.forEach(t => extToolNames.add(t)));
let toolList = '';
tools.forEach(t => {
const desc = t.description || '';
const req = t.inputSchema?.required;
const params = req?.length ? ` (${req.join(', ')})` : '';
const badge = extToolNames.has(t.name)
? '<span style="font-size:10px;color:#f0ad4e;margin-left:4px">ext</span>' : '';
toolList += `<div class="mcp-tool"><span class="name">${esc(t.name)}${esc(params)}${badge}</span><span class="desc">${esc(desc)}</span></div>`;
});
// External servers info
let extInfo = '';
if (extServers.length > 0) {
extInfo = '<div style="margin-top:12px;padding-top:10px;border-top:1px solid #2a2a3a">';
extInfo += '<div style="font-size:12px;color:#888;margin-bottom:6px">外部 MCP 服务器</div>';
extServers.forEach(s => {
const icon = s.connected ? '✓' : '✗';
const color = s.connected ? '#4ade80' : '#f87171';
extInfo += `<div style="font-size:12px;color:#aaa;margin-bottom:4px"><span style="color:${color}">${icon}</span> <strong>${esc(s.name)}</strong> (${s.transport}) — ${s.tools?.length || 0} tools</div>`;
});
extInfo += '</div>';
}
const builtinCount = tools.length - extToolNames.size;
const summary = extServers.length > 0
? `${tools.length} 个工具 (${builtinCount} 内置 + ${extToolNames.size} 外部)`
: `${tools.length} 个工具`;
secStatus.innerHTML = `
<div class="mcp-status"><span class="ok">已连接</span> — ${summary}</div>
${extInfo}
<div style="margin-top:8px">${toolList || '<div style="color:#665">无可用工具</div>'}</div>
<div style="margin-top:12px">
<button class="mcp-btn pri" id="mcp-refresh">刷新</button>
</div>
`;
secStatus.querySelector('#mcp-refresh').onclick = refreshStatus;
console.log(`${SCRIPT_PREFIX} ready — ${tools.length} tools (${extToolNames.size} external)`);
}
// ═══════════════════════════════════════════════════════════════
// Health Check — 自动轮询 + 断线提示 + 自动重连
// ═══════════════════════════════════════════════════════════════
let _healthConnected = null; // null = unknown, true/false = last known state
async function checkConnection() {
const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
try {
const resp = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: mcpUrl.replace('/mcp', '/health'),
onload: (r) => {
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('invalid response')); }
},
onerror: () => reject(new Error('network error')),
timeout: 5000,
});
});
const nowOk = resp.status === 'ok';
if (_healthConnected === false && nowOk) {
// 刚恢复连接 — 自动重新加载工具列表
toast('服务器已恢复连接,正在重新加载...', 'success');
refreshStatus();
} else if (_healthConnected === null && nowOk) {
// 首次检测到已连接
}
_healthConnected = nowOk;
fab.classList.toggle('disconnected', !nowOk);
} catch {
if (_healthConnected !== false) {
_healthConnected = false;
fab.classList.add('disconnected');
toolRegistry = [];
toast('服务器连接断开,请检查 MCP 服务器是否运行中', 'error');
}
}
}
// 首次检测 + 每 30 秒轮询
checkConnection();
setInterval(checkConnection, 30000);
// ═══════════════════════════════════════════════════════════════
// Tab: Test
// ═══════════════════════════════════════════════════════════════
const secTest = panel.querySelector('#mcp-sec-test');
function renderTestTab() {
if (!toolRegistry.length) {
secTest.innerHTML = '<div style="color:#665;font-size:13px">请先在"状态"页连接服务器</div>';
return;
}
let opts = '<option value="">选择工具...</option>';
toolRegistry.forEach(t => { opts += `<option value="${t.name}">${t.name}</option>`; });
secTest.innerHTML = `
<div class="mcp-label-row">
<label class="mcp-label" style="margin:0">工具</label>
</div>
<select class="mcp-sel" id="mcp-test-sel">${opts}</select>
<div id="mcp-test-info" style="margin-top:8px;font-size:12px;color:#666"></div>
<div id="mcp-test-args" style="margin-top:10px"></div>
<div style="margin-top:10px">
<button class="mcp-btn pri" id="mcp-test-run">执行</button>
</div>
<div id="mcp-test-result"></div>
`;
const sel = secTest.querySelector('#mcp-test-sel');
const info = secTest.querySelector('#mcp-test-info');
const argsDiv = secTest.querySelector('#mcp-test-args');
const resultDiv = secTest.querySelector('#mcp-test-result');
sel.onchange = () => {
const tool = toolRegistry.find(t => t.name === sel.value);
if (!tool) { info.textContent = ''; argsDiv.innerHTML = ''; return; }
info.textContent = tool.description || '';
const schema = tool.inputSchema || {};
const props = schema.properties || {};
const required = schema.required || [];
let fields = '';
for (const [key, prop] of Object.entries(props)) {
const req = required.includes(key) ? ' *' : '';
const ph = prop.description || prop.type || '';
fields += `<div style="margin-bottom:6px">
<label class="mcp-label">${key}${req}</label>
<input class="mcp-input" data-arg="${key}" placeholder="${ph}" />
</div>`;
}
if (!fields) fields = '<div style="color:#666;font-size:12px">此工具无需参数</div>';
argsDiv.innerHTML = fields;
};
secTest.querySelector('#mcp-test-run').onclick = async () => {
const toolName = sel.value;
if (!toolName) { toast('请选择工具', 'error'); return; }
const args = {};
argsDiv.querySelectorAll('.mcp-input').forEach(inp => {
const key = inp.dataset.arg;
const val = inp.value.trim();
if (val) {
try { args[key] = JSON.parse(val); }
catch { args[key] = val; }
}
});
resultDiv.innerHTML = '<div class="mcp-result">执行中...</div>';
const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
const client = new MCPClient(mcpUrl);
try {
const result = await client.callTool(toolName, args);
const text = result?.content?.[0]?.text || '(no result)';
const isErr = result?.isError;
resultDiv.innerHTML = `<div class="mcp-result${isErr ? ' error' : ''}">${esc(text)}</div>`;
} catch (e) {
resultDiv.innerHTML = `<div class="mcp-result error">Error: ${esc(e.message)}</div>`;
}
};
}
// Watch for tab switch to render test tab
panel.querySelectorAll('#mcp-tabs button').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'test') renderTestTab();
if (btn.dataset.tab === 'ext') renderExtTab();
});
});
// ═══════════════════════════════════════════════════════════════
// Tab: External MCP Servers
// ═══════════════════════════════════════════════════════════════
const secExt = panel.querySelector('#mcp-sec-ext');
function getExtBaseUrl() {
const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
try { return new URL(mcpUrl).origin; }
catch { return mcpUrl.replace(/\/[^/]*$/, ''); }
}
function getBaseUrl() {
const mcpUrl = GM_getValue('mcp_url', DEFAULT_MCP_URL);
// Extract origin from any MCP URL: http://host:port/mcp → http://host:port
try {
const u = new URL(mcpUrl);
return u.origin;
} catch {
// Fallback: strip last path segment
return mcpUrl.replace(/\/[^/]*$/, '');
}
}
function extApiUrl(path) {
return getBaseUrl() + path;
}
async function extApiCall(path, method = 'GET', body) {
const url = extApiUrl(path);
return new Promise((resolve, reject) => {
const opts = {
method, url, timeout: 15000,
headers: { 'Content-Type': 'application/json' },
onload: (r) => {
try { resolve(JSON.parse(r.responseText)); }
catch { reject(new Error('Invalid JSON')); }
},
onerror: (e) => reject(new Error(e.error || 'Network error')),
ontimeout: () => reject(new Error('Timeout')),
};
if (body) opts.data = JSON.stringify(body);
GM_xmlhttpRequest(opts);
});
}
let extFormOpen = false;
let presetParamForm = null; // {presetId, preset} when param form is open
async function renderExtTab() {
secExt.innerHTML = '<div style="color:#888;font-size:13px">加载中...</div>';
// Fetch presets and servers in parallel
let presets = [], servers = [];
try {
const [presetData, serverData] = await Promise.all([
extApiCall('/api/presets'),
extApiCall('/api/external-servers'),
]);
presets = presetData.presets || [];
servers = serverData.servers || [];
} catch (e) {
secExt.innerHTML = `<div style="color:#f87171;font-size:13px">连接失败: ${esc(e.message)}</div>`;
return;
}
const installedIds = new Set(servers.map(s => s.name));
let html = '';
// ═══ Preset Marketplace ═══
html += '<div style="font-size:12px;font-weight:600;color:#ccc;margin-bottom:8px">工具预设</div>';
// Group by category
const categories = {};
presets.forEach(p => {
const cat = p.category || '其他';
if (!categories[cat]) categories[cat] = [];
categories[cat].push(p);
});
for (const [cat, items] of Object.entries(categories)) {
html += `<div style="font-size:10px;color:#666;margin:6px 0 3px">${esc(cat)}</div>`;
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:6px">';
items.forEach(p => {
const installed = installedIds.has(p.id);
const hasParams = p.params?.length > 0;
const btnText = installed ? (hasParams ? '重新配置' : '已启用') : (hasParams ? '配置' : '启用');
const btnClass = 'ext-preset-install';
const btnStyle = installed
? (hasParams
? 'background:#222;color:#7aa2f7;border-color:#7aa2f7'
: 'background:#1a3a2a;color:#4ade80;border-color:#4ade80;pointer-events:none')
: 'background:#222;color:#7aa2f7;border-color:#7aa2f7';
html += `
<div class="ext-preset-card ext-preset-install" data-preset-id="${esc(p.id)}" style="padding:6px 8px;border:1px solid ${installed ? '#2a4a3a' : '#333'};border-radius:6px;background:${installed ? '#1a2a22' : '#1a1a28'}">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;font-weight:500;color:#ccc">${esc(p.name)}</span>
<button class="${btnClass} mcp-btn" data-preset-id="${esc(p.id)}" style="${btnStyle};font-size:10px;padding:1px 7px">${btnText}</button>
</div>
<div style="font-size:10px;color:#888;margin-top:2px">${esc(p.description)}</div>
</div>
`;
});
html += '</div>';
}
// ═══ Param form (shown when configuring a preset) ═══
if (presetParamForm) {
const p = presetParamForm;
html += `
<div class="ext-section" id="ext-param-form">
<div style="font-size:13px;font-weight:600;color:#ccc;margin-bottom:8px">配置: ${esc(p.name)}</div>
`;
p.params.forEach(param => {
const req = param.required ? ' *' : '';
const inputType = param.secret ? 'password' : 'text';
html += `
<div style="margin-bottom:6px">
<label style="font-size:11px;color:#888;display:block;margin-bottom:2px">${esc(param.label)}${req}</label>
<input class="mcp-input ext-param-input" data-key="${esc(param.key)}" type="${inputType}"
placeholder="${esc(param.placeholder || '')}" style="font-size:12px" />
</div>
`;
});
html += `
<div style="margin-top:8px;display:flex;gap:6px">
<button class="mcp-btn pri" id="ext-param-submit">安装</button>
<button class="mcp-btn" id="ext-param-cancel">取消</button>
</div>
</div>
`;
}
// ═══ Installed Servers ═══
if (servers.length > 0) {
html += '<div class="ext-section">';
html += '<div style="font-size:12px;font-weight:600;color:#ccc;margin-bottom:6px">已安装</div>';
servers.forEach(s => {
const dotClass = s.status === 'running' ? 'dot-green' : s.status === 'stopped' ? 'dot-gray' : 'dot-red';
const statusText = s.status === 'running' ? '运行中' : s.status === 'stopped' ? '已停止' : '异常';
const statusColor = s.status === 'running' ? '#4ade80' : s.status === 'stopped' ? '#888' : '#f87171';
const toolsStr = s.tools?.length ? s.tools.join(', ') : '—';
let actions = '';
if (s.status === 'running') {
actions = `<button class="mcp-btn ext-stop" data-name="${esc(s.name)}" style="font-size:11px;padding:3px 8px">停止</button>`;
} else {
actions = `<button class="mcp-btn pri ext-start" data-name="${esc(s.name)}" style="font-size:11px;padding:3px 8px">启动</button>`;
}
actions += `<button class="mcp-btn ext-remove" data-name="${esc(s.name)}" style="color:#f87171;border-color:#f87171;font-size:11px;padding:3px 8px">删除</button>`;
html += `
<div class="ext-card" style="padding:8px 10px;margin-bottom:6px">
<div class="ext-card-hd">
<div style="display:flex;align-items:center;gap:8px">
<span class="ext-card-name" style="font-size:13px">${esc(s.name)}</span>
<span class="ext-card-transport">${s.transport}</span>
</div>
<span class="ext-card-status"><span class="dot ${dotClass}"></span><span style="color:${statusColor}">${statusText}</span></span>
</div>
<div class="ext-card-tools" style="font-size:10px">工具: ${esc(toolsStr)}</div>
<div class="ext-card-actions" style="margin-top:6px">${actions}</div>
</div>
`;
});
html += '</div>';
}
// Add form — JSON import
const defaultJson = JSON.stringify({
"mcpServers": {
"context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] },
"fetch": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fetch"] }
}
}, null, 2);
html += `<div style="margin-top:10px"><button class="ext-add-toggle" id="ext-add-btn">+ 导入 JSON 配置</button></div>`;
html += `<div id="ext-add-form" style="display:${extFormOpen ? 'block' : 'none'};margin-top:6px">`;
html += `
<div style="font-size:10px;color:#888;margin-bottom:4px">
支持粘贴任意格式的 MCP 配置 JSON,可同时导入多个
</div>
<textarea id="ext-f-json" style="width:100%;height:120px;padding:6px;border-radius:6px;border:1px solid #444;background:#0d0d18;color:#a0a0c0;font-size:10px;font-family:monospace;resize:vertical;box-sizing:border-box;outline:none;line-height:1.4" spellcheck="false">${esc(defaultJson)}</textarea>
<div style="margin-top:6px;display:flex;gap:6px">
<button class="mcp-btn pri" id="ext-add-submit" style="font-size:11px;padding:4px 10px">导入并启动</button>
<button class="mcp-btn" id="ext-add-cancel" style="font-size:11px;padding:4px 10px">取消</button>
</div>
</div>`;
html += `<div style="margin-top:8px"><button class="mcp-btn" id="ext-refresh" style="font-size:11px">刷新</button></div>`;
secExt.innerHTML = html;
// ── Preset install/configure buttons ──
secExt.querySelectorAll('.ext-preset-install').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const presetId = btn.dataset.presetId;
const preset = presets.find(p => p.id === presetId);
if (!preset) return;
if (preset.params?.length > 0) {
// Show param form (works for both new install and re-configure)
presetParamForm = preset;
renderExtTab();
} else if (!installedIds.has(presetId)) {
// One-click install (only for no-params presets that aren't installed)
try {
const result = await extApiCall(`/api/presets/${presetId}/install`, 'POST', {});
if (result.ok) {
toast(`${preset.name} 已安装,${result.tools?.length || 0} 个工具`, 'success');
renderExtTab();
refreshStatus();
} else {
toast(result.error || '安装失败', 'error');
}
} catch (e) { toast(e.message, 'error'); }
}
};
});
// ── Param form submit ──
const paramSubmit = secExt.querySelector('#ext-param-submit');
if (paramSubmit) {
paramSubmit.onclick = async () => {
const params = {};
secExt.querySelectorAll('.ext-param-input').forEach(inp => {
params[inp.dataset.key] = inp.value.trim();
});
try {
const result = await extApiCall(`/api/presets/${presetParamForm.id}/install`, 'POST', { params });
if (result.ok) {
toast(`${presetParamForm.name} 已安装,${result.tools?.length || 0} 个工具`, 'success');
presetParamForm = null;
renderExtTab();
refreshStatus();
} else {
toast(result.error || '安装失败', 'error');
}
} catch (e) { toast(e.message, 'error'); }
};
}
const paramCancel = secExt.querySelector('#ext-param-cancel');
if (paramCancel) {
paramCancel.onclick = () => { presetParamForm = null; renderExtTab(); };
}
// ── Existing server management bindings ──
// Add form toggle
secExt.querySelector('#ext-add-btn')?.addEventListener('click', () => {
extFormOpen = !extFormOpen;
secExt.querySelector('#ext-add-form').style.display = extFormOpen ? 'block' : 'none';
});
// Cancel
secExt.querySelector('#ext-add-cancel')?.addEventListener('click', () => {
extFormOpen = false;
secExt.querySelector('#ext-add-form').style.display = 'none';
});
// Submit JSON import
secExt.querySelector('#ext-add-submit')?.addEventListener('click', async () => {
const raw = secExt.querySelector('#ext-f-json').value.trim();
if (!raw) { toast('请粘贴 JSON 配置', 'error'); return; }
let parsed;
try { parsed = JSON.parse(raw); }
catch (e) { toast(`JSON 解析失败: ${e.message}`, 'error'); return; }
// Auto-unwrap common wrappers
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') parsed = parsed.mcpServers;
else if (parsed.servers && typeof parsed.servers === 'object') parsed = parsed.servers;
let entries;
if (parsed.name && typeof parsed.name === 'string') {
const { name, ...cfg } = parsed;
entries = { [name]: cfg };
} else if (parsed.command || parsed.url) {
toast('缺少 name 字段', 'error'); return;
} else {
entries = parsed;
}
try {
const result = await extApiCall('/api/external-servers', 'POST', { mcpServers: entries });
let added = 0, errors = [];
for (const r of (result.results || [])) {
if (r.ok) added++;
else errors.push(`${r.name}: ${r.error || '未知错误'}`);
}
if (added > 0) {
toast(`已添加 ${added} 个服务器`, 'success');
extFormOpen = false;
renderExtTab();
refreshStatus();
}
errors.forEach(e => toast(e, 'error'));
} catch (e) { toast(`请求失败: ${e.message || '网络错误'}`, 'error'); }
});
// Start/Stop/Remove
secExt.querySelectorAll('.ext-start').forEach(btn => {
btn.onclick = async () => {
const name = btn.dataset.name;
try {
const result = await extApiCall(`/api/external-servers/${name}/start`, 'POST');
toast(result.ok ? `${name} 已启动` : result.error, result.ok ? 'success' : 'error');
renderExtTab(); refreshStatus();
} catch (e) { toast(e.message, 'error'); }
};
});
secExt.querySelectorAll('.ext-stop').forEach(btn => {
btn.onclick = async () => {
const name = btn.dataset.name;
try {
const result = await extApiCall(`/api/external-servers/${name}/stop`, 'POST');
toast(result.ok ? `${name} 已停止` : result.error, result.ok ? 'success' : 'error');
renderExtTab(); refreshStatus();
} catch (e) { toast(e.message, 'error'); }
};
});
secExt.querySelectorAll('.ext-remove').forEach(btn => {
btn.onclick = async () => {
const name = btn.dataset.name;
if (!confirm(`确定删除 ${name}?`)) return;
try {
const result = await extApiCall(`/api/external-servers/${name}`, 'DELETE');
toast(result.ok ? `${name} 已删除` : result.error, result.ok ? 'success' : 'error');
renderExtTab(); refreshStatus();
} catch (e) { toast(e.message, 'error'); }
};
});
// Refresh
secExt.querySelector('#ext-refresh')?.addEventListener('click', renderExtTab);
// Start/Stop/Remove
secExt.querySelectorAll('.ext-start').forEach(btn => {
btn.onclick = async () => {
const name = btn.dataset.name;
try {
const result = await extApiCall(`/api/external-servers/${name}/start`, 'POST');
toast(result.ok ? `${name} 已启动` : result.error, result.ok ? 'success' : 'error');
renderExtTab(); refreshStatus();
} catch (e) { toast(e.message, 'error'); }
};
});
secExt.querySelectorAll('.ext-stop').forEach(btn => {
btn.onclick = async () => {
const name = btn.dataset.name;
try {
const result = await extApiCall(`/api/external-servers/${name}/stop`, 'POST');
toast(result.ok ? `${name} 已停止` : result.error, result.ok ? 'success' : 'error');
renderExtTab(); refreshStatus();
} catch (e) { toast(e.message, 'error'); }
};
});
secExt.querySelectorAll('.ext-remove').forEach(btn => {
btn.onclick = async () => {
const name = btn.dataset.name;
if (!confirm(`确定删除 ${name}?`)) return;
try {
const result = await extApiCall(`/api/external-servers/${name}`, 'DELETE');
toast(result.ok ? `${name} 已删除` : result.error, result.ok ? 'success' : 'error');
renderExtTab(); refreshStatus();
} catch (e) { toast(e.message, 'error'); }
};
});
// Refresh
secExt.querySelector('#ext-refresh').onclick = renderExtTab;
}
// ═══════════════════════════════════════════════════════════════
// Tab: Settings
// ═══════════════════════════════════════════════════════════════
const secSettings = panel.querySelector('#mcp-sec-settings');
secSettings.innerHTML = `
<div>
<label class="mcp-label">MCP 服务器地址</label>
<input class="mcp-input" id="mcp-cfg-url" value="${GM_getValue('mcp_url', DEFAULT_MCP_URL)}" />
</div>
<div style="margin-top:16px">
<label class="mcp-label">模块开关</label>
<div class="mcp-toggle-row">
<div><div class="mcp-toggle-label">🔧 MCP 工具调用</div><div class="mcp-toggle-desc">拦截 AI 回复并执行本地工具</div></div>
<label class="mcp-switch"><input type="checkbox" id="mod-toggle-mcp" ${getModuleEnabled('mcp') ? 'checked' : ''} /><span class="slider"></span></label>
</div>
<div class="mcp-toggle-row">
<div><div class="mcp-toggle-label">📨 工具结果自动发送</div><div class="mcp-toggle-desc">工具结果填入输入框后自动提交</div></div>
<label class="mcp-switch"><input type="checkbox" id="mcp-cfg-autosend" ${getAutoSendEnabled() ? 'checked' : ''} /><span class="slider"></span></label>
</div>
<div class="mcp-toggle-row">
<div><div class="mcp-toggle-label">🔊 TTS 朗读</div><div class="mcp-toggle-desc">AI 回复旁显示朗读按钮</div></div>
<label class="mcp-switch"><input type="checkbox" id="mod-toggle-tts" ${getModuleEnabled('tts') ? 'checked' : ''} /><span class="slider"></span></label>
</div>
<div class="mcp-toggle-row">
<div><div class="mcp-toggle-label">🔊 自动朗读</div><div class="mcp-toggle-desc">AI 回复完成后自动 TTS 播放</div></div>
<label class="mcp-switch"><input type="checkbox" id="mod-toggle-ttsAutoPlay" ${getModuleEnabled('ttsAutoPlay') ? 'checked' : ''} /><span class="slider"></span></label>
</div>
</div>
<div style="margin-top:16px">
<label class="mcp-label">TTS 设置</label>
<div class="mcp-toggle-row">
<div><div class="mcp-toggle-label">引擎</div><div class="mcp-toggle-desc">TTS 提供者</div></div>
<select class="mcp-sel" id="mcp-tts-provider" style="width:auto">
<option value="edge" ${GM_getValue('tts_provider', 'edge') === 'edge' ? 'selected' : ''}>Edge TTS (免费)</option>
<option value="openai" ${GM_getValue('tts_provider', 'edge') === 'openai' ? 'selected' : ''}>OpenAI 兼容</option>
</select>
</div>
<div style="margin-top:8px">
<label class="mcp-label">语音</label>
<div style="display:flex;gap:6px;margin-bottom:6px">
<select class="mcp-sel" id="mcp-tts-voice-filter-locale" style="width:auto;font-size:11px">
<option value="">全部语言</option>
<option value="zh-">中文</option>
<option value="en-">英语</option>
<option value="ja-">日语</option>
<option value="ko-">韩语</option>
</select>
<select class="mcp-sel" id="mcp-tts-voice-filter-gender" style="width:auto;font-size:11px">
<option value="">全部性别</option>
<option value="Female">女声</option>
<option value="Male">男声</option>
</select>
</div>
<select class="mcp-sel" id="mcp-tts-voice" style="width:100%">
<option value="${GM_getValue('tts_voice', 'zh-CN-XiaoxiaoNeural')}">加载中...</option>
</select>
<div id="mcp-tts-voice-status" style="font-size:10px;color:#666;margin-top:4px"></div>
</div>
<div id="mcp-tts-adv" style="display:${GM_getValue('tts_provider', 'edge') !== 'edge' ? 'block' : 'none'};margin-top:8px;padding:8px;background:#1a1a28;border-radius:8px;border:1px solid #333">
<div style="font-size:11px;color:#888;margin-bottom:6px">OpenAI 兼容配置</div>
<div style="margin-bottom:6px"><label class="mcp-label">API Key</label><input class="mcp-input" id="mcp-tts-apikey" type="password" value="${GM_getValue('tts_api_key', '')}" placeholder="sk-..." style="font-size:12px" /></div>
<div style="margin-bottom:6px"><label class="mcp-label">Base URL</label><input class="mcp-input" id="mcp-tts-baseurl" value="${GM_getValue('tts_base_url', 'https://api.openai.com/v1')}" style="font-size:12px" /></div>
<div><label class="mcp-label">模型</label><input class="mcp-input" id="mcp-tts-model" value="${GM_getValue('tts_model', 'tts-1')}" placeholder="tts-1 / tts-1-hd" style="font-size:12px" /></div>
</div>
</div>
<div style="margin-top:8px;font-size:11px;color:#555">适配器: ${currentAdapter ? currentAdapter.name : '无'}</div>
<div style="margin-top:12px"><button class="mcp-btn pri" id="mcp-cfg-save">保存</button></div>
`;
secSettings.querySelector('#mcp-cfg-save').onclick = () => {
const url = secSettings.querySelector('#mcp-cfg-url').value.trim();
if (!url) { toast('地址不能为空', 'error'); return; }
GM_setValue('mcp_url', url);
['mcp', 'tts', 'ttsAutoPlay'].forEach(mod => {
const toggle = secSettings.querySelector('#mod-toggle-' + mod);
if (toggle) setModuleEnabled(mod, toggle.checked);
});
const autoSendToggle = secSettings.querySelector('#mcp-cfg-autosend');
if (autoSendToggle) GM_setValue(AUTO_SEND_KEY, autoSendToggle.checked);
const providerSel = secSettings.querySelector('#mcp-tts-provider');
if (providerSel) GM_setValue('tts_provider', providerSel.value);
const voiceInput = secSettings.querySelector('#mcp-tts-voice');
if (voiceInput) GM_setValue('tts_voice', voiceInput.value.trim());
const apiKeyInput = secSettings.querySelector('#mcp-tts-apikey');
if (apiKeyInput) GM_setValue('tts_api_key', apiKeyInput.value.trim());
const baseUrlInput = secSettings.querySelector('#mcp-tts-baseurl');
if (baseUrlInput) GM_setValue('tts_base_url', baseUrlInput.value.trim());
const modelInput = secSettings.querySelector('#mcp-tts-model');
if (modelInput) GM_setValue('tts_model', modelInput.value.trim());
toast('已保存', 'success');
refreshStatus();
};
secSettings.querySelector('#mcp-tts-provider')?.addEventListener('change', (e) => {
const adv = secSettings.querySelector('#mcp-tts-adv');
if (adv) adv.style.display = e.target.value !== 'edge' ? 'block' : 'none';
});
// Load Edge TTS voices — with built-in fallback list
(function loadVoices() {
const voiceSel = secSettings.querySelector('#mcp-tts-voice');
const localeFilter = secSettings.querySelector('#mcp-tts-voice-filter-locale');
const genderFilter = secSettings.querySelector('#mcp-tts-voice-filter-gender');
const status = secSettings.querySelector('#mcp-tts-voice-status');
if (!voiceSel) return;
// Built-in Chinese voices (works without server)
const BUILTIN_VOICES = [
{ id: 'zh-CN-XiaoxiaoNeural', gender: 'Female', locale: 'zh-CN' },
{ id: 'zh-CN-XiaoyiNeural', gender: 'Female', locale: 'zh-CN' },
{ id: 'zh-CN-YunjianNeural', gender: 'Male', locale: 'zh-CN' },
{ id: 'zh-CN-YunxiNeural', gender: 'Male', locale: 'zh-CN' },
{ id: 'zh-CN-YunxiaNeural', gender: 'Male', locale: 'zh-CN' },
{ id: 'zh-CN-YunyangNeural', gender: 'Male', locale: 'zh-CN' },
{ id: 'zh-CN-liaoning-XiaobeiNeural', gender: 'Female', locale: 'zh-CN-liaoning' },
{ id: 'zh-CN-shaanxi-XiaoniNeural', gender: 'Female', locale: 'zh-CN-shaanxi' },
{ id: 'zh-HK-HiuGaaiNeural', gender: 'Female', locale: 'zh-HK' },
{ id: 'zh-HK-HiuMaanNeural', gender: 'Female', locale: 'zh-HK' },
{ id: 'zh-HK-WanLungNeural', gender: 'Male', locale: 'zh-HK' },
{ id: 'zh-TW-HsiaoChenNeural', gender: 'Female', locale: 'zh-TW' },
{ id: 'zh-TW-YunJheNeural', gender: 'Male', locale: 'zh-TW' },
{ id: 'zh-TW-HsiaoYuNeural', gender: 'Female', locale: 'zh-TW' },
{ id: 'en-US-JennyNeural', gender: 'Female', locale: 'en-US' },
{ id: 'en-US-GuyNeural', gender: 'Male', locale: 'en-US' },
{ id: 'en-US-AriaNeural', gender: 'Female', locale: 'en-US' },
{ id: 'ja-JP-NanamiNeural', gender: 'Female', locale: 'ja-JP' },
{ id: 'ja-JP-KeitaNeural', gender: 'Male', locale: 'ja-JP' },
{ id: 'ko-KR-SunHiNeural', gender: 'Female', locale: 'ko-KR' },
{ id: 'ko-KR-InJoonNeural', gender: 'Male', locale: 'ko-KR' },
];
let allVoices = BUILTIN_VOICES;
const currentVoice = GM_getValue('tts_voice', 'zh-CN-XiaoxiaoNeural');
function renderVoices() {
const locale = localeFilter?.value || '';
const gender = genderFilter?.value || '';
const filtered = allVoices.filter(v =>
(!locale || v.id.includes(locale) || v.id.startsWith(locale)) &&
(!gender || v.gender === gender)
);
voiceSel.innerHTML = '';
if (!filtered.length) {
voiceSel.innerHTML = '<option value="">无匹配语音</option>';
return;
}
for (const v of filtered) {
const opt = document.createElement('option');
opt.value = v.id;
const g = v.gender === 'Female' ? '♀' : '♂';
opt.textContent = g + ' ' + v.id;
if (v.id === currentVoice) opt.selected = true;
voiceSel.appendChild(opt);
}
status.textContent = filtered.length + ' / ' + allVoices.length + ' 个语音' + (allVoices.length > BUILTIN_VOICES.length ? '' : ' (内置列表)');
}
// Render builtin list immediately
renderVoices();
// Try loading full list from server in background
const url = getExtBaseUrl() + '/api/tts/voices';
GM_xmlhttpRequest({
method: 'GET', url, timeout: 10000,
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.voices?.length) {
allVoices = data.voices;
renderVoices();
}
} catch { }
},
onerror: () => { }, ontimeout: () => { },
});
localeFilter?.addEventListener('change', renderVoices);
genderFilter?.addEventListener('change', renderVoices);
})();
// ═══════════════════════════════════════════════════════════════
// MCP Code Block Actions + System Instruction Folding
// ═══════════════════════════════════════════════════════════════
function parseToolArgs(rawArgs) {
const text = (rawArgs || '').trim();
if (!text) return {};
try { return JSON.parse(text); }
catch { return { input: text }; }
}
function getMCPCodeInfo(block) {
const banner = block.querySelector?.('.md-code-block-banner, [class*="code-block-banner"], [class*="code-header"]');
const blockText = block.textContent || '';
const langText = banner?.textContent || '';
let match = langText.match(/(?:^|\s)mcp:([\w.-]+)(?:\s|$)/);
if (!match) match = blockText.match(/```mcp:([\w.-]+)\s*([\s\S]*?)```/);
if (!match) match = blockText.match(/^\s*mcp:([\w.-]+)(?:\s|$)/);
if (!match) return null;
const toolName = match[1];
let rawArgs = '';
if (match[2]) rawArgs = match[2].trim();
else {
const pre = block.matches?.('pre') ? block : block.querySelector?.('pre') || block.querySelector?.('code');
rawArgs = (pre?.innerText || pre?.textContent || '').trim();
}
rawArgs = rawArgs.replace(new RegExp(`^mcp:${toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'i'), '').trim();
return { toolName, rawArgs };
}
function getCodeActionContainer(block) {
const banner = block.querySelector?.('.md-code-block-banner, [class*="code-block-banner"], [class*="code-header"]');
if (banner) {
return banner.querySelector('[class*="action"], [class*="copy"], [class*="toolbar"], .efa13877') || banner;
}
const pre = block.matches?.('pre') ? block : block.querySelector?.('pre');
if (!pre) return null;
const bar = document.createElement('div');
bar.style.cssText = 'display:flex;justify-content:flex-end;gap:4px;margin:4px 0;';
pre.parentNode.insertBefore(bar, pre);
return bar;
}
function enhanceMCPCodeBlocks() {
const candidates = new Set();
document.querySelectorAll('.md-code-block, [class*="code-block"], pre').forEach(el => {
if (el.closest('#mcp-panel')) return;
candidates.add(el.closest('.md-code-block, [class*="code-block"]') || el);
});
candidates.forEach(block => {
if (!block || block.dataset?.mcpEnhanced === '1') return;
const info = getMCPCodeInfo(block);
if (!info) return;
const actionContainer = getCodeActionContainer(block);
if (!actionContainer) return;
block.dataset.mcpEnhanced = '1';
block.classList.add('mcp-code-hidden');
const collapseBtn = document.createElement('button');
collapseBtn.type = 'button';
collapseBtn.className = 'mcp-code-action collapse';
collapseBtn.textContent = '展开';
collapseBtn.title = '展开或折叠 MCP 指令';
collapseBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const hidden = block.classList.toggle('mcp-code-hidden');
collapseBtn.textContent = hidden ? '展开' : '折叠';
};
const resendBtn = document.createElement('button');
resendBtn.type = 'button';
resendBtn.className = 'mcp-code-action resend';
resendBtn.textContent = '重发';
resendBtn.title = `重新执行 ${info.toolName}`;
resendBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const latest = getMCPCodeInfo(block) || info;
executeToolCall(latest.toolName, parseToolArgs(latest.rawArgs));
toast(`已重发: ${latest.toolName}`, 'info');
};
actionContainer.appendChild(collapseBtn);
actionContainer.appendChild(resendBtn);
});
}
function findTightSystemInstructionElement(root) {
let node = root;
while (node?.children?.length) {
const children = Array.from(node.children).filter(child => {
if (child.closest?.('.mcp-sys-fold')) return false;
const text = child.textContent || '';
return text.includes(SYSTEM_HINT_START) && text.includes(SYSTEM_HINT_END);
});
if (children.length !== 1) break;
node = children[0];
}
return node;
}
function foldSystemInstructionElement(target) {
if (!target || target.dataset?.mcpSystemFolded === '1') return;
if (target.querySelector?.('.mcp-sys-fold')) return;
const text = target.textContent || '';
const start = text.indexOf(SYSTEM_HINT_START);
const endStart = text.indexOf(SYSTEM_HINT_END, start);
if (start < 0 || endStart < 0) return;
const end = endStart + SYSTEM_HINT_END.length;
const before = text.slice(0, start).trim();
const hidden = text.slice(start, end).trim();
const after = text.slice(end).trim();
if (hidden.length < 80) return;
const frag = document.createDocumentFragment();
const addTextBlock = (content) => {
if (!content) return;
const el = document.createElement('div');
el.style.whiteSpace = 'pre-wrap';
el.textContent = content;
frag.appendChild(el);
};
addTextBlock(before);
const fold = document.createElement('div');
fold.className = 'mcp-sys-fold';
fold.innerHTML = `
<div class="mcp-sys-fold-hd"><span class="mcp-sys-fold-title">${SYSTEM_HINT_START}</span><span class="mcp-sys-fold-toggle">展开</span></div>
<div class="mcp-sys-fold-body"></div>
`;
fold.querySelector('.mcp-sys-fold-body').textContent = hidden;
fold.querySelector('.mcp-sys-fold-hd').onclick = () => {
const open = fold.classList.toggle('open');
fold.querySelector('.mcp-sys-fold-toggle').textContent = open ? '折叠' : '展开';
};
frag.appendChild(fold);
addTextBlock(after);
target.dataset.mcpSystemFolded = '1';
target.replaceChildren(frag);
}
function collapseSystemInstructions() {
const roots = document.querySelectorAll('.ds-markdown, .ds-markdown--block, [class*="markdown"], [data-message-author-role="user"]');
roots.forEach(root => {
if (root.closest('#mcp-panel, .mcp-sys-fold')) return;
if (root.dataset?.mcpSystemFolded === '1' || root.querySelector?.('.mcp-sys-fold')) return;
const text = root.textContent || '';
if (!text.includes(SYSTEM_HINT_START) || !text.includes(SYSTEM_HINT_END)) return;
const target = findTightSystemInstructionElement(root);
if (!target || target === document.body || target === document.documentElement) return;
foldSystemInstructionElement(target);
});
}
function enhanceChatUI() {
enhanceMCPCodeBlocks();
collapseSystemInstructions();
injectTTSButtons();
}
// ═══════════════════════════════════════════════════════════════
// TTS Button Injection — Add 🔊 to assistant messages
// ═══════════════════════════════════════════════════════════════
const _ttsSeenMessages = new WeakSet();
const _ttsAutoPlayContainers = new Set();
const _ttsAutoPlayTimers = new Map();
/** Find the action bar (copy/regenerate buttons area) within a message container */
function _findActionBar(container) {
// Try common class patterns first
const byClass = container.querySelector('[class*="action"], [class*="toolbar"], [class*="buttons"], [class*="footer"]');
if (byClass) return byClass;
// Fallback: find any element with direct button children (likely the action bar)
const allEls = Array.from(container.querySelectorAll('*'));
for (let i = allEls.length - 1; i >= 0; i--) {
const el = allEls[i];
if (el.querySelectorAll(':scope > button').length >= 1) return el;
}
return null;
}
function injectTTSButtons() {
if (!getModuleEnabled('tts')) return;
const selectors = currentAdapter?.selectors?.assistantMessages || '.ds-markdown--block, [class*="markdown"]';
const messages = document.querySelectorAll(selectors);
messages.forEach(msg => {
const text = msg.textContent?.trim();
if (!text || text.length < 10) return;
const isNew = !_ttsSeenMessages.has(msg);
_ttsSeenMessages.add(msg);
const container = msg.closest('[class*="message"]') || msg.parentElement || msg;
// Check for existing button on the container (not just msg, since we insert into action bar)
if (container.querySelector('.mcp-tts-btn')) return;
const btn = document.createElement('button');
btn.className = 'mcp-tts-btn';
btn.innerHTML = '🔊 朗读';
btn.title = '朗读';
btn.onclick = (e) => {
e.stopPropagation();
const freshText = msg.textContent?.trim() || text;
if (ttsClient.playing && ttsClient.currentText === freshText) {
ttsClient.stop();
btn.innerHTML = '🔊 朗读';
btn.classList.remove('playing', 'paused');
return;
}
btn.innerHTML = '⏳ 合成中...';
btn.style.minWidth = (btn.offsetWidth || 56) + 'px';
ttsClient.play(freshText).then(() => {
btn.innerHTML = '⏸ 暂停';
btn.classList.add('playing');
const check = setInterval(() => {
if (!ttsClient.playing) {
btn.innerHTML = '🔊 朗读';
btn.classList.remove('playing', 'paused');
btn.style.minWidth = '';
clearInterval(check);
}
}, 500);
}).catch(err => {
btn.innerHTML = '🔊 朗读';
btn.style.minWidth = '';
toast('TTS 失败: ' + err.message, 'error');
});
};
// Insert into action bar at the bottom of the message
const actionBar = _findActionBar(container);
if (actionBar) {
actionBar.appendChild(btn);
} else {
container.appendChild(btn);
}
// Auto-play: track by container, wait for text to stabilize
if (isNew && getModuleEnabled('ttsAutoPlay') && !_ttsAutoPlayContainers.has(container)) {
_ttsAutoPlayContainers.add(container);
_watchTextStable(container, selectors);
}
});
}
// Watch a container until its text stops changing, then play TTS
function _watchTextStable(container, selectors) {
const selectorStr = selectors.split(',').map(s => s.trim()).join(',');
let lastLen = 0;
let stableCount = 0;
const check = setInterval(() => {
const md = container.querySelector(selectorStr);
if (!md) return;
const len = md.textContent.length;
if (len > 0 && len === lastLen) {
stableCount++;
} else {
stableCount = 0;
lastLen = len;
}
if (stableCount >= 2 && lastLen > 10) {
clearInterval(check);
_ttsAutoPlayTimers.delete(container);
ttsClient.play(md.textContent.trim()).catch(err => console.warn('Auto TTS:', err.message));
}
}, 1000);
setTimeout(() => clearInterval(check), 60000);
_ttsAutoPlayTimers.set(container, check);
}
// ═══════════════════════════════════════════════════════════════
// MutationObserver — TTS button injection
// ═══════════════════════════════════════════════════════════════
let _uiDebounce = null;
let _isPageVisible = true;
const uiObserver = new MutationObserver((mutations) => {
let hasNewNodes = false;
for (const m of mutations) {
if (m.addedNodes.length > 0) { hasNewNodes = true; break; }
}
if (!hasNewNodes) return;
if (_uiDebounce) clearTimeout(_uiDebounce);
_uiDebounce = setTimeout(() => {
if (!_isPageVisible) return;
const _run = () => enhanceChatUI();
if ('requestIdleCallback' in window) {
requestIdleCallback(_run, { timeout: 2000 });
} else {
setTimeout(_run, 200);
}
}, 1000);
});
function _reconnectObserver() {
const chatContainer = document.querySelector('[class*="chat-message-list"]')
|| document.querySelector('[class*="message-list"]')
|| document.querySelector('main')
|| document.body;
uiObserver.observe(chatContainer, { childList: true, subtree: true });
}
setTimeout(() => {
_reconnectObserver();
enhanceChatUI();
}, 2000);
// ── Visibility change: pause/resume to prevent deferred-callback pile-up ──
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
_isPageVisible = false;
uiObserver.disconnect();
if (_uiDebounce) { clearTimeout(_uiDebounce); _uiDebounce = null; }
// Clear all auto-play timers so they don't pile up while hidden
for (const [container, timer] of _ttsAutoPlayTimers) {
clearInterval(timer);
}
_ttsAutoPlayTimers.clear();
// Stop any active TTS playback
if (ttsClient.playing) ttsClient.stop();
} else {
_isPageVisible = true;
_reconnectObserver();
enhanceChatUI();
}
});
// ── Auto-connect on load ──
refreshStatus();
function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
});
})();