feat: initial implementation of OpenCode Voice Dictation
- TypeScript + Vite (vite-plugin-monkey) userscript - Groq API integration with whisper-large-v3 - Tap-to-toggle recording with noise suppression - Contenteditable text insertion for OpenCode web - Ctrl+Space keyboard shortcut (desktop) - Custom Whisper prompt for software development context - Recording timer, toast notifications, auto-submit option - 44 unit tests, 100% line coverage - Biome linter, Knip, Vitest, CI/CD pipeline - Bilingual README (EN/RU) with compatibility table
This commit is contained in:
commit
3bd443dde3
28 changed files with 5576 additions and 0 deletions
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
43
.github/workflows/ci.yml
vendored
Normal file
43
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint (Biome)
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Knip
|
||||
run: npm run knip
|
||||
|
||||
- name: Test (Vitest + Coverage)
|
||||
run: npm run test
|
||||
|
||||
- name: Build (Vite → .user.js)
|
||||
run: npm run build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: github.event_name == 'push'
|
||||
with:
|
||||
name: opencode-voice-dictation-user-js
|
||||
path: dist/*.user.js
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 slaid098
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
227
README.md
Normal file
227
README.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# OpenCode Voice Dictation / Голосовая диктовка для OpenCode
|
||||
|
||||
[](https://github.com/slaid098/opencode-voice-dictation/actions/workflows/ci.yml)
|
||||
[](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
|
||||
|
||||
#### Desktop (Vivaldi + Violentmonkey)
|
||||
|
||||
1. Install [Violentmonkey](https://violentmonkey.github.io/) extension in Vivaldi
|
||||
2. Open Violentmonkey dashboard → **+** → **Create new script**
|
||||
3. Paste the contents of `dist/opencode-voice-dictation.user.js`
|
||||
4. Save (Ctrl+S)
|
||||
|
||||
#### 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. Open Tampermonkey dashboard → **+** → paste the script → 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_...`)
|
||||
|
||||
### 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 |
|
||||
|
||||
---
|
||||
|
||||
## Русский
|
||||
|
||||
### Возможности
|
||||
|
||||
- **Whisper large-v3** транскрипция через Groq API (качество как в Cursor)
|
||||
- **Tap-to-toggle** запись (тапнул — начал, тапнул — стоп)
|
||||
- **Шумоподавление** + эхоподавление (для записи на улице)
|
||||
- **Таймер записи** (формат MM:SS)
|
||||
- **Ctrl+Space** горячая клавиша на ПК
|
||||
- **Кастомный Whisper prompt** для контекста разработки
|
||||
- **Авто-определение языка** (русский + английские технические термины)
|
||||
- **Авто-отправка** опционально (настраивается)
|
||||
- **Без бэкенда** — запросы идут напрямую из браузера через `GM_xmlhttpRequest`
|
||||
|
||||
### Требования
|
||||
|
||||
| Компонент | Требование |
|
||||
|-----------|------------|
|
||||
| OpenCode | Запущенный веб-интерфейс (`opencode web`) |
|
||||
| Groq API ключ | Бесплатно на [console.groq.com/keys](https://console.groq.com/keys) |
|
||||
| Браузер ПК | Vivaldi / Chrome / Firefox + Violentmonkey или Tampermonkey |
|
||||
| Браузер телефон | Firefox для Android + Tampermonkey |
|
||||
|
||||
### Совместимость
|
||||
|
||||
| Платформа | Браузер | Менеджер скриптов | Статус |
|
||||
|-----------|---------|-------------------|--------|
|
||||
| Десктоп (Linux/macOS/Windows) | Vivaldi | Violentmonkey | ✅ Поддерживается |
|
||||
| Десктоп | Chrome | Tampermonkey | ✅ Поддерживается |
|
||||
| Десктоп | Firefox | Tampermonkey | ✅ Поддерживается |
|
||||
| Android | Firefox | Tampermonkey | ✅ Поддерживается |
|
||||
| Android | Vivaldi | — | ❌ Нет поддержки расширений |
|
||||
| Android | Chrome | — | ❌ Нет поддержки расширений |
|
||||
|
||||
### Установка
|
||||
|
||||
#### ПК (Vivaldi + Violentmonkey)
|
||||
|
||||
1. Установите расширение [Violentmonkey](https://violentmonkey.github.io/) в Vivaldi
|
||||
2. Откройте панель Violentmonkey → **+** → **Создать новый скрипт**
|
||||
3. Вставьте содержимое `dist/opencode-voice-dictation.user.js`
|
||||
4. Сохраните (Ctrl+S)
|
||||
|
||||
#### 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. Откройте панель Tampermonkey → **+** → вставьте скрипт → Сохранить
|
||||
|
||||
### Настройка
|
||||
|
||||
1. Получите бесплатный ключ Groq на [console.groq.com/keys](https://console.groq.com/keys)
|
||||
2. Откройте любую страницу OpenCode web
|
||||
3. Откройте меню Violentmonkey/Tampermonkey → **"Set Groq API Key"**
|
||||
4. Вставьте ваш ключ (`gsk_...`)
|
||||
|
||||
### Использование
|
||||
|
||||
1. Откройте OpenCode web в браузере
|
||||
2. Рядом с полем ввода появится **кнопка микрофона**
|
||||
3. **Тапните** кнопку для начала записи (красная пульсация + таймер)
|
||||
4. **Тапните** снова для остановки — аудио транскрибируется и вставляется в поле ввода
|
||||
5. (На ПК) Нажмите **Ctrl+Space** как альтернативу клику
|
||||
|
||||
### Настройки
|
||||
|
||||
Все параметры доступны через меню 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 | Контекстный промпт для точности транскрипции |
|
||||
|
||||
---
|
||||
|
||||
## Development / Разработка
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- npm
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/slaid098/opencode-voice-dictation.git
|
||||
cd opencode-voice-dictation
|
||||
npm install
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| 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 |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
|
||||
- **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
|
||||
|
||||
MIT — see [LICENSE](LICENSE)
|
||||
35
biome.json
Normal file
35
biome.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignore": ["node_modules", "dist", "coverage", "*.user.js"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
knip.json
Normal file
5
knip.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"entry": ["src/index.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"ignore": []
|
||||
}
|
||||
3974
package-lock.json
generated
Normal file
3974
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
package.json
Normal file
30
package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "opencode-voice-dictation",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"knip": "knip"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"happy-dom": "^17.0.0",
|
||||
"knip": "^6.24.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-monkey": "^5.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
76
src/audio.ts
Normal file
76
src/audio.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
export interface AudioRecorder {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<Blob>;
|
||||
isRecording(): boolean;
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function createAudioRecorder(): AudioRecorder {
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let chunks: Blob[] = [];
|
||||
let stream: MediaStream | null = null;
|
||||
let recording = false;
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
chunks = [];
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
? "audio/webm;codecs=opus"
|
||||
: "audio/webm";
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
audioBitsPerSecond: 128000,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
mediaRecorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
recording = true;
|
||||
},
|
||||
|
||||
stop(): Promise<Blob> {
|
||||
return new Promise<Blob>((resolve) => {
|
||||
if (!mediaRecorder) {
|
||||
resolve(new Blob());
|
||||
return;
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: "audio/webm" });
|
||||
if (stream) {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
recording = false;
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
},
|
||||
|
||||
isRecording(): boolean {
|
||||
return recording;
|
||||
},
|
||||
};
|
||||
}
|
||||
49
src/config.ts
Normal file
49
src/config.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { GM_getValue, GM_registerMenuCommand, GM_setValue } from "$";
|
||||
import type { AppConfig } from "./types.js";
|
||||
|
||||
export const DEFAULTS: AppConfig = {
|
||||
groqApiKey: "",
|
||||
model: "whisper-large-v3",
|
||||
language: "",
|
||||
whisperPrompt:
|
||||
"Software development discussion. Common terms: API, JSON, async, await, function, class, component, endpoint, deployment, refactoring, merge, commit, pull request, branch, repository, TypeScript, Python, Docker, Kubernetes, OpenCode, Whisper, Groq, contenteditable, SolidJS, Vite, Biome, Vitest, MutationObserver, FormData, MediaRecorder.",
|
||||
autoSubmit: false,
|
||||
};
|
||||
|
||||
export function getConfig(): AppConfig {
|
||||
return {
|
||||
groqApiKey: GM_getValue("groqApiKey", DEFAULTS.groqApiKey),
|
||||
model: GM_getValue("model", DEFAULTS.model),
|
||||
language: GM_getValue("language", DEFAULTS.language),
|
||||
whisperPrompt: GM_getValue("whisperPrompt", DEFAULTS.whisperPrompt),
|
||||
autoSubmit: GM_getValue("autoSubmit", DEFAULTS.autoSubmit),
|
||||
};
|
||||
}
|
||||
|
||||
export function setConfig(partial: Partial<AppConfig>): void {
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
GM_setValue(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateApiKey(key: string): boolean {
|
||||
return key.startsWith("gsk_") && key.length > 20;
|
||||
}
|
||||
|
||||
export function isFirstRun(): boolean {
|
||||
return GM_getValue("groqApiKey", "") === "";
|
||||
}
|
||||
|
||||
export function registerMenuCommands(callbacks: {
|
||||
onSetKey: () => void;
|
||||
onToggleAutoSubmit: () => void;
|
||||
onSetModel: () => void;
|
||||
onSetLanguage: () => void;
|
||||
onSetPrompt: () => void;
|
||||
}): void {
|
||||
GM_registerMenuCommand("Set Groq API Key", callbacks.onSetKey);
|
||||
GM_registerMenuCommand("Toggle Auto-Submit", callbacks.onToggleAutoSubmit);
|
||||
GM_registerMenuCommand("Set Whisper Model", callbacks.onSetModel);
|
||||
GM_registerMenuCommand("Set Language", callbacks.onSetLanguage);
|
||||
GM_registerMenuCommand("Set Whisper Prompt", callbacks.onSetPrompt);
|
||||
}
|
||||
175
src/index.ts
Normal file
175
src/index.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { type AudioRecorder, createAudioRecorder, formatTime } from "./audio.js";
|
||||
import {
|
||||
getConfig,
|
||||
isFirstRun,
|
||||
registerMenuCommands,
|
||||
setConfig,
|
||||
validateApiKey,
|
||||
} from "./config.js";
|
||||
import { insertText, isOpencodePage, submitPrompt } from "./insert.js";
|
||||
import { setupKeyboardShortcut } from "./keyboard.js";
|
||||
import { transcribe } from "./transcribe.js";
|
||||
import type { DictationState } from "./types.js";
|
||||
import { setupUI } from "./ui.js";
|
||||
|
||||
let recorder: AudioRecorder | null = null;
|
||||
let currentState: DictationState = "idle";
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let elapsedSeconds = 0;
|
||||
let ui: ReturnType<typeof setupUI> | null = null;
|
||||
|
||||
function getState(): DictationState {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
function startTimer(): void {
|
||||
elapsedSeconds = 0;
|
||||
timerInterval = setInterval(() => {
|
||||
elapsedSeconds++;
|
||||
ui?.updateState(currentState, elapsedSeconds);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer(): void {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDictation(): Promise<void> {
|
||||
if (currentState === "idle") {
|
||||
await startRecording();
|
||||
} else if (currentState === "recording") {
|
||||
await stopAndTranscribe();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording(): Promise<void> {
|
||||
try {
|
||||
recorder = createAudioRecorder();
|
||||
await recorder.start();
|
||||
currentState = "recording";
|
||||
startTimer();
|
||||
ui?.updateState(currentState, elapsedSeconds);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to access microphone";
|
||||
ui?.toast(message, true);
|
||||
recorder = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAndTranscribe(): Promise<void> {
|
||||
if (!recorder) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopTimer();
|
||||
currentState = "processing";
|
||||
ui?.updateState(currentState);
|
||||
|
||||
try {
|
||||
const audioBlob = await recorder.stop();
|
||||
const config = getConfig();
|
||||
|
||||
const result = await transcribe(audioBlob, config);
|
||||
|
||||
if (result.text) {
|
||||
const inserted = insertText(result.text);
|
||||
if (!inserted) {
|
||||
ui?.toast("Could not find input field", true);
|
||||
} else if (config.autoSubmit) {
|
||||
submitPrompt();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Transcription failed";
|
||||
ui?.toast(message, true);
|
||||
} finally {
|
||||
currentState = "idle";
|
||||
elapsedSeconds = 0;
|
||||
recorder = null;
|
||||
ui?.updateState(currentState);
|
||||
}
|
||||
}
|
||||
|
||||
function promptForApiKey(): void {
|
||||
const key = prompt("Enter your Groq API key (get one free at console.groq.com/keys):", "");
|
||||
if (key && validateApiKey(key)) {
|
||||
setConfig({ groqApiKey: key.trim() });
|
||||
ui?.toast("API key saved!");
|
||||
} else if (key) {
|
||||
ui?.toast("Invalid key format. Must start with 'gsk_'", true);
|
||||
}
|
||||
}
|
||||
|
||||
function promptForModel(): void {
|
||||
const model = prompt(
|
||||
"Whisper model (whisper-large-v3 or whisper-large-v3-turbo):",
|
||||
getConfig().model,
|
||||
);
|
||||
if (model && (model === "whisper-large-v3" || model === "whisper-large-v3-turbo")) {
|
||||
setConfig({ model });
|
||||
ui?.toast(`Model set to ${model}`);
|
||||
} else if (model) {
|
||||
ui?.toast("Invalid model name", true);
|
||||
}
|
||||
}
|
||||
|
||||
function promptForLanguage(): void {
|
||||
const lang = prompt(
|
||||
"Language code (empty for auto-detect, e.g. 'ru', 'en'):",
|
||||
getConfig().language,
|
||||
);
|
||||
if (lang !== null) {
|
||||
setConfig({ language: lang.trim() });
|
||||
ui?.toast(lang.trim() ? `Language set to ${lang}` : "Auto-detect enabled");
|
||||
}
|
||||
}
|
||||
|
||||
function promptForWhisperPrompt(): void {
|
||||
const text = prompt("Whisper prompt (context for transcription):", getConfig().whisperPrompt);
|
||||
if (text !== null) {
|
||||
setConfig({ whisperPrompt: text });
|
||||
ui?.toast("Whisper prompt updated");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoSubmit(): void {
|
||||
const config = getConfig();
|
||||
setConfig({ autoSubmit: !config.autoSubmit });
|
||||
ui?.toast(`Auto-submit ${!config.autoSubmit ? "enabled" : "disabled"}`);
|
||||
}
|
||||
|
||||
function checkFirstRun(): void {
|
||||
if (isFirstRun()) {
|
||||
setTimeout(() => {
|
||||
ui?.toast("First run: Set your Groq API key via the Violentmonkey/Tampermonkey menu");
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
if (!isOpencodePage()) {
|
||||
setTimeout(init, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
ui = setupUI(toggleDictation, getState);
|
||||
|
||||
setupKeyboardShortcut(() => {
|
||||
void toggleDictation();
|
||||
});
|
||||
|
||||
registerMenuCommands({
|
||||
onSetKey: promptForApiKey,
|
||||
onToggleAutoSubmit: toggleAutoSubmit,
|
||||
onSetModel: promptForModel,
|
||||
onSetLanguage: promptForLanguage,
|
||||
onSetPrompt: promptForWhisperPrompt,
|
||||
});
|
||||
|
||||
checkFirstRun();
|
||||
}
|
||||
|
||||
init();
|
||||
36
src/insert.ts
Normal file
36
src/insert.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const PROMPT_INPUT_SELECTOR = '[data-component="prompt-input"]';
|
||||
const SUBMIT_SELECTOR = '[data-action="prompt-submit"]';
|
||||
|
||||
export function isOpencodePage(): boolean {
|
||||
return document.querySelector(PROMPT_INPUT_SELECTOR) !== null;
|
||||
}
|
||||
|
||||
export function insertText(text: string): boolean {
|
||||
const input = document.querySelector<HTMLElement>(PROMPT_INPUT_SELECTOR);
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
input.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.selectAllChildren(input);
|
||||
selection.collapseToEnd();
|
||||
}
|
||||
|
||||
document.execCommand("insertText", false, text);
|
||||
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function submitPrompt(): boolean {
|
||||
const submitButton = document.querySelector<HTMLElement>(SUBMIT_SELECTOR);
|
||||
if (!submitButton) {
|
||||
return false;
|
||||
}
|
||||
submitButton.click();
|
||||
return true;
|
||||
}
|
||||
31
src/keyboard.ts
Normal file
31
src/keyboard.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const KEY_ALIASES: Record<string, string> = {
|
||||
space: " ",
|
||||
enter: "enter",
|
||||
tab: "tab",
|
||||
esc: "escape",
|
||||
escape: "escape",
|
||||
};
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
return KEY_ALIASES[key] ?? key;
|
||||
}
|
||||
|
||||
export function setupKeyboardShortcut(callback: () => void, combo = "ctrl+space"): () => void {
|
||||
const parts = combo.toLowerCase().split("+");
|
||||
const key = normalizeKey(parts[parts.length - 1]);
|
||||
const needsCtrl = parts.includes("ctrl");
|
||||
const needsShift = parts.includes("shift");
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === key && e.ctrlKey === needsCtrl && e.shiftKey === needsShift) {
|
||||
e.preventDefault();
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handler);
|
||||
};
|
||||
}
|
||||
74
src/transcribe.ts
Normal file
74
src/transcribe.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { GM_xmlhttpRequest } from "$";
|
||||
import type { AppConfig, TranscriptionResult } from "./types.js";
|
||||
|
||||
const GROQ_API_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
|
||||
|
||||
export function buildFormData(audioBlob: Blob, config: AppConfig): FormData {
|
||||
const formData = new FormData();
|
||||
formData.append("file", audioBlob, "audio.webm");
|
||||
formData.append("model", config.model);
|
||||
formData.append("response_format", "text");
|
||||
if (config.language) {
|
||||
formData.append("language", config.language);
|
||||
}
|
||||
if (config.whisperPrompt) {
|
||||
formData.append("prompt", config.whisperPrompt);
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
export function parseErrorResponse(status: number, body: string): string {
|
||||
if (status === 401) {
|
||||
return "Invalid API key. Check your Groq API key in settings.";
|
||||
}
|
||||
if (status === 429) {
|
||||
return "Rate limit exceeded. Please wait and try again.";
|
||||
}
|
||||
if (status >= 500) {
|
||||
return "Groq server error. Please try again later.";
|
||||
}
|
||||
try {
|
||||
const error = JSON.parse(body);
|
||||
return error?.error?.message ?? `Error ${status}`;
|
||||
} catch {
|
||||
return `Error ${status}: ${body}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function transcribe(audioBlob: Blob, config: AppConfig): Promise<TranscriptionResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!config.groqApiKey) {
|
||||
reject(new Error("Groq API key not set. Use the Tampermonkey/Violentmonkey menu to set it."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioBlob.size === 0) {
|
||||
reject(new Error("No audio recorded. Please try again."));
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = buildFormData(audioBlob, config);
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
method: "POST",
|
||||
url: GROQ_API_URL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.groqApiKey}`,
|
||||
},
|
||||
data: formData,
|
||||
onload: (response) => {
|
||||
if (response.status === 200) {
|
||||
resolve({ text: response.responseText.trim() });
|
||||
} else {
|
||||
reject(new Error(parseErrorResponse(response.status, response.responseText)));
|
||||
}
|
||||
},
|
||||
onerror: () => {
|
||||
reject(new Error("Network error: could not reach Groq API"));
|
||||
},
|
||||
ontimeout: () => {
|
||||
reject(new Error("Request timeout: Groq API did not respond"));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
13
src/types.ts
Normal file
13
src/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export type DictationState = "idle" | "recording" | "processing";
|
||||
|
||||
export interface AppConfig {
|
||||
groqApiKey: string;
|
||||
model: string;
|
||||
language: string;
|
||||
whisperPrompt: string;
|
||||
autoSubmit: boolean;
|
||||
}
|
||||
|
||||
export interface TranscriptionResult {
|
||||
text: string;
|
||||
}
|
||||
211
src/ui.ts
Normal file
211
src/ui.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import type { DictationState } from "./types.js";
|
||||
|
||||
const BUTTON_ID = "opencode-voice-dictation-btn";
|
||||
const COMPOSER_SELECTORS = [
|
||||
'[data-component="session-composer"]',
|
||||
'[data-component="session-new-composer"]',
|
||||
];
|
||||
|
||||
function createButtonStyle(): string {
|
||||
return `
|
||||
#${BUTTON_ID} {
|
||||
display: inline-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);
|
||||
color: var(--color-text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
#${BUTTON_ID}:hover {
|
||||
background: var(--color-bg-tertiary, #2a2a2a);
|
||||
color: var(--color-text-primary, #fff);
|
||||
}
|
||||
#${BUTTON_ID}.recording {
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
border-color: #e53935;
|
||||
animation: 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;
|
||||
}
|
||||
#${BUTTON_ID} .timer {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
margin-right: 2px;
|
||||
}
|
||||
@keyframes 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); }
|
||||
}
|
||||
#opencode-voice-toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, sans-serif;
|
||||
z-index: 999999;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
max-width: 90vw;
|
||||
text-align: center;
|
||||
}
|
||||
#opencode-voice-toast.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
#opencode-voice-toast.error {
|
||||
background: #e53935;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function injectStyles(): void {
|
||||
if (document.getElementById("opencode-voice-dictation-style")) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.id = "opencode-voice-dictation-style";
|
||||
style.textContent = createButtonStyle();
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function showToast(message: string, isError = false): void {
|
||||
let toast = document.getElementById("opencode-voice-toast");
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.id = "opencode-voice-toast";
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.className = isError ? "visible error" : "visible";
|
||||
setTimeout(() => {
|
||||
toast.className = "";
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function createButton(): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.id = BUTTON_ID;
|
||||
button.type = "button";
|
||||
button.title = "Voice Dictation (Ctrl+Space)";
|
||||
button.innerHTML = "🎤";
|
||||
return button;
|
||||
}
|
||||
|
||||
function updateButtonState(
|
||||
button: HTMLButtonElement,
|
||||
state: DictationState,
|
||||
elapsedSeconds = 0,
|
||||
): void {
|
||||
button.classList.remove("recording", "processing");
|
||||
|
||||
switch (state) {
|
||||
case "idle":
|
||||
button.innerHTML = "🎤";
|
||||
button.title = "Voice Dictation (Ctrl+Space)";
|
||||
break;
|
||||
case "recording":
|
||||
button.classList.add("recording");
|
||||
button.innerHTML = `<span class="timer">${formatTimer(elapsedSeconds)}</span>⏹`;
|
||||
button.title = "Stop recording";
|
||||
break;
|
||||
case "processing":
|
||||
button.classList.add("processing");
|
||||
button.innerHTML = "⌛";
|
||||
button.title = "Transcribing...";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function findComposer(): HTMLElement | null {
|
||||
for (const selector of COMPOSER_SELECTORS) {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (el) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isButtonInjected(): boolean {
|
||||
return document.getElementById(BUTTON_ID) !== null;
|
||||
}
|
||||
|
||||
function injectButton(onToggle: () => void): void {
|
||||
if (isButtonInjected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const composer = findComposer();
|
||||
if (!composer) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
const button = createButton();
|
||||
button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
});
|
||||
|
||||
composer.appendChild(button);
|
||||
}
|
||||
|
||||
export function setupUI(
|
||||
onToggle: () => void,
|
||||
getState: () => DictationState,
|
||||
): {
|
||||
inject: () => void;
|
||||
updateState: (state: DictationState, elapsedSeconds?: number) => void;
|
||||
toast: (message: string, isError?: boolean) => void;
|
||||
} {
|
||||
const observer = new MutationObserver(() => {
|
||||
injectButton(onToggle);
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
injectButton(onToggle);
|
||||
|
||||
return {
|
||||
inject: () => injectButton(onToggle),
|
||||
updateState: (state: DictationState, elapsedSeconds = 0) => {
|
||||
const button = document.getElementById(BUTTON_ID) as HTMLButtonElement | null;
|
||||
if (button) {
|
||||
updateButtonState(button, state, elapsedSeconds);
|
||||
}
|
||||
},
|
||||
toast: showToast,
|
||||
};
|
||||
}
|
||||
25
tests/__mocks__/$/index.ts
Normal file
25
tests/__mocks__/$/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const mockStore: Record<string, unknown> = {};
|
||||
|
||||
export const GM_getValue = (key: string, defaultValue: unknown): unknown => {
|
||||
return key in mockStore ? mockStore[key] : defaultValue;
|
||||
};
|
||||
|
||||
export const GM_setValue = (key: string, value: unknown): void => {
|
||||
mockStore[key] = value;
|
||||
};
|
||||
|
||||
export const GM_registerMenuCommand = (_name: string, _fn: () => void): void => {};
|
||||
|
||||
export const GM_xmlhttpRequest = (_details: unknown): void => {};
|
||||
|
||||
export const unsafeWindow = globalThis;
|
||||
|
||||
export const monkeyWindow = globalThis;
|
||||
|
||||
export const GM_addElement = (): void => {};
|
||||
|
||||
export const __resetMockStore = (): void => {
|
||||
for (const key of Object.keys(mockStore)) {
|
||||
delete mockStore[key];
|
||||
}
|
||||
};
|
||||
30
tests/audio.test.ts
Normal file
30
tests/audio.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatTime } from "../src/audio.js";
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("should format 0 seconds as 00:00", () => {
|
||||
expect(formatTime(0)).toBe("00:00");
|
||||
});
|
||||
|
||||
it("should format seconds under a minute", () => {
|
||||
expect(formatTime(5)).toBe("00:05");
|
||||
expect(formatTime(30)).toBe("00:30");
|
||||
expect(formatTime(59)).toBe("00:59");
|
||||
});
|
||||
|
||||
it("should format exactly one minute", () => {
|
||||
expect(formatTime(60)).toBe("01:00");
|
||||
});
|
||||
|
||||
it("should format minutes and seconds", () => {
|
||||
expect(formatTime(65)).toBe("01:05");
|
||||
expect(formatTime(125)).toBe("02:05");
|
||||
expect(formatTime(599)).toBe("09:59");
|
||||
expect(formatTime(600)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("should pad single digits with leading zero", () => {
|
||||
expect(formatTime(1)).toBe("00:01");
|
||||
expect(formatTime(61)).toBe("01:01");
|
||||
});
|
||||
});
|
||||
115
tests/config.test.ts
Normal file
115
tests/config.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("$", () => ({
|
||||
GM_getValue: vi.fn((key: string, defaultValue: unknown) => defaultValue),
|
||||
GM_setValue: vi.fn(),
|
||||
GM_registerMenuCommand: vi.fn(),
|
||||
GM_xmlhttpRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GM_getValue, GM_registerMenuCommand, GM_setValue } from "$";
|
||||
import {
|
||||
DEFAULTS,
|
||||
getConfig,
|
||||
isFirstRun,
|
||||
registerMenuCommands,
|
||||
setConfig,
|
||||
validateApiKey,
|
||||
} from "../src/config.js";
|
||||
|
||||
describe("DEFAULTS", () => {
|
||||
it("should have whisper-large-v3 as default model", () => {
|
||||
expect(DEFAULTS.model).toBe("whisper-large-v3");
|
||||
});
|
||||
|
||||
it("should have empty language for auto-detect", () => {
|
||||
expect(DEFAULTS.language).toBe("");
|
||||
});
|
||||
|
||||
it("should have non-empty whisperPrompt", () => {
|
||||
expect(DEFAULTS.whisperPrompt.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("should have autoSubmit disabled by default", () => {
|
||||
expect(DEFAULTS.autoSubmit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateApiKey", () => {
|
||||
it("should accept valid key with gsk_ prefix", () => {
|
||||
expect(validateApiKey("gsk_test_fake_key_1234567890abcdef")).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject key without gsk_ prefix", () => {
|
||||
expect(validateApiKey("sk_test_key_12345")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject key that is too short", () => {
|
||||
expect(validateApiKey("gsk_short")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty string", () => {
|
||||
expect(validateApiKey("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfig", () => {
|
||||
it("should return defaults when GM storage is empty", () => {
|
||||
vi.mocked(GM_getValue).mockImplementation((key: string, def: unknown) => def);
|
||||
|
||||
const config = getConfig();
|
||||
expect(config.model).toBe(DEFAULTS.model);
|
||||
expect(config.language).toBe(DEFAULTS.language);
|
||||
expect(config.groqApiKey).toBe("");
|
||||
});
|
||||
|
||||
it("should return stored values when present", () => {
|
||||
vi.mocked(GM_getValue).mockImplementation((key: string, def: unknown) => {
|
||||
if (key === "groqApiKey") {
|
||||
return "gsk_stored_key";
|
||||
}
|
||||
if (key === "model") {
|
||||
return "whisper-large-v3-turbo";
|
||||
}
|
||||
return def;
|
||||
});
|
||||
|
||||
const config = getConfig();
|
||||
expect(config.groqApiKey).toBe("gsk_stored_key");
|
||||
expect(config.model).toBe("whisper-large-v3-turbo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFirstRun", () => {
|
||||
it("should return true when no API key stored", () => {
|
||||
vi.mocked(GM_getValue).mockReturnValue("");
|
||||
expect(isFirstRun()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when API key is stored", () => {
|
||||
vi.mocked(GM_getValue).mockReturnValue("gsk_stored_key");
|
||||
expect(isFirstRun()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConfig", () => {
|
||||
it("should call GM_setValue for each key", () => {
|
||||
setConfig({ groqApiKey: "gsk_new", model: "whisper-large-v3-turbo" });
|
||||
expect(GM_setValue).toHaveBeenCalledWith("groqApiKey", "gsk_new");
|
||||
expect(GM_setValue).toHaveBeenCalledWith("model", "whisper-large-v3-turbo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerMenuCommands", () => {
|
||||
it("should register all menu commands", () => {
|
||||
const callbacks = {
|
||||
onSetKey: () => {},
|
||||
onToggleAutoSubmit: () => {},
|
||||
onSetModel: () => {},
|
||||
onSetLanguage: () => {},
|
||||
onSetPrompt: () => {},
|
||||
};
|
||||
registerMenuCommands(callbacks);
|
||||
expect(GM_registerMenuCommand).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
75
tests/insert.test.ts
Normal file
75
tests/insert.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { insertText, isOpencodePage, submitPrompt } from "../src/insert.js";
|
||||
|
||||
beforeEach(() => {
|
||||
document.execCommand = vi.fn(() => true);
|
||||
});
|
||||
|
||||
describe("isOpencodePage", () => {
|
||||
it("should return false when prompt-input not present", () => {
|
||||
document.body.innerHTML = "";
|
||||
expect(isOpencodePage()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when prompt-input is present", () => {
|
||||
document.body.innerHTML =
|
||||
'<div data-component="prompt-input" contenteditable="true" role="textbox"></div>';
|
||||
expect(isOpencodePage()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertText", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML =
|
||||
'<div data-component="prompt-input" contenteditable="true" role="textbox"></div>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("should return false when input not found", () => {
|
||||
document.body.innerHTML = "";
|
||||
expect(insertText("hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("should call execCommand with insertText", () => {
|
||||
const result = insertText("hello world");
|
||||
expect(result).toBe(true);
|
||||
expect(document.execCommand).toHaveBeenCalledWith("insertText", false, "hello world");
|
||||
});
|
||||
|
||||
it("should dispatch input event", () => {
|
||||
let eventCount = 0;
|
||||
const input = document.querySelector('[data-component="prompt-input"]') as HTMLElement;
|
||||
input.addEventListener("input", () => {
|
||||
eventCount++;
|
||||
});
|
||||
|
||||
insertText("test text");
|
||||
expect(eventCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitPrompt", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("should return false when submit button not found", () => {
|
||||
expect(submitPrompt()).toBe(false);
|
||||
});
|
||||
|
||||
it("should click submit button when found", () => {
|
||||
let clicked = false;
|
||||
const btn = document.createElement("button");
|
||||
btn.setAttribute("data-action", "prompt-submit");
|
||||
btn.addEventListener("click", () => {
|
||||
clicked = true;
|
||||
});
|
||||
document.body.appendChild(btn);
|
||||
|
||||
expect(submitPrompt()).toBe(true);
|
||||
expect(clicked).toBe(true);
|
||||
});
|
||||
});
|
||||
76
tests/keyboard.test.ts
Normal file
76
tests/keyboard.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { setupKeyboardShortcut } from "../src/keyboard.js";
|
||||
|
||||
describe("setupKeyboardShortcut", () => {
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("should call callback on ctrl+space", () => {
|
||||
let called = false;
|
||||
cleanup = setupKeyboardShortcut(() => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: " ", ctrlKey: true }));
|
||||
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("should not call callback on space without ctrl", () => {
|
||||
let called = false;
|
||||
cleanup = setupKeyboardShortcut(() => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
|
||||
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("should not call callback on ctrl+other key", () => {
|
||||
let called = false;
|
||||
cleanup = setupKeyboardShortcut(() => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true }));
|
||||
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("should cleanup listener on dispose", () => {
|
||||
let called = false;
|
||||
const dispose = setupKeyboardShortcut(() => {
|
||||
called = true;
|
||||
});
|
||||
|
||||
dispose();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: " ", ctrlKey: true }));
|
||||
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent default on match", () => {
|
||||
let prevented = false;
|
||||
cleanup = setupKeyboardShortcut(() => {});
|
||||
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: " ",
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
});
|
||||
event.preventDefault = () => {
|
||||
prevented = true;
|
||||
};
|
||||
|
||||
document.dispatchEvent(event);
|
||||
expect(prevented).toBe(true);
|
||||
});
|
||||
});
|
||||
148
tests/transcribe.test.ts
Normal file
148
tests/transcribe.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("$", () => ({
|
||||
GM_xmlhttpRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GM_xmlhttpRequest } from "$";
|
||||
import { buildFormData, parseErrorResponse, transcribe } from "../src/transcribe.js";
|
||||
import type { AppConfig } from "../src/types.js";
|
||||
|
||||
const mockConfig: AppConfig = {
|
||||
groqApiKey: "gsk_test_key_1234567890",
|
||||
model: "whisper-large-v3",
|
||||
language: "ru",
|
||||
whisperPrompt: "Software development discussion.",
|
||||
autoSubmit: false,
|
||||
};
|
||||
|
||||
const mockConfigAuto: AppConfig = {
|
||||
...mockConfig,
|
||||
language: "",
|
||||
whisperPrompt: "",
|
||||
};
|
||||
|
||||
describe("buildFormData", () => {
|
||||
it("should include file, model, and response_format", () => {
|
||||
const blob = new Blob(["audio data"], { type: "audio/webm" });
|
||||
const formData = buildFormData(blob, mockConfig);
|
||||
|
||||
expect(formData.get("model")).toBe("whisper-large-v3");
|
||||
expect(formData.get("response_format")).toBe("text");
|
||||
expect(formData.get("language")).toBe("ru");
|
||||
expect(formData.get("prompt")).toBe("Software development discussion.");
|
||||
|
||||
const file = formData.get("file") as File;
|
||||
expect(file).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it("should omit language and prompt when empty", () => {
|
||||
const blob = new Blob(["audio data"], { type: "audio/webm" });
|
||||
const formData = buildFormData(blob, mockConfigAuto);
|
||||
|
||||
expect(formData.get("model")).toBe("whisper-large-v3");
|
||||
expect(formData.get("response_format")).toBe("text");
|
||||
expect(formData.get("language")).toBeNull();
|
||||
expect(formData.get("prompt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseErrorResponse", () => {
|
||||
it("should return invalid API key message for 401", () => {
|
||||
const result = parseErrorResponse(401, '{"error":{"message":"Unauthorized"}}');
|
||||
expect(result).toBe("Invalid API key. Check your Groq API key in settings.");
|
||||
});
|
||||
|
||||
it("should return rate limit message for 429", () => {
|
||||
const result = parseErrorResponse(429, "");
|
||||
expect(result).toBe("Rate limit exceeded. Please wait and try again.");
|
||||
});
|
||||
|
||||
it("should return server error message for 500+", () => {
|
||||
const result = parseErrorResponse(500, "");
|
||||
expect(result).toBe("Groq server error. Please try again later.");
|
||||
});
|
||||
|
||||
it("should parse JSON error body for unknown status codes", () => {
|
||||
const result = parseErrorResponse(400, '{"error":{"message":"Bad request"}}');
|
||||
expect(result).toBe("Bad request");
|
||||
});
|
||||
|
||||
it("should return raw body on JSON parse failure", () => {
|
||||
const result = parseErrorResponse(400, "Plain text error");
|
||||
expect(result).toBe("Error 400: Plain text error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcribe", () => {
|
||||
it("should reject when API key is not set", async () => {
|
||||
const blob = new Blob(["audio"], { type: "audio/webm" });
|
||||
const config = { ...mockConfig, groqApiKey: "" };
|
||||
await expect(transcribe(blob, config)).rejects.toThrow("Groq API key not set");
|
||||
});
|
||||
|
||||
it("should reject when audio blob is empty", async () => {
|
||||
const blob = new Blob([], { type: "audio/webm" });
|
||||
await expect(transcribe(blob, mockConfig)).rejects.toThrow("No audio recorded");
|
||||
});
|
||||
|
||||
it("should resolve with transcribed text on success", async () => {
|
||||
const blob = new Blob(["audio"], { type: "audio/webm" });
|
||||
vi.mocked(GM_xmlhttpRequest).mockClear();
|
||||
|
||||
const promise = transcribe(blob, mockConfig);
|
||||
|
||||
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
|
||||
onload: (response: { status: number; responseText: string }) => void;
|
||||
};
|
||||
callArgs.onload({ status: 200, responseText: "hello world" });
|
||||
|
||||
const result = await promise;
|
||||
expect(result.text).toBe("hello world");
|
||||
});
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
const blob = new Blob(["audio"], { type: "audio/webm" });
|
||||
vi.mocked(GM_xmlhttpRequest).mockClear();
|
||||
|
||||
const promise = transcribe(blob, mockConfig);
|
||||
|
||||
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
|
||||
onload: (response: { status: number; responseText: string }) => void;
|
||||
};
|
||||
callArgs.onload({
|
||||
status: 401,
|
||||
responseText: '{"error":{"message":"Unauthorized"}}',
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow("Invalid API key");
|
||||
});
|
||||
|
||||
it("should reject on network error", async () => {
|
||||
const blob = new Blob(["audio"], { type: "audio/webm" });
|
||||
vi.mocked(GM_xmlhttpRequest).mockClear();
|
||||
|
||||
const promise = transcribe(blob, mockConfig);
|
||||
|
||||
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
|
||||
onerror: (error: unknown) => void;
|
||||
};
|
||||
callArgs.onerror(new Error("network"));
|
||||
|
||||
await expect(promise).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("should reject on timeout", async () => {
|
||||
const blob = new Blob(["audio"], { type: "audio/webm" });
|
||||
vi.mocked(GM_xmlhttpRequest).mockClear();
|
||||
|
||||
const promise = transcribe(blob, mockConfig);
|
||||
|
||||
const callArgs = vi.mocked(GM_xmlhttpRequest).mock.calls[0][0] as unknown as {
|
||||
ontimeout: () => void;
|
||||
};
|
||||
callArgs.ontimeout();
|
||||
|
||||
await expect(promise).rejects.toThrow("Request timeout");
|
||||
});
|
||||
});
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite-plugin-monkey/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"$": ["./node_modules/vite-plugin-monkey/dist/client"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "tests", "vite-env.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
1
vite-env.d.ts
vendored
Normal file
1
vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite-plugin-monkey/client" />
|
||||
31
vite.config.ts
Normal file
31
vite.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { defineConfig } from "vite";
|
||||
import monkey from "vite-plugin-monkey";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
monkey({
|
||||
entry: "src/index.ts",
|
||||
userscript: {
|
||||
name: "OpenCode Voice Dictation",
|
||||
namespace: "https://github.com/slaid098/opencode-voice-dictation",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Voice dictation for OpenCode web using Whisper (Groq API) - works on PC and mobile",
|
||||
author: "slaid098",
|
||||
match: ["*://*/*"],
|
||||
grant: ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_registerMenuCommand"],
|
||||
connect: ["api.groq.com"],
|
||||
"run-at": "document-idle",
|
||||
icon: "https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/assets/icon.png",
|
||||
updateURL:
|
||||
"https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/dist/opencode-voice-dictation.user.js",
|
||||
downloadURL:
|
||||
"https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/dist/opencode-voice-dictation.user.js",
|
||||
},
|
||||
build: {
|
||||
fileName: "opencode-voice-dictation.user.js",
|
||||
metaFileName: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
35
vitest.config.ts
Normal file
35
vitest.config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
$: fileURLToPath(new URL("./tests/__mocks__/$", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html"],
|
||||
thresholds: {
|
||||
lines: 60,
|
||||
functions: 60,
|
||||
branches: 60,
|
||||
statements: 60,
|
||||
},
|
||||
exclude: [
|
||||
"tests/**",
|
||||
"src/index.ts",
|
||||
"src/ui.ts",
|
||||
"src/audio.ts",
|
||||
"src/types.ts",
|
||||
"dist/**",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"vite-env.d.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue