duxiaolu166 / GlobalChatgpt Pro Cartoon Cat Background + Voice (修正版)

// ==UserScript==
// @name         GlobalChatgpt Pro Cartoon Cat Background + Voice (修正版)
// @namespace    http://tampermonkey.net/
// @version      1.14
// @description  多密钥支持、拖动、主题切换、卡通猫背景、最小化/隐藏、语音输入+朗读、真实 Assistants 聊天,连接状态显示
// @author       maken
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_notification
// @grant        GM_xmlhttpRequest
// @connect      api.openai.com
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  function safeGetValue(key, def = '') {
    if (typeof GM_getValue === 'function') return GM_getValue(key, def);
    try {
      return localStorage.getItem(key) ?? def;
    }
    catch {
      return def;
    }
  }

  function safeSetValue(key, val) {
    if (typeof GM_setValue === 'function') return GM_setValue(key, val);
    try {
      localStorage.setItem(key, val);
    }
    catch {}
  }

  const CONFIG = {
    customKey: safeGetValue('gcui_apiKey', ''),
    assistantId: safeGetValue('gcui_assistantId', 'asst_你的ID'),
    theme: safeGetValue('gcui_theme', 'pink'),
    posX: Number(safeGetValue('gcui_posX', 100)),
    posY: Number(safeGetValue('gcui_posY', 100))
  };

  const assistantState = {
    threadId: ''
  };
  const state = {
    container: null,
    header: null,
    body: null,
    inputArea: null,
    input: null,
    sendBtn: null,
    themeBtn: null,
    keyBtn: null,
    toggleBtn: null,
    hideBtn: null,
    voiceBtn: null,
    statusIcon: null
  };

  function getCurrentApiKey() {
    return CONFIG.customKey || '';
  }

  function savePosition(x, y) {
    safeSetValue('gcui_posX', x);
    safeSetValue('gcui_posY', y);
  }

  function showMessage(sender, text) {
    const msg = document.createElement('div');
    Object.assign(msg.style, {
      marginBottom: '8px',
      wordBreak: 'break-word',
      backgroundColor: sender === '我' ? 'rgba(255,255,255,0.7)' : 'rgba(255,255,255,0.3)',
      padding: '6px 10px',
      borderRadius: '6px',
      alignSelf: sender === '我' ? 'flex-end' : 'flex-start',
      maxWidth: '80%'
    });
    msg.innerHTML = `<strong style="color:${sender === '我' ? '#d6006e' : '#333'}">${sender}:</strong> ${text}`;
    state.body.appendChild(msg);
    state.body.scrollTop = state.body.scrollHeight;
  }

  async function initThread() {
    const apiKey = getCurrentApiKey();
    if (!apiKey || !CONFIG.assistantId.startsWith('asst_')) {
      updateStatusIcon('red', '无效的 API Key 或 Assistant ID');
      return false;
    }
    const headers = {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      'OpenAI-Beta': 'assistants=v2'
    };
    try {
      const threadRes = await fetch('https://api.openai.com/v1/threads', {
        method: 'POST',
        headers,
        body: JSON.stringify({
          json: {}
        })
      });
      const threadData = await threadRes.json();
      if (threadData.error || !threadData.id) {
        console.error('线程创建失败:', threadData.error || threadData);
        updateStatusIcon('red', '线程创建失败: ' + (threadData.error?.message || '未知错误'));
        return false;
      }
      assistantState.threadId = threadData.id;
      updateStatusIcon('green', '连接正常');
      return true;
    }
    catch (e) {
      console.error('线程创建异常:', e);
      updateStatusIcon('red', '线程创建异常,检查网络');
      return false;
    }
  }

  function updateStatusIcon(color, title) {
    if (state.statusIcon) {
      state.statusIcon.style.backgroundColor = color;
      state.statusIcon.title = title;
    }
  }

  async function sendMessage() {
    const inputText = state.input.value.trim();
    if (!inputText) return;

    // 确保线程有效,线程不存在则初始化
    if (!assistantState.threadId) {
      const ok = await initThread();
      if (!ok) {
        GM_notification({
          text: '初始化线程失败,无法发送消息',
          timeout: 3000
        });
        return;
      }
    }

    showMessage('我', inputText);
    state.input.value = '';
    state.input.focus();

    const apiKey = getCurrentApiKey();
    if (!apiKey || !CONFIG.assistantId.startsWith('asst_')) {
      GM_notification({
        text: '请设置有效的 API Key 和 Assistant ID',
        timeout: 2000
      });
      updateStatusIcon('red', '无效的 API Key 或 Assistant ID');
      return;
    }

    const headers = {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      'OpenAI-Beta': 'assistants=v2'
    };

    // 发送消息
    try {
      const messageRes = await fetch(`https://api.openai.com/v1/threads/${assistantState.threadId}/messages`, {
        method: 'POST',
        headers,
        body: JSON.stringify({
          role: 'user',
          content: inputText
        })
      });
      const messageData = await messageRes.json();
      if (messageData.error) {
        console.error('发送消息失败:', messageData.error);
        GM_notification({
          text: '发送消息失败: ' + messageData.error.message,
          timeout: 3000
        });
        updateStatusIcon('red', '发送消息失败');
        return;
      }
      GM_notification({
        text: '消息已发送',
        timeout: 1500
      });
    }
    catch (e) {
      console.error('发送消息异常:', e);
      GM_notification({
        text: '发送消息异常,检查网络',
        timeout: 3000
      });
      updateStatusIcon('red', '发送消息异常');
      return;
    }

    // 触发执行
    let runId = '';
    try {
      const runRes = await fetch(`https://api.openai.com/v1/threads/${assistantState.threadId}/runs`, {
        method: 'POST',
        headers,
        body: JSON.stringify({
          assistant_id: CONFIG.assistantId
        })
      });
      const runData = await runRes.json();
      if (runData.error || !runData.id) {
        console.error('触发执行失败:', runData.error || runData);
        GM_notification({
          text: '触发执行失败: ' + (runData.error?.message || '未知错误'),
          timeout: 3000
        });
        updateStatusIcon('red', '触发执行失败');
        return;
      }
      runId = runData.id;
    }
    catch (e) {
      console.error('触发执行异常:', e);
      GM_notification({
        text: '触发执行异常,检查网络',
        timeout: 3000
      });
      updateStatusIcon('red', '触发执行异常');
      return;
    }

    // 轮询获取回复
    let status = 'queued';
    while (status !== 'completed' && status !== 'failed') {
      await new Promise(r => setTimeout(r, 1500));
      try {
        const statusRes = await fetch(`https://api.openai.com/v1/threads/${assistantState.threadId}/runs/${runId}`, {
          headers
        });
        const statusData = await statusRes.json();
        if (statusData.error) {
          console.error('查询状态失败:', statusData.error);
          GM_notification({
            text: '查询状态失败: ' + statusData.error.message,
            timeout: 3000
          });
          updateStatusIcon('red', '查询状态失败');
          return;
        }
        status = statusData.status;
      }
      catch (e) {
        console.error('查询状态异常:', e);
        GM_notification({
          text: '查询状态异常,检查网络',
          timeout: 3000
        });
        updateStatusIcon('red', '查询状态异常');
        return;
      }
    }

    // 获取回复消息
    try {
      const msgRes = await fetch(`https://api.openai.com/v1/threads/${assistantState.threadId}/messages`, {
        headers
      });
      const msgData = await msgRes.json();
      if (msgData.error) {
        console.error('获取回复失败:', msgData.error);
        GM_notification({
          text: '获取回复失败: ' + msgData.error.message,
          timeout: 3000
        });
        updateStatusIcon('red', '获取回复失败');
        return;
      }
      const assistantMsgs = msgData.data.filter(m => m.role === 'assistant');
      const reply = assistantMsgs.length > 0 ? assistantMsgs[assistantMsgs.length - 1].content[0].text.value : '⚠️ 无有效回复';
      showMessage('AI', reply);
      updateStatusIcon('green', '连接正常');
    }
    catch (e) {
      console.error('获取回复异常:', e);
      GM_notification({
        text: '获取回复异常,检查网络',
        timeout: 3000
      });
      updateStatusIcon('red', '获取回复异常');
    }
  }

  function speakText(text) {
    if (!window.speechSynthesis) return;
    const msg = new SpeechSynthesisUtterance(text);
    msg.lang = 'zh-CN';
    window.speechSynthesis.speak(msg);
  }

  function updateTheme() {
    const t = CONFIG.theme;
    const {
      container,
      body,
      inputArea,
      sendBtn,
      themeBtn
    } = state;
    container.style.backgroundColor = t === 'pink' ? '#ffc0d9' : '#2c2c2c';
    state.header.style.backgroundColor = t === 'pink' ? '#ff66b2' : '#444';
    body.style.color = t === 'pink' ? '#333' : '#ddd';
    if (t === 'pink') {
      body.style.backgroundImage = 'url("https://img.redocn.com/sheji/20240607/keaikatongmaosucaituAItu_13339783.jpg")';
      body.style.backgroundRepeat = 'no-repeat';
      body.style.backgroundPosition = 'bottom right';
      body.style.backgroundSize = '150px';
      body.style.backgroundColor = '#fff0f7';
      themeBtn.textContent = '🌙';
    }
    else {
      body.style.backgroundImage = 'none';
      body.style.backgroundColor = '#222';
      themeBtn.textContent = '☀️';
    }
    inputArea.style.backgroundColor = t === 'pink' ? '#ffd6e8' : '#333';
    sendBtn.style.backgroundColor = t === 'pink' ? '#ff66b2' : '#666';
  }

  async function buildUI() {
    const container = document.createElement('div');
    Object.assign(container.style, {
      position: 'fixed',
      top: CONFIG.posY + 'px',
      left: CONFIG.posX + 'px',
      width: '360px',
      height: '500px',
      borderRadius: '10px',
      zIndex: 99999,
      boxShadow: '0 0 10px rgba(0,0,0,0.3)',
      display: 'flex',
      flexDirection: 'column'
    });

    const header = document.createElement('div');
    Object.assign(header.style, {
      padding: '8px 10px',
      fontSize: '16px',
      fontWeight: 'bold',
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'center',
      cursor: 'grab'
    });
    const title = document.createElement('span');
    title.textContent = 'GlobalChat Pro';
    header.appendChild(title);

    const controls = document.createElement('div');
    controls.style.display = 'flex';
    controls.style.gap = '5px';

    const themeBtn = document.createElement('button');
    themeBtn.textContent = '🌙';
    themeBtn.title = '切换主题';
    themeBtn.style.cssText = 'background:#fff;border:none;padding:2px 6px;border-radius:4px;cursor:pointer;';
    themeBtn.onclick = () => {
      CONFIG.theme = CONFIG.theme === 'pink' ? 'dark' : 'pink';
      safeSetValue('gcui_theme', CONFIG.theme);
      updateTheme();
    };
    controls.appendChild(themeBtn);
    state.themeBtn = themeBtn;

    const statusIcon = document.createElement('div');
    statusIcon.style.width = '12px';
    statusIcon.style.height = '12px';
    statusIcon.style.borderRadius = '50%';
    statusIcon.style.backgroundColor = 'red';
    statusIcon.title = '连接状态未知';
    controls.appendChild(statusIcon);
    state.statusIcon = statusIcon;

    const keyBtn = document.createElement('button');
    keyBtn.textContent = '🔑';
    keyBtn.title = '设置 API Key 与 Assistant ID';
    keyBtn.style.cssText = themeBtn.style.cssText;
    keyBtn.onclick = () => {
      const key = prompt('请输入 API Key (sk-开头)', CONFIG.customKey);
      const aid = prompt('请输入 Assistant ID (asst_ 开头)', CONFIG.assistantId);
      if (key) safeSetValue('gcui_apiKey', key);
      if (aid) safeSetValue('gcui_assistantId', aid);
      CONFIG.customKey = key;
      CONFIG.assistantId = aid;
      GM_notification({
        text: '密钥设置已保存',
        timeout: 1500
      });
      // 重新初始化线程及状态
      assistantState.threadId = '';
      initThread();
    };
    controls.appendChild(keyBtn);

    const toggleBtn = document.createElement('button');
    toggleBtn.textContent = '🗕';
    toggleBtn.title = '最小化';
    toggleBtn.style.cssText = themeBtn.style.cssText;
    toggleBtn.onclick = () => {
      const show = state.body.style.display !== 'none';
      state.body.style.display = show ? 'none' : 'flex';
      state.inputArea.style.display = show ? 'none' : 'flex';
    };
    controls.appendChild(toggleBtn);

    const hideBtn = document.createElement('button');
    hideBtn.textContent = '👁️';
    hideBtn.title = '隐藏窗口';
    hideBtn.style.cssText = themeBtn.style.cssText;
    hideBtn.onclick = () => {
      state.container.style.display = 'none';
      setTimeout(() => {
        const btn = document.createElement('button');
        btn.textContent = '📢 显示聊天';
        btn.style.cssText = 'position:fixed;bottom:10px;left:10px;z-index:999999;padding:5px 10px;border-radius:6px;background:#ff66b2;color:#fff;border:none;cursor:pointer;';
        document.body.appendChild(btn);
        btn.onclick = () => {
          state.container.style.display = 'flex';
          btn.remove();
        };
      }, 100);
    };
    controls.appendChild(hideBtn);

    header.appendChild(controls);
    container.appendChild(header);
    state.header = header;

    const body = document.createElement('div');
    Object.assign(body.style, {
      flex: '1',
      overflowY: 'auto',
      padding: '10px',
      fontSize: '14px',
      display: 'flex',
      flexDirection: 'column'
    });
    container.appendChild(body);
    state.body = body;

    const inputArea = document.createElement('div');
    Object.assign(inputArea.style, {
      display: 'flex',
      gap: '5px',
      alignItems: 'center',
      padding: '10px',
      borderTop: '1px solid #ccc'
    });
    const input = document.createElement('textarea');
    input.rows = 2;
    input.placeholder = '请输入消息...';
    Object.assign(input.style, {
      flex: '1',
      resize: 'none',
      padding: '5px',
      borderRadius: '5px',
      border: '1px solid #ccc'
    });
    const sendBtn = document.createElement('button');
    sendBtn.textContent = '发送';
    sendBtn.style.cssText = 'padding:6px 15px;border:none;border-radius:5px;color:#fff;background:#ff66b2;cursor:pointer;';
    inputArea.appendChild(input);
    inputArea.appendChild(sendBtn);
    container.appendChild(inputArea);

    Object.assign(state, {
      container,
      input,
      sendBtn,
      inputArea
    });

    sendBtn.onclick = sendMessage;
    input.addEventListener('keydown', e => {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        sendBtn.click();
      }
    });

    document.body.appendChild(container);
    updateTheme();

    // 拖动
    let dragging = false,
      offsetX = 0,
      offsetY = 0;
    header.addEventListener('mousedown', e => {
      dragging = true;
      offsetX = e.clientX - container.offsetLeft;
      offsetY = e.clientY - container.offsetTop;
      header.style.cursor = 'grabbing';
    });
    document.addEventListener('mouseup', () => {
      if (dragging) {
        dragging = false;
        header.style.cursor = 'grab';
        savePosition(container.offsetLeft, container.offsetTop);
      }
    });
    document.addEventListener('mousemove', e => {
      if (!dragging) return;
      const x = e.clientX - offsetX,
        y = e.clientY - offsetY;
      container.style.left = Math.max(0, Math.min(x, window.innerWidth - container.offsetWidth)) + 'px';
      container.style.top = Math.max(0, Math.min(y, window.innerHeight - container.offsetHeight)) + 'px';
    });

    // 页面加载完成后立即初始化线程检测连接状态
    initThread();
  }

  buildUI();
})();