diff --git a/README.md b/README.md index f448224..d3bcf73 100644 --- a/README.md +++ b/README.md @@ -1,268 +1,110 @@ # OpenCode Voice Dictation / Голосовая диктовка для OpenCode [![CI](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml/badge.svg)](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml) +[![Release](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/release.yml/badge.svg)](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Voice dictation for [OpenCode](https://opencode.ai) web interface using OpenAI Whisper via Groq API. Works on both desktop and mobile browsers through a Tampermonkey/Violentmonkey userscript. - -Голосовая диктовка для веб-интерфейса [OpenCode](https://opencode.ai) с использованием OpenAI Whisper через Groq API. Работает как на десктопных, так и на мобильных браузерах через юзерскрипт Tampermonkey/Violentmonkey. - ---- - -## English - -### Features - -- **Whisper large-v3** transcription via Groq API (same quality as Cursor) -- **Tap-to-toggle** recording (tap to start, tap to stop) -- **Noise suppression** + echo cancellation (great for outdoor use) -- **Recording timer** (MM:SS display) -- **Ctrl+Space** keyboard shortcut on desktop -- **Custom Whisper prompt** for software development context -- **Auto-detect language** (Russian + English technical terms) -- **Auto-submit** option (configurable) -- **No backend needed** — API calls go directly from browser via `GM_xmlhttpRequest` - -### Requirements - -| Component | Requirement | -|-----------|-------------| -| OpenCode | Web interface running (`opencode web`) | -| Groq API key | Free at [console.groq.com/keys](https://console.groq.com/keys) | -| PC Browser | Vivaldi / Chrome / Firefox + Violentmonkey or Tampermonkey | -| Mobile Browser | Firefox for Android + Tampermonkey | - -### Compatibility - -| Platform | Browser | Userscript Manager | Status | -|----------|---------|-------------------|--------| -| Desktop (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Supported | -| Desktop | Chrome | Tampermonkey | ✅ Supported | -| Desktop | Firefox | Tampermonkey | ✅ Supported | -| Android | Firefox | Tampermonkey | ✅ Supported | -| Android | Vivaldi | — | ❌ No extension support | -| Android | Chrome | — | ❌ No extension support | - -### Install - -> **One-click install**: Open this link in your browser with Violentmonkey/Tampermonkey installed: -> -> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** -> -> Your userscript manager will intercept the download and show an installation dialog. - -#### Desktop (Vivaldi + Violentmonkey) - -1. Install [Violentmonkey](https://violentmonkey.github.io/) extension in Vivaldi -2. Click the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link -3. Confirm installation in the Violentmonkey dialog - -#### Android (Firefox + Tampermonkey) - -1. Install [Firefox for Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) from Play Store -2. Install [Tampermonkey](https://addons.mozilla.org/en-US/android/addon/tampermonkey/) from Firefox Add-ons -3. In Firefox, open the [Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) link -4. Confirm installation in the Tampermonkey dialog - -#### Manual install (from GitHub Releases) - -1. Go to [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) -2. Download `opencode-voice-dictation.user.js` from the latest release -3. Open Violentmonkey/Tampermonkey dashboard → **+** → paste the file contents → Save - -### Setup - -1. Get a free Groq API key at [console.groq.com/keys](https://console.groq.com/keys) -2. Open any OpenCode web page -3. Open Violentmonkey/Tampermonkey menu → **"Set Groq API Key"** -4. Paste your key (`gsk_...`) - -### Auto-Update - -The script updates automatically. No action needed. - -- `@updateURL` checks for new versions periodically (every 24h in Tampermonkey, every 7 days in Violentmonkey) -- Only a tiny `.meta.js` (~1KB) is downloaded to check the version -- If a new version is found, the full `.user.js` (~18KB) is downloaded and installed -- Updates are published automatically by CI when code is pushed to `main` - -### Usage - -1. Open OpenCode web in your browser -2. A **microphone button** appears next to the chat input -3. **Tap** the button to start recording (red pulse + timer) -4. **Tap** again to stop — audio is transcribed and inserted into the input -5. (Desktop) Press **Ctrl+Space** as an alternative to clicking - -### Configuration - -All settings available via the Violentmonkey/Tampermonkey menu: - -| Menu Item | Description | -|-----------|-------------| -| Set Groq API Key | Enter your `gsk_...` key | -| Toggle Auto-Submit | Auto-send message after transcription | -| Set Whisper Model | `whisper-large-v3` (default) or `whisper-large-v3-turbo` (faster) | -| Set Language | Language code (e.g., `ru`, `en`) or empty for auto-detect | -| Set Whisper Prompt | Context prompt for transcription accuracy | - --- ## Русский -### Возможности +Голосовая диктовка для [OpenCode](https://opencode.ai) web через Whisper (Groq API). Качество как в Cursor. Работает на ПК и телефоне. -- **Whisper large-v3** транскрипция через Groq API (качество как в Cursor) -- **Tap-to-toggle** запись (тапнул — начал, тапнул — стоп) -- **Шумоподавление** + эхоподавление (для записи на улице) -- **Таймер записи** (формат MM:SS) -- **Ctrl+Space** горячая клавиша на ПК -- **Кастомный Whisper prompt** для контекста разработки -- **Авто-определение языка** (русский + английские технические термины) -- **Авто-отправка** опционально (настраивается) -- **Без бэкенда** — запросы идут напрямую из браузера через `GM_xmlhttpRequest` +### Установка -### Требования +> **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — открой ссылку в браузере с Violentmonkey/Tampermonkey и подтверди установку. -| Компонент | Требование | -|-----------|------------| -| OpenCode | Запущенный веб-интерфейс (`opencode web`) | -| Groq API ключ | Бесплатно на [console.groq.com/keys](https://console.groq.com/keys) | -| Браузер ПК | Vivaldi / Chrome / Firefox + Violentmonkey или Tampermonkey | -| Браузер телефон | Firefox для Android + Tampermonkey | +Или скачай с [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) и импортируй вручную. ### Совместимость | Платформа | Браузер | Менеджер скриптов | Статус | |-----------|---------|-------------------|--------| -| Десктоп (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Поддерживается | -| Десктоп | Chrome | Tampermonkey | ✅ Поддерживается | -| Десктоп | Firefox | Tampermonkey | ✅ Поддерживается | -| Android | Firefox | Tampermonkey | ✅ Поддерживается | -| Android | Vivaldi | — | ❌ Нет поддержки расширений | -| Android | Chrome | — | ❌ Нет поддержки расширений | - -### Установка - -> **Установка в один клик**: Откройте эту ссылку в браузере с установленным Violentmonkey/Tampermonkey: -> -> **[Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** -> -> Менеджер скриптов перехватит загрузку и покажет диалог установки. - -#### ПК (Vivaldi + Violentmonkey) - -1. Установите расширение [Violentmonkey](https://violentmonkey.github.io/) в Vivaldi -2. Нажмите на ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) -3. Подтвердите установку в диалоге Violentmonkey - -#### Android (Firefox + Tampermonkey) - -1. Установите [Firefox для Android](https://play.google.com/store/apps/details?id=org.mozilla.firefox) из Play Store -2. Установите [Tampermonkey](https://addons.mozilla.org/ru/android/addon/tampermonkey/) из дополнений Firefox -3. В Firefox откройте ссылку [Установить скрипт](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) -4. Подтвердите установку в диалоге Tampermonkey - -#### Ручная установка (из GitHub Releases) - -1. Перейдите на страницу [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) -2. Скачайте `opencode-voice-dictation.user.js` из последнего релиза -3. Откройте панель Violentmonkey/Tampermonkey → **+** → вставьте содержимое файла → Сохранить +| ПК | Vivaldi | Violentmonkey | ✅ | +| ПК | Chrome | Tampermonkey | ✅ | +| ПК | Firefox | Tampermonkey | ✅ | +| Android | Firefox | Tampermonkey | ✅ | +| Android | Vivaldi | — | ❌ Нет расширений | +| Android | Chrome | — | ❌ Нет расширений | ### Настройка -1. Получите бесплатный ключ Groq на [console.groq.com/keys](https://console.groq.com/keys) -2. Откройте любую страницу OpenCode web -3. Откройте меню Violentmonkey/Tampermonkey → **"Set Groq API Key"** -4. Вставьте ваш ключ (`gsk_...`) - -### Автообновление - -Скрипт обновляется автоматически. Ничего делать не нужно. - -- `@updateURL` периодически проверяет новые версии (каждые 24ч в Tampermonkey, каждые 7 дней в Violentmonkey) -- Скачивается только маленький `.meta.js` (~1KB) для проверки версии -- Если найдена новая версия — скачивается полный `.user.js` (~18KB) и устанавливается -- Обновления публикуются автоматически CI при пуше в `main` +1. Получи бесплатный ключ на [console.groq.com/keys](https://console.groq.com/keys) +2. Открой OpenCode web +3. Меню Violentmonkey/Tampermonkey → **Set Groq API Key** → вставь `gsk_...` ### Использование -1. Откройте OpenCode web в браузере -2. Рядом с полем ввода появится **кнопка микрофона** -3. **Тапните** кнопку для начала записи (красная пульсация + таймер) -4. **Тапните** снова для остановки — аудио транскрибируется и вставляется в поле ввода -5. (На ПК) Нажмите **Ctrl+Space** как альтернативу клику +1. Нажми кнопку микрофона в правом верхнем углу поля ввода +2. Говори (виден таймер записи) +3. Нажми снова — текст появится в поле ввода +4. На ПК: **Ctrl+Space** — горячая клавиша + +### Автообновление + +Скрипт обновляется сам. При пуше в `main` CI собирает новую версию и публикует в ветку `dist`. Пользователи получают обновление автоматически. ### Настройки -Все параметры доступны через меню Violentmonkey/Tampermonkey: - | Пункт меню | Описание | |------------|----------| -| Set Groq API Key | Ввести ключ `gsk_...` | -| Toggle Auto-Submit | Автоматическая отправка после транскрипции | -| Set Whisper Model | `whisper-large-v3` (по умолчанию) или `whisper-large-v3-turbo` (быстрее) | -| Set Language | Код языка (например, `ru`, `en`) или пусто для авто-определения | -| Set Whisper Prompt | Контекстный промпт для точности транскрипции | +| Set Groq API Key | Ключ `gsk_...` | +| Toggle Auto-Submit | Отправлять после транскрипции | +| Set Whisper Model | `whisper-large-v3` или `whisper-large-v3-turbo` | +| Set Language | `ru`, `en` или пусто (авто) | +| Set Whisper Prompt | Контекст для точности | --- -## Development / Разработка +## English -### Prerequisites +Voice dictation for [OpenCode](https://opencode.ai) web via Whisper (Groq API). Cursor-quality transcription. Works on desktop and mobile. -- Node.js 22+ -- npm +### Install + +> **[Install Script](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js)** — open this link in a browser with Violentmonkey/Tampermonkey and confirm installation. + +Or download from [Releases](https://github.com/slaid098/opencode-voice-dictation/releases) and import manually. + +### Compatibility + +| Platform | Browser | Userscript Manager | Status | +|----------|---------|-------------------|--------| +| Desktop | Vivaldi | Violentmonkey | ✅ | +| Desktop | Chrome | Tampermonkey | ✅ | +| Desktop | Firefox | Tampermonkey | ✅ | +| Android | Firefox | Tampermonkey | ✅ | +| Android | Vivaldi | — | ❌ No extensions | +| Android | Chrome | — | ❌ No extensions | ### Setup -```bash -git clone https://github.com/slaid098/opencode-voice-dictation.git -cd opencode-voice-dictation -npm install -``` +1. Get a free API key at [console.groq.com/keys](https://console.groq.com/keys) +2. Open OpenCode web +3. Violentmonkey/Tampermonkey menu → **Set Groq API Key** → paste `gsk_...` -### Commands +### Usage -| Command | Description | -|---------|-------------| -| `npm run dev` | Development mode with auto-reload | -| `npm run build` | Build to `dist/opencode-voice-dictation.user.js` | -| `npm run lint` | Lint with Biome | -| `npm run format` | Format with Biome | -| `npm run typecheck` | TypeScript type checking | -| `npm run test` | Run tests with coverage (60% threshold) | -| `npm run knip` | Find unused code | +1. Click the mic button in the top-right corner of the input area +2. Speak (recording timer is displayed) +3. Click again — transcribed text appears in the input +4. Desktop: **Ctrl+Space** shortcut -### Project Structure +### Auto-Update -``` -src/ -├── index.ts # Entry point — orchestrates all modules -├── config.ts # GM storage, defaults, validation, menu commands -├── audio.ts # MediaRecorder, getUserMedia, audio constraints -├── transcribe.ts # Groq API call, FormData builder, error parsing -├── insert.ts # Contenteditable text insertion + InputEvent dispatch -├── ui.ts # Button injection, MutationObserver, toast notifications -├── keyboard.ts # Ctrl+Space keyboard shortcut handler -└── types.ts # Shared TypeScript types +The script updates automatically. When code is pushed to `main`, CI builds and publishes to the `dist` branch. Users receive updates automatically. -tests/ -├── transcribe.test.ts # FormData format, error parsing (5 tests) -├── insert.test.ts # Contenteditable insertion, event dispatch (5 tests) -├── config.test.ts # Defaults, key validation, state (9 tests) -├── audio.test.ts # Time formatting (5 tests) -└── __mocks__/$/ # Mock for GM_api functions -``` +### Settings -### CI/CD +| Menu Item | Description | +|-----------|-------------| +| Set Groq API Key | API key `gsk_...` | +| Toggle Auto-Submit | Auto-send after transcription | +| Set Whisper Model | `whisper-large-v3` or `whisper-large-v3-turbo` | +| Set Language | `ru`, `en`, or empty (auto-detect) | +| Set Whisper Prompt | Context for accuracy | -- **Lint**: Biome (recommended rules, `noExplicitAny: error`) -- **Typecheck**: `tsc --noEmit` -- **Knip**: Dead code detection -- **Test**: Vitest + happy-dom, 60% coverage threshold -- **Build**: Vite → single `.user.js` file -- **Dependabot**: Weekly npm + GitHub Actions updates +--- ## License diff --git a/src/ui.ts b/src/ui.ts index 9ac34bd..2e05c65 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,56 +1,103 @@ import type { DictationState } from "./types.js"; const BUTTON_ID = "opencode-voice-dictation-btn"; +const CONTAINER_ID = "opencode-voice-dictation-container"; +const TIMER_ID = "opencode-voice-dictation-timer"; const COMPOSER_SELECTORS = [ '[data-component="session-composer"]', '[data-component="session-new-composer"]', ]; +const ICON_MIC = ``; +const ICON_STOP = ``; +const ICON_SPINNER = ``; + function createButtonStyle(): string { return ` + #${CONTAINER_ID} { + position: absolute !important; + top: 8px; + right: 8px; + z-index: 999998; + display: flex; + align-items: center; + gap: 6px; + pointer-events: none; + } + #${CONTAINER_ID} > * { + pointer-events: auto; + } #${BUTTON_ID} { - display: inline-flex; + display: flex; align-items: center; justify-content: center; - width: 36px; - height: 36px; - border-radius: 8px; - border: 1px solid var(--color-border, #333); - background: var(--color-bg-secondary, #1a1a1a); + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: var(--color-bg-tertiary, rgba(128, 128, 128, 0.15)); color: var(--color-text-secondary, #888); cursor: pointer; transition: all 0.2s ease; - flex-shrink: 0; - font-size: 18px; padding: 0; - gap: 4px; + flex-shrink: 0; } #${BUTTON_ID}:hover { - background: var(--color-bg-tertiary, #2a2a2a); + background: var(--color-bg-hover, rgba(128, 128, 128, 0.25)); color: var(--color-text-primary, #fff); } #${BUTTON_ID}.recording { background: #e53935; color: #fff; - border-color: #e53935; - animation: pulse 1.5s ease-in-out infinite; + animation: ocvd-pulse 1.5s ease-in-out infinite; } #${BUTTON_ID}.processing { background: var(--color-accent, #4a9eff); color: #fff; - border-color: var(--color-accent, #4a9eff); pointer-events: none; - opacity: 0.7; + opacity: 0.8; } - #${BUTTON_ID} .timer { - font-size: 11px; - font-weight: 600; + #${TIMER_ID} { + display: none; font-family: monospace; - margin-right: 2px; + font-size: 13px; + font-weight: 600; + color: #e53935; + background: rgba(229, 57, 53, 0.1); + padding: 3px 10px; + border-radius: 12px; + align-items: center; + gap: 5px; + line-height: 1; + white-space: nowrap; } - @keyframes pulse { + #${TIMER_ID}.visible { + display: inline-flex; + } + #${TIMER_ID}::before { + content: ""; + width: 7px; + height: 7px; + background: #e53935; + border-radius: 50%; + animation: ocvd-blink 1s ease-in-out infinite; + flex-shrink: 0; + } + @keyframes ocvd-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(229, 57, 53, 0.4); } - 50% { box-shadow: 0 0 0 8px rgba(229, 57, 53, 0); } + 50% { box-shadow: 0 0 0 6px rgba(229, 57, 53, 0); } + } + @keyframes ocvd-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } + @keyframes ocvd-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + .ocvd-spin { + animation: ocvd-spin 0.8s linear infinite; + transform-origin: center; } #opencode-voice-toast { position: fixed; @@ -103,35 +150,50 @@ function showToast(message: string, isError = false): void { }, 4000); } -function createButton(): HTMLButtonElement { +function createContainer(): HTMLDivElement { + const container = document.createElement("div"); + container.id = CONTAINER_ID; + + const timer = document.createElement("span"); + timer.id = TIMER_ID; + timer.textContent = "00:00"; + const button = document.createElement("button"); button.id = BUTTON_ID; button.type = "button"; button.title = "Voice Dictation (Ctrl+Space)"; - button.innerHTML = "🎤"; - return button; + button.innerHTML = ICON_MIC; + + container.appendChild(timer); + container.appendChild(button); + + return container; } function updateButtonState( button: HTMLButtonElement, + timer: HTMLElement, state: DictationState, elapsedSeconds = 0, ): void { button.classList.remove("recording", "processing"); + timer.classList.remove("visible"); switch (state) { case "idle": - button.innerHTML = "🎤"; + button.innerHTML = ICON_MIC; button.title = "Voice Dictation (Ctrl+Space)"; break; case "recording": button.classList.add("recording"); - button.innerHTML = `${formatTimer(elapsedSeconds)}⏹`; + button.innerHTML = ICON_STOP; button.title = "Stop recording"; + timer.textContent = formatTimer(elapsedSeconds); + timer.classList.add("visible"); break; case "processing": button.classList.add("processing"); - button.innerHTML = "⌛"; + button.innerHTML = ICON_SPINNER; button.title = "Transcribing..."; break; } @@ -153,12 +215,12 @@ function findComposer(): HTMLElement | null { return null; } -function isButtonInjected(): boolean { - return document.getElementById(BUTTON_ID) !== null; +function isContainerInjected(): boolean { + return document.getElementById(CONTAINER_ID) !== null; } function injectButton(onToggle: () => void): void { - if (isButtonInjected()) { + if (isContainerInjected()) { return; } @@ -169,19 +231,25 @@ function injectButton(onToggle: () => void): void { injectStyles(); - const button = createButton(); + const computedPosition = window.getComputedStyle(composer).position; + if (computedPosition === "static") { + composer.style.position = "relative"; + } + + const container = createContainer(); + const button = container.querySelector(`#${BUTTON_ID}`) as HTMLButtonElement; button.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onToggle(); }); - composer.appendChild(button); + composer.appendChild(container); } export function setupUI( onToggle: () => void, - getState: () => DictationState, + _getState: () => DictationState, ): { inject: () => void; updateState: (state: DictationState, elapsedSeconds?: number) => void; @@ -202,8 +270,9 @@ export function setupUI( inject: () => injectButton(onToggle), updateState: (state: DictationState, elapsedSeconds = 0) => { const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null; - if (button) { - updateButtonState(button, state, elapsedSeconds); + const timer = document.getElementById(TIMER_ID); + if (button && timer) { + updateButtonState(button, timer, state, elapsedSeconds); } }, toast: showToast,