Add docs/backlog/stt-providers-and-async.md: how to add new synchronous STT request formats (Deepgram, native Gemini, ElevenLabs) via the explicit sttApiStyle axis, which providers are inherently async and don't fit the current sync model, and a target job-based async architecture (BullMQ job table, sync+async unification, polling -> push -> live streaming) with the migration path and security/cleanup considerations. Add docs/streaming-dictation-plan.md — a design document for true "text appears as you speak" dictation via the OpenAI Realtime API. - Maps the current batch dictation flow (client MediaRecorder -> single blob -> POST /ai-chat/transcribe) and why streaming is impossible there. - Documents the Realtime API contract (transcription session, ephemeral token, pcm16 audio, input_audio_buffer.append, input_audio_transcription delta/completed events, server_vad). - Recommends a server-side WS proxy transport (key stays server-side, SSRF-guarded, provider-agnostic via sttBaseUrl) over direct browser WebRTC, and a ProseMirror decoration for interim text with final-only commit to avoid polluting Yjs collab/history. - Covers config additions, AudioWorklet PCM16 capture, security per repo conventions, edge cases, phased rollout, risks, and impacted files.
15 KiB
STT: дополнительные провайдеры и переход на асинхронную схему
Статус: беклог / план развития. Контекст — фича «голосовая диктовка» (STT, speech-to-text): кнопка-микрофон в чате агента и в редакторе, аудио распознаётся на сервере через AI-провайдер воркспейса. Документ фиксирует (1) какие ещё форматы STT-API имеет смысл поддержать и как, и (2) как в будущем перейти с текущей синхронной схемы (push-to-talk) на асинхронную.
1. Где мы сейчас
Распознавание построено как синхронный запрос-ответ:
- Клиент пишет звук (
MediaRecorder), POST-ит blob → сервер распознаёт → возвращает{ text }, который вставляется в ввод. Никакого состояния задачи нет. - Клиентская часть:
apps/client/src/features/dictation/(hooks/use-dictation.ts,components/mic-button.tsx,services/dictation-service.ts). - Эндпоинт:
POST /ai-chat/transcribe(apps/server/src/core/ai-chat/ai-chat.controller.ts) — фича-гейтsettings.ai.dictation, throttle, лимит 25 МБ, whitelist mime, вывод реальной ошибки провайдера (describeProviderError), формат контейнера выводится из mime. - Тонкая обёртка:
apps/server/src/core/ai-chat/ai-transcription.service.ts→ делегирует вAiService.transcribe(workspaceId, audio, format). - Выбор кодировки запроса — явное поле
sttApiStyle(apps/server/src/integrations/ai/ai.types.ts,SttApiStyle,STT_API_STYLES):multipart— OpenAI-совместимыйPOST /v1/audio/transcriptions(form-data) через AI SDK (createOpenAI(...).transcription()+experimental_transcribe);json— OpenRouter-стиль:POST {baseURL}/audio/transcriptions,Content-Type: application/json, тело{ model, input_audio: { data:<base64>, format } }, ответ{ text }(AiService.transcribeJsonBase64).
- Поле прокладывается как любой не-секрет:
resolve()/getMasked()/ whitelist вAiSettingsService.update(apps/server/src/integrations/ai/ai-settings.service.ts) и массивALLOWEDвWorkspaceRepo.updateAiProviderSettings(apps/server/src/database/repos/workspace/workspace.repo.ts). - UI: селектор «Request format» на карточке Voice / STT
(
apps/client/.../settings/components/ai-provider-settings.tsx) + кнопка «Test endpoint» (бэкенд-проба — тихий WAV через тот жеtranscribe).
Важно: multipart уже покрывает почти всю экосистему — её реализуют OpenAI,
Azure OpenAI (Whisper), Groq, Together, Fireworks, DeepInfra, vLLM, LM Studio,
whisper.cpp/llama.cpp server, speaches, faster-whisper-server, WhisperX.
Для них новый формат не нужен, достаточно base URL + модель + ключ.
json покрывает OpenRouter. Ось sttApiStyle — это абстракция над
контрактом запроса/ответа: каждый реально иной контракт = одно значение enum
- одна ветка-энкодер.
Точки расширения для нового СИНХРОННОГО формата (чек-лист)
ai.types.ts— добавить значение вSttApiStyleиSTT_API_STYLES.dto/update-ai-settings.dto.ts—@IsIn(STT_API_STYLES)подхватит автоматически.ai.service.ts— ветка вtranscribe()+ приватный энкодер (по образцуtranscribeJsonBase64): сборка запроса, заголовок авторизации,!res.ok→ бросок со статусом+телом (без утечки ключа), парс ответа вtext.- Клиент:
ai-settings-service.ts(типSttApiStyle), опция в<Select>на карточке Voice / STT, i18n-строки. - Проба «Test endpoint» работает автоматически (идёт через тот же
transcribe).
2. Кандидаты на новые синхронные форматы
Ранжировано по польза/трудозатраты. Все — синхронные (request→response), вписываются в текущую модель без переделки.
2.1. Deepgram — самый сильный кандидат
POST https://api.deepgram.com/v1/listen, аудио сырыми байтами в теле (Content-Type: audio/*) или JSON{ "url": ... }; параметры (model,language,smart_format) — в query.- Авторизация: заголовок
Authorization: Token <key>(неBearer). - Ответ — свой JSON:
results.channels[0].alternatives[0].transcript. - Значение enum:
deepgram. Энкодер шлёт байты + Token-заголовок и вынимает transcript из вложенной структуры.
2.2. Gemini (нативно) — переиспользует существующий драйвер
- У воркспейса уже может быть драйвер
gemini. Транскрипция =generateContentс инлайн-аудио (inlineData: { mimeType, data:<base64> }) и промптом «transcribe verbatim». - Плюс: один ключ на чат + STT. Минус: это LLM, а не STT-эндпоинт — латентность и качество отличаются, формат ответа надо чистить (модель может «болтать»).
- Значение enum:
gemini(или ветка поcfg.driver === 'gemini').
2.3. ElevenLabs Scribe — ниша, растёт
POST https://api.elevenlabs.io/v1/speech-to-text, multipart, заголовокxi-api-key: <key>(неAuthorization), полеmodel_id, свой ответ.- Значение enum:
elevenlabs.
Groq — отдельный формат НЕ нужен
OpenAI-совместимый multipart. Работает уже сейчас: поставить base URL Groq и
модель whisper-large-v3 при sttApiStyle = multipart.
3. Что НЕ влезает в синхронную модель (и почему)
Эти провайдеры по своей природе асинхронные (upload → poll/webhook) или
батч-ориентированные; их нельзя дождаться одним коротким HTTP-ответом, поэтому
они требуют именно асинхронной схемы из раздела 4 (а не ещё одного значения
sttApiStyle):
- AssemblyAI — upload → создать job → polling статуса / webhook.
- AWS Transcribe — job на основе S3, long-running.
- Google Cloud Speech-to-Text —
longrunningrecognize(operation polling). - Azure Speech (batch transcription) — job + polling.
- Gladia, Speechmatics, Rev.ai — job + polling/webhook.
Их подключение = новая фича с очередью и состоянием задачи, а не маленькая ветка.
4. Будущая асинхронная схема (целевая архитектура)
Зачем переходить (драйверы):
- Длинная диктовка / батч: запись > 25 МБ или длиннее пары минут не лезет в один синхронный запрос (см. лимит в контроллере) и держит HTTP-соединение.
- Async-провайдеры (раздел 3) вообще не поддаются синхронной модели.
- Живая транскрипция (промежуточный текст по мере речи) — отдельная, но смежная цель.
- Устойчивость: ретраи, наблюдаемость, разъединение клиента и провайдера.
4.1. Модель задачи (job-based)
Ввести сущность «задача транскрипции» и гонять её через очередь (у нас уже есть
BullMQ на Redis и AI_QUEUE — по образцу RAG-индексатора в
apps/server/src/core/ai-chat/embedding/):
- Клиент загружает аудио → сервер кладёт его во временное хранилище
(
StorageService: local/S3/Azure) и создаёт запись задачи в новой таблицеtranscription_jobs(миграция только добавляет таблицу — см. правила в CLAUDE.md):id, workspaceId, userId, status (queued|processing|done|error), provider/sttApiStyle, audioRef, resultText, errorText, createdAt, updatedAt. - Сервер ставит job в очередь (новый
QueueJob.TRANSCRIBEнаAI_QUEUEили отдельная очередь) и сразу отвечает клиенту{ jobId, status: 'queued' }. - Консьюнер берёт job, читает аудио, вызывает провайдера:
- синхронные провайдеры (multipart/json/deepgram/…) — просто выполняются
внутри воркера и завершают job (тот же код
AiService.transcribe, но без HTTP-таймаута запроса клиента); - асинхронные провайдеры (AssemblyAI и т.п.) — воркер сабмитит job провайдеру и либо поллит статус, либо ждёт webhook (нужен публичный callback-эндпоинт), затем дописывает результат.
- синхронные провайдеры (multipart/json/deepgram/…) — просто выполняются
внутри воркера и завершают job (тот же код
- Результат сохраняется в задачу; аудио сразу удаляется (или по TTL).
Главная мысль: единая job-модель поглощает и sync-, и async-провайдеров —
для синхронных воркер завершает задачу за один проход, для асинхронных ведёт её
до готовности. sttApiStyle остаётся осью выбора энкодера.
4.2. Доставка результата клиенту
Варианты (от простого к «живому»):
- Polling: клиент дёргает
POST /ai-chat/transcribe/status { jobId }каждые N секунд доdone|error. Просто, надёжно, первый шаг. - SSE / WebSocket push: переиспользовать существующую Socket.IO/Redis-инфру (как у коллаборации) и слать обновление статуса в сессию пользователя.
- Live-стриминг (отдельная фаза): WebSocket-мост к realtime-API провайдера (Deepgram streaming, OpenAI Realtime) с промежуточным текстом. Это уже не job-модель, а постоянное соединение; держать как самостоятельный режим.
4.3. Путь миграции (без слома текущего UX)
- Сохранить нынешний синхронный
POST /ai-chat/transcribeдля коротких клипов (push-to-talk остаётся мгновенным) — это «быстрый путь». - Добавить job-путь для длинных/батч записей и для async-провайдеров.
- Клиентский хук
use-dictationполучает развилку: короткая запись → sync, длинная (по длительности/размеру) → job + статус. UI: индикатор «распознаётся…» уже есть (transcribing), добавить состояние «в очереди». sttApiStyleрасширяется теми же шагами из раздела 1; async-провайдеры добавляются только в job-путь.
4.4. На что обратить внимание при реализации
- Хранение аудио: временное, с обязательной очисткой (TTL/после job). Не логировать аудио и ключи (см. правило об ошибках в CLAUDE.md).
- Безопасность: job скоупится воркспейсом и пользователем (CASL), статус
доступен только владельцу job; webhook-эндпоинт для async — с проверкой
подписи/секрета и через
ssrf-guard, если зовём наружу. - Лимиты/квоты: throttle на постановку задач; ограничение длины/размера; бюджет на параллельные job.
- Ошибки: каждая неудача job пишет полную причину в лог и в
errorText, пользователю показывается конкретное объяснение (а не «не получилось»). - Идемпотентность/ретраи: BullMQ
jobId, removeOnComplete/Fail, дедуп повторных постановок (как в RAG-реиндексе). - Миграции: новая таблица только добавляется; следить за порядком таймстампов при мёрдже веток (см. CLAUDE.md → «Migration ordering»).
5. Рекомендация (приоритеты)
- Оставить текущие
multipart+json— этого хватает большинству, включая self-hosted. - Если нужен облачный не-OpenAI вариант — добавить Deepgram (синхронно, маленькая ветка).
- Gemini-нативный — дёшево, раз драйвер
geminiуже есть. - Async-схему (раздел 4) делать, когда появится реальная потребность в длинной диктовке / батче / async-провайдерах; начинать с job-модели + polling, затем push, и только потом live-стриминг.
Перед реализацией любого провайдера — сверить актуальную форму запроса/ответа по его документации (API дрейфуют), затем добавить значение
sttApiStyle+ энкодер по чек-листу из раздела 1.