From c074dfd7775341e19d90fc64064a143af818e1ca Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:32:39 +0300 Subject: [PATCH] feat(transcription): custom endpoint and temperature (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Что сделано - `src/types.ts`: добавлены поля `endpoint: string` и `temperature: number` в интерфейс `AppConfig`. - `src/config.ts`: - `DEFAULTS.endpoint = "https://api.groq.com/openai/v1/audio/transcriptions"` (обратная совместимость). - `DEFAULTS.temperature = 0` (детерминированный вывод). - `DEFAULTS.whisperPrompt` сокращён с ~270 до 107 символов — термины через запятую, без предложений. - `getConfig()` читает новые поля через `GM_getValue` с fallback на `DEFAULTS`. - Добавлены 2 меню-команды: «Set STT Endpoint» и «Set Temperature» в `registerMenuCommands` (параметры `onSetEndpoint`, `onSetTemperature`). - `src/index.ts`: реализованы `promptForEndpoint` (fallback на дефолт при пустом вводе) и `promptForTemperature` (clamp к `[0, 1]`, NaN-валидация, сохранение текущего значения при cancel), зарегистрированы в `registerMenuCommands`. - `src/transcribe.ts`: удалён хардкод `GROQ_API_URL`, используется `config.endpoint`; в `buildFormData` добавлено `formData.append("temperature", String(config.temperature))`. - `vite.config.ts`: `@connect api.groq.com` → `@connect *` (разрешает `GM_xmlhttpRequest` к любому домену). - `README.md`: добавлены разделы «Custom STT Endpoint» (EN+RU) с примером nginx `proxy_pass` и «Temperature». - `tests/`: обновлены `mockConfig` (новые поля), ассерты на `temperature` в FormData, тест на длину `whisperPrompt < 120`, `registerMenuCommands` теперь ожидает 7 команд. ## Почему Groq периодически блокирует прямые IP-запросы к `api.groq.com` — нужен обход через пользовательский nginx-прокси (свой endpoint). Whisper галлюцинирует на тишине/шуме — `temperature=0` снижает галлюцинации. Длинный `whisperPrompt` с целыми предложениями мог «утекать» в вывод транскрипции — сокращён до списка терминов через запятую (~107 символов). Хардкод `GROQ_API_URL` и `@connect api.groq.com` блокировали использование кастомных доменов. ## Watch out - `@connect *` в метаблоке расширяет поверхность запросов userscript-а на любой домен — юзер должен доверять установленному endpoint. Это намеренный trade-off для поддержки произвольных прокси. - Существующие юзеры со старым `whisperPrompt` в `GM_getValue` сохраняют своё значение (новый короткий default применяется только если ключ не задан) — обратная совместимость сохранена. - Кламп температуры: ввод `NaN` → toast «Invalid temperature» (значение не меняется); ввод `5` → `1`; ввод `-0.3` → `0`. - Пустой endpoint (юзер ввёл пробелы) → fallback на `DEFAULTS.endpoint` (`api.groq.com`), не пустая строка. - Cancel в prompt (Esc) по endpoint/temperature → значение не меняется (поведение `null`-check). - Error-сообщения `transcribe.ts` всё ещё упоминают «Groq API» — оставлено намеренно, т.к. дефолтный endpoint = Groq и большинство юзеров используют его. ## Pending - Обновить `@version` в `vite.config.ts` при релизе (сейчас `1.0.3` — не тронуто, решает релиз-процесс). - Trim тишины (RMS-based) — явно вне scope этого issue (отдельный PR если `temperature=0` не поможет). Closes #42 Closes #42 --------- Co-authored-by: opencode-agent --- README.md | 36 ++++++++++++++++++++++++++++++++++++ src/config.ts | 10 +++++++++- src/index.ts | 33 +++++++++++++++++++++++++++++++++ src/transcribe.ts | 5 ++--- src/types.ts | 2 ++ tests/config.test.ts | 16 +++++++++++++++- tests/transcribe.test.ts | 12 ++++++++++++ vite.config.ts | 2 +- 8 files changed, 110 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 411358d..000aa04 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,24 @@ A Tampermonkey/Violentmonkey userscript — a mic button in the OpenCode web UI. 3. Open the [script install link](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) — it installs into Tampermonkey 4. Tampermonkey menu → **Set Groq API Key** → paste `gsk_...` +### 🌐 Custom STT Endpoint + +Groq may block direct requests from some networks. Point the script at your own nginx proxy: + +1. Deploy an nginx reverse proxy that forwards to `api.groq.com`: + ```nginx + location /groq/ { + proxy_pass https://api.groq.com/; + proxy_set_header Host api.groq.com; + } + ``` +2. Tampermonkey menu → **Set STT Endpoint** → paste `https://your-domain.com/groq/openai/v1/audio/transcriptions` +3. Requests now go through your proxy. The userscript metadata uses `@connect *`, so any domain is allowed. + +### 🌡️ Temperature + +Whisper may hallucinate on silence/noise. **Set Temperature** (default `0` = deterministic, range `0`–`1`) reduces hallucinations. + --- ## 🇷🇺 Русский @@ -77,6 +95,24 @@ A Tampermonkey/Violentmonkey userscript — a mic button in the OpenCode web UI. 3. Открой [ссылку установки скрипта](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) — скрипт установится в Tampermonkey 4. Меню Tampermonkey → **Set Groq API Key** → вставь `gsk_...` +### 🌐 Кастомный STT endpoint + +Groq может блокировать прямые запросы из некоторых сетей. Направь скрипт на свой nginx-прокси: + +1. Разверни nginx reverse proxy, который форвардит на `api.groq.com`: + ```nginx + location /groq/ { + proxy_pass https://api.groq.com/; + proxy_set_header Host api.groq.com; + } + ``` +2. Меню Tampermonkey → **Set STT Endpoint** → вставь `https://your-domain.com/groq/openai/v1/audio/transcriptions` +3. Запросы пойдут через твой прокси. Метаблок юзерскрипта использует `@connect *`, поэтому разрешён любой домен. + +### 🌡️ Temperature + +Whisper может галлюцинировать на тишине/шуме. **Set Temperature** (по умолчанию `0` = детерминированный вывод, диапазон `0`–`1`) снижает галлюцинации. + --- ## 💬 Support and contacts / Поддержка и контакты diff --git a/src/config.ts b/src/config.ts index 35671e3..b9fb6c3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,7 +6,9 @@ export const DEFAULTS: AppConfig = { 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.", + "opencode, voice, dictation, transcribe, command, terminal, commit, branch, pull, push, merge, issue, prompt", + endpoint: "https://api.groq.com/openai/v1/audio/transcriptions", + temperature: 0, autoSubmit: false, }; @@ -16,6 +18,8 @@ export function getConfig(): AppConfig { model: GM_getValue("model", DEFAULTS.model), language: GM_getValue("language", DEFAULTS.language), whisperPrompt: GM_getValue("whisperPrompt", DEFAULTS.whisperPrompt), + endpoint: GM_getValue("endpoint", DEFAULTS.endpoint), + temperature: GM_getValue("temperature", DEFAULTS.temperature), autoSubmit: GM_getValue("autoSubmit", DEFAULTS.autoSubmit), }; } @@ -40,10 +44,14 @@ export function registerMenuCommands(callbacks: { onSetModel: () => void; onSetLanguage: () => void; onSetPrompt: () => void; + onSetEndpoint: () => void; + onSetTemperature: () => 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); + GM_registerMenuCommand("Set STT Endpoint", callbacks.onSetEndpoint); + GM_registerMenuCommand("Set Temperature", callbacks.onSetTemperature); } diff --git a/src/index.ts b/src/index.ts index 08d1049..8434f44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,14 @@ import { type AudioRecorder, createAudioRecorder } from "./audio.js"; import { + DEFAULTS, getConfig, isFirstRun, registerMenuCommands, setConfig, validateApiKey, } from "./config.js"; + +const DEFAULTS_ENDPOINT = DEFAULTS.endpoint; import { type InsertTarget, insertText, @@ -158,6 +161,34 @@ function promptForWhisperPrompt(): void { } } +function promptForEndpoint(): void { + const current = getConfig().endpoint; + const url = prompt("STT endpoint URL:", current); + if (url === null) { + return; + } + const trimmed = url.trim(); + const value = trimmed || DEFAULTS_ENDPOINT; + setConfig({ endpoint: value }); + ui?.toast(`Endpoint set to ${value}`); +} + +function promptForTemperature(): void { + const current = getConfig().temperature; + const input = prompt("Temperature (0-1):", String(current)); + if (input === null) { + return; + } + const parsed = Number(input); + if (Number.isNaN(parsed)) { + ui?.toast("Invalid temperature", true); + return; + } + const clamped = Math.min(1, Math.max(0, parsed)); + setConfig({ temperature: clamped }); + ui?.toast(`Temperature set to ${clamped}`); +} + function toggleAutoSubmit(): void { const config = getConfig(); setConfig({ autoSubmit: !config.autoSubmit }); @@ -199,6 +230,8 @@ function init(): void { onSetModel: promptForModel, onSetLanguage: promptForLanguage, onSetPrompt: promptForWhisperPrompt, + onSetEndpoint: promptForEndpoint, + onSetTemperature: promptForTemperature, }); checkFirstRun(); diff --git a/src/transcribe.ts b/src/transcribe.ts index f903e5d..3c26350 100644 --- a/src/transcribe.ts +++ b/src/transcribe.ts @@ -1,13 +1,12 @@ 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"); + formData.append("temperature", String(config.temperature)); if (config.language) { formData.append("language", config.language); } @@ -51,7 +50,7 @@ export function transcribe(audioBlob: Blob, config: AppConfig): Promise { expect(DEFAULTS.whisperPrompt.length).toBeGreaterThan(50); }); + it("should have whisperPrompt under 120 characters (terms only, no sentences)", () => { + expect(DEFAULTS.whisperPrompt.length).toBeLessThan(120); + }); + it("should have autoSubmit disabled by default", () => { expect(DEFAULTS.autoSubmit).toBe(false); }); + + it("should have default Groq endpoint", () => { + expect(DEFAULTS.endpoint).toBe("https://api.groq.com/openai/v1/audio/transcriptions"); + }); + + it("should have temperature 0 by default", () => { + expect(DEFAULTS.temperature).toBe(0); + }); }); describe("validateApiKey", () => { @@ -108,8 +120,10 @@ describe("registerMenuCommands", () => { onSetModel: () => {}, onSetLanguage: () => {}, onSetPrompt: () => {}, + onSetEndpoint: () => {}, + onSetTemperature: () => {}, }; registerMenuCommands(callbacks); - expect(GM_registerMenuCommand).toHaveBeenCalledTimes(5); + expect(GM_registerMenuCommand).toHaveBeenCalledTimes(7); }); }); diff --git a/tests/transcribe.test.ts b/tests/transcribe.test.ts index 15d720f..abd933f 100644 --- a/tests/transcribe.test.ts +++ b/tests/transcribe.test.ts @@ -13,6 +13,8 @@ const mockConfig: AppConfig = { model: "whisper-large-v3", language: "ru", whisperPrompt: "Software development discussion.", + endpoint: "https://api.groq.com/openai/v1/audio/transcriptions", + temperature: 0, autoSubmit: false, }; @@ -31,6 +33,7 @@ describe("buildFormData", () => { expect(formData.get("response_format")).toBe("text"); expect(formData.get("language")).toBe("ru"); expect(formData.get("prompt")).toBe("Software development discussion."); + expect(formData.get("temperature")).toBe("0"); const file = formData.get("file") as File; expect(file).toBeInstanceOf(Blob); @@ -44,6 +47,15 @@ describe("buildFormData", () => { expect(formData.get("response_format")).toBe("text"); expect(formData.get("language")).toBeNull(); expect(formData.get("prompt")).toBeNull(); + expect(formData.get("temperature")).toBe("0"); + }); + + it("should send temperature as string", () => { + const blob = new Blob(["audio data"], { type: "audio/webm" }); + const config = { ...mockConfig, temperature: 0.5 }; + const formData = buildFormData(blob, config); + + expect(formData.get("temperature")).toBe("0.5"); }); }); diff --git a/vite.config.ts b/vite.config.ts index a6e5a4d..2f253b2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ author: "slaid098", match: ["*://*/*"], grant: ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_registerMenuCommand"], - connect: ["api.groq.com"], + connect: ["*"], "run-at": "document-idle", icon: "https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/assets/icon.png", updateURL: