opencode-voice-dictation/src/index.ts
Sergey 36169b4b08
fix(ui): broaden page gate + debug logging + version bump (#30)
## Что сделано
- Расширен gate `isOpencodePage()` в `src/insert.ts`: теперь проверяет
любой из 6 селекторов (`prompt-input`, `prompt-input-v2`,
`session-prompt-dock`, `session-composer`, `session-new-composer`,
`question-custom-input`) вместо одного `prompt-input`. Это активирует
скрипт раньше на v1.18.x, где композер может монтироваться lazy.
- Добавлено отладочное логирование с префиксом `[ocvd]` в `src/index.ts`
(init, gate retry) и `src/ui.ts` (composer found/not found, mic button
injected). Пользователь может видеть в DevTools Console где скрипт
останавливается.
- Бамп `@version` до `1.0.1` в `package.json` и `vite.config.ts` —
разблокирует автообновление userscript-менеджеров (после PR #29 версия
осталась `1.0.0`, автообновление не сработает).

## Почему
PR #29 добавил корректные селекторы для v1.18.x, но пользователи не
получили обновление из-за не-бампнутой `@version`. Также gate
`isOpencodePage()` проверял только `prompt-input`, которого может не
быть в DOM на ранних этапах lazy-mount. Добавление логирования поможет
диагностировать runtime-проблемы в DevTools.

Closes #28

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-25 02:03:39 +03:00

207 lines
5.1 KiB
TypeScript

import { type AudioRecorder, createAudioRecorder } from "./audio.js";
import {
getConfig,
isFirstRun,
registerMenuCommands,
setConfig,
validateApiKey,
} from "./config.js";
import {
type InsertTarget,
insertText,
isOpencodePage,
isQuestionPromptOpen,
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;
let currentTarget: InsertTarget = "composer";
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(target: InsertTarget): Promise<void> {
if (currentState === "idle") {
currentTarget = target;
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, currentTarget);
if (!inserted) {
ui?.toast("Could not find input field", true);
} else if (config.autoSubmit && currentTarget === "composer") {
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);
}
}
async function cancelRecording(): Promise<void> {
if (currentState !== "recording" || !recorder) {
return;
}
stopTimer();
try {
await recorder.stop();
} catch {
// discard audio
}
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()) {
console.log("[ocvd] OpenCode page not detected, retrying in 1.5s...");
setTimeout(init, 1500);
return;
}
console.log("[ocvd] OpenCode page detected, setting up UI");
ui = setupUI({
onToggle: (target: InsertTarget) => {
void toggleDictation(target);
},
onCancel: () => {
void cancelRecording();
},
});
setupKeyboardShortcut(() => {
void toggleDictation(isQuestionPromptOpen() ? "question" : "composer");
});
registerMenuCommands({
onSetKey: promptForApiKey,
onToggleAutoSubmit: toggleAutoSubmit,
onSetModel: promptForModel,
onSetLanguage: promptForLanguage,
onSetPrompt: promptForWhisperPrompt,
});
checkFirstRun();
}
init();