// ==UserScript== // @name 语助 · 轻音输入 (语音助手) // @name:en Voice Assistant - Speech to Text // @name:ja 音声入力アシスタント (音声認識) // @name:ko 음성 입력 도우미 (음성 인식) // @name:zh-TW 語助 · 輕音輸入 (語音助手) // @name:zh-HK 語助 · 輕音輸入 (語音助手) // @icon https://raw.githubusercontent.com/shiyi312/Voice-Assistant---Speech-to-Text/refs/heads/main/%E8%AF%AD%E9%9F%B3%20.ico // @namespace https://github.com/shiyi312/Voice-Assistant---Speech-to-Text // @version 2.3.0 // @description 在任何输入框中通过语音高效填字,支持连续识别、自动聚焦、自定义快捷键、3秒静音自动停止。支持多国语言识别。 // @author shiyi312 // @match *://*/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @run-at document-end // @license MIT // ==/UserScript== (function() { 'use strict'; // ---------- 配置默认值 ---------- const CONFIG_DEFAULTS = { lang: 'zh-CN', autoSend: false, copyToClipboard: false, showInterim: true, shortcutKey: 'v', shortcutCtrl: true, shortcutShift: true, shortcutAlt: false }; // ---------- 存储工具 ---------- function getStorage(key, def) { try { if (typeof GM_getValue !== 'undefined') { const val = GM_getValue(key); return val !== undefined && val !== null ? val : def; } const val = localStorage.getItem('voice_helper_' + key); return val !== null ? JSON.parse(val) : def; } catch { return def; } } function setStorage(key, val) { try { if (typeof GM_setValue !== 'undefined') { GM_setValue(key, val); return; } localStorage.setItem('voice_helper_' + key, JSON.stringify(val)); } catch {} } // ---------- 加载配置 ---------- let config = {}; function loadConfig() { for (let [key, def] of Object.entries(CONFIG_DEFAULTS)) { config[key] = getStorage(key, def); } } loadConfig(); function saveConfig() { for (let [key, val] of Object.entries(config)) { setStorage(key, val); } } // ---------- 状态常量 ---------- const STATE = { IDLE: 'idle', LISTENING: 'listening', PROCESSING: 'processing', ERROR: 'error' }; let currentState = STATE.IDLE; let recognition = null; let isFinalReceived = false; let micButton, statusIndicator, interimDisplay, container, configPanel; let shortcutHandler = null; let isConfigOpen = false; let silenceTimer = null; const SILENCE_TIMEOUT = 3000; // ---------- 检测浏览器支持 ---------- const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { createFallbackUI(); return; } // ---------- CSS 样式 ---------- GM_addStyle(` /* -------- 容器 -------- */ #voice-assistant-container { position: fixed; bottom: 30px; right: 30px; z-index: 2147483647; display: flex; flex-direction: column; align-items: center; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", sans-serif; user-select: none; pointer-events: none; touch-action: none; } #voice-assistant-container * { pointer-events: auto; touch-action: none; } /* -------- 主按钮 -------- */ .va-mic { width: 64px; height: 64px; border-radius: 50%; border: none; background: rgba(30, 35, 42, 0.85); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); box-shadow: 0 8px 32px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.08); color: #d0d7de; cursor: grab; display: flex; align-items: center; justify-content: center; transition: transform 0.25s cubic-bezier(0.34,1.56,0.64,1), background 0.3s, box-shadow 0.3s; will-change: transform; touch-action: none; } .va-mic:hover { transform: scale(1.06); background: rgba(44,52,64,0.92); } .va-mic:active { transform: scale(0.94); cursor: grabbing; } .va-mic.listening { background: rgba(26,115,74,0.9); color: #fff; animation: va-pulse-glow 1.6s ease-in-out infinite; box-shadow: 0 0 0 0 rgba(46,213,115,0.5); } @keyframes va-pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(46,213,115,0.45); } 70% { box-shadow: 0 0 0 22px rgba(46,213,115,0); } 100% { box-shadow: 0 0 0 0 rgba(46,213,115,0); } } .va-mic.error { background: rgba(200,60,60,0.9); color: #fff; animation: va-shake 0.4s ease; } @keyframes va-shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } } /* -------- 状态提示 -------- */ .va-status { margin-top: 12px; padding: 8px 18px; border-radius: 12px; background: #1E232B; box-shadow: 0 8px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.06); font-size: 14px; font-weight: 500; letter-spacing: 0.02em; line-height: 1.6; color: #F0F4F8; text-align: center; max-width: 300px; pointer-events: none; border: 1px solid rgba(255,255,255,0.06); opacity: 0; transform: translateY(6px) scale(0.98); transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.34,1.56,0.64,1); position: relative; overflow: hidden; } .va-status::after { content: ''; position: absolute; bottom: 0; left: 20%; width: 60%; height: 3px; border-radius: 4px; background: linear-gradient(90deg, transparent, rgba(46,213,115,0.35), transparent); opacity: 0.6; transition: opacity 0.4s; } .va-status:not(:empty) { opacity: 1; transform: translateY(0) scale(1); } .va-status:not(:empty)::after { opacity: 1; } .va-status[data-icon]::before { content: attr(data-icon); margin-right: 6px; } .va-interim { margin-top: 10px; padding: 4px 14px; border-radius: 20px; background: rgba(20,24,30,0.75); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); font-size: 13px; font-weight: 400; letter-spacing: 0.02em; color: #E6EDF3; max-width: 280px; text-align: center; opacity: 0; transition: opacity 0.25s ease; box-shadow: 0 4px 16px rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.05); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } .va-interim.visible { opacity: 0.9; } /* -------- 配置面板 -------- */ .va-config-overlay { display: none; position: fixed; inset: 0; z-index: 2147483646; background: rgba(0,0,0,0.3); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); opacity: 0; transition: opacity 0.35s ease; pointer-events: none; } .va-config-overlay.open { display: block; opacity: 1; pointer-events: auto; } .va-config-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.92); width: 320px; max-width: 92vw; padding: 28px 24px 24px; background: linear-gradient(145deg, rgba(30,35,42,0.96), rgba(22,26,32,0.98)); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 20px; box-shadow: 0 32px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06); z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", sans-serif; color: #F0F4F8; font-size: 13px; transition: transform 0.4s cubic-bezier(0.34,1.56,0.64,1), opacity 0.35s ease; opacity: 0; pointer-events: none; max-height: 90vh; overflow-y: auto; } .va-config-overlay.open .va-config-panel { opacity: 1; pointer-events: auto; transform: translate(-50%, -50%) scale(1); } .va-config-panel::-webkit-scrollbar { width: 4px; } .va-config-panel::-webkit-scrollbar-track { background: transparent; } .va-config-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } .va-config-panel .va-config-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.06); } .va-config-panel .va-config-header h4 { margin: 0; font-weight: 600; font-size: 16px; letter-spacing: 0.02em; color: #F0F4F8; display: flex; align-items: center; gap: 8px; } .va-config-panel .va-config-header h4::before { content: '🎙️'; font-size: 18px; } .va-config-panel .va-close-btn { background: rgba(255,255,255,0.05); border: none; border-radius: 50%; width: 32px; height: 32px; color: #8b949e; font-size: 16px; cursor: pointer; transition: background 0.2s, color 0.2s, transform 0.2s; display: flex; align-items: center; justify-content: center; } .va-config-panel .va-close-btn:hover { background: rgba(255,255,255,0.12); color: #F0F4F8; transform: rotate(90deg); } .va-config-panel .va-config-group { margin-bottom: 16px; } .va-config-panel .va-config-group:last-child { margin-bottom: 0; } .va-config-panel .va-config-label { display: block; font-size: 11px; font-weight: 500; color: #8b949e; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; } /* -------- 下拉菜单 - 修复显示不全 + 宽度自适应 -------- */ .va-config-panel select { width: 100%; min-width: 100%; padding: 8px 32px 8px 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; color: #F0F4F8; font-size: 13px; transition: border-color 0.2s, background 0.2s; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238b949e'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; cursor: pointer; box-sizing: border-box; } .va-config-panel select:hover { border-color: rgba(46,213,115,0.3); } .va-config-panel select:focus { outline: none; border-color: #2ed573; background: rgba(46,213,115,0.05); } /* 让下拉选项文字完整显示 */ .va-config-panel select option { padding: 4px 8px; background: #1E232B; color: #F0F4F8; white-space: nowrap; } .va-config-panel .va-checkbox-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; cursor: pointer; } .va-config-panel .va-checkbox-row input[type="checkbox"] { width: 18px; height: 18px; accent-color: #2ed573; cursor: pointer; flex-shrink: 0; } .va-config-panel .va-checkbox-row span { font-size: 13px; color: #e6edf3; user-select: none; } .va-config-panel .va-shortcut-box { padding: 10px 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; color: #F0F4F8; font-size: 14px; font-weight: 500; text-align: center; cursor: pointer; user-select: none; transition: border-color 0.25s, background 0.25s; letter-spacing: 0.5px; } .va-config-panel .va-shortcut-box:focus { outline: none; border-color: #2ed573; background: rgba(46,213,115,0.06); } .va-config-panel .va-shortcut-box:empty::before { content: "按下组合键"; color: #8b949e; font-weight: 400; } .va-config-panel .va-hint { font-size: 11px; color: #8b949e; margin-top: 4px; padding-left: 2px; } .va-config-panel .va-divider { height: 1px; background: rgba(255,255,255,0.06); margin: 16px 0; } .va-config-panel .va-footer-hint { font-size: 11px; color: #6b7a8a; text-align: center; padding-top: 4px; } /* -------- 响应式适配 -------- */ @media (max-width: 480px) { .va-mic { width: 56px; height: 56px; } .va-config-panel { width: 92vw; padding: 20px 16px 20px; } .va-status { font-size: 13px; max-width: 220px; } } `); // ---------- UI 创建 ---------- function createUI() { // 注册脚本猫/油猴菜单中的“设置”按钮 if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand('⚙️ 打开设置', toggleConfigPanel); } // 主容器 container = document.createElement('div'); container.id = 'voice-assistant-container'; container.innerHTML = `
`; document.body.appendChild(container); micButton = document.getElementById('va-mic'); interimDisplay = document.getElementById('va-interim'); statusIndicator = document.getElementById('va-status'); // 创建配置面板(含遮罩) createConfigPanel(); // 按钮交互:拖拽 + 单击/双击 setupButtonInteraction(micButton); document.addEventListener('click', (e) => { if (e.target.closest('#voice-assistant-container') || e.target.closest('.va-config-overlay')) return; if (currentState === STATE.IDLE) clearInterim(); }); createRecognitionInstance(); bindShortcut(); } // ---------- 按钮交互:拖拽 + 单击/双击 ---------- function setupButtonInteraction(btn) { let startX, startY, isDragging = false; let clickCount = 0; let clickTimer = null; const onDown = (e) => { const pos = e.touches ? e.touches[0] : e; startX = pos.clientX; startY = pos.clientY; isDragging = false; btn.style.cursor = 'grabbing'; }; const onMove = (e) => { if (startX === undefined) return; const pos = e.touches ? e.touches[0] : e; const dx = pos.clientX - startX; const dy = pos.clientY - startY; if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { isDragging = true; const c = btn.parentElement; let x = pos.clientX - 32; let y = pos.clientY - 32; x = Math.max(0, Math.min(window.innerWidth - c.offsetWidth, x)); y = Math.max(0, Math.min(window.innerHeight - c.offsetHeight, y)); c.style.left = x + 'px'; c.style.top = y + 'px'; c.style.right = 'auto'; c.style.bottom = 'auto'; } }; const onUp = (e) => { if (startX === undefined) return; const pos = e.changedTouches ? e.changedTouches[0] : e; const dx = pos.clientX - startX; const dy = pos.clientY - startY; const dist = Math.sqrt(dx*dx + dy*dy); startX = startY = undefined; btn.style.cursor = 'grab'; if (isDragging || dist > 5) { isDragging = false; return; } clickCount++; if (clickCount === 1) { clickTimer = setTimeout(() => { clickCount = 0; toggleListening(); }, 280); } else if (clickCount >= 2) { clearTimeout(clickTimer); clickCount = 0; toggleConfigPanel(); } }; btn.addEventListener('mousedown', onDown); document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); btn.addEventListener('touchstart', onDown, { passive: true }); document.addEventListener('touchmove', onMove, { passive: true }); document.addEventListener('touchend', onUp, { passive: true }); btn.addEventListener('dragstart', (e) => e.preventDefault()); } // ---------- 配置面板 ---------- function createConfigPanel() { const overlay = document.createElement('div'); overlay.className = 'va-config-overlay'; overlay.id = 'va-config-overlay'; document.body.appendChild(overlay); configPanel = document.createElement('div'); configPanel.className = 'va-config-panel'; configPanel.id = 'va-config-panel'; configPanel.innerHTML = `