## Что сделано - `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>
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
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.",
|
|
endpoint: "https://api.groq.com/openai/v1/audio/transcriptions",
|
|
temperature: 0,
|
|
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.");
|
|
expect(formData.get("temperature")).toBe("0");
|
|
|
|
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();
|
|
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");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|