feat(transcription): custom endpoint and temperature (#43)

## Что сделано

- `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 <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-04 12:32:39 +03:00 committed by GitHub
parent 4bc4939436
commit c074dfd777
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 110 additions and 6 deletions

View file

@ -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 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_...` 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 3. Открой [ссылку установки скрипта](https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/dist/opencode-voice-dictation.user.js) — скрипт установится в Tampermonkey
4. Меню Tampermonkey → **Set Groq API Key** → вставь `gsk_...` 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 / Поддержка и контакты ## 💬 Support and contacts / Поддержка и контакты

View file

@ -6,7 +6,9 @@ export const DEFAULTS: AppConfig = {
model: "whisper-large-v3", model: "whisper-large-v3",
language: "", language: "",
whisperPrompt: 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, autoSubmit: false,
}; };
@ -16,6 +18,8 @@ export function getConfig(): AppConfig {
model: GM_getValue("model", DEFAULTS.model), model: GM_getValue("model", DEFAULTS.model),
language: GM_getValue("language", DEFAULTS.language), language: GM_getValue("language", DEFAULTS.language),
whisperPrompt: GM_getValue("whisperPrompt", DEFAULTS.whisperPrompt), whisperPrompt: GM_getValue("whisperPrompt", DEFAULTS.whisperPrompt),
endpoint: GM_getValue("endpoint", DEFAULTS.endpoint),
temperature: GM_getValue("temperature", DEFAULTS.temperature),
autoSubmit: GM_getValue("autoSubmit", DEFAULTS.autoSubmit), autoSubmit: GM_getValue("autoSubmit", DEFAULTS.autoSubmit),
}; };
} }
@ -40,10 +44,14 @@ export function registerMenuCommands(callbacks: {
onSetModel: () => void; onSetModel: () => void;
onSetLanguage: () => void; onSetLanguage: () => void;
onSetPrompt: () => void; onSetPrompt: () => void;
onSetEndpoint: () => void;
onSetTemperature: () => void;
}): void { }): void {
GM_registerMenuCommand("Set Groq API Key", callbacks.onSetKey); GM_registerMenuCommand("Set Groq API Key", callbacks.onSetKey);
GM_registerMenuCommand("Toggle Auto-Submit", callbacks.onToggleAutoSubmit); GM_registerMenuCommand("Toggle Auto-Submit", callbacks.onToggleAutoSubmit);
GM_registerMenuCommand("Set Whisper Model", callbacks.onSetModel); GM_registerMenuCommand("Set Whisper Model", callbacks.onSetModel);
GM_registerMenuCommand("Set Language", callbacks.onSetLanguage); GM_registerMenuCommand("Set Language", callbacks.onSetLanguage);
GM_registerMenuCommand("Set Whisper Prompt", callbacks.onSetPrompt); GM_registerMenuCommand("Set Whisper Prompt", callbacks.onSetPrompt);
GM_registerMenuCommand("Set STT Endpoint", callbacks.onSetEndpoint);
GM_registerMenuCommand("Set Temperature", callbacks.onSetTemperature);
} }

View file

@ -1,11 +1,14 @@
import { type AudioRecorder, createAudioRecorder } from "./audio.js"; import { type AudioRecorder, createAudioRecorder } from "./audio.js";
import { import {
DEFAULTS,
getConfig, getConfig,
isFirstRun, isFirstRun,
registerMenuCommands, registerMenuCommands,
setConfig, setConfig,
validateApiKey, validateApiKey,
} from "./config.js"; } from "./config.js";
const DEFAULTS_ENDPOINT = DEFAULTS.endpoint;
import { import {
type InsertTarget, type InsertTarget,
insertText, 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 { function toggleAutoSubmit(): void {
const config = getConfig(); const config = getConfig();
setConfig({ autoSubmit: !config.autoSubmit }); setConfig({ autoSubmit: !config.autoSubmit });
@ -199,6 +230,8 @@ function init(): void {
onSetModel: promptForModel, onSetModel: promptForModel,
onSetLanguage: promptForLanguage, onSetLanguage: promptForLanguage,
onSetPrompt: promptForWhisperPrompt, onSetPrompt: promptForWhisperPrompt,
onSetEndpoint: promptForEndpoint,
onSetTemperature: promptForTemperature,
}); });
checkFirstRun(); checkFirstRun();

View file

@ -1,13 +1,12 @@
import { GM_xmlhttpRequest } from "$"; import { GM_xmlhttpRequest } from "$";
import type { AppConfig, TranscriptionResult } from "./types.js"; 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 { export function buildFormData(audioBlob: Blob, config: AppConfig): FormData {
const formData = new FormData(); const formData = new FormData();
formData.append("file", audioBlob, "audio.webm"); formData.append("file", audioBlob, "audio.webm");
formData.append("model", config.model); formData.append("model", config.model);
formData.append("response_format", "text"); formData.append("response_format", "text");
formData.append("temperature", String(config.temperature));
if (config.language) { if (config.language) {
formData.append("language", config.language); formData.append("language", config.language);
} }
@ -51,7 +50,7 @@ export function transcribe(audioBlob: Blob, config: AppConfig): Promise<Transcri
GM_xmlhttpRequest({ GM_xmlhttpRequest({
method: "POST", method: "POST",
url: GROQ_API_URL, url: config.endpoint,
headers: { headers: {
Authorization: `Bearer ${config.groqApiKey}`, Authorization: `Bearer ${config.groqApiKey}`,
}, },

View file

@ -5,6 +5,8 @@ export interface AppConfig {
model: string; model: string;
language: string; language: string;
whisperPrompt: string; whisperPrompt: string;
endpoint: string;
temperature: number;
autoSubmit: boolean; autoSubmit: boolean;
} }

View file

@ -30,9 +30,21 @@ describe("DEFAULTS", () => {
expect(DEFAULTS.whisperPrompt.length).toBeGreaterThan(50); 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", () => { it("should have autoSubmit disabled by default", () => {
expect(DEFAULTS.autoSubmit).toBe(false); 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", () => { describe("validateApiKey", () => {
@ -108,8 +120,10 @@ describe("registerMenuCommands", () => {
onSetModel: () => {}, onSetModel: () => {},
onSetLanguage: () => {}, onSetLanguage: () => {},
onSetPrompt: () => {}, onSetPrompt: () => {},
onSetEndpoint: () => {},
onSetTemperature: () => {},
}; };
registerMenuCommands(callbacks); registerMenuCommands(callbacks);
expect(GM_registerMenuCommand).toHaveBeenCalledTimes(5); expect(GM_registerMenuCommand).toHaveBeenCalledTimes(7);
}); });
}); });

View file

@ -13,6 +13,8 @@ const mockConfig: AppConfig = {
model: "whisper-large-v3", model: "whisper-large-v3",
language: "ru", language: "ru",
whisperPrompt: "Software development discussion.", whisperPrompt: "Software development discussion.",
endpoint: "https://api.groq.com/openai/v1/audio/transcriptions",
temperature: 0,
autoSubmit: false, autoSubmit: false,
}; };
@ -31,6 +33,7 @@ describe("buildFormData", () => {
expect(formData.get("response_format")).toBe("text"); expect(formData.get("response_format")).toBe("text");
expect(formData.get("language")).toBe("ru"); expect(formData.get("language")).toBe("ru");
expect(formData.get("prompt")).toBe("Software development discussion."); expect(formData.get("prompt")).toBe("Software development discussion.");
expect(formData.get("temperature")).toBe("0");
const file = formData.get("file") as File; const file = formData.get("file") as File;
expect(file).toBeInstanceOf(Blob); expect(file).toBeInstanceOf(Blob);
@ -44,6 +47,15 @@ describe("buildFormData", () => {
expect(formData.get("response_format")).toBe("text"); expect(formData.get("response_format")).toBe("text");
expect(formData.get("language")).toBeNull(); expect(formData.get("language")).toBeNull();
expect(formData.get("prompt")).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");
}); });
}); });

View file

@ -14,7 +14,7 @@ export default defineConfig({
author: "slaid098", author: "slaid098",
match: ["*://*/*"], match: ["*://*/*"],
grant: ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_registerMenuCommand"], grant: ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_registerMenuCommand"],
connect: ["api.groq.com"], connect: ["*"],
"run-at": "document-idle", "run-at": "document-idle",
icon: "https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/assets/icon.png", icon: "https://raw.githubusercontent.com/slaid098/opencode-voice-dictation/main/assets/icon.png",
updateURL: updateURL: