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:
parent
4bc4939436
commit
c074dfd777
8 changed files with 110 additions and 6 deletions
36
README.md
36
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
|
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 / Поддержка и контакты
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
src/index.ts
33
src/index.ts
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue