feat(desktop): add microphone transcription input (#13023)

* feat(desktop): add voice input

* fix(desktop): harden voice transcription input

* fix(llms): scope voice transcription models

* fix(desktop): guard batch voice transcripts

* refactor(voice): defer chat model filtering

* fix(desktop): invalidate stale streaming transcripts

* fix(desktop): preserve batch transcription lifecycle

* chore(llms): refresh voice model catalog

* Mic Icon

* Auto

* test(desktop): align speech input icon assertions
This commit is contained in:
Bee
2026-08-14 10:57:29 -07:00
committed by GitHub
parent 2be49cf91b
commit da05eeb02d
50 changed files with 10280 additions and 423 deletions
@@ -81,6 +81,7 @@ describe("applyInteractiveModelChange", () => {
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
providers: {},
modes: {},
}));
const ensureReady = vi.fn(async () => {});
const restartWithCurrentMessages = vi.fn(async () => {});
+22
View File
@@ -155,6 +155,14 @@ Logging can be configured with the same environment variables as the CLI:
- `CLINE_LOG_PATH` overrides the log destination.
- `CLINE_LOG_NAME` overrides the logger name.
In a development webview, sidecar voice-input diagnostics are also streamed to
the webview console as `[desktop:voice-input]` entries. Production builds can
enable the same console stream with `NEXT_PUBLIC_CLINE_DEBUG_LOGS=1` at build
time, or at runtime from DevTools with
`localStorage.setItem("cline.debugLogs", "1")` followed by a reload. Diagnostic
events include the selected provider/model and sanitized endpoint, but never
credentials, request headers, recorded audio, or transcript contents.
## Troubleshooting
- If live updates stall, verify the desktop backend websocket is connected and `chat_event` messages are arriving.
@@ -165,3 +173,17 @@ Logging can be configured with the same environment variables as the CLI:
The next desktop or CLI Hub connection will reuse a compatible running Hub or
replace an incompatible one through the shared discovery path.
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
- Speech input requires an enabled provider whose models.dev metadata identifies
a dedicated `audio`-to-`text` model, or the built-in ElevenLabs provider with
its Scribe v2 model. Choose the voice input provider and model explicitly under
**Settings → Models → Voice input**. That selection is stored separately from
the chat model as `modes.voiceInput` in
`~/.cline/data/settings/providers.json`; provider credentials remain in their
existing provider entry and never enter the webview. ElevenLabs uses its native
`/v1/speech-to-text` API. Text-to-speech models with `output: ["audio"]` are
not used for microphone transcription.
- Streaming transcription models, such as Vercel AI Gateway's
`openai/gpt-realtime-whisper`, update the composer while the user speaks.
The sidecar mints a short-lived transcription token; the long-lived gateway
credential is never sent to the webview. Batch models such as
`openai/whisper-1` continue to transcribe after recording stops.
@@ -141,6 +141,9 @@ Supported commands:
| `chat_session_command` | shared Hub through `ClineCore` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_voice_input_settings` | validates and persists the selected transcription provider/model |
| `create_streaming_transcription_session` | mints a short-lived, transcription-bound browser token without exposing provider credentials |
| `transcribe_audio` | configured voice input selection + provider credentials |
| `save_provider_settings` | `saveLocalProviderSettings` |
| `add_provider` | `addLocalProvider` |
| `run_provider_oauth_login` | `loginLocalProvider` |
@@ -8,6 +8,7 @@ import type {
CoreSettingsSnapshot,
ProviderCapability,
ProviderClient,
ProviderConfig,
ProviderProtocol,
SaveProviderSettingsActionRequest,
} from "@cline/core";
@@ -15,6 +16,7 @@ import {
addLocalProvider,
ClineAccountService,
captureAuthRefreshSoftFailure,
createConfiguredStreamingTranscriptionSession,
createUserInstructionConfigService,
ensureCustomProvidersLoaded,
executeClineAccountAction,
@@ -34,16 +36,20 @@ import {
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
SqliteSessionStore,
saveLocalProviderSettings,
saveVoiceInputSettings,
setAutoUpdateEnabledGlobally,
setMcpServerDisabled,
setModelToolEnabledGlobally,
setTelemetryOptOutGlobally,
transcribeConfiguredVoiceInput,
updateLocalProvider,
updateMcpSettingsFileSync,
} from "@cline/core";
import { resolveAudioTranscriptionRoute } from "@cline/llms";
import {
CLINE_DEFAULT_MODEL_ID,
getClineEnvironmentConfig,
isCanonicalBase64,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
readHubScheduleMode,
@@ -51,6 +57,7 @@ import {
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
import packageJson from "../package.json";
import { CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT } from "../webview/lib/cline-account-state";
import { MAX_RECORDED_AUDIO_BYTES } from "../webview/lib/voice-input-limits";
import {
connectorChannelsPayload,
startConnectorChannel,
@@ -108,6 +115,66 @@ import { pickWorkspaceDirectory } from "./workspace-picker";
// a synchronous exec (git, folder picker, editor discovery) freezes the whole
// app until the child exits.
const execFileAsync = promisify(execFile);
type DesktopDebugLogLevel = "debug" | "info" | "error";
function sanitizeDiagnosticUrl(value: string | undefined): string | undefined {
if (!value) return undefined;
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return "[invalid URL]";
}
}
function sanitizeDiagnosticFailure(
error: unknown,
providerConfig: ProviderConfig | undefined,
): string {
let message = error instanceof Error ? error.message : String(error);
const sanitizedBaseUrl = sanitizeDiagnosticUrl(providerConfig?.baseUrl);
if (providerConfig?.baseUrl && sanitizedBaseUrl) {
message = message.replaceAll(providerConfig.baseUrl, sanitizedBaseUrl);
}
const secrets = [
providerConfig?.apiKey,
providerConfig?.accessToken,
...Object.values(providerConfig?.headers ?? {}),
];
for (const secret of secrets) {
const value = secret?.trim();
if (value) {
message = message.replaceAll(value, "[redacted]");
}
}
return message;
}
function emitDesktopDebugLog(
ctx: SidecarContext,
level: DesktopDebugLogLevel,
message: string,
metadata?: Record<string, unknown>,
): void {
if (level === "error") {
ctx.logger?.error?.(message, metadata);
} else if (level === "info") {
ctx.logger?.log(message, metadata);
} else {
ctx.logger?.debug(message, metadata);
}
broadcastEvent(ctx, "desktop_debug_log", {
scope: "voice-input",
level,
message,
timestamp: new Date().toISOString(),
metadata,
});
}
// Strict allowlist: the opener hands the URL to the OS protocol handler, so
// anything broader (file:, custom app schemes) would let webview content
@@ -1423,6 +1490,142 @@ export async function handleCommand(
manager.getProviderConfig(String(args?.provider ?? "").trim()),
);
}
if (command === "create_streaming_transcription_session") {
const manager = new ProviderSettingsManager();
const selection = manager.getVoiceInputSettings();
const providerConfig = selection
? manager.getProviderConfig(selection.providerId, {
includeKnownModels: false,
})
: undefined;
const route = providerConfig
? resolveAudioTranscriptionRoute(providerConfig)
: undefined;
const diagnostics = {
providerId: selection?.providerId,
modelId: selection?.modelId,
transport: route?.kind,
endpoint: sanitizeDiagnosticUrl(route?.endpoint),
};
emitDesktopDebugLog(
ctx,
"debug",
"Creating streaming transcription session",
diagnostics,
);
const startedAt = Date.now();
try {
const session = await createConfiguredStreamingTranscriptionSession(
manager,
{ expiresAfterSeconds: 300 },
);
emitDesktopDebugLog(
ctx,
"debug",
"Streaming transcription session created",
{
...diagnostics,
durationMs: Date.now() - startedAt,
expiresAt: session.expiresAt,
},
);
return session;
} catch (error) {
const failure = sanitizeDiagnosticFailure(error, providerConfig);
emitDesktopDebugLog(
ctx,
"error",
"Streaming transcription session setup failed",
{
...diagnostics,
durationMs: Date.now() - startedAt,
failure,
},
);
throw new Error(failure, { cause: error });
}
}
if (command === "transcribe_audio") {
const audioBase64 = String(args?.audioBase64 ?? "");
const mediaType = String(args?.mediaType ?? "").trim() || undefined;
if (!isCanonicalBase64(audioBase64)) {
throw new Error("recorded audio must be canonical base64");
}
const decodedBytes =
Math.floor((audioBase64.length * 3) / 4) -
(audioBase64.endsWith("==") ? 2 : audioBase64.endsWith("=") ? 1 : 0);
if (decodedBytes > MAX_RECORDED_AUDIO_BYTES) {
throw new Error(
`recorded audio exceeds the ${MAX_RECORDED_AUDIO_BYTES} byte limit`,
);
}
const manager = new ProviderSettingsManager();
const selection = manager.getVoiceInputSettings();
const providerConfig = selection
? manager.getProviderConfig(selection.providerId, {
includeKnownModels: false,
})
: undefined;
const route = providerConfig
? resolveAudioTranscriptionRoute(providerConfig)
: undefined;
const diagnostics = {
providerId: selection?.providerId,
modelId: selection?.modelId,
transport: route?.kind,
endpoint: sanitizeDiagnosticUrl(route?.endpoint),
mediaType,
audioBytes: decodedBytes,
};
emitDesktopDebugLog(
ctx,
"debug",
"Starting audio transcription",
diagnostics,
);
const startedAt = Date.now();
try {
const result = await transcribeConfiguredVoiceInput(manager, {
audio: Buffer.from(audioBase64, "base64"),
mediaType,
});
emitDesktopDebugLog(ctx, "debug", "Audio transcription completed", {
...diagnostics,
durationMs: Date.now() - startedAt,
transcriptCharacters: result.text.length,
language: result.language,
});
return result;
} catch (error) {
const failure = sanitizeDiagnosticFailure(error, providerConfig);
emitDesktopDebugLog(ctx, "error", "Audio transcription failed", {
...diagnostics,
durationMs: Date.now() - startedAt,
failure,
});
throw new Error(failure, { cause: error });
}
}
if (command === "save_voice_input_settings") {
const providerId = String(args?.provider ?? "").trim();
const modelId = String(args?.model ?? "").trim();
if (Boolean(providerId) !== Boolean(modelId)) {
throw new Error(
"voice input provider and model must both be set or both be cleared",
);
}
const manager = new ProviderSettingsManager();
const result = await saveVoiceInputSettings(
manager,
providerId && modelId ? { providerId, modelId } : undefined,
);
emitDesktopDebugLog(ctx, "info", "Voice input settings saved", {
providerId: result.voiceInput?.providerId,
modelId: result.voiceInput?.modelId,
configured: Boolean(result.voiceInput),
});
return result;
}
if (command === "save_provider_settings") {
const manager = new ProviderSettingsManager();
return saveLocalProviderSettings(manager, {
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createFetchHandler } from "./server";
import {
MAX_RECORDED_AUDIO_BASE64_BYTES,
MAX_RECORDED_AUDIO_BYTES,
} from "../webview/lib/voice-input-limits";
import { createFetchHandler, createWebSocketHandler } from "./server";
import type { SidecarContext } from "./types";
function createTestServer() {
@@ -20,6 +24,17 @@ function createTelemetryHandler(capture = vi.fn()) {
};
}
describe("sidecar WebSocket payload limit", () => {
it("accepts every recording allowed by the voice input size limit", () => {
const handler = createWebSocketHandler({} as SidecarContext);
expect(MAX_RECORDED_AUDIO_BYTES).toBe(25 * 1024 * 1024);
expect(handler.maxPayloadLength).toBeGreaterThan(
MAX_RECORDED_AUDIO_BASE64_BYTES,
);
});
});
describe("sidecar HTTP origin checks", () => {
it("rejects cross-origin shutdown preflight requests", async () => {
const server = createTestServer();
+3 -1
View File
@@ -1,5 +1,6 @@
import { captureSdkError } from "@cline/shared";
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES } from "../webview/lib/voice-input-limits";
import { handleCommand } from "./commands";
import { encodeSidecarEvent, sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
@@ -319,8 +320,9 @@ export function createFetchHandler(
};
}
function createWebSocketHandler(ctx: SidecarContext) {
export function createWebSocketHandler(ctx: SidecarContext) {
return {
maxPayloadLength: MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES,
open(ws: SidecarWebSocketClient) {
ctx.wsClients.add(ws);
sendEvent(ctx, "host_ready", {
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Cline Code uses the microphone to transcribe speech into chat input.</string>
</dict>
</plist>
@@ -465,6 +465,9 @@ export default function Home() {
handleSettingsSectionChange("Models")
}
parentSession={activeParentSession}
onOpenVoiceInputSettings={() =>
handleSettingsSectionChange("Models")
}
onThreadStarted={handleThreadStarted}
/>
</div>
@@ -515,6 +518,7 @@ function ChatThreadPane({
onOpenSetup,
onOpenModelSettings,
parentSession,
onOpenVoiceInputSettings,
onThreadStarted,
}: {
threadId: string;
@@ -536,6 +540,7 @@ function ChatThreadPane({
onOpenSetup?: () => void;
onOpenModelSettings?: () => void;
parentSession?: { sessionId: string; title?: string };
onOpenVoiceInputSettings?: () => void;
onThreadStarted?: (threadId: string) => void;
}) {
const {
@@ -1509,6 +1514,7 @@ function ChatThreadPane({
onModelChange={handleModelChange}
onModeToggle={handleModeToggle}
onPromptInputChange={handlePromptInputChange}
onOpenVoiceInputSettings={onOpenVoiceInputSettings}
onReasoningChange={handleReasoningChange}
onSteerPromptInQueue={steerPromptInQueue}
onEditPromptInQueue={updatePromptInQueue}
@@ -0,0 +1,468 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SpeechInput } from "./speech-input";
class FakeMediaRecorder extends EventTarget {
static instances: FakeMediaRecorder[] = [];
static deferStopEvents = false;
static stopError: unknown;
static recordedAudio: Blob | null = new Blob(["recorded audio"], {
type: "audio/webm",
});
readonly mimeType = "audio/webm";
state: RecordingState = "inactive";
private stopPending = false;
constructor(readonly stream: MediaStream) {
super();
FakeMediaRecorder.instances.push(this);
}
start(): void {
this.state = "recording";
}
stop(): void {
if (FakeMediaRecorder.stopError) throw FakeMediaRecorder.stopError;
this.state = "inactive";
if (FakeMediaRecorder.deferStopEvents) {
this.stopPending = true;
return;
}
this.dispatchStopEvents();
}
flushStopEvents(): void {
if (!this.stopPending) return;
this.stopPending = false;
this.dispatchStopEvents();
}
private dispatchStopEvents(): void {
if (FakeMediaRecorder.recordedAudio) {
const dataEvent = new Event("dataavailable");
Object.defineProperty(dataEvent, "data", {
value: FakeMediaRecorder.recordedAudio,
});
this.dispatchEvent(dataEvent);
}
this.dispatchEvent(new Event("stop"));
}
}
class FakeSpeechRecognition extends EventTarget {
static instances: FakeSpeechRecognition[] = [];
continuous = false;
interimResults = false;
lang = "";
constructor() {
super();
FakeSpeechRecognition.instances.push(this);
}
start(): void {
this.dispatchEvent(new Event("start"));
}
stop(): void {
this.dispatchEvent(new Event("end"));
}
emitFinal(transcript: string): void {
const event = new Event("result");
Object.defineProperties(event, {
resultIndex: { value: 0 },
results: {
value: [
{
0: { confidence: 1, transcript },
isFinal: true,
length: 1,
},
],
},
});
this.dispatchEvent(event);
}
}
let container: HTMLDivElement;
let root: Root;
let stopTrack: ReturnType<typeof vi.fn>;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
FakeMediaRecorder.instances = [];
FakeMediaRecorder.deferStopEvents = false;
FakeMediaRecorder.stopError = undefined;
FakeMediaRecorder.recordedAudio = new Blob(["recorded audio"], {
type: "audio/webm",
});
FakeSpeechRecognition.instances = [];
stopTrack = vi.fn();
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
Object.defineProperty(window, "SpeechRecognition", {
configurable: true,
value: FakeSpeechRecognition,
});
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: class {},
});
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
})),
},
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
delete (
window as typeof window & { MediaRecorder?: typeof FakeMediaRecorder }
).MediaRecorder;
delete (
window as typeof window & {
SpeechRecognition?: typeof FakeSpeechRecognition;
}
).SpeechRecognition;
delete (window as typeof window & { AudioContext?: typeof AudioContext })
.AudioContext;
vi.restoreAllMocks();
});
describe("SpeechInput", () => {
it("emits browser speech-recognition text before recording stops", async () => {
const onAudioRecorded = vi.fn(async () => "batch transcript");
const onTranscriptionChange = vi.fn();
await act(async () => {
root.render(
<SpeechInput
onAudioRecorded={onAudioRecorded}
onTranscriptionChange={onTranscriptionChange}
recordingMode="auto"
/>,
);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => button?.click());
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
await act(async () => {
FakeSpeechRecognition.instances[0]?.emitFinal("live transcript");
});
expect(onTranscriptionChange).toHaveBeenCalledWith(
"live transcript",
"speech-recognition",
);
expect(onAudioRecorded).not.toHaveBeenCalled();
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
});
it("records audio and forwards the provider transcript", async () => {
FakeMediaRecorder.deferStopEvents = true;
let resolveTranscript: (transcript: string) => void = () => {};
const transcript = new Promise<string>((resolve) => {
resolveTranscript = resolve;
});
const onAudioRecorded = vi.fn(() => transcript);
const onActiveChange = vi.fn();
const onProcessingChange = vi.fn();
const onTranscriptionChange = vi.fn();
await act(async () => {
root.render(
<SpeechInput
onActiveChange={onActiveChange}
onAudioRecorded={onAudioRecorded}
onProcessingChange={onProcessingChange}
onTranscriptionChange={onTranscriptionChange}
recordingMode="media-recorder"
/>,
);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
expect(button?.disabled).toBe(false);
expect(onActiveChange).toHaveBeenLastCalledWith(false);
expect(button?.querySelector(".lucide-mic")).not.toBeNull();
expect(button?.querySelector(".lucide-square")).toBeNull();
await act(async () => {
button?.click();
await Promise.resolve();
});
expect(FakeMediaRecorder.instances).toHaveLength(1);
expect(onActiveChange).toHaveBeenLastCalledWith(true);
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
expect(button?.title).toBe("Stop recording");
expect(
button?.querySelector(".lucide-mic")?.getAttribute("class"),
).toContain("animate-pulse");
expect(
button?.querySelector(".lucide-mic")?.getAttribute("class"),
).toContain("group-hover:opacity-0");
expect(
button?.querySelector(".lucide-square")?.getAttribute("class"),
).toContain("group-hover:opacity-100");
await act(async () => {
button?.click();
await Promise.resolve();
});
// Stopping is asynchronous in browsers. The component must stay active
// during the gap before MediaRecorder dispatches its stop event.
expect(onAudioRecorded).not.toHaveBeenCalled();
expect(onProcessingChange).toHaveBeenLastCalledWith(true);
expect(onActiveChange).toHaveBeenLastCalledWith(true);
expect(button?.disabled).toBe(true);
await act(async () => {
FakeMediaRecorder.instances[0]?.flushStopEvents();
await Promise.resolve();
});
expect(onAudioRecorded).toHaveBeenCalledWith(
expect.objectContaining({ type: "audio/webm" }),
);
expect(onProcessingChange).toHaveBeenCalledWith(true);
expect(onActiveChange).toHaveBeenLastCalledWith(true);
expect(button?.disabled).toBe(true);
await act(async () => {
resolveTranscript("transcribed prompt");
await transcript;
});
expect(onTranscriptionChange).toHaveBeenCalledWith(
"transcribed prompt",
"media-recorder",
);
expect(onProcessingChange).toHaveBeenLastCalledWith(false);
expect(onActiveChange).toHaveBeenLastCalledWith(false);
expect(stopTrack).toHaveBeenCalledOnce();
});
it("starts and stops a streaming transcription session", async () => {
let resolveDone: () => void = () => {};
const done = new Promise<void>((resolve) => {
resolveDone = resolve;
});
const stop = vi.fn(resolveDone);
const cancel = vi.fn(resolveDone);
const onStartStreaming = vi.fn(async () => ({ done, stop, cancel }));
const onStreamingStart = vi.fn();
const onStreamingEnd = vi.fn();
await act(async () => {
root.render(
<SpeechInput
onStartStreaming={onStartStreaming}
onStreamingEnd={onStreamingEnd}
onStreamingStart={onStreamingStart}
recordingMode="streaming"
/>,
);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
expect(onStreamingStart).toHaveBeenCalledOnce();
expect(onStartStreaming).toHaveBeenCalledOnce();
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
await act(async () => {
button?.click();
await done;
});
expect(stop).toHaveBeenCalledOnce();
expect(onStreamingEnd).toHaveBeenCalledOnce();
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("returns to inactive when MediaRecorder rejects stop", async () => {
const stopError = new DOMException("Already stopped", "InvalidStateError");
const onActiveChange = vi.fn();
const onError = vi.fn();
await act(async () => {
root.render(
<SpeechInput
onActiveChange={onActiveChange}
onAudioRecorded={vi.fn(async () => "unused")}
onError={onError}
recordingMode="media-recorder"
/>,
);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
FakeMediaRecorder.stopError = stopError;
await act(async () => button?.click());
expect(onError).toHaveBeenCalledWith(stopError);
expect(onActiveChange).toHaveBeenLastCalledWith(false);
expect(stopTrack).toHaveBeenCalledOnce();
expect(button?.disabled).toBe(false);
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("ignores a pending batch transcript after unmount", async () => {
let resolveTranscript: (transcript: string) => void = () => {};
const pendingTranscript = new Promise<string>((resolve) => {
resolveTranscript = resolve;
});
const onTranscriptionChange = vi.fn();
const onError = vi.fn();
await act(async () => {
root.render(
<SpeechInput
onAudioRecorded={() => pendingTranscript}
onError={onError}
onTranscriptionChange={onTranscriptionChange}
recordingMode="media-recorder"
/>,
);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
await act(async () => {
button?.click();
await Promise.resolve();
});
await act(async () => root.render(<div />));
await act(async () => {
resolveTranscript("stale transcript");
await pendingTranscript;
});
expect(onTranscriptionChange).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
it("returns to an inactive state when the recorder produces no audio", async () => {
FakeMediaRecorder.recordedAudio = null;
const onActiveChange = vi.fn();
const onAudioRecorded = vi.fn(async () => "should not run");
const onProcessingChange = vi.fn();
await act(async () => {
root.render(
<SpeechInput
onActiveChange={onActiveChange}
onAudioRecorded={onAudioRecorded}
onProcessingChange={onProcessingChange}
recordingMode="media-recorder"
/>,
);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
await act(async () => {
button?.click();
await Promise.resolve();
});
expect(onAudioRecorded).not.toHaveBeenCalled();
expect(onActiveChange).toHaveBeenLastCalledWith(false);
expect(onProcessingChange).toHaveBeenLastCalledWith(false);
expect(button?.disabled).toBe(false);
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("cancels a streaming session that arrives after the component unmounts", async () => {
let resolveSession: (session: {
done: Promise<void>;
stop: () => void;
cancel: () => void;
}) => void = () => {};
const pendingSession = new Promise<{
done: Promise<void>;
stop: () => void;
cancel: () => void;
}>((resolve) => {
resolveSession = resolve;
});
const cancel = vi.fn();
const onStreamingEnd = vi.fn();
const onError = vi.fn();
await act(async () => {
root.render(
<SpeechInput
onError={onError}
onStartStreaming={() => pendingSession}
onStreamingEnd={onStreamingEnd}
recordingMode="streaming"
/>,
);
});
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
await act(async () => root.render(<div />));
await act(async () => {
resolveSession({
done: new Promise<void>(() => {}),
stop: vi.fn(),
cancel,
});
await pendingSession;
});
expect(cancel).toHaveBeenCalledOnce();
expect(onStreamingEnd).not.toHaveBeenCalled();
expect(onError).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,452 @@
"use client";
import { MicIcon, SquareIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import type { StreamingSpeechSession } from "@/lib/vercel-streaming-transcription";
interface SpeechRecognition extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
}
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList;
resultIndex: number;
}
interface SpeechRecognitionResultList {
readonly length: number;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
readonly length: number;
[index: number]: SpeechRecognitionAlternative;
isFinal: boolean;
}
interface SpeechRecognitionAlternative {
transcript: string;
confidence: number;
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition;
webkitSpeechRecognition: new () => SpeechRecognition;
}
}
export type SpeechInputMode =
| "speech-recognition"
| "media-recorder"
| "streaming"
| "none";
export type SpeechTranscriptionSource = Extract<
SpeechInputMode,
"speech-recognition" | "media-recorder"
>;
export type SpeechInputProps = Omit<
ComponentProps<typeof Button>,
"onError"
> & {
allowUnavailableClick?: boolean;
onTranscriptionChange?: (
text: string,
source: SpeechTranscriptionSource,
) => void;
onAudioRecorded?: (audioBlob: Blob) => Promise<string>;
onStartStreaming?: () => Promise<StreamingSpeechSession>;
onStreamingStart?: () => void;
onStreamingEnd?: () => void;
onActiveChange?: (active: boolean) => void;
onProcessingChange?: (processing: boolean) => void;
onError?: (error: unknown) => void;
lang?: string;
recordingMode?: "auto" | "media-recorder" | "streaming";
};
function detectSpeechInputMode(
recordingMode: NonNullable<SpeechInputProps["recordingMode"]>,
): SpeechInputMode {
if (typeof window === "undefined") return "none";
if (recordingMode === "streaming") {
return typeof navigator !== "undefined" &&
"WebSocket" in window &&
"AudioContext" in window &&
"mediaDevices" in navigator
? "streaming"
: "none";
}
if (recordingMode === "media-recorder") {
return typeof navigator !== "undefined" &&
"MediaRecorder" in window &&
"mediaDevices" in navigator
? "media-recorder"
: "none";
}
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
return "speech-recognition";
}
if (
typeof navigator !== "undefined" &&
"MediaRecorder" in window &&
"mediaDevices" in navigator
) {
return "media-recorder";
}
return "none";
}
export function SpeechInput({
allowUnavailableClick = false,
className,
disabled,
lang = "en-US",
onAudioRecorded,
onActiveChange,
onClick,
onError,
onProcessingChange,
onStartStreaming,
onStreamingEnd,
onStreamingStart,
onTranscriptionChange,
recordingMode = "auto",
title,
...props
}: SpeechInputProps) {
const [mode] = useState<SpeechInputMode>(() =>
detectSpeechInputMode(recordingMode),
);
const [isListening, setIsListening] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isRecognitionReady, setIsRecognitionReady] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamingSessionRef = useRef<StreamingSpeechSession | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const mountedRef = useRef(true);
const operationIdRef = useRef(0);
const onAudioRecordedRef = useRef(onAudioRecorded);
const onErrorRef = useRef(onError);
const onStartStreamingRef = useRef(onStartStreaming);
const onStreamingEndRef = useRef(onStreamingEnd);
const onStreamingStartRef = useRef(onStreamingStart);
const onTranscriptionChangeRef = useRef(onTranscriptionChange);
onAudioRecordedRef.current = onAudioRecorded;
onErrorRef.current = onError;
onStartStreamingRef.current = onStartStreaming;
onStreamingEndRef.current = onStreamingEnd;
onStreamingStartRef.current = onStreamingStart;
onTranscriptionChangeRef.current = onTranscriptionChange;
useEffect(() => {
onActiveChange?.(isListening || isProcessing);
}, [isListening, isProcessing, onActiveChange]);
useEffect(() => {
onProcessingChange?.(isProcessing);
}, [isProcessing, onProcessingChange]);
useEffect(() => {
if (mode !== "speech-recognition") return;
const Recognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new Recognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = lang;
const handleStart = () => setIsListening(true);
const handleEnd = () => setIsListening(false);
const handleResult = (event: Event) => {
const speechEvent = event as SpeechRecognitionEvent;
let transcript = "";
for (
let index = speechEvent.resultIndex;
index < speechEvent.results.length;
index += 1
) {
const result = speechEvent.results[index];
if (result?.isFinal) {
transcript += result[0]?.transcript ?? "";
}
}
if (transcript.trim()) {
onTranscriptionChangeRef.current?.(transcript, "speech-recognition");
}
};
const handleError = (event: Event) => {
setIsListening(false);
onErrorRef.current?.(event);
};
recognition.addEventListener("start", handleStart);
recognition.addEventListener("end", handleEnd);
recognition.addEventListener("result", handleResult);
recognition.addEventListener("error", handleError);
recognitionRef.current = recognition;
setIsRecognitionReady(true);
return () => {
recognition.removeEventListener("start", handleStart);
recognition.removeEventListener("end", handleEnd);
recognition.removeEventListener("result", handleResult);
recognition.removeEventListener("error", handleError);
recognition.stop();
recognitionRef.current = null;
setIsRecognitionReady(false);
};
}, [lang, mode]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
operationIdRef.current += 1;
streamingSessionRef.current?.cancel();
streamingSessionRef.current = null;
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop();
}
};
}, []);
const startStreaming = useCallback(async () => {
if (!onStartStreamingRef.current) return;
const operationId = ++operationIdRef.current;
setIsProcessing(true);
try {
onStreamingStartRef.current?.();
const session = await onStartStreamingRef.current();
if (!mountedRef.current || operationId !== operationIdRef.current) {
session.cancel();
return;
}
streamingSessionRef.current = session;
setIsListening(true);
setIsProcessing(false);
void session.done.then(
() => {
if (!mountedRef.current || streamingSessionRef.current !== session) {
return;
}
streamingSessionRef.current = null;
setIsListening(false);
setIsProcessing(false);
onStreamingEndRef.current?.();
},
(error) => {
if (!mountedRef.current || streamingSessionRef.current !== session) {
return;
}
streamingSessionRef.current = null;
setIsListening(false);
setIsProcessing(false);
onStreamingEndRef.current?.();
onErrorRef.current?.(error);
},
);
} catch (error) {
if (!mountedRef.current || operationId !== operationIdRef.current) {
return;
}
setIsListening(false);
setIsProcessing(false);
onStreamingEndRef.current?.();
onErrorRef.current?.(error);
}
}, []);
const startMediaRecorder = useCallback(async () => {
if (!onAudioRecordedRef.current) return;
const operationId = ++operationIdRef.current;
setIsProcessing(true);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!mountedRef.current || operationId !== operationIdRef.current) {
for (const track of stream.getTracks()) track.stop();
return;
}
const recorder = new MediaRecorder(stream);
streamRef.current = stream;
mediaRecorderRef.current = recorder;
audioChunksRef.current = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) audioChunksRef.current.push(event.data);
});
recorder.addEventListener("error", (event) => {
if (!mountedRef.current || operationId !== operationIdRef.current) {
return;
}
setIsListening(false);
setIsProcessing(false);
operationIdRef.current += 1;
for (const track of stream.getTracks()) track.stop();
streamRef.current = null;
mediaRecorderRef.current = null;
onErrorRef.current?.(event);
});
recorder.addEventListener("stop", async () => {
for (const track of stream.getTracks()) track.stop();
streamRef.current = null;
mediaRecorderRef.current = null;
if (!mountedRef.current || operationId !== operationIdRef.current) {
return;
}
setIsListening(false);
const audioBlob = new Blob(audioChunksRef.current, {
type: recorder.mimeType || "audio/webm",
});
audioChunksRef.current = [];
if (audioBlob.size === 0 || !onAudioRecordedRef.current) {
setIsProcessing(false);
return;
}
setIsProcessing(true);
try {
const transcript = await onAudioRecordedRef.current(audioBlob);
if (!mountedRef.current || operationId !== operationIdRef.current) {
return;
}
if (transcript.trim()) {
onTranscriptionChangeRef.current?.(transcript, "media-recorder");
}
} catch (error) {
if (mountedRef.current && operationId === operationIdRef.current) {
onErrorRef.current?.(error);
}
} finally {
if (mountedRef.current && operationId === operationIdRef.current) {
setIsProcessing(false);
}
}
});
recorder.start();
setIsListening(true);
setIsProcessing(false);
} catch (error) {
if (!mountedRef.current || operationId !== operationIdRef.current) {
return;
}
setIsListening(false);
setIsProcessing(false);
onErrorRef.current?.(error);
}
}, []);
const toggleListening = useCallback(() => {
if (mode === "speech-recognition" && recognitionRef.current) {
if (isListening) recognitionRef.current.stop();
else recognitionRef.current.start();
return;
}
if (mode === "media-recorder") {
if (isListening) {
const recorder = mediaRecorderRef.current;
if (!recorder) return;
// Keep the recording operation active while MediaRecorder schedules its
// stop event and the completed audio is transcribed. Without this state
// transition, consumers briefly observe an inactive session and may
// discard the draft range needed by the asynchronous result.
setIsProcessing(true);
try {
recorder.stop();
} catch (error) {
operationIdRef.current += 1;
for (const track of streamRef.current?.getTracks() ?? [])
track.stop();
streamRef.current = null;
mediaRecorderRef.current = null;
setIsListening(false);
setIsProcessing(false);
onErrorRef.current?.(error);
}
} else {
void startMediaRecorder();
}
return;
}
if (mode === "streaming") {
if (isListening) {
setIsListening(false);
setIsProcessing(true);
streamingSessionRef.current?.stop();
} else {
void startStreaming();
}
}
}, [isListening, mode, startMediaRecorder, startStreaming]);
const unavailable =
mode === "none" ||
(mode === "speech-recognition" && !isRecognitionReady) ||
(mode === "media-recorder" && !onAudioRecorded) ||
(mode === "streaming" && !onStartStreaming);
return (
<div className="relative inline-flex items-center justify-center">
{isListening ? (
<div className="absolute inset-0 animate-ping rounded-full border-2 border-destructive/40" />
) : null}
<Button
{...props}
aria-label={isListening ? "Stop recording" : "Record speech"}
aria-pressed={isListening}
className={cn(
"group relative z-10 size-7 rounded-md p-1.5 transition-colors",
isListening
? "bg-destructive text-white hover:bg-destructive/80"
: "bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground",
className,
)}
disabled={
disabled || (unavailable && !allowUnavailableClick) || isProcessing
}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) toggleListening();
}}
title={
isListening
? "Stop recording"
: (title ??
(unavailable
? "Speech input is not supported in this browser"
: "Record speech"))
}
type="button"
>
{isProcessing ? (
<Spinner className="size-4" />
) : isListening ? (
<span className="relative size-4">
<MicIcon className="absolute inset-0 size-4 animate-pulse transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0" />
<SquareIcon className="absolute inset-0 m-auto size-3.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
</span>
) : (
<MicIcon className="size-4" />
)}
</Button>
</div>
);
}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, type MouseEvent as ReactMouseEvent } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
@@ -17,17 +17,67 @@ import {
const {
loadProviderModelCatalogMock,
loadProviderModelsMock,
speechInputMockState,
startVercelStreamingTranscriptionMock,
subscribeToProviderModelsMock,
} = vi.hoisted(() => ({
loadProviderModelCatalogMock: vi.fn(),
loadProviderModelsMock: vi.fn(),
speechInputMockState: {
current: null as MockSpeechInputProps | null,
},
startVercelStreamingTranscriptionMock: vi.fn(),
subscribeToProviderModelsMock: vi.fn(() => vi.fn()),
}));
type MockSpeechInputProps = {
disabled?: boolean;
onActiveChange?: (active: boolean) => void;
onClick?: (event: ReactMouseEvent<HTMLButtonElement>) => void;
onProcessingChange?: (processing: boolean) => void;
onStartStreaming?: () => Promise<unknown>;
onStreamingEnd?: () => void;
onStreamingStart?: () => void;
onTranscriptionChange?: (
transcript: string,
source?: "speech-recognition" | "media-recorder",
) => void;
recordingMode?: "auto" | "media-recorder" | "streaming";
};
vi.mock("@/components/ai-elements/speech-input", async () => {
const React = await vi.importActual<typeof import("react")>("react");
return {
SpeechInput: (props: MockSpeechInputProps) => {
speechInputMockState.current = props;
const [initialRecordingMode] = React.useState(props.recordingMode);
React.useEffect(() => {
props.onActiveChange?.(false);
props.onProcessingChange?.(false);
}, [props.onActiveChange, props.onProcessingChange]);
return (
<div data-initial-recording-mode={initialRecordingMode}>
<button
aria-label="Record speech"
disabled={props.disabled}
onClick={props.onClick}
type="button"
/>
</div>
);
},
};
});
vi.mock("@/lib/provider-model-catalog", () => ({
loadProviderModelCatalog: loadProviderModelCatalogMock,
loadProviderModels: loadProviderModelsMock,
subscribeToProviderModels: subscribeToProviderModelsMock,
VOICE_INPUT_SETTINGS_CHANGED_EVENT: "cline:test-voice-input-settings-changed",
}));
vi.mock("@/lib/vercel-streaming-transcription", () => ({
startVercelStreamingTranscription: startVercelStreamingTranscriptionMock,
}));
let container: HTMLDivElement;
@@ -40,8 +90,15 @@ beforeEach(() => {
enabledProviderIds: ["cline"],
providerModels: { cline: ["test-model"] },
providerReasoningModels: { cline: [] },
voiceInput: null,
});
loadProviderModelsMock.mockReset().mockResolvedValue([]);
speechInputMockState.current = null;
startVercelStreamingTranscriptionMock.mockReset().mockResolvedValue({
done: new Promise<void>(() => {}),
stop: vi.fn(),
cancel: vi.fn(),
});
subscribeToProviderModelsMock.mockReset().mockReturnValue(vi.fn());
HTMLElement.prototype.scrollIntoView = vi.fn();
HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
@@ -58,6 +115,92 @@ afterEach(async () => {
vi.restoreAllMocks();
});
const workspaceValue = {
workspaceRoot: "/workspace/cline",
workspaces: ["/workspace/cline"],
listWorkspaces: vi.fn(async () => ["/workspace/cline"]),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
};
function providerCatalog(
voiceInput: {
providerId: string;
providerName: string;
modelId: string;
modelName: string;
supportsStreaming: boolean;
} | null,
) {
return {
providers: [],
enabledProviderIds: ["cline"],
providerModels: { cline: ["test-model"] },
providerReasoningModels: { cline: [] },
voiceInput,
};
}
function deferred<T>() {
let resolve: (value: T) => void = () => {};
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function renderVoiceComposer({
onPromptInputChange = vi.fn(),
onSend = vi.fn(),
prompt = "",
promptVersion = 0,
}: {
onPromptInputChange?: ReturnType<typeof vi.fn>;
onSend?: ReturnType<typeof vi.fn>;
prompt?: string;
promptVersion?: number;
} = {}) {
await act(async () => {
root.render(
<WorkspaceProvider value={workspaceValue}>
<ChatInputBar
attachments={[]}
gitBranch="main"
mode="act"
model="test-model"
onAbort={vi.fn()}
onAttachFiles={vi.fn()}
onEditPromptInQueue={vi.fn()}
onListGitBranches={vi.fn(async () => ({
current: "main",
branches: ["main"],
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onPromptInputChange={onPromptInputChange}
onProviderChange={vi.fn()}
onReasoningChange={vi.fn()}
onRemoveAttachment={vi.fn()}
onRemovePromptInQueue={vi.fn()}
onSend={onSend}
onSteerPromptInQueue={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
promptDraft={{ version: promptVersion, value: prompt }}
promptsInQueue={[]}
provider="cline"
reasoningEffort="low"
status="idle"
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
thinking
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
}
describe("ChatInputBar", () => {
it("builds slash commands from both workflows and skills", () => {
expect(
@@ -140,8 +283,457 @@ describe("ChatInputBar", () => {
expect(promptInput?.className).toContain("self-start");
});
it("protects the draft and send action for the full streaming transcription lifecycle", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "vercel-ai-gateway",
providerName: "Vercel AI Gateway",
modelId: "openai/gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: true,
}),
);
const onPromptInputChange = vi.fn();
const onSend = vi.fn();
await renderVoiceComposer({
onPromptInputChange,
onSend,
prompt: "alpha omega",
});
await vi.waitFor(() => {
expect(
container
.querySelector("[data-initial-recording-mode]")
?.getAttribute("data-initial-recording-mode"),
).toBe("streaming");
});
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
textarea?.setSelectionRange(6, 6);
await act(async () => {
speechInputMockState.current?.onStreamingStart?.();
speechInputMockState.current?.onActiveChange?.(true);
});
const sendButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Send message"]',
);
expect(textarea?.readOnly).toBe(true);
expect(sendButton?.disabled).toBe(true);
await act(async () => {
await speechInputMockState.current?.onStartStreaming?.();
});
const onTranscript = (
startVercelStreamingTranscriptionMock.mock.calls.at(-1)?.[0] as
| { onTranscript?: (text: string) => void }
| undefined
)?.onTranscript;
await act(async () => onTranscript?.("hello"));
expect(textarea?.value).toBe("alpha hello omega");
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setValue?.call(textarea, "tampered draft");
textarea?.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(onPromptInputChange).toHaveBeenLastCalledWith("alpha hello omega");
await act(async () => onTranscript?.("hello world"));
expect(textarea?.value).toBe("alpha hello world omega");
await renderVoiceComposer({
onPromptInputChange,
onSend,
prompt: "external replacement",
promptVersion: 1,
});
expect(textarea?.value).toBe("external replacement");
expect(textarea?.readOnly).toBe(true);
await act(async () => onTranscript?.("must not overwrite"));
expect(textarea?.value).toBe("external replacement");
await act(async () => {
textarea?.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
);
sendButton?.click();
speechInputMockState.current?.onProcessingChange?.(true);
});
expect(onSend).not.toHaveBeenCalled();
expect(
container.querySelector('output[aria-live="polite"]'),
).not.toBeNull();
expect(textarea?.placeholder).toBe("Transcribing voice input…");
await act(async () => {
speechInputMockState.current?.onStreamingEnd?.();
speechInputMockState.current?.onProcessingChange?.(false);
speechInputMockState.current?.onActiveChange?.(false);
});
expect(textarea?.readOnly).toBe(false);
expect(sendButton?.disabled).toBe(false);
await act(async () => sendButton?.click());
expect(onSend).toHaveBeenCalledWith("external replacement");
});
it("discards streaming updates after an equal-valued draft replacement", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "vercel-ai-gateway",
providerName: "Vercel AI Gateway",
modelId: "openai/gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: true,
}),
);
const onPromptInputChange = vi.fn();
await renderVoiceComposer({ onPromptInputChange });
await vi.waitFor(() =>
expect(speechInputMockState.current?.recordingMode).toBe("streaming"),
);
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
await act(async () => {
speechInputMockState.current?.onStreamingStart?.();
speechInputMockState.current?.onActiveChange?.(true);
await speechInputMockState.current?.onStartStreaming?.();
});
const onTranscript = (
startVercelStreamingTranscriptionMock.mock.calls.at(-1)?.[0] as
| { onTranscript?: (text: string) => void }
| undefined
)?.onTranscript;
await renderVoiceComposer({
onPromptInputChange,
prompt: "",
promptVersion: 1,
});
expect(textarea?.value).toBe("");
await act(async () => onTranscript?.("stale transcript"));
expect(textarea?.value).toBe("");
expect(onPromptInputChange).not.toHaveBeenCalledWith("stale transcript");
});
it("keeps the streaming draft identity across internal transcript updates", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "vercel-ai-gateway",
providerName: "Vercel AI Gateway",
modelId: "openai/gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: true,
}),
);
await renderVoiceComposer({ prompt: "alpha omega" });
await vi.waitFor(() =>
expect(speechInputMockState.current?.recordingMode).toBe("streaming"),
);
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
textarea?.setSelectionRange(6, 6);
await act(async () => {
speechInputMockState.current?.onStreamingStart?.();
speechInputMockState.current?.onActiveChange?.(true);
await speechInputMockState.current?.onStartStreaming?.();
});
const onTranscript = (
startVercelStreamingTranscriptionMock.mock.calls.at(-1)?.[0] as
| { onTranscript?: (text: string) => void }
| undefined
)?.onTranscript;
await act(async () => onTranscript?.("hello"));
expect(textarea?.value).toBe("alpha hello omega");
await act(async () => onTranscript?.("hello world"));
expect(textarea?.value).toBe("alpha hello world omega");
});
it("adds browser speech-recognition chunks while recording remains active", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "openai-native",
providerName: "OpenAI",
modelId: "gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: false,
}),
);
await renderVoiceComposer({ prompt: "alpha omega" });
await vi.waitFor(() =>
expect(speechInputMockState.current?.recordingMode).toBe("auto"),
);
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
textarea?.setSelectionRange(6, 6);
await act(async () => {
speechInputMockState.current?.onActiveChange?.(true);
speechInputMockState.current?.onTranscriptionChange?.(
"hello",
"speech-recognition",
);
});
expect(textarea?.readOnly).toBe(true);
expect(textarea?.value).toBe("alpha hello omega");
await act(async () => {
speechInputMockState.current?.onTranscriptionChange?.(
"world",
"speech-recognition",
);
});
expect(textarea?.value).toBe("alpha hello world omega");
});
it("discards a batch transcript after the draft lifecycle is replaced", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "openai-native",
providerName: "OpenAI",
modelId: "gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: false,
}),
);
const onPromptInputChange = vi.fn();
await renderVoiceComposer({
onPromptInputChange,
prompt: "alpha omega",
});
await vi.waitFor(() => {
expect(
container
.querySelector("[data-initial-recording-mode]")
?.getAttribute("data-initial-recording-mode"),
).toBe("auto");
});
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
textarea?.setSelectionRange(6, 6);
await act(async () => {
speechInputMockState.current?.onActiveChange?.(true);
});
await renderVoiceComposer({
onPromptInputChange,
prompt: "external replacement",
promptVersion: 1,
});
expect(textarea?.value).toBe("external replacement");
await act(async () => {
speechInputMockState.current?.onTranscriptionChange?.("late transcript");
});
expect(textarea?.value).toBe("external replacement");
expect(onPromptInputChange).toHaveBeenLastCalledWith(
"external replacement",
);
});
it("inserts a batch transcript only into its captured draft range", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "openai-native",
providerName: "OpenAI",
modelId: "gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: false,
}),
);
await renderVoiceComposer({ prompt: "alpha omega" });
await vi.waitFor(() => {
expect(speechInputMockState.current?.recordingMode).toBe("auto");
});
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
textarea?.setSelectionRange(6, 6);
await act(async () => {
speechInputMockState.current?.onActiveChange?.(true);
speechInputMockState.current?.onTranscriptionChange?.("hello");
});
expect(textarea?.value).toBe("alpha hello omega");
await act(async () => {
speechInputMockState.current?.onTranscriptionChange?.("replayed");
});
expect(textarea?.value).toBe("alpha hello omega");
});
it("discards a pending batch transcript when the voice target changes", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "openai-native",
providerName: "OpenAI",
modelId: "gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: false,
}),
);
await renderVoiceComposer({ prompt: "alpha omega" });
await vi.waitFor(() => {
expect(speechInputMockState.current?.recordingMode).toBe("auto");
});
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
textarea?.setSelectionRange(6, 6);
await act(async () => {
speechInputMockState.current?.onActiveChange?.(true);
});
const staleBatchResult =
speechInputMockState.current?.onTranscriptionChange;
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "vercel-ai-gateway",
providerName: "Vercel AI Gateway",
modelId: "openai/gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: true,
}),
);
await act(async () => {
window.dispatchEvent(
new Event("cline:test-voice-input-settings-changed"),
);
});
await vi.waitFor(() => {
expect(speechInputMockState.current?.recordingMode).toBe("streaming");
});
await act(async () => staleBatchResult?.("late transcript"));
expect(textarea?.value).toBe("alpha omega");
});
it("ignores stale voice catalog responses and remounts when the recording mode changes", async () => {
const firstCatalog = deferred<ReturnType<typeof providerCatalog>>();
const refreshedCatalog = deferred<ReturnType<typeof providerCatalog>>();
loadProviderModelCatalogMock
.mockReset()
// ModelSelector loads the same catalog independently before the
// composer's voice-input effect runs.
.mockResolvedValueOnce(providerCatalog(null))
.mockReturnValueOnce(firstCatalog.promise)
.mockReturnValueOnce(refreshedCatalog.promise);
await renderVoiceComposer();
await act(async () => {
window.dispatchEvent(
new Event("cline:test-voice-input-settings-changed"),
);
});
expect(loadProviderModelCatalogMock).toHaveBeenCalledTimes(3);
await act(async () => {
refreshedCatalog.resolve(
providerCatalog({
providerId: "vercel-ai-gateway",
providerName: "Vercel AI Gateway",
modelId: "openai/gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: true,
}),
);
await refreshedCatalog.promise;
});
await vi.waitFor(() => {
expect(
container
.querySelector("[data-initial-recording-mode]")
?.getAttribute("data-initial-recording-mode"),
).toBe("streaming");
});
await act(async () => {
firstCatalog.resolve(
providerCatalog({
providerId: "openai",
providerName: "OpenAI",
modelId: "whisper-1",
modelName: "Whisper",
supportsStreaming: false,
}),
);
await firstCatalog.promise;
});
expect(speechInputMockState.current?.recordingMode).toBe("streaming");
expect(
container
.querySelector("[data-initial-recording-mode]")
?.getAttribute("data-initial-recording-mode"),
).toBe("streaming");
});
it("ignores late transcripts from a session canceled by a provider change", async () => {
loadProviderModelCatalogMock.mockResolvedValue(
providerCatalog({
providerId: "vercel-ai-gateway",
providerName: "Vercel AI Gateway",
modelId: "openai/gpt-4o-mini-transcribe",
modelName: "GPT-4o mini Transcribe",
supportsStreaming: true,
}),
);
await renderVoiceComposer({ prompt: "draft" });
await vi.waitFor(() =>
expect(speechInputMockState.current?.recordingMode).toBe("streaming"),
);
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[role="combobox"]',
);
textarea?.setSelectionRange(5, 5);
await act(async () => {
speechInputMockState.current?.onStreamingStart?.();
speechInputMockState.current?.onActiveChange?.(true);
await speechInputMockState.current?.onStartStreaming?.();
});
const oldSessionTranscript = (
startVercelStreamingTranscriptionMock.mock.calls.at(-1)?.[0] as
| { onTranscript?: (text: string) => void }
| undefined
)?.onTranscript;
await act(async () => oldSessionTranscript?.("one"));
expect(textarea?.value).toBe("draft one");
loadProviderModelCatalogMock.mockResolvedValueOnce(
providerCatalog({
providerId: "openai",
providerName: "OpenAI",
modelId: "whisper-1",
modelName: "Whisper",
supportsStreaming: false,
}),
);
await act(async () => {
window.dispatchEvent(
new Event("cline:test-voice-input-settings-changed"),
);
});
await vi.waitFor(() =>
expect(speechInputMockState.current?.recordingMode).toBe("auto"),
);
await act(async () => oldSessionTranscript?.("late replacement"));
expect(textarea?.value).toBe("draft one");
});
it("preserves an explicit High selection across capability and status updates", async () => {
const onReasoningChange = vi.fn();
const onOpenVoiceInputSettings = vi.fn();
const render = async (status: ChatSessionStatus) => {
await act(async () => {
root.render(
@@ -170,6 +762,7 @@ describe("ChatInputBar", () => {
}))}
onModeToggle={vi.fn()}
onModelChange={vi.fn()}
onOpenVoiceInputSettings={onOpenVoiceInputSettings}
onPromptInputChange={vi.fn()}
onProviderChange={vi.fn()}
onReasoningChange={onReasoningChange}
@@ -276,6 +869,9 @@ describe("ChatInputBar", () => {
const attachTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Attach files"]',
);
const speechTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
const thinkingTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Thinking level"]',
);
@@ -283,6 +879,7 @@ describe("ChatInputBar", () => {
expect(leftControls?.className).toContain("max-[560px]:flex-nowrap");
expect(leftControls?.contains(compactModelTrigger ?? null)).toBe(true);
expect(leftControls?.contains(thinkingTrigger ?? null)).toBe(true);
expect(leftControls?.contains(speechTrigger ?? null)).toBe(false);
expect(workspaceTrigger?.disabled).toBe(true);
expect(workspaceTrigger?.className).toContain("max-[560px]:size-7");
@@ -296,13 +893,23 @@ describe("ChatInputBar", () => {
expect(workspaceFooterSlot?.className).not.toContain("max-w-");
const rightControls = workspaceFooterSlot?.parentElement?.parentElement;
expect(rightControls?.contains(workspaceTrigger ?? null)).toBe(true);
const sendTrigger = container.querySelector('[aria-label="Send message"]');
const stopTrigger = container.querySelector('[aria-label="Stop agent"]');
expect(promptInput?.parentElement?.className).toContain("items-end");
const sendTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Send message"]',
);
const stopTrigger = container.querySelector<HTMLButtonElement>(
'[aria-label="Stop agent"]',
);
expect(promptInput?.parentElement?.className).toContain("items-start");
expect(promptInput?.parentElement?.contains(sendTrigger)).toBe(true);
expect(promptInput?.parentElement?.contains(stopTrigger)).toBe(true);
expect(promptInput?.parentElement?.contains(speechTrigger)).toBe(true);
expect(sendTrigger?.parentElement?.className).toContain("self-end");
expect(rightControls?.contains(sendTrigger)).toBe(false);
expect(rightControls?.contains(speechTrigger ?? null)).toBe(false);
expect(speechTrigger?.parentElement?.nextElementSibling).toBe(sendTrigger);
expect(leftControls?.parentElement).toBe(rightControls?.parentElement);
await act(async () => speechTrigger?.click());
expect(onOpenVoiceInputSettings).toHaveBeenCalledOnce();
});
it("selects High from the supported model thinking menu", async () => {
@@ -7,6 +7,10 @@ import {
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
SpeechInput,
type SpeechTranscriptionSource,
} from "@/components/ai-elements/speech-input";
import { Button } from "@/components/ui/button";
import {
Popover,
@@ -20,12 +24,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { useWorkspace } from "@/contexts/workspace-context";
import type { PromptInQueue } from "@/hooks/chat-session/types";
import { formatCostUsd } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
import { desktopClient } from "@/lib/desktop-client";
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
import {
readModelSelectionStorageFromWindow,
writeModelSelectionStorageToWindow,
@@ -35,8 +41,12 @@ import {
loadProviderModelCatalog,
loadProviderModels,
subscribeToProviderModels,
type TranscriptionModelTarget,
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import { startVercelStreamingTranscription } from "@/lib/vercel-streaming-transcription";
import { MAX_RECORDED_AUDIO_BYTES } from "@/lib/voice-input-limits";
import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector";
// Memoized: the workspace/branch selector fans out into popovers and lists
@@ -145,6 +155,22 @@ const PROMPT_INPUT_COLLAPSED_ROWS = 1;
const PROMPT_INPUT_EXPANDED_ROWS = 2;
const PROMPT_INPUT_MAX_ROWS = 5;
const PROMPT_INPUT_LINE_HEIGHT_REM = 1.25;
const AUDIO_BASE64_CHUNK_SIZE = 0x8000;
async function blobToBase64(blob: Blob): Promise<string> {
const bytes = new Uint8Array(await blob.arrayBuffer());
let binary = "";
for (
let offset = 0;
offset < bytes.length;
offset += AUDIO_BASE64_CHUNK_SIZE
) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + AUDIO_BASE64_CHUNK_SIZE),
);
}
return window.btoa(binary);
}
function resolveEffortIndex(
thinking: ChatSessionConfig["thinking"],
@@ -276,6 +302,7 @@ type ChatInputBarProps = {
prompt: string,
) => Promise<void> | void;
onRemovePromptInQueue: (promptId: string) => Promise<void> | void;
onOpenVoiceInputSettings?: () => void;
summary: {
toolCalls: number;
tokensIn: number;
@@ -312,6 +339,7 @@ function ChatInputBarImpl({
onSteerPromptInQueue,
onEditPromptInQueue,
onRemovePromptInQueue,
onOpenVoiceInputSettings,
summary,
}: ChatInputBarProps) {
const {
@@ -324,9 +352,38 @@ function ChatInputBarImpl({
// Keystrokes only update this local state; the parent page tree is not
// re-rendered per keypress. External writers push text in via promptDraft.
const [promptInput, setPromptInputState] = useState(promptDraft.value);
const promptInputValueRef = useRef(promptDraft.value);
const appliedDraftVersionRef = useRef(promptDraft.version);
const latestDraftVersionRef = useRef(promptDraft.version);
latestDraftVersionRef.current = promptDraft.version;
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
const batchTranscriptSessionRef = useRef<{
start: number;
end: number;
expectedValue: string;
draftVersion: number;
generation: number;
} | null>(null);
const speechRecognitionSessionRef = useRef<{
start: number;
end: number;
expectedValue: string;
draftVersion: number;
generation: number;
} | null>(null);
const streamingTranscriptRangeRef = useRef<{
start: number;
end: number;
expectedValue: string;
draftVersion: number;
generation: number;
} | null>(null);
const transcriptionGenerationRef = useRef(0);
const transcriptionTargetIdentityRef = useRef("unconfigured");
const transcriptionTargetStreamsRef = useRef(false);
const setPromptInput = useCallback(
(value: string) => {
promptInputValueRef.current = value;
setPromptInputState(value);
onPromptInputChange(value);
},
@@ -337,12 +394,18 @@ function ChatInputBarImpl({
return;
}
appliedDraftVersionRef.current = promptDraft.version;
batchTranscriptSessionRef.current = null;
speechRecognitionSessionRef.current = null;
streamingTranscriptRangeRef.current = null;
setPromptInput(promptDraft.value);
}, [promptDraft, setPromptInput]);
const isBusy =
status === "starting" || status === "running" || status === "stopping";
const canAbort = status === "running" || status === "stopping";
const hasDraft = promptInput.trim().length > 0 || attachments.length > 0;
const [speechInputActive, setSpeechInputActive] = useState(false);
const speechInputActiveRef = useRef(false);
const [speechInputProcessing, setSpeechInputProcessing] = useState(false);
const [reasoningCapability, setReasoningCapability] = useState<{
provider: string;
@@ -369,14 +432,34 @@ function ChatInputBarImpl({
},
[model, provider],
);
const canSend = hasDraft;
const canSend = hasDraft && !speechInputActive;
const handleSend = useCallback(() => {
if (speechInputActive) return;
const prompt = promptInput.trim();
setPromptInput("");
onSend(prompt);
}, [onSend, promptInput, setPromptInput]);
}, [onSend, promptInput, setPromptInput, speechInputActive]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
const [transcriptionTarget, setTranscriptionTarget] =
useState<TranscriptionModelTarget | null>(null);
const updateTranscriptionTarget = useCallback(
(target: TranscriptionModelTarget | null) => {
const identity = target
? `${target.providerId}:${target.modelId}:${target.supportsStreaming ? "streaming" : "auto"}`
: "unconfigured";
transcriptionTargetStreamsRef.current =
target?.supportsStreaming ?? false;
if (identity !== transcriptionTargetIdentityRef.current) {
transcriptionTargetIdentityRef.current = identity;
transcriptionGenerationRef.current += 1;
batchTranscriptSessionRef.current = null;
speechRecognitionSessionRef.current = null;
streamingTranscriptRangeRef.current = null;
}
setTranscriptionTarget(target);
},
[],
);
const [promptInputFocused, setPromptInputFocused] = useState(false);
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
// Mention/slash detection is derived synchronously from the input +
@@ -417,6 +500,259 @@ function ChatInputBarImpl({
const [slashLoading, setSlashLoading] = useState(false);
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
useEffect(() => {
let cancelled = false;
let loadId = 0;
const loadVoiceInput = () => {
const currentLoadId = ++loadId;
loadProviderModelCatalog()
.then((catalog) => {
if (!cancelled && currentLoadId === loadId) {
updateTranscriptionTarget(catalog.voiceInput);
}
})
.catch(() => {
if (!cancelled && currentLoadId === loadId) {
updateTranscriptionTarget(null);
}
});
};
loadVoiceInput();
window.addEventListener(VOICE_INPUT_SETTINGS_CHANGED_EVENT, loadVoiceInput);
return () => {
cancelled = true;
loadId += 1;
window.removeEventListener(
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
loadVoiceInput,
);
};
}, [updateTranscriptionTarget]);
const handleTranscriptionChange = useCallback(
(
transcript: string,
source: SpeechTranscriptionSource = "media-recorder",
) => {
const text = transcript.trim();
const session =
source === "speech-recognition"
? speechRecognitionSessionRef.current
: batchTranscriptSessionRef.current;
if (source === "speech-recognition") {
// Browser speech recognition yields final chunks while the microphone
// remains open. Keep its insertion cursor alive across those chunks.
batchTranscriptSessionRef.current = null;
} else {
// A completed recording produces one batch result.
speechRecognitionSessionRef.current = null;
batchTranscriptSessionRef.current = null;
}
// Each result must belong to the draft captured when recording began.
// Batch snapshots are consumed once; browser recognition advances its
// cursor after each final chunk.
if (!text || !session) return;
const current = promptInputValueRef.current;
if (
session.generation !== transcriptionGenerationRef.current ||
session.draftVersion !== latestDraftVersionRef.current ||
session.expectedValue !== current
) {
return;
}
const insertionStart = session.start;
const insertionEnd = session.end;
const before = current.slice(0, insertionStart);
const after = current.slice(insertionEnd);
const leadingSpace = before.length > 0 && !/\s$/.test(before) ? " " : "";
const trailingSpace = after.length > 0 && !/^\s/.test(after) ? " " : "";
const insertedText = `${leadingSpace}${text}${trailingSpace}`;
const next = `${before}${insertedText}${after}`;
const nextCursor = before.length + insertedText.length;
if (source === "speech-recognition") {
speechRecognitionSessionRef.current = {
start: nextCursor,
end: nextCursor,
expectedValue: next,
draftVersion: session.draftVersion,
generation: session.generation,
};
}
setPromptInput(next);
requestAnimationFrame(() => {
const textarea = promptInputRef.current;
if (!textarea) return;
textarea.focus();
textarea.setSelectionRange(nextCursor, nextCursor);
setCursorIndex(nextCursor);
});
},
[setPromptInput],
);
const handleSpeechInputActiveChange = useCallback((active: boolean) => {
const wasActive = speechInputActiveRef.current;
speechInputActiveRef.current = active;
setSpeechInputActive(active);
if (!active) {
batchTranscriptSessionRef.current = null;
speechRecognitionSessionRef.current = null;
return;
}
if (wasActive || transcriptionTargetStreamsRef.current) return;
const current = promptInputValueRef.current;
const input = promptInputRef.current;
const start = input?.selectionStart ?? current.length;
const end = input?.selectionEnd ?? start;
const session = {
start,
end,
expectedValue: current,
draftVersion: latestDraftVersionRef.current,
generation: transcriptionGenerationRef.current,
};
// `auto` chooses browser speech recognition when available and falls back
// to MediaRecorder. Capture both session shapes until the result tells us
// which transport was selected.
batchTranscriptSessionRef.current = session;
speechRecognitionSessionRef.current = session;
}, []);
const handleStreamingTranscriptionStart = useCallback(() => {
const current = promptInputValueRef.current;
const input = promptInputRef.current;
const start = input?.selectionStart ?? current.length;
const end = input?.selectionEnd ?? start;
streamingTranscriptRangeRef.current = {
start,
end,
expectedValue: current,
draftVersion: latestDraftVersionRef.current,
generation: transcriptionGenerationRef.current,
};
}, []);
const handleStreamingTranscriptionChange = useCallback(
(transcript: string) => {
const text = transcript.trim();
const range = streamingTranscriptRangeRef.current;
if (!text || !range) return;
const current = promptInputValueRef.current;
// A live transcript range is only valid for the exact draft produced by
// its previous update. Refuse to apply stale numeric offsets if another
// writer changes the draft while the microphone is active.
if (
range.generation !== transcriptionGenerationRef.current ||
range.draftVersion !== latestDraftVersionRef.current ||
current !== range.expectedValue
) {
streamingTranscriptRangeRef.current = null;
return;
}
const before = current.slice(0, range.start);
const after = current.slice(range.end);
const leadingSpace = before.length > 0 && !/\s$/.test(before) ? " " : "";
const trailingSpace = after.length > 0 && !/^\s/.test(after) ? " " : "";
const insertion = `${leadingSpace}${text}${trailingSpace}`;
const next = `${before}${insertion}${after}`;
const nextEnd = range.start + insertion.length;
streamingTranscriptRangeRef.current = {
start: range.start,
end: nextEnd,
expectedValue: next,
draftVersion: range.draftVersion,
generation: range.generation,
};
setPromptInput(next);
requestAnimationFrame(() => {
const textarea = promptInputRef.current;
textarea?.focus();
textarea?.setSelectionRange(nextEnd, nextEnd);
setCursorIndex(nextEnd);
});
},
[setPromptInput],
);
const handleStreamingTranscriptionEnd = useCallback(() => {
streamingTranscriptRangeRef.current = null;
}, []);
const handleStartStreamingTranscription = useCallback(() => {
const generation = transcriptionGenerationRef.current;
return startVercelStreamingTranscription({
onTranscript: (transcript) => {
if (generation === transcriptionGenerationRef.current) {
handleStreamingTranscriptionChange(transcript);
}
},
});
}, [handleStreamingTranscriptionChange]);
const handleAudioRecorded = useCallback(
async (audioBlob: Blob): Promise<string> => {
if (!transcriptionTarget) {
throw new Error(
"Configure an audio-to-text provider before using speech input",
);
}
if (audioBlob.size > MAX_RECORDED_AUDIO_BYTES) {
throw new Error("Recorded audio exceeds the 25 MiB upload limit");
}
writeDesktopDebugLog({
scope: "voice-input",
level: "debug",
message: "Webview recorded audio and is sending it to the sidecar",
timestamp: new Date().toISOString(),
metadata: {
providerId: transcriptionTarget.providerId,
modelId: transcriptionTarget.modelId,
mediaType: audioBlob.type,
audioBytes: audioBlob.size,
},
});
const audioBase64 = await blobToBase64(audioBlob);
const result = await desktopClient.invoke<{ text?: string }>(
"transcribe_audio",
{
audioBase64,
mediaType: audioBlob.type,
},
);
const text = result.text?.trim();
if (!text) {
throw new Error("The transcription provider returned no text");
}
return text;
},
[transcriptionTarget],
);
const handleSpeechInputError = useCallback((error: unknown) => {
const message =
error instanceof Error
? error.message
: "Check microphone permission and audio provider settings.";
writeDesktopDebugLog({
scope: "voice-input",
level: "error",
message: "Speech input failed in the webview",
timestamp: new Date().toISOString(),
metadata: { failure: message },
});
toast({
variant: "destructive",
title: "Speech input failed",
description: message,
});
}, []);
const effortIndex = useMemo(
() => resolveEffortIndex(thinking, reasoningEffort),
[reasoningEffort, thinking],
@@ -782,6 +1118,15 @@ function ChatInputBarImpl({
promptInputRef.current?.focus();
}}
>
{speechInputProcessing && (
<output
aria-live="polite"
className="flex shrink-0 items-center gap-1.5 self-center text-xs text-muted-foreground"
>
<Spinner className="size-3.5" />
<span className="sr-only">Transcribing voice input</span>
</output>
)}
<textarea
aria-activedescendant={
slashOpen && filteredSlashCommands.length > 0
@@ -805,6 +1150,7 @@ function ChatInputBarImpl({
variant === "welcome" && "self-start",
)}
onChange={(e) => {
if (speechInputActive) return;
setPromptInput(e.target.value);
setCursorIndex(
e.target.selectionStart ?? e.target.value.length,
@@ -904,12 +1250,15 @@ function ChatInputBarImpl({
)
}
placeholder={
variant === "welcome"
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
: isBusy
? "Agent is working... submit to queue another message"
: "Enter your question or type / for commands or @ for context"
speechInputProcessing
? "Transcribing voice input…"
: variant === "welcome"
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
: isBusy
? "Agent is working... submit to queue another message"
: "Enter your question or type / for commands or @ for context"
}
readOnly={speechInputActive}
ref={promptInputRef}
role="combobox"
rows={promptInputRows}
@@ -939,6 +1288,44 @@ function ChatInputBarImpl({
<CircleStop className="size-3" />
</button>
)}
<SpeechInput
allowUnavailableClick={!transcriptionTarget}
key={
transcriptionTarget
? `${transcriptionTarget.providerId}:${transcriptionTarget.modelId}:${transcriptionTarget.supportsStreaming ? "streaming" : "auto"}`
: "unconfigured"
}
onActiveChange={handleSpeechInputActiveChange}
onAudioRecorded={handleAudioRecorded}
onClick={(event) => {
if (!transcriptionTarget) {
event.preventDefault();
onOpenVoiceInputSettings?.();
}
}}
onError={handleSpeechInputError}
onProcessingChange={setSpeechInputProcessing}
onStartStreaming={
transcriptionTarget?.supportsStreaming
? handleStartStreamingTranscription
: undefined
}
onStreamingEnd={handleStreamingTranscriptionEnd}
onStreamingStart={handleStreamingTranscriptionStart}
onTranscriptionChange={
transcriptionTarget?.supportsStreaming
? undefined
: handleTranscriptionChange
}
recordingMode={
transcriptionTarget?.supportsStreaming ? "streaming" : "auto"
}
title={
transcriptionTarget
? `${transcriptionTarget.supportsStreaming ? "Transcribe live" : "Transcribe"} with ${transcriptionTarget.providerName} / ${transcriptionTarget.modelName}`
: "Configure voice input in Settings → Models"
}
/>
{(!isBusy || canSend) && (
<button
aria-label="Send message"
@@ -1236,15 +1623,17 @@ const ModelSelector = memo(function ModelSelector({
if (cancelled || models.length === 0) {
return;
}
const modelIds = models.map((entry) => entry.id);
const reasoningModelIds = models
.filter((entry) => entry.supportsReasoning)
.map((entry) => entry.id);
setProviderModels((current) => ({
...current,
[normalizedProvider]: models.map((entry) => entry.id),
[normalizedProvider]: modelIds,
}));
setProviderReasoningModels((current) => ({
...current,
[normalizedProvider]: models
.filter((entry) => entry.supportsReasoning)
.map((entry) => entry.id),
[normalizedProvider]: reasoningModelIds,
}));
setReasoningCapabilitySource("catalog");
setEnabledProviderIds((current) =>
@@ -3,8 +3,11 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Provider } from "@/lib/provider-schema";
import { ProviderDetailContent } from "./provider-list-view";
import type { Provider, VoiceInputSelection } from "@/lib/provider-schema";
import {
ProviderDetailContent,
ProviderListContent,
} from "./provider-list-view";
const provider: Provider = {
id: "ollama",
@@ -93,3 +96,196 @@ describe("ProviderDetailContent models", () => {
expect(onUpdateModels).toHaveBeenCalledWith(["alpha", "beta", "gamma"]);
});
});
const voiceProviders: Provider[] = [
{
id: "elevenlabs",
name: "ElevenLabs",
models: 1,
color: "#000000",
letter: "EL",
enabled: true,
modelList: [
{
id: "scribe_v2",
name: "Scribe v2",
inputModalities: ["audio"],
outputModalities: ["text"],
},
],
},
{
id: "groq",
name: "Groq",
models: 3,
color: "#000000",
letter: "GR",
enabled: true,
modelList: [
{
id: "whisper-large-v3",
name: "Whisper Large v3",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "whisper-large-v3-turbo",
name: "Whisper Large v3 Turbo",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "llama-chat",
name: "Llama Chat",
inputModalities: ["text"],
outputModalities: ["text"],
},
],
},
];
describe("ProviderListContent voice input settings", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
it("lets the user choose and clear the voice provider and model", async () => {
const onVoiceInputChange = vi.fn();
let selection: VoiceInputSelection | undefined = {
providerId: "elevenlabs",
modelId: "scribe_v2",
};
const render = async () => {
await act(async () => {
root.render(
<ProviderListContent
onAddProvider={vi.fn()}
onConfigure={vi.fn()}
onToggle={vi.fn()}
onVoiceInputChange={onVoiceInputChange}
providers={voiceProviders}
voiceInput={selection}
/>,
);
});
};
await render();
const providerSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input provider"]',
);
const modelSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input model"]',
);
expect(providerSelect?.value).toBe("elevenlabs");
expect(modelSelect?.value).toBe("scribe_v2");
await act(async () => {
if (!providerSelect) return;
providerSelect.value = "groq";
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
providerId: "groq",
modelId: "whisper-large-v3",
});
selection = {
providerId: "groq",
modelId: "whisper-large-v3",
};
await render();
const groqModelSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input model"]',
);
await act(async () => {
if (!groqModelSelect) return;
groqModelSelect.value = "whisper-large-v3-turbo";
groqModelSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
providerId: "groq",
modelId: "whisper-large-v3-turbo",
});
const groqProviderSelect = container.querySelector<HTMLSelectElement>(
'[aria-label="Voice input provider"]',
);
await act(async () => {
if (!groqProviderSelect) return;
groqProviderSelect.value = "";
groqProviderSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onVoiceInputChange).toHaveBeenLastCalledWith(undefined);
});
});
describe("ProviderDetailContent audio capabilities", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it("shows audio icons beside transcription-capable models", async () => {
const provider: Provider = {
id: "capability-provider",
name: "Capability Provider",
models: 2,
color: "#000000",
letter: "CP",
enabled: true,
modelList: [
{
id: "audio-input",
name: "Audio Input",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "chat",
name: "Chat",
inputModalities: ["text"],
outputModalities: ["text"],
},
],
};
await act(async () => {
root.render(
<ProviderDetailContent
onBack={vi.fn()}
onUpdate={vi.fn()}
provider={provider}
/>,
);
});
expect(
container.querySelectorAll(
'[role="img"][aria-label="Audio support"] .lucide-mic',
),
).toHaveLength(1);
});
});
@@ -2,6 +2,7 @@
import {
ArrowLeft,
Brain,
ChevronRight,
Copy,
ExternalLink,
@@ -11,6 +12,7 @@ import {
ImageIcon,
Link as LinkIcon,
Loader2,
Mic,
Plus,
PlusCircle,
RefreshCw,
@@ -24,12 +26,17 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { openExternalUrl } from "@/lib/desktop-client";
import {
isDedicatedTranscriptionModel,
supportsAudio,
} from "@/lib/provider-model-catalog";
import { getProviderApiKeyUrl } from "@/lib/provider-key-urls";
import type {
Provider,
ProviderConfigField,
ProviderConfigFieldPrimitive,
ProviderSettingsUpdate,
VoiceInputSelection,
} from "@/lib/provider-schema";
import { cn } from "@/lib/utils";
@@ -124,15 +131,21 @@ export function ProviderListContent({
onToggle,
onConfigure,
onAddProvider,
onVoiceInputChange,
selectedProviderId,
variant = "page",
voiceInput,
voiceInputSaving = false,
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
onVoiceInputChange: (selection: VoiceInputSelection | undefined) => void;
selectedProviderId?: string | null;
variant?: "page" | "panel";
voiceInput?: VoiceInputSelection;
voiceInputSaving?: boolean;
}) {
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
const [providerSearch, setProviderSearch] = useState("");
@@ -146,6 +159,17 @@ export function ProviderListContent({
)
: providers;
const isPanel = variant === "panel";
const voiceProviders = providers
.filter((provider) => provider.enabled)
.map((provider) => ({
provider,
models: (provider.modelList ?? []).filter(isDedicatedTranscriptionModel),
}))
.filter((entry) => entry.models.length > 0);
const selectedVoiceProvider = voiceProviders.find(
(entry) => entry.provider.id === voiceInput?.providerId,
);
const selectedVoiceModels = selectedVoiceProvider?.models ?? [];
return (
<ScrollArea className="h-full">
@@ -197,6 +221,82 @@ export function ProviderListContent({
</div>
</div>
<div
className={cn(
"mb-7 border-y py-4",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<div className="mb-3">
<h2 className="text-[17px] font-semibold text-foreground">
Voice input
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
Choose the configured audio-to-text model used by the microphone
in chat. Streaming models show text live; other models transcribe
after recording stops.
</p>
</div>
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Provider</span>
<select
aria-label="Voice input provider"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
disabled={voiceInputSaving}
onChange={(event) => {
const providerId = event.target.value;
if (!providerId) {
onVoiceInputChange(undefined);
return;
}
const entry = voiceProviders.find(
(candidate) => candidate.provider.id === providerId,
);
const modelId = entry?.models[0]?.id;
if (modelId) {
onVoiceInputChange({ providerId, modelId });
}
}}
value={selectedVoiceProvider?.provider.id ?? ""}
>
<option value="">Not configured</option>
{voiceProviders.map(({ provider }) => (
<option key={provider.id} value={provider.id}>
{provider.name}
</option>
))}
</select>
</label>
<label className="space-y-1.5 text-sm text-muted-foreground">
<span>Model</span>
<select
aria-label="Voice input model"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
disabled={!selectedVoiceProvider || voiceInputSaving}
onChange={(event) => {
if (!selectedVoiceProvider || !event.target.value) return;
onVoiceInputChange({
providerId: selectedVoiceProvider.provider.id,
modelId: event.target.value,
});
}}
value={voiceInput?.modelId ?? ""}
>
{selectedVoiceModels.length === 0 ? (
<option value="">Enable an audio provider first</option>
) : null}
{selectedVoiceModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
{model.supportsStreamingTranscription ? " (Live)" : ""}
</option>
))}
</select>
</label>
</div>
</div>
{providerSearchOpen ? (
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-2xl")}>
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
@@ -715,14 +815,52 @@ export function ProviderDetailContent({
<span className="truncate">{model.name}</span>
{/* Capability icons */}
{model.supportsAttachments && (
<div title="File Support">
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<span
aria-label="File support"
role="img"
title="File support"
>
<FileIcon
aria-hidden="true"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</span>
)}
{model.supportsVision && (
<div title="Image Support">
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<span
aria-label="Image support"
role="img"
title="Image support"
>
<ImageIcon
aria-hidden="true"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</span>
)}
{supportsAudio(model) && (
<span
aria-label="Audio support"
role="img"
title="Audio support"
>
<Mic
aria-hidden="true"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</span>
)}
{model.supportsReasoning && (
<span
aria-label="Reasoning support"
role="img"
title="Reasoning support"
>
<Brain
aria-hidden="true"
className="h-3.5 w-3.5 text-muted-foreground"
/>
</span>
)}
</div>
<button
@@ -24,6 +24,7 @@ import { desktopClient } from "@/lib/desktop-client";
import { resetOnboarding } from "@/lib/onboarding";
import {
invalidateProviderCatalogCache,
notifyVoiceInputSettingsChanged,
publishProviderModels,
} from "@/lib/provider-model-catalog";
import type {
@@ -31,6 +32,7 @@ import type {
ProviderCatalogResponse,
ProviderModelsResponse,
ProviderSettingsUpdate,
VoiceInputSelection,
} from "@/lib/provider-schema";
import {
type HubAccent,
@@ -76,6 +78,7 @@ let providerCatalogCache: {
providers: Provider[];
fetchedAt: number;
} | null = null;
let voiceInputCache: VoiceInputSelection | undefined;
// -----------------------------------------------------------
// Component
@@ -113,6 +116,10 @@ export function SettingsView({
null,
);
const [addingProvider, setAddingProvider] = useState(false);
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
() => voiceInputCache,
);
const [voiceInputSaving, setVoiceInputSaving] = useState(false);
useEffect(() => {
if (section !== "Models") {
@@ -145,6 +152,7 @@ export function SettingsView({
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
) {
setProviders(providerCatalogCache.providers);
setVoiceInput(voiceInputCache);
setProvidersLoading(false);
setProviderCatalogError(null);
return;
@@ -157,6 +165,8 @@ export function SettingsView({
"list_provider_catalog",
);
setProvidersWithCache(payload.providers);
voiceInputCache = payload.voiceInput;
setVoiceInput(payload.voiceInput);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setProviderCatalogError(message);
@@ -185,7 +195,7 @@ export function SettingsView({
baseUrl?: string;
configValues?: ProviderSettingsUpdate["configValues"];
},
) => {
): Promise<boolean> => {
try {
await desktopClient.invoke("save_provider_settings", {
provider: id,
@@ -196,9 +206,11 @@ export function SettingsView({
? toSettingsPatch(updates.configValues)
: undefined,
});
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to save provider settings for ${id}: ${message}`);
return false;
} finally {
// Keep the shared short-lived catalog cache (composer model
// selector, onboarding) in sync with the just-saved settings.
@@ -216,12 +228,45 @@ export function SettingsView({
return p;
}
const nextEnabled = !p.enabled;
void persistProviderSettings(id, { enabled: nextEnabled });
const clearsVoiceInput =
!nextEnabled && voiceInput?.providerId === id;
void persistProviderSettings(id, { enabled: nextEnabled }).then(
(saved) => {
if (saved && clearsVoiceInput) {
voiceInputCache = undefined;
setVoiceInput(undefined);
notifyVoiceInputSettingsChanged();
}
},
);
return { ...p, enabled: nextEnabled };
}),
);
},
[persistProviderSettings, setProvidersWithCache],
[persistProviderSettings, setProvidersWithCache, voiceInput],
);
const updateVoiceInput = useCallback(
async (selection: VoiceInputSelection | undefined) => {
setVoiceInputSaving(true);
try {
const result = await desktopClient.invoke<{
voiceInput?: VoiceInputSelection;
}>("save_voice_input_settings", {
provider: selection?.providerId,
model: selection?.modelId,
});
voiceInputCache = result.voiceInput;
setVoiceInput(result.voiceInput);
notifyVoiceInputSettingsChanged();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
window.alert(`Failed to save voice input settings: ${message}`);
} finally {
setVoiceInputSaving(false);
}
},
[],
);
const updateProvider = useCallback(
@@ -415,9 +460,12 @@ export function SettingsView({
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
providers={providers}
selectedProviderId={selectedProvider.id}
variant="panel"
voiceInput={voiceInput}
voiceInputSaving={voiceInputSaving}
/>
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
<ProviderDetailContent
@@ -445,7 +493,10 @@ export function SettingsView({
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
providers={providers}
voiceInput={voiceInput}
voiceInputSaving={voiceInputSaving}
/>
);
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeDesktopDebugLog } from "./desktop-client";
type SentDesktopRequest = {
id: string;
@@ -97,6 +98,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
globalThis.WebSocket = originalWebSocket;
@@ -316,3 +318,47 @@ describe("DesktopClient command deadlines", () => {
).toBe(0);
});
});
describe("writeDesktopDebugLog", () => {
it.each([
"debug",
"info",
"error",
] as const)("prints valid %s sidecar diagnostics with a static format string", (level) => {
const consoleSpy = vi.spyOn(console, level).mockImplementation(() => {});
writeDesktopDebugLog({
scope: "voice-input",
level,
message: "Starting audio transcription",
timestamp: "2026-07-28T00:00:00.000Z",
metadata: {
providerId: "vercel-ai-gateway",
modelId: "openai/whisper-1",
endpoint: "https://ai-gateway.vercel.sh/v1/ai/transcription-model",
},
});
expect(consoleSpy).toHaveBeenCalledWith(
"%s %o",
"[desktop:voice-input] Starting audio transcription",
expect.objectContaining({
providerId: "vercel-ai-gateway",
modelId: "openai/whisper-1",
endpoint: "https://ai-gateway.vercel.sh/v1/ai/transcription-model",
}),
);
});
it("ignores malformed debug events", () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
writeDesktopDebugLog({
scope: "voice-input",
level: "verbose",
message: "invalid",
});
expect(debugSpy).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
"use client";
import type {
DesktopDebugLogPayload,
DesktopTransportEvent,
DesktopTransportMessage,
DesktopTransportRequest,
@@ -134,6 +135,7 @@ function finiteReportNumber(value: unknown): number | undefined {
const REQUEST_TIMEOUT_MS = 120_000;
const RECONNECT_BASE_DELAY_MS = 400;
const RECONNECT_MAX_DELAY_MS = 4_000;
const DESKTOP_DEBUG_LOG_EVENT = "desktop_debug_log";
// Commands that should be routed to Tauri's native invoke bridge instead of
// the WebSocket transport — only applicable in the full Tauri app shell.
// In sidecar/web mode these commands are handled by the sidecar over WebSocket.
@@ -141,6 +143,67 @@ export function isTauriAvailable(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function parseDesktopDebugLogPayload(
payload: unknown,
): DesktopDebugLogPayload | null {
if (!isRecord(payload)) return null;
const { level, message, metadata, scope, timestamp } = payload;
if (
(level !== "debug" && level !== "info" && level !== "error") ||
typeof message !== "string" ||
typeof scope !== "string" ||
typeof timestamp !== "string"
) {
return null;
}
return {
level,
message,
scope,
timestamp,
metadata: isRecord(metadata) ? metadata : undefined,
};
}
function webviewDebugLoggingEnabled(): boolean {
let runtimeEnabled = false;
try {
runtimeEnabled =
typeof window !== "undefined" &&
window.localStorage.getItem("cline.debugLogs") === "1";
} catch {
// Some embedded/privacy contexts deny localStorage access.
}
return (
process.env.NODE_ENV !== "production" ||
process.env.NEXT_PUBLIC_CLINE_DEBUG_LOGS === "1" ||
runtimeEnabled
);
}
export function writeDesktopDebugLog(payload: unknown): void {
const entry = parseDesktopDebugLogPayload(payload);
if (!entry || !webviewDebugLoggingEnabled()) {
return;
}
const prefix = `[desktop:${entry.scope}] ${entry.message}`;
const details = {
timestamp: entry.timestamp,
...(entry.metadata ?? {}),
};
if (entry.level === "error") {
console.error("%s %o", prefix, details);
} else if (entry.level === "info") {
console.info("%s %o", prefix, details);
} else {
console.debug("%s %o", prefix, details);
}
}
const NATIVE_COMMANDS = new Set([
"pick_workspace_directory",
"open_mcp_settings_file",
@@ -283,6 +346,9 @@ class DesktopClient {
}
private dispatchEvent(message: DesktopTransportEvent) {
if (message.event.name === DESKTOP_DEBUG_LOG_EVENT) {
writeDesktopDebugLog(message.event.payload);
}
const handlers = this.handlers.get(message.event.name);
if (!handlers || handlers.size === 0) {
return;
@@ -21,6 +21,14 @@ export type DesktopTransportEvent = {
};
};
export type DesktopDebugLogPayload = {
scope: string;
level: "debug" | "info" | "error";
message: string;
timestamp: string;
metadata?: Record<string, unknown>;
};
export type DesktopTransportMessage =
| DesktopTransportResponse
| DesktopTransportEvent;
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from "vitest";
import {
buildProviderModelCatalog,
filterChatModels,
isDedicatedTranscriptionModel,
publishProviderModels,
selectTranscriptionModel,
subscribeToProviderModels,
supportsAudio,
} from "./provider-model-catalog";
import type { Provider } from "./provider-schema";
describe("transcription model selection", () => {
it("distinguishes speech-to-text from text-to-speech and chat audio", () => {
expect(
isDedicatedTranscriptionModel({
id: "whisper",
name: "Whisper",
inputModalities: ["audio"],
outputModalities: ["text"],
}),
).toBe(true);
expect(
isDedicatedTranscriptionModel({
id: "elevenlabs",
name: "ElevenLabs",
inputModalities: ["text"],
outputModalities: ["audio"],
}),
).toBe(false);
expect(
isDedicatedTranscriptionModel({
id: "omni",
name: "Omni",
inputModalities: ["text", "audio"],
outputModalities: ["text"],
}),
).toBe(false);
});
it("detects audio support in either modality direction", () => {
expect(
supportsAudio({
id: "transcription",
name: "Transcription",
inputModalities: ["audio"],
outputModalities: ["text"],
}),
).toBe(true);
expect(
supportsAudio({
id: "speech",
name: "Speech",
inputModalities: ["text"],
outputModalities: ["audio"],
}),
).toBe(true);
expect(
supportsAudio({
id: "text",
name: "Text",
inputModalities: ["text"],
outputModalities: ["text"],
}),
).toBe(false);
});
it("selects only the explicitly configured enabled model", () => {
const providers: Provider[] = [
{
id: "groq",
name: "Groq",
models: 1,
color: "#000000",
letter: "GR",
enabled: true,
modelList: [
{
id: "whisper-large-v3",
name: "Whisper",
inputModalities: ["audio"],
outputModalities: ["text"],
},
],
},
{
id: "nvidia",
name: "Nvidia",
models: 1,
color: "#000000",
letter: "NV",
enabled: true,
modelList: [
{
id: "whisper-large-v3",
name: "Whisper",
inputModalities: ["audio"],
outputModalities: ["text"],
},
],
},
];
expect(
selectTranscriptionModel(providers, {
providerId: "nvidia",
modelId: "whisper-large-v3",
}),
).toEqual({
providerId: "nvidia",
providerName: "Nvidia",
modelId: "whisper-large-v3",
modelName: "Whisper",
supportsStreaming: false,
});
expect(selectTranscriptionModel(providers, undefined)).toBeNull();
});
it("keeps voice selection in the provider catalog", () => {
const elevenLabs: Provider = {
id: "elevenlabs",
name: "ElevenLabs",
models: 1,
color: "#000000",
letter: "EL",
enabled: true,
modelList: [
{
id: "scribe_v2",
name: "Scribe v2",
inputModalities: ["audio"],
outputModalities: ["text"],
},
],
};
const selection = {
providerId: "elevenlabs",
modelId: "scribe_v2",
};
const catalog = buildProviderModelCatalog([elevenLabs], selection);
expect(catalog.providerModels.elevenlabs).toEqual([]);
expect(catalog.voiceInput).toMatchObject({
providerId: "elevenlabs",
modelId: "scribe_v2",
supportsStreaming: false,
});
});
it("keeps transcription-only models out of chat while retaining chat audio models", () => {
const provider: Provider = {
id: "openai",
name: "OpenAI",
models: 3,
color: "#000000",
letter: "OA",
enabled: true,
modelList: [
{
id: "gpt-4o-mini-transcribe",
name: "GPT-4o mini Transcribe",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "gpt-audio",
name: "GPT Audio",
inputModalities: ["text", "audio"],
outputModalities: ["text", "audio"],
},
{
id: "gpt-text",
name: "GPT Text",
inputModalities: ["text"],
outputModalities: ["text"],
},
],
};
const catalog = buildProviderModelCatalog([provider]);
expect(catalog.providerModels.openai).toEqual(["gpt-audio", "gpt-text"]);
expect(
filterChatModels(provider.modelList).map((model) => model.id),
).toEqual(["gpt-audio", "gpt-text"]);
const listener = vi.fn();
const unsubscribe = subscribeToProviderModels(listener);
try {
publishProviderModels("openai", provider.modelList ?? []);
expect(listener).toHaveBeenCalledWith(
"openai",
expect.arrayContaining([
expect.objectContaining({ id: "gpt-audio" }),
expect.objectContaining({ id: "gpt-text" }),
]),
);
expect(listener.mock.calls[0]?.[1]).toHaveLength(2);
} finally {
unsubscribe();
}
});
it("preserves streaming transcription capability for the composer", () => {
const provider: Provider = {
id: "vercel-ai-gateway",
name: "Vercel AI Gateway",
models: 1,
color: "#000000",
letter: "VA",
enabled: true,
modelList: [
{
id: "openai/gpt-realtime-whisper",
name: "GPT Realtime Whisper",
supportsStreamingTranscription: true,
inputModalities: ["audio"],
outputModalities: ["text"],
},
],
};
expect(
selectTranscriptionModel([provider], {
providerId: provider.id,
modelId: "openai/gpt-realtime-whisper",
}),
).toMatchObject({ supportsStreaming: true });
});
});
@@ -6,6 +6,7 @@ import type {
ProviderCatalogResponse,
ProviderModel,
ProviderModelsResponse,
VoiceInputSelection,
} from "@/lib/provider-schema";
export type ProviderModelCatalog = {
@@ -13,20 +14,78 @@ export type ProviderModelCatalog = {
enabledProviderIds: string[];
providerModels: Record<string, string[]>;
providerReasoningModels: Record<string, string[]>;
voiceInput: TranscriptionModelTarget | null;
};
export type TranscriptionModelTarget = {
providerId: string;
providerName: string;
modelId: string;
modelName: string;
supportsStreaming: boolean;
};
export function isDedicatedTranscriptionModel(model: ProviderModel): boolean {
return (
model.inputModalities?.length === 1 &&
model.inputModalities[0] === "audio" &&
model.outputModalities?.length === 1 &&
model.outputModalities[0] === "text"
);
}
export function supportsAudio(model: ProviderModel): boolean {
return (
model.inputModalities?.includes("audio") === true ||
model.outputModalities?.includes("audio") === true
);
}
export function filterChatModels(
models: ProviderModel[] | undefined,
): ProviderModel[] {
return (models ?? []).filter(
(model) => !isDedicatedTranscriptionModel(model),
);
}
export function selectTranscriptionModel(
providers: Provider[],
selection: VoiceInputSelection | undefined,
): TranscriptionModelTarget | null {
if (!selection) return null;
const provider = providers.find(
(candidate) => candidate.enabled && candidate.id === selection.providerId,
);
const model = provider?.modelList?.find(
(candidate) =>
candidate.id === selection.modelId &&
isDedicatedTranscriptionModel(candidate),
);
return provider && model
? {
providerId: provider.id,
providerName: provider.name,
modelId: model.id,
modelName: model.name,
supportsStreaming: model.supportsStreamingTranscription === true,
}
: null;
}
function toModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? []).map((model) => model.id);
return filterChatModels(models).map((model) => model.id);
}
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
return filterChatModels(models)
.filter((model) => model.supportsReasoning)
.map((model) => model.id);
}
export function buildProviderModelCatalog(
providers: Provider[],
voiceInput?: VoiceInputSelection,
): ProviderModelCatalog {
return {
providers,
@@ -45,6 +104,7 @@ export function buildProviderModelCatalog(
toReasoningModelIds(provider.modelList),
]),
),
voiceInput: selectTranscriptionModel(providers, voiceInput),
};
}
@@ -53,6 +113,8 @@ export function buildProviderModelCatalog(
// Deduplicate concurrent requests and keep the response briefly so the app
// boot issues a single round-trip instead of one per consumer.
const PROVIDER_CATALOG_CACHE_TTL_MS = 5_000;
export const VOICE_INPUT_SETTINGS_CHANGED_EVENT =
"cline:voice-input-settings-changed";
let providerCatalogCache: {
fetchedAt: number;
@@ -70,8 +132,9 @@ export function publishProviderModels(
models: ProviderModel[],
): void {
invalidateProviderCatalogCache();
const chatModels = filterChatModels(models);
for (const listener of providerModelsListeners) {
listener(providerId, models);
listener(providerId, chatModels);
}
}
@@ -153,9 +216,16 @@ export function writeProviderCatalogSnapshot(
providerCatalogSnapshot = snapshot;
}
export function notifyVoiceInputSettingsChanged(): void {
invalidateProviderCatalogCache();
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(VOICE_INPUT_SETTINGS_CHANGED_EVENT));
}
}
export async function loadProviderModelCatalog(): Promise<ProviderModelCatalog> {
const payload = await fetchProviderCatalog();
return buildProviderModelCatalog(payload.providers ?? []);
return buildProviderModelCatalog(payload.providers ?? [], payload.voiceInput);
}
export async function loadProviderModels(
@@ -167,5 +237,5 @@ export async function loadProviderModels(
provider: providerId,
},
);
return payload.models ?? [];
return filterChatModels(payload.models);
}
@@ -5,8 +5,13 @@ export interface ProviderModel {
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
supportsStreamingTranscription?: boolean;
inputModalities?: ModelModality[];
outputModalities?: ModelModality[];
}
export type ModelModality = "text" | "image" | "audio" | "video" | "pdf";
export type ProviderConfigFieldType =
| "text"
| "password"
@@ -64,6 +69,12 @@ export interface ProviderSettingsUpdate {
export interface ProviderCatalogResponse {
providers: Provider[];
settingsPath: string;
voiceInput?: VoiceInputSelection;
}
export interface VoiceInputSelection {
providerId: string;
modelId: string;
}
export interface ProviderModelsResponse {
@@ -0,0 +1,167 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { invokeMock, writeDesktopDebugLogMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
writeDesktopDebugLogMock: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
writeDesktopDebugLog: writeDesktopDebugLogMock,
}));
import { startVercelStreamingTranscription } from "./vercel-streaming-transcription";
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
binaryType = "";
onopen: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
send = vi.fn();
close = vi.fn(() => {
this.readyState = FakeWebSocket.CLOSED;
});
constructor(
readonly url: string,
readonly protocols: string[],
) {
FakeWebSocket.instances.push(this);
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
message(part: unknown) {
this.onmessage?.({ data: JSON.stringify(part) });
}
}
type FakeAudioProcess = {
inputBuffer: { getChannelData: () => Float32Array };
};
class FakeAudioContext {
static instances: FakeAudioContext[] = [];
readonly sampleRate = 48_000;
readonly destination = {};
readonly source = { connect: vi.fn(), disconnect: vi.fn() };
readonly processor = {
onaudioprocess: null as ((event: FakeAudioProcess) => void) | null,
connect: vi.fn(),
disconnect: vi.fn(),
};
readonly gain = {
gain: { value: 1 },
connect: vi.fn(),
disconnect: vi.fn(),
};
resume = vi.fn(async () => undefined);
close = vi.fn(async () => undefined);
constructor() {
FakeAudioContext.instances.push(this);
}
createMediaStreamSource() {
return this.source;
}
createScriptProcessor() {
return this.processor;
}
createGain() {
return this.gain;
}
}
describe("Vercel streaming transcription", () => {
const stopTrack = vi.fn();
beforeEach(() => {
FakeWebSocket.instances = [];
FakeAudioContext.instances = [];
invokeMock.mockReset().mockResolvedValue({
token: "vcst_short_lived",
url: "wss://ai-gateway.vercel.sh/v4/ai/transcription-model?ai-model-id=openai%2Fgpt-realtime-whisper",
});
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FakeWebSocket,
});
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: FakeAudioContext,
});
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
})),
},
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("streams PCM audio and emits cumulative transcript text", async () => {
const onTranscript = vi.fn();
const startPromise = startVercelStreamingTranscription({ onTranscript });
const socket = await vi.waitFor(() => {
expect(FakeWebSocket.instances).toHaveLength(1);
return FakeWebSocket.instances[0] as FakeWebSocket;
});
expect(socket.protocols).toEqual([
"ai-gateway-transcription.v1",
"ai-gateway-auth.vcst_short_lived",
]);
socket.open();
const session = await startPromise;
expect(JSON.parse(String(socket.send.mock.calls[0]?.[0]))).toEqual({
type: "transcription-stream.start",
inputAudioFormat: { type: "audio/pcm", rate: 24_000 },
});
const audioContext = FakeAudioContext.instances[0] as FakeAudioContext;
audioContext.processor.onaudioprocess?.({
inputBuffer: {
getChannelData: () => Float32Array.from([0, 0.25, -0.25, 0.5, -0.5, 0]),
},
});
expect(socket.send).toHaveBeenCalledWith(expect.any(Uint8Array));
socket.message({ type: "transcript-delta", delta: "hello" });
socket.message({ type: "transcript-delta", delta: " world" });
expect(onTranscript).toHaveBeenLastCalledWith("hello world");
session.stop();
expect(
socket.send.mock.calls.some(([value]) => {
if (typeof value !== "string") return false;
return (
(JSON.parse(value) as { type?: string }).type ===
"transcription-stream.audio-done"
);
}),
).toBe(true);
socket.message({ type: "finish", text: "hello world" });
await expect(session.done).resolves.toBeUndefined();
expect(stopTrack).toHaveBeenCalled();
});
});
@@ -0,0 +1,326 @@
"use client";
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
const OUTPUT_SAMPLE_RATE = 24_000;
const STREAM_FINISH_TIMEOUT_MS = 15_000;
const GATEWAY_TRANSCRIPTION_PROTOCOL = "ai-gateway-transcription.v1";
const GATEWAY_AUTH_PROTOCOL_PREFIX = "ai-gateway-auth.";
type StreamingTranscriptionCredentials = {
token: string;
url: string;
expiresAt?: number;
};
type TranscriptionStreamPart =
| { type: "stream-start" }
| { type: "transcript-delta"; delta: string }
| { type: "transcript-partial"; id?: string; text: string }
| { type: "transcript-final"; id?: string; text: string }
| { type: "finish"; text: string }
| { type: "error"; error: unknown }
| { type: string };
export type StreamingSpeechSession = {
done: Promise<void>;
stop(): void;
cancel(): void;
};
type AudioCapture = {
stop(): void;
};
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (error && typeof error === "object") {
const message = (error as { message?: unknown }).message;
if (typeof message === "string") return message;
try {
return JSON.stringify(error);
} catch {
// Fall through to the generic message.
}
}
return typeof error === "string" ? error : "Streaming transcription failed";
}
function floatsToPcm16(samples: Float32Array): Uint8Array {
const bytes = new Uint8Array(samples.length * 2);
const view = new DataView(bytes.buffer);
for (let index = 0; index < samples.length; index += 1) {
const sample = Math.max(-1, Math.min(1, samples[index] ?? 0));
view.setInt16(
index * 2,
sample < 0 ? sample * 0x8000 : sample * 0x7fff,
true,
);
}
return bytes;
}
function createResampler(inputRate: number, outputRate: number) {
const step = inputRate / outputRate;
let previousSample: number | undefined;
let nextPosition = 0;
return (input: Float32Array): Float32Array => {
if (input.length === 0) return new Float32Array();
const source =
previousSample === undefined
? input
: Float32Array.from([previousSample, ...input]);
const output: number[] = [];
while (nextPosition < source.length - 1) {
const leftIndex = Math.floor(nextPosition);
const fraction = nextPosition - leftIndex;
const left = source[leftIndex] ?? 0;
const right = source[leftIndex + 1] ?? left;
output.push(left + (right - left) * fraction);
nextPosition += step;
}
nextPosition -= source.length - 1;
previousSample = source[source.length - 1];
return Float32Array.from(output);
};
}
async function startPcmCapture(
stream: MediaStream,
onAudio: (bytes: Uint8Array) => void,
): Promise<AudioCapture> {
const context = new AudioContext();
await context.resume();
const source = context.createMediaStreamSource(stream);
const processor = context.createScriptProcessor(4096, 1, 1);
const silentOutput = context.createGain();
silentOutput.gain.value = 0;
const resample = createResampler(context.sampleRate, OUTPUT_SAMPLE_RATE);
processor.onaudioprocess = (event) => {
const samples = resample(event.inputBuffer.getChannelData(0));
if (samples.length > 0) {
onAudio(floatsToPcm16(samples));
}
};
source.connect(processor);
processor.connect(silentOutput);
silentOutput.connect(context.destination);
let stopped = false;
return {
stop() {
if (stopped) return;
stopped = true;
processor.onaudioprocess = null;
source.disconnect();
processor.disconnect();
silentOutput.disconnect();
for (const track of stream.getTracks()) track.stop();
void context.close();
},
};
}
function parseStreamPart(value: unknown): TranscriptionStreamPart | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const type = (value as { type?: unknown }).type;
return typeof type === "string" ? (value as TranscriptionStreamPart) : null;
}
export async function startVercelStreamingTranscription(options: {
onTranscript: (text: string) => void;
}): Promise<StreamingSpeechSession> {
writeDesktopDebugLog({
scope: "voice-input",
level: "debug",
message: "Requesting a streaming transcription session",
timestamp: new Date().toISOString(),
});
const credentials =
await desktopClient.invoke<StreamingTranscriptionCredentials>(
"create_streaming_transcription_session",
);
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
let capture: AudioCapture | null = null;
let socket: WebSocket | null = null;
let stopped = false;
let finished = false;
let receivedDeltas = false;
let deltaTranscript = "";
const finalSegments: string[] = [];
const partialSegments = new Map<string, string>();
let finishTimeout: ReturnType<typeof setTimeout> | null = null;
let resolveDone: () => void = () => {};
let rejectDone: (error: Error) => void = () => {};
const done = new Promise<void>((resolve, reject) => {
resolveDone = resolve;
rejectDone = reject;
});
const cleanup = () => {
if (finishTimeout) {
clearTimeout(finishTimeout);
finishTimeout = null;
}
capture?.stop();
capture = null;
for (const track of mediaStream.getTracks()) track.stop();
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close(1000);
}
socket = null;
};
const fail = (error: unknown) => {
if (finished) return;
finished = true;
cleanup();
rejectDone(new Error(errorMessage(error)));
};
const complete = (text: string) => {
if (finished) return;
finished = true;
const transcript = text.trim();
if (transcript) options.onTranscript(transcript);
cleanup();
resolveDone();
};
const emitSegmentTranscript = () => {
const text = [...finalSegments, ...partialSegments.values()]
.join(" ")
.trim();
if (text) options.onTranscript(text);
};
try {
socket = new WebSocket(credentials.url, [
GATEWAY_TRANSCRIPTION_PROTOCOL,
`${GATEWAY_AUTH_PROTOCOL_PREFIX}${credentials.token}`,
]);
socket.binaryType = "arraybuffer";
await new Promise<void>((resolve, reject) => {
if (!socket) {
reject(new Error("Streaming transcription socket was not created"));
return;
}
const connectionTimeout = window.setTimeout(
() => reject(new Error("Streaming transcription connection timed out")),
15_000,
);
socket.onopen = () => {
window.clearTimeout(connectionTimeout);
resolve();
};
socket.onerror = () => {
window.clearTimeout(connectionTimeout);
reject(new Error("Unable to connect to streaming transcription"));
};
});
socket.send(
JSON.stringify({
type: "transcription-stream.start",
inputAudioFormat: { type: "audio/pcm", rate: OUTPUT_SAMPLE_RATE },
}),
);
capture = await startPcmCapture(mediaStream, (bytes) => {
if (socket?.readyState === WebSocket.OPEN && !stopped && !finished) {
socket.send(bytes);
}
});
socket.onmessage = (event) => {
if (typeof event.data !== "string") return;
let parsed: unknown;
try {
parsed = JSON.parse(event.data) as unknown;
} catch {
return;
}
const part = parseStreamPart(parsed);
if (!part) return;
switch (part.type) {
case "transcript-delta":
if (typeof part.delta !== "string") return;
receivedDeltas = true;
deltaTranscript += part.delta;
if (deltaTranscript.trim()) {
options.onTranscript(deltaTranscript.trim());
}
break;
case "transcript-partial":
if (receivedDeltas || typeof part.text !== "string") return;
partialSegments.set(part.id ?? "active", part.text);
emitSegmentTranscript();
break;
case "transcript-final":
if (receivedDeltas || typeof part.text !== "string") return;
partialSegments.delete(part.id ?? "active");
if (part.text.trim()) finalSegments.push(part.text.trim());
emitSegmentTranscript();
break;
case "finish":
complete(typeof part.text === "string" ? part.text : "");
break;
case "error":
fail(part.error);
break;
}
};
socket.onerror = () => {
fail(new Error("Streaming transcription connection failed"));
};
socket.onclose = () => {
if (!finished) {
fail(
new Error(
"Streaming transcription ended before a final transcript was received",
),
);
}
};
writeDesktopDebugLog({
scope: "voice-input",
level: "debug",
message: "Streaming transcription is connected",
timestamp: new Date().toISOString(),
metadata: { expiresAt: credentials.expiresAt },
});
} catch (error) {
fail(error);
await done;
}
return {
done,
stop() {
if (stopped || finished) return;
stopped = true;
capture?.stop();
capture = null;
if (socket?.readyState === WebSocket.OPEN) {
socket.send(
JSON.stringify({ type: "transcription-stream.audio-done" }),
);
finishTimeout = setTimeout(() => {
fail(new Error("Streaming transcription timed out while finalizing"));
}, STREAM_FINISH_TIMEOUT_MS);
} else {
fail(new Error("Streaming transcription connection is not open"));
}
},
cancel() {
if (finished) return;
finished = true;
cleanup();
resolveDone();
},
};
}
@@ -0,0 +1,11 @@
export const MAX_RECORDED_AUDIO_BYTES = 25 * 1024 * 1024;
// A binary recording expands to four base64 characters for every three bytes.
export const MAX_RECORDED_AUDIO_BASE64_BYTES =
4 * Math.ceil(MAX_RECORDED_AUDIO_BYTES / 3);
// Bun enforces maxPayloadLength before the sidecar can validate a command.
// Leave room for the JSON transport envelope and media-type metadata so every
// recording accepted by the composer can reach the transcribe_audio handler.
export const MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES =
MAX_RECORDED_AUDIO_BASE64_BYTES + 1024 * 1024;
+10
View File
@@ -655,10 +655,13 @@ export {
} from "./services/providers/local-provider-registry";
export {
addLocalProvider,
type CreateConfiguredStreamingTranscriptionSessionRequest,
createConfiguredStreamingTranscriptionSession,
type DeleteLocalProviderRequest,
deleteLocalProvider,
ensureCustomProvidersLoaded,
getLocalProviderModels,
isDedicatedTranscriptionModel,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
loginLocalProvider,
@@ -668,6 +671,11 @@ export {
resolveLocalClineAuthToken,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
saveVoiceInputSettings,
type TranscribeConfiguredVoiceInputRequest,
type TranscribeLocalAudioRequest,
transcribeConfiguredVoiceInput,
transcribeLocalAudio,
type UpdateLocalProviderRequest,
updateLocalProvider,
} from "./services/providers/local-provider-service";
@@ -1049,11 +1057,13 @@ export type {
} from "./types/events";
export type {
ProviderTokenSource,
StoredProviderModes,
StoredProviderSettings,
StoredProviderSettingsEntry,
} from "./types/provider-settings";
export {
emptyStoredProviderSettings,
StoredProviderModesSchema,
StoredProviderSettingsEntrySchema,
StoredProviderSettingsSchema,
} from "./types/provider-settings";
@@ -14,6 +14,7 @@ import {
type ModelCapability,
ModelCapabilitySchema,
type ModelInfo,
ModelModalitiesSchema,
type ProviderCapability,
ProviderCapabilitySchema,
type ProviderClient,
@@ -54,6 +55,7 @@ export const StoredModelEntrySchema = z
supportsVision: z.boolean().optional(),
supportsAttachments: z.boolean().optional(),
supportsReasoning: z.boolean().optional(),
modalities: ModelModalitiesSchema.optional(),
inputPrice: OptionalNonNegativeFiniteNumberSchema,
outputPrice: OptionalNonNegativeFiniteNumberSchema,
cacheReadsPrice: OptionalNonNegativeFiniteNumberSchema,
@@ -217,7 +219,7 @@ export function toProviderModel(
modelId: string,
info: Pick<
ModelInfo,
"name" | "contextWindow" | "capabilities" | "thinkingConfig"
"name" | "contextWindow" | "capabilities" | "thinkingConfig" | "modalities"
>,
): ProviderModel {
return {
@@ -230,6 +232,11 @@ export function toProviderModel(
supportsVision: info.capabilities?.includes("images"),
supportsReasoning:
info.capabilities?.includes("reasoning") || info.thinkingConfig != null,
...(info.capabilities?.includes("transcription-streaming")
? { supportsStreamingTranscription: true }
: {}),
inputModalities: info.modalities?.input,
outputModalities: info.modalities?.output,
};
}
@@ -345,6 +352,9 @@ function toStoredModelInfo(
? { temperature: model.temperature }
: {}),
...(apiFormat !== undefined ? { apiFormat } : {}),
...(model?.modalities !== undefined
? { modalities: model.modalities }
: {}),
...(hasPricing
? {
pricing: {
@@ -15,14 +15,19 @@ import {
} from "./local-provider-registry";
import {
addLocalProvider,
createConfiguredStreamingTranscriptionSession,
deleteLocalProvider,
getLocalProviderModels,
isDedicatedTranscriptionModel,
listLocalProviders,
markLocalProviderEnabled,
normalizeOAuthProvider,
refreshProviderModelsFromSource,
resolveLocalClineAuthToken,
saveLocalProviderSettings,
saveVoiceInputSettings,
transcribeConfiguredVoiceInput,
transcribeLocalAudio,
updateLocalProvider,
} from "./local-provider-service";
@@ -872,6 +877,184 @@ describe("addLocalProvider capabilities", () => {
});
});
describe("audio transcription", () => {
let manager: ProviderSettingsManager;
let cleanup: () => void;
beforeEach(async () => {
({ manager, cleanup } = makeTempManager());
await addLocalProvider(manager, {
providerId: "audio-provider",
name: "Audio Provider",
baseUrl: "https://audio.example.invalid/v1",
apiKey: "audio-key",
models: ["whisper-large-v3"],
});
LlmsModels.registerModel("audio-provider", "whisper-large-v3", {
id: "whisper-large-v3",
name: "Whisper Large v3",
modalities: { input: ["audio"], output: ["text"] },
});
});
afterEach(() => cleanup());
it("recognizes only dedicated audio-to-text models", () => {
expect(
isDedicatedTranscriptionModel({
inputModalities: ["audio"],
outputModalities: ["text"],
}),
).toBe(true);
expect(
isDedicatedTranscriptionModel({
inputModalities: ["text"],
outputModalities: ["audio"],
}),
).toBe(false);
expect(
isDedicatedTranscriptionModel({
inputModalities: ["text", "audio"],
outputModalities: ["text"],
}),
).toBe(false);
});
it("transcribes with the configured provider and requested model", async () => {
const transcribeSpy = vi
.spyOn(LlmsModels, "transcribeAudio")
.mockResolvedValue({ text: "transcribed text" });
await expect(
transcribeLocalAudio(manager, {
providerId: "audio-provider",
modelId: "whisper-large-v3",
audio: new Uint8Array([1, 2, 3]),
mediaType: "audio/webm",
}),
).resolves.toEqual({ text: "transcribed text" });
expect(transcribeSpy).toHaveBeenCalledWith(
expect.objectContaining({
modelId: "whisper-large-v3",
audio: new Uint8Array([1, 2, 3]),
mediaType: "audio/webm",
providerConfig: expect.objectContaining({
providerId: "audio-provider",
apiKey: "audio-key",
}),
}),
);
});
it("persists and uses the configured voice input model", async () => {
await expect(
saveVoiceInputSettings(manager, {
providerId: "audio-provider",
modelId: "whisper-large-v3",
}),
).resolves.toMatchObject({
voiceInput: {
providerId: "audio-provider",
modelId: "whisper-large-v3",
},
});
const transcribeSpy = vi
.spyOn(LlmsModels, "transcribeAudio")
.mockResolvedValue({ text: "configured transcript" });
await expect(
transcribeConfiguredVoiceInput(manager, {
audio: new Uint8Array([4, 5, 6]),
mediaType: "audio/webm",
}),
).resolves.toEqual({ text: "configured transcript" });
expect(transcribeSpy).toHaveBeenCalledWith(
expect.objectContaining({
modelId: "whisper-large-v3",
providerConfig: expect.objectContaining({
providerId: "audio-provider",
}),
}),
);
});
it("creates a streaming session only for a streaming transcription model", async () => {
LlmsModels.registerModel("audio-provider", "realtime-whisper", {
id: "realtime-whisper",
name: "Realtime Whisper",
capabilities: ["transcription-streaming"],
modalities: { input: ["audio"], output: ["text"] },
});
await saveVoiceInputSettings(manager, {
providerId: "audio-provider",
modelId: "realtime-whisper",
});
const createSessionSpy = vi
.spyOn(LlmsModels, "createStreamingAudioTranscriptionSession")
.mockResolvedValue({
token: "short-lived-token",
url: "wss://audio.example.invalid/transcription",
});
await expect(
createConfiguredStreamingTranscriptionSession(manager),
).resolves.toMatchObject({ token: "short-lived-token" });
expect(createSessionSpy).toHaveBeenCalledWith(
expect.objectContaining({
modelId: "realtime-whisper",
providerConfig: expect.objectContaining({
providerId: "audio-provider",
}),
}),
);
});
it("rejects a voice input selection that is not an audio-to-text model", async () => {
await expect(
saveVoiceInputSettings(manager, {
providerId: "audio-provider",
modelId: "missing-model",
}),
).rejects.toThrow(
'Model "missing-model" is not a dedicated audio-to-text transcription model',
);
expect(manager.getVoiceInputSettings()).toBeUndefined();
});
it("resolves the built-in ElevenLabs endpoint from providers.json", async () => {
manager.saveProviderSettings(
{
provider: "elevenlabs",
model: "scribe_v2",
apiKey: "eleven-key",
},
{ setLastUsed: false },
);
const transcribeSpy = vi
.spyOn(LlmsModels, "transcribeAudio")
.mockResolvedValue({ text: "ElevenLabs transcript" });
await expect(
transcribeLocalAudio(manager, {
providerId: "elevenlabs",
modelId: "scribe_v2",
audio: new Uint8Array([1, 2, 3]),
mediaType: "audio/webm",
}),
).resolves.toEqual({ text: "ElevenLabs transcript" });
expect(transcribeSpy).toHaveBeenCalledWith(
expect.objectContaining({
modelId: "scribe_v2",
providerConfig: expect.objectContaining({
providerId: "elevenlabs",
apiKey: "eleven-key",
baseUrl: "https://api.elevenlabs.io/v1",
}),
}),
);
});
});
// ===========================================================================
// models.json built-in provider model overlays
// ===========================================================================
@@ -954,6 +1137,10 @@ describe("saveLocalProviderSettings", () => {
afterEach(() => cleanup());
it("disabling a provider removes it from settings", () => {
manager.setVoiceInputSettings({
providerId: "test-provider",
modelId: "m1",
});
const result = saveLocalProviderSettings(manager, {
providerId: "test-provider",
enabled: false,
@@ -961,6 +1148,7 @@ describe("saveLocalProviderSettings", () => {
expect(result.enabled).toBe(false);
expect(manager.getProviderSettings("test-provider")).toBeUndefined();
expect(manager.getVoiceInputSettings()).toBeUndefined();
});
it("updates apiKey", () => {
@@ -1348,6 +1536,30 @@ describe("listLocalProviders", () => {
expect(p?.enabled).toBe(true);
});
it("returns the configured voice input selection", async () => {
await addLocalProvider(manager, {
providerId: "voice-list-provider",
name: "Voice List Provider",
baseUrl: "https://example.invalid/v1",
models: ["whisper"],
});
LlmsModels.registerModel("voice-list-provider", "whisper", {
id: "whisper",
name: "Whisper",
modalities: { input: ["audio"], output: ["text"] },
});
await saveVoiceInputSettings(manager, {
providerId: "voice-list-provider",
modelId: "whisper",
});
const catalog = await listLocalProviders(manager);
expect(catalog.voiceInput).toEqual({
providerId: "voice-list-provider",
modelId: "whisper",
});
});
it("marks alias providers enabled without copying shared OAuth credentials", async () => {
manager.saveProviderSettings(
{
@@ -8,6 +8,7 @@ import type {
ProviderListItem,
ProviderModel,
SaveProviderSettingsActionRequest,
VoiceInputSelection,
} from "@cline/shared";
import { createOAuthClientCallbacks } from "../../auth/client";
import {
@@ -65,6 +66,25 @@ export interface DeleteLocalProviderRequest {
providerId: string;
}
export interface TranscribeLocalAudioRequest {
providerId: string;
modelId: string;
audio: Uint8Array;
mediaType?: string;
abortSignal?: AbortSignal;
}
export interface TranscribeConfiguredVoiceInputRequest {
audio: Uint8Array;
mediaType?: string;
abortSignal?: AbortSignal;
}
export interface CreateConfiguredStreamingTranscriptionSessionRequest {
expiresAfterSeconds?: number;
abortSignal?: AbortSignal;
}
// --- Small pure helpers ---
function resolveVisibleApiKey(settings: {
@@ -111,6 +131,17 @@ function stableColor(id: string): string {
return palette[hash % palette.length];
}
export function isDedicatedTranscriptionModel(
model: Pick<ProviderModel, "inputModalities" | "outputModalities">,
): boolean {
return (
model.inputModalities?.length === 1 &&
model.inputModalities[0] === "audio" &&
model.outputModalities?.length === 1 &&
model.outputModalities[0] === "text"
);
}
function toSortedProviderModels(
modelMap: Record<string, ModelInfo>,
): ProviderModel[] {
@@ -346,6 +377,10 @@ function removeProviderFromSettingsState(
delete state.lastUsedProvider;
mutated = true;
}
if (state.modes.voiceInput?.providerId === providerId) {
delete state.modes.voiceInput;
mutated = true;
}
if (mutated) manager.write(state);
LlmsModels.unregisterProvider(providerId);
}
@@ -688,7 +723,11 @@ export function markLocalProviderEnabled(
export async function listLocalProviders(
manager: ProviderSettingsManager,
options: ListLocalProvidersOptions = {},
): Promise<{ providers: ProviderListItem[]; settingsPath: string }> {
): Promise<{
providers: ProviderListItem[];
settingsPath: string;
voiceInput?: VoiceInputSelection;
}> {
const state = manager.read();
const ids = LlmsModels.getProviderIds();
@@ -759,7 +798,22 @@ export async function listLocalProviders(
);
}
return { providers, settingsPath: manager.getFilePath() };
const configuredVoiceInput = manager.getVoiceInputSettings();
const voiceProvider = configuredVoiceInput
? providers.find(
(provider) =>
provider.id === configuredVoiceInput.providerId && provider.enabled,
)
: undefined;
const voiceModel = voiceProvider?.modelList?.find(
(model) =>
model.id === configuredVoiceInput?.modelId &&
isDedicatedTranscriptionModel(model),
);
const voiceInput =
configuredVoiceInput && voiceModel ? configuredVoiceInput : undefined;
return { providers, settingsPath: manager.getFilePath(), voiceInput };
}
export async function getLocalProviderModels(
@@ -772,6 +826,130 @@ export async function getLocalProviderModels(
return { providerId: id, models };
}
export async function transcribeLocalAudio(
manager: ProviderSettingsManager,
request: TranscribeLocalAudioRequest,
): Promise<LlmsModels.AudioTranscriptionResult> {
const providerId = request.providerId.trim();
const modelId = request.modelId.trim();
const config = manager.getProviderConfig(providerId, {
includeKnownModels: false,
});
if (!config) {
throw new Error(
`Transcription provider "${providerId}" is not configured in providers.json`,
);
}
const { models } = await getLocalProviderModels(providerId, config);
const model = models.find((candidate) => candidate.id === modelId);
if (!model || !isDedicatedTranscriptionModel(model)) {
throw new Error(
`Model "${modelId}" is not a dedicated audio-to-text transcription model`,
);
}
if (model.supportsStreamingTranscription) {
throw new Error(
`Model "${modelId}" requires streaming transcription and cannot transcribe a completed recording`,
);
}
return LlmsModels.transcribeAudio({
providerConfig: config,
modelId,
audio: request.audio,
mediaType: request.mediaType,
abortSignal: request.abortSignal,
});
}
export async function saveVoiceInputSettings(
manager: ProviderSettingsManager,
selection: VoiceInputSelection | undefined,
): Promise<{ settingsPath: string; voiceInput?: VoiceInputSelection }> {
if (!selection) {
manager.setVoiceInputSettings(undefined);
return { settingsPath: manager.getFilePath() };
}
const providerId = selection.providerId.trim();
const modelId = selection.modelId.trim();
if (!providerId || !modelId) {
throw new Error("Voice input provider and model are required");
}
const state = manager.read();
if (!state.providers[providerId]) {
throw new Error(
`Voice input provider "${providerId}" must be enabled and configured`,
);
}
const config = manager.getProviderConfig(providerId, {
includeKnownModels: false,
});
const { models } = await getLocalProviderModels(providerId, config);
const model = models.find((candidate) => candidate.id === modelId);
if (!model || !isDedicatedTranscriptionModel(model)) {
throw new Error(
`Model "${modelId}" is not a dedicated audio-to-text transcription model`,
);
}
const voiceInput = { providerId, modelId };
manager.setVoiceInputSettings(voiceInput);
return { settingsPath: manager.getFilePath(), voiceInput };
}
export async function transcribeConfiguredVoiceInput(
manager: ProviderSettingsManager,
request: TranscribeConfiguredVoiceInputRequest,
): Promise<LlmsModels.AudioTranscriptionResult> {
const selection = manager.getVoiceInputSettings();
if (!selection) {
throw new Error("Configure a voice input provider and model in Settings");
}
return transcribeLocalAudio(manager, {
...selection,
audio: request.audio,
mediaType: request.mediaType,
abortSignal: request.abortSignal,
});
}
export async function createConfiguredStreamingTranscriptionSession(
manager: ProviderSettingsManager,
request: CreateConfiguredStreamingTranscriptionSessionRequest = {},
): Promise<LlmsModels.StreamingAudioTranscriptionSession> {
const selection = manager.getVoiceInputSettings();
if (!selection) {
throw new Error("Configure a voice input provider and model in Settings");
}
const config = manager.getProviderConfig(selection.providerId, {
includeKnownModels: false,
});
if (!config) {
throw new Error(
`Transcription provider "${selection.providerId}" is not configured in providers.json`,
);
}
const { models } = await getLocalProviderModels(selection.providerId, config);
const model = models.find((candidate) => candidate.id === selection.modelId);
if (
!model ||
!isDedicatedTranscriptionModel(model) ||
!model.supportsStreamingTranscription
) {
throw new Error(
`Model "${selection.modelId}" does not support streaming transcription`,
);
}
return LlmsModels.createStreamingAudioTranscriptionSession({
providerConfig: config,
modelId: selection.modelId,
expiresAfterSeconds: request.expiresAfterSeconds,
abortSignal: request.abortSignal,
});
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -815,6 +993,9 @@ export function saveLocalProviderSettings(
const state = manager.read();
delete state.providers[providerId];
if (state.lastUsedProvider === providerId) delete state.lastUsedProvider;
if (state.modes.voiceInput?.providerId === providerId) {
delete state.modes.voiceInput;
}
manager.write(state);
return { providerId, enabled: false, settingsPath: manager.getFilePath() };
}
@@ -58,6 +58,56 @@ describe("ProviderSettingsManager", () => {
expect(reloaded.read().providers.anthropic?.tokenSource).toBe("manual");
});
it("persists voice input selection independently of the chat provider", () => {
const tempDir = mkdtempSync(
path.join(os.tmpdir(), "core-provider-settings-"),
);
tempDirs.push(tempDir);
const filePath = path.join(tempDir, "provider-settings.json");
const manager = new ProviderSettingsManager({ filePath });
manager.saveProviderSettings(
{
provider: "anthropic",
model: "claude-sonnet-4-6",
apiKey: "chat-key",
},
{ setLastUsed: true },
);
manager.setVoiceInputSettings({
providerId: "elevenlabs",
modelId: "scribe_v2",
});
const reloaded = new ProviderSettingsManager({ filePath });
expect(reloaded.getVoiceInputSettings()).toEqual({
providerId: "elevenlabs",
modelId: "scribe_v2",
});
const persisted = JSON.parse(readFileSync(filePath, "utf8")) as Record<
string,
unknown
>;
expect(persisted).toMatchObject({
modes: {
voiceInput: {
providerId: "elevenlabs",
modelId: "scribe_v2",
},
},
});
expect(persisted).not.toHaveProperty("voiceInput");
expect(reloaded.getLastUsedProviderSettings()?.provider).toBe("anthropic");
reloaded.setVoiceInputSettings(undefined);
expect(
new ProviderSettingsManager({ filePath }).getVoiceInputSettings(),
).toBe(undefined);
expect(JSON.parse(readFileSync(filePath, "utf8"))).toMatchObject({
modes: {},
});
});
it("writes atomically, leaving no temp file behind", () => {
const tempDir = mkdtempSync(
path.join(os.tmpdir(), "core-provider-settings-"),
@@ -537,6 +587,7 @@ describe("ProviderSettingsManager", () => {
const manager = new ProviderSettingsManager({ filePath });
expect(manager.read()).toEqual({
version: 1,
modes: {},
providers: {},
});
});
@@ -22,6 +22,8 @@ import {
StoredProviderSettingsSchema,
type ToProviderConfigOptions,
toProviderConfig,
type VoiceInputSettings,
VoiceInputSettingsSchema,
} from "../../types/provider-settings";
import {
ensureCustomProvidersLoadedSync,
@@ -214,6 +216,23 @@ export class ProviderSettingsManager {
return this.resolveProviderSettings(state, providerId);
}
getVoiceInputSettings(): VoiceInputSettings | undefined {
return this.read().modes.voiceInput;
}
setVoiceInputSettings(
settings: VoiceInputSettings | undefined,
): StoredProviderSettings {
const state = this.read();
if (settings) {
state.modes.voiceInput = VoiceInputSettingsSchema.parse(settings);
} else {
delete state.modes.voiceInput;
}
this.write(state);
return state;
}
private resolveLastUsedProviderId(
state: StoredProviderSettings,
options: ResolveLastUsedProviderSettingsOptions,
+1
View File
@@ -19,6 +19,7 @@ export type {
ProviderConfig,
ProviderSettings,
ProviderTokenSource,
StoredProviderModes,
StoredProviderSettings,
StoredProviderSettingsEntry,
} from "./provider-settings";
@@ -22,6 +22,17 @@ export { toProviderConfig };
export type ProviderTokenSource = "manual" | "oauth" | "migration";
export const VoiceInputSettingsSchema = z.object({
providerId: z.string().min(1),
modelId: z.string().min(1),
});
export type VoiceInputSettings = z.infer<typeof VoiceInputSettingsSchema>;
export interface StoredProviderModes {
voiceInput?: VoiceInputSettings;
}
export interface StoredProviderSettingsEntry {
settings: ProviderSettings;
updatedAt: string;
@@ -31,9 +42,15 @@ export interface StoredProviderSettingsEntry {
export interface StoredProviderSettings {
version: 1;
lastUsedProvider?: string;
modes: StoredProviderModes;
providers: Record<string, StoredProviderSettingsEntry>;
}
export const StoredProviderModesSchema: z.ZodType<StoredProviderModes> =
z.object({
voiceInput: VoiceInputSettingsSchema.optional(),
});
export const StoredProviderSettingsEntrySchema: z.ZodType<StoredProviderSettingsEntry> =
z.object({
settings: ProviderSettingsSchema,
@@ -45,12 +62,14 @@ export const StoredProviderSettingsSchema: z.ZodType<StoredProviderSettings> =
z.object({
version: z.literal(1),
lastUsedProvider: z.string().min(1).optional(),
modes: StoredProviderModesSchema.default({}),
providers: z.record(z.string(), StoredProviderSettingsEntrySchema),
});
export function emptyStoredProviderSettings(): StoredProviderSettings {
return {
version: 1,
modes: {},
providers: {},
};
}
+34
View File
@@ -73,6 +73,40 @@ selection UIs, defaults, or validation.
For generated catalog field semantics and token-limit behavior, see
[`src/catalog/README.md`](./src/catalog/README.md).
Audio-capable catalog entries preserve their models.dev `modalities.input` and
`modalities.output` values. Node clients can transcribe recorded audio with the
same provider configuration used by the gateway. OpenAI-compatible providers
use `/audio/transcriptions`; the built-in ElevenLabs provider uses its native
`/speech-to-text` endpoint. Vercel AI Gateway uses its AI SDK-native
`/v4/ai/transcription-model` transport rather than its OpenAI-compatible
surface:
```ts
import { transcribeAudio } from "@cline/llms";
const result = await transcribeAudio({
providerConfig,
modelId: "whisper-large-v3",
audio: recordedBytes,
});
```
Models marked with the `transcription-streaming` capability use a live
WebSocket instead of the recorded-audio call. The SDK can mint a short-lived,
transcription-bound browser credential without exposing the provider API key:
```ts
import { createStreamingAudioTranscriptionSession } from "@cline/llms";
const session = await createStreamingAudioTranscriptionSession({
providerConfig,
modelId: "openai/gpt-realtime-whisper",
});
```
Vercel AI Gateway is the first built-in streaming transcription transport.
Batch models continue to use `transcribeAudio`.
## Entry Points
- `@cline/llms`: runtime-focused convenience entrypoint
+15
View File
@@ -8,6 +8,21 @@ generation scripts.
This file documents the intended meaning of the token-limit fields and the
boundary between catalog metadata and runtime request policy.
## Audio Modalities
Audio-capable entries retain models.dev's directional modality metadata:
```text
modalities.input content accepted by the model
modalities.output content produced by the model
```
These directions are not interchangeable. A microphone transcription model
accepts `audio` and produces `text`; a text-to-speech model accepts `text` and
produces `audio`. Audio models are retained even when they do not support tool
calling so non-chat surfaces can discover them. Chat model pickers must exclude
utility models that do not accept and produce text.
## Source Fields
`models.dev` exposes model limits under `limit`:
@@ -28,6 +28,10 @@ describe("models-dev-catalog", () => {
reasoning: true,
reasoning_options: [{ type: "effort", values: ["medium", "high"] }],
cost: { cache_read: 1 },
modalities: {
input: ["text", "audio"],
output: ["text"],
},
},
},
},
@@ -97,6 +101,10 @@ describe("models-dev-catalog", () => {
docsUrl: "https://platform.openai.com/docs/models",
capabilities: ["tools", "reasoning", "prompt-cache"],
});
expect(providerModels["openai-native"]["gpt-test"].modalities).toEqual({
input: ["text", "audio"],
output: ["text"],
});
expect(providerSpecs.poolside).toMatchObject({
id: "poolside",
family: "openai-compatible",
@@ -123,6 +131,124 @@ describe("models-dev-catalog", () => {
).toEqual([{ type: "effort", values: ["medium", "high"] }]);
});
it("keeps non-tool transcription models without admitting other specialized audio models", () => {
const providerModels = normalizeModelsDevProviderModels({
groq: {
id: "groq",
name: "Groq",
models: {
"chat-model": {
tool_call: true,
modalities: { input: ["text"], output: ["text"] },
},
"whisper-large-v3": {
tool_call: false,
modalities: { input: ["audio"], output: ["text"] },
},
"gpt-realtime-whisper": {
name: "GPT Realtime Whisper",
tool_call: false,
modalities: { input: ["audio"], output: ["text"] },
},
"speech-model": {
tool_call: false,
modalities: { input: ["text"], output: ["audio"] },
},
"multimodal-audio-model": {
tool_call: false,
modalities: { input: ["text", "audio"], output: ["text"] },
},
"tool-capable-audio-chat": {
tool_call: true,
modalities: {
input: ["text", "audio"],
output: ["text", "audio"],
},
},
"embedding-model": {
tool_call: false,
modalities: { input: ["text"], output: ["text"] },
},
},
},
});
expect(providerModels.groq).toMatchObject({
"whisper-large-v3": {
modalities: { input: ["audio"], output: ["text"] },
},
"gpt-realtime-whisper": {
capabilities: ["transcription-streaming"],
modalities: { input: ["audio"], output: ["text"] },
},
"tool-capable-audio-chat": {
capabilities: ["tools"],
modalities: {
input: ["text", "audio"],
output: ["text", "audio"],
},
},
});
expect(providerModels.groq).not.toHaveProperty("embedding-model");
expect(providerModels.groq).not.toHaveProperty("speech-model");
expect(providerModels.groq).not.toHaveProperty("multimodal-audio-model");
expect(
normalizeModelsDevProviderSpecs(
{
groq: {
id: "groq",
name: "Groq",
models: {
"speech-model": {
tool_call: false,
modalities: { input: ["text"], output: ["audio"] },
},
"chat-model": {
tool_call: true,
modalities: { input: ["text"], output: ["text"] },
},
},
},
},
providerModels,
).groq.defaultModelId,
).toBe("chat-model");
});
it("keeps non-tool transcription only for providers supported by the runtime transport", () => {
const providerModels = normalizeModelsDevProviderModels({
groq: {
id: "groq",
name: "Groq",
models: {
"whisper-large-v3": {
tool_call: false,
modalities: { input: ["audio"], output: ["text"] },
},
},
},
greenpt: {
id: "greenpt",
name: "GreenPT",
npm: "@ai-sdk/openai-compatible",
models: {
"chat-model": {
tool_call: true,
modalities: { input: ["text"], output: ["text"] },
},
"green-s": {
tool_call: false,
modalities: { input: ["audio"], output: ["text"] },
},
},
},
});
expect(providerModels.groq).toHaveProperty("whisper-large-v3");
expect(providerModels.greenpt).toHaveProperty("chat-model");
expect(providerModels.greenpt).not.toHaveProperty("green-s");
});
it("normalizes Cline recommended clinePass models as a generated provider source", () => {
const result = normalizeClineRecommendedProviderModels(
{
@@ -652,6 +778,34 @@ describe("models-dev-catalog", () => {
}
});
it("ships transcription models, excludes non-tool speech, and preserves tool-capable audio models", () => {
expect(
getGeneratedModelsForProvider("groq")["whisper-large-v3"]?.modalities,
).toEqual({
input: ["audio"],
output: ["text"],
});
expect(
getGeneratedModelsForProvider("poe")["elevenlabs/elevenlabs-v2.5-turbo"]
?.capabilities,
).toContain("tools");
expect(
getGeneratedModelsForProvider("groq")["canopylabs/orpheus-v1-english"],
).toBeUndefined();
expect(getGeneratedModelsForProvider("greenpt")["green-s"]).toBeUndefined();
expect(
getGeneratedModelsForProvider("alibaba")["qwen3-asr-flash"],
).toBeUndefined();
expect(
getGeneratedModelsForProvider("stepfun")["stepaudio-2.5-asr"],
).toBeUndefined();
expect(
getGeneratedModelsForProvider("vercel-ai-gateway")[
"openai/gpt-realtime-whisper"
]?.capabilities,
).toContain("transcription-streaming");
});
it("fetches and normalizes models.dev payload", async () => {
const fetcher = vi.fn(async () => ({
ok: true,
+102 -5
View File
@@ -7,7 +7,7 @@ import {
fetchClineRecommendedModelsPayload,
normalizeClineRecommendedProviderModels,
} from "./catalog-cline-recommended";
import type { ModelInfo } from "./types";
import type { ModelInfo, ModelModality } from "./types";
export interface ModelsDevModel {
name?: string;
@@ -31,6 +31,7 @@ export interface ModelsDevModel {
};
modalities?: {
input?: string[];
output?: string[];
};
status?: string;
}
@@ -78,6 +79,23 @@ interface SelectedModelsDevProvider {
const DEFAULT_MAX_INPUT_TOKENS = 128_000;
const DEFAULT_MAX_TOKENS = 4096;
// Non-tool models are only useful to the voice-input catalog when the current
// runtime can send them through its OpenAI-compatible (or provider-specific)
// transcription transport. Keep this list intentionally explicit: a provider
// advertising audio modalities does not imply that its chat base URL exposes
// POST /audio/transcriptions.
const TRANSCRIPTION_TRANSPORT_PROVIDER_IDS = new Set([
"evroc",
"groq",
"mistral",
"nearai",
"openai-native",
"privatemode-ai",
"scaleway",
"vercel-ai-gateway",
]);
const MODELS_DEV_AI_SDK_PROVIDER_FAMILIES = {
"@ai-sdk/openai": "openai",
"@ai-sdk/openai-compatible": "openai-compatible",
@@ -178,7 +196,34 @@ function getSelectedModelsDevProviders(
return selected;
}
function toCapabilities(model: ModelsDevModel): ModelInfo["capabilities"] {
function isDedicatedTranscriptionModel(model: ModelsDevModel): boolean {
return (
model.modalities?.input?.length === 1 &&
model.modalities.input[0] === "audio" &&
model.modalities.output?.length === 1 &&
model.modalities.output[0] === "text"
);
}
function isStreamingTranscriptionModel(
modelId: string,
model: ModelsDevModel,
): boolean {
if (!isDedicatedTranscriptionModel(model)) {
return false;
}
// models.dev exposes modalities but does not currently distinguish batch
// transcription from WebSocket-only transcription. Realtime transcription
// models consistently carry "realtime" in their canonical model/name.
const identity = `${modelId} ${model.name ?? ""}`.toLowerCase();
return /(?:^|[/_.-])realtime(?:$|[/_.-])/.test(identity);
}
function toCapabilities(
modelId: string,
model: ModelsDevModel,
): ModelInfo["capabilities"] {
const capabilities: NonNullable<ModelInfo["capabilities"]> = [];
if (model.modalities?.input?.includes("image")) {
capabilities.push("images");
@@ -201,6 +246,9 @@ function toCapabilities(model: ModelsDevModel): ModelInfo["capabilities"] {
if (model.temperature === true) {
capabilities.push("temperature");
}
if (isStreamingTranscriptionModel(modelId, model)) {
capabilities.push("transcription-streaming");
}
if (
(model.cost?.cache_read && model.cost?.cache_read >= 0) ||
(model.cost?.cache_write && model.cost?.cache_write >= 0)
@@ -210,6 +258,44 @@ function toCapabilities(model: ModelsDevModel): ModelInfo["capabilities"] {
return Array.from(new Set(capabilities));
}
const KNOWN_MODEL_MODALITIES = new Set<ModelModality>([
"text",
"image",
"audio",
"video",
"pdf",
]);
function toModalities(
modalities: ModelsDevModel["modalities"],
): ModelInfo["modalities"] {
if (!modalities) {
return undefined;
}
const normalize = (values: string[] | undefined): ModelModality[] =>
Array.from(
new Set(
(values ?? []).filter((value): value is ModelModality =>
KNOWN_MODEL_MODALITIES.has(value as ModelModality),
),
),
);
const input = normalize(modalities.input);
const output = normalize(modalities.output);
if (!input.includes("audio") && !output.includes("audio")) {
return undefined;
}
return { input, output };
}
function isChatModel(model: ModelInfo): boolean {
return (
(model.modalities === undefined ||
model.modalities.input.includes("text")) &&
(model.modalities === undefined || model.modalities.output.includes("text"))
);
}
function toStatus(status: string | undefined): ModelInfo["status"] {
if (
status === "active" ||
@@ -238,6 +324,7 @@ function toModelInfo(modelId: string, model: ModelsDevModel): ModelInfo {
const maxInputTokens = resolveMaxInputTokens(model.limit);
const outputToken = model.limit?.output ?? DEFAULT_MAX_TOKENS;
const rawContextLimit = model.limit?.context;
const modalities = toModalities(model.modalities);
return {
id: modelId,
@@ -245,7 +332,7 @@ function toModelInfo(modelId: string, model: ModelsDevModel): ModelInfo {
contextWindow: rawContextLimit,
maxInputTokens,
maxTokens: Math.floor(outputToken),
capabilities: toCapabilities(model),
capabilities: toCapabilities(modelId, model),
reasoningOptions: model.reasoning_options,
pricing: {
input: model.cost?.input ?? 0,
@@ -256,6 +343,7 @@ function toModelInfo(modelId: string, model: ModelsDevModel): ModelInfo {
status: toStatus(model.status),
releaseDate: model.release_date,
family: model.family,
...(modalities !== undefined ? { modalities } : {}),
};
}
@@ -277,7 +365,13 @@ export function normalizeModelsDevProviderModels(
const models: Record<string, ModelInfo> = {};
for (const [modelId, model] of Object.entries(source.models)) {
if (model.tool_call !== true || isDeprecatedModel(model)) {
const hasSupportedTranscriptionTransport =
isDedicatedTranscriptionModel(model) &&
TRANSCRIPTION_TRANSPORT_PROVIDER_IDS.has(targetProviderId);
if (
(model.tool_call !== true && !hasSupportedTranscriptionTransport) ||
isDeprecatedModel(model)
) {
continue;
}
models[modelId] = toModelInfo(modelId, model);
@@ -341,6 +435,9 @@ export function normalizeModelsDevProviderSpecs(
)) {
const baseUrl = normalizeBaseUrl(source.api);
const models = providerModels[targetProviderId];
const defaultModelId =
Object.values(models ?? {}).find(isChatModel)?.id ??
Object.keys(models ?? {})[0];
const spec: ModelsDevGeneratedProviderSpec = {
id: targetProviderId,
name: source.name || targetProviderId,
@@ -348,7 +445,7 @@ export function normalizeModelsDevProviderSpecs(
family: toProviderFamily(source),
capabilities: toProviderCapabilities(models),
modelsProviderId: targetProviderId,
defaultModelId: Object.keys(models ?? {})[0],
defaultModelId,
apiKeyEnv: source.env?.length ? [...source.env] : undefined,
docsUrl: source.doc,
defaults: baseUrl ? { baseUrl } : undefined,
File diff suppressed because it is too large Load Diff
+4
View File
@@ -17,6 +17,10 @@ export {
ModelInfoSchema,
type ModelMetadata,
ModelMetadataSchema,
type ModelModalities,
ModelModalitiesSchema,
type ModelModality,
ModelModalitySchema,
type ModelPricing,
ModelPricingSchema,
type ModelStatus,
+12
View File
@@ -123,3 +123,15 @@ export {
createCline,
} from "./providers/vendors/cline";
export { disposeLangfuseTelemetry } from "./services/langfuse-telemetry";
export {
type AudioTranscriptionRequest,
type AudioTranscriptionResult,
type AudioTranscriptionRoute,
createStreamingAudioTranscriptionSession,
DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
isStreamingTranscriptionModelId,
resolveAudioTranscriptionRoute,
type StreamingAudioTranscriptionSession,
type StreamingAudioTranscriptionSessionRequest,
transcribeAudio,
} from "./transcription";
@@ -228,6 +228,25 @@ describe("cline-pass builtin spec", () => {
});
describe("built-in provider metadata", () => {
it("registers ElevenLabs Scribe v2 as a dedicated transcription provider", async () => {
await expect(getProvider("elevenlabs")).resolves.toMatchObject({
id: "elevenlabs",
name: "ElevenLabs",
baseUrl: "https://api.elevenlabs.io/v1",
defaultModelId: "scribe_v2",
client: "fetch",
});
await expect(getModelsForProvider("elevenlabs")).resolves.toEqual({
scribe_v2: expect.objectContaining({
id: "scribe_v2",
modalities: {
input: ["audio"],
output: ["text"],
},
}),
});
});
it("merges generated provider specs with handwritten built-in overrides", async () => {
const generatedIds = new Set(
GENERATED_PROVIDER_SPECS.map((spec) => spec.id),
@@ -421,6 +421,22 @@ const VERCEL_ONLY_CLINE_MODEL_IDS: readonly string[] = [
"meta/muse-spark-1.2-contributor",
];
function buildElevenLabsModels(): Record<string, ModelInfo> {
return {
scribe_v2: {
id: "scribe_v2",
name: "Scribe v2",
description:
"ElevenLabs speech recognition model for accurate multilingual transcription",
family: "elevenlabs",
modalities: {
input: ["audio"],
output: ["text"],
},
},
};
}
function buildClineModels(): Record<string, ModelInfo> {
// Cline is OpenRouter-backed generally, but its recommended-model endpoint
// can return Vercel-style ids. Include those exact ids so runtime metadata
@@ -1054,6 +1070,18 @@ const BUILTIN_SPEC_OVERRIDES: BuiltinSpecOverride[] = [
configFields: [],
metadata: { usageCostDisplay: "subscription" },
},
{
id: "elevenlabs",
name: "ElevenLabs",
description: "ElevenLabs speech-to-text and audio services",
family: "openai-compatible",
client: "fetch",
defaultModelId: "scribe_v2",
apiKeyEnv: ["ELEVENLABS_API_KEY"],
modelsFactory: buildElevenLabsModels,
docsUrl: "https://elevenlabs.io/docs/overview/capabilities/speech-to-text",
defaults: { baseUrl: "https://api.elevenlabs.io/v1" },
},
{
id: "anthropic",
name: "Anthropic",
+1
View File
@@ -13,6 +13,7 @@ export enum BUILT_IN_PROVIDER {
CLAUDE_CODE = "claude-code",
CLINE = "cline",
CLINE_PASS = "cline-pass",
ELEVENLABS = "elevenlabs",
// OpenAI variants
OPENAI_COMPATIBLE = "openai-compatible",
OPENAI_NATIVE = "openai-native",
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { resolveVercelAiGatewayBaseUrl, trimTrailingSlashes } from "./url";
describe("provider URL helpers", () => {
it("trims arbitrarily long trailing slash runs in linear time", () => {
expect(
trimTrailingSlashes(`https://example.test${"/".repeat(10_000)}`),
).toBe("https://example.test");
});
it.each([
["https://example.test/v1", "https://example.test/v4/ai"],
["https://example.test/v12/ai", "https://example.test/v4/ai"],
["https://example.test/v4/ai", "https://example.test/v4/ai"],
["https://example.test/api", "https://example.test/api/v4/ai"],
])("normalizes %s", (input, expected) => {
expect(resolveVercelAiGatewayBaseUrl(input, "unused")).toBe(expected);
});
});
+43
View File
@@ -0,0 +1,43 @@
export function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === 47) {
end -= 1;
}
return value.slice(0, end);
}
function isVersionSegment(value: string): boolean {
if (value.length < 2 || value.charCodeAt(0) !== 118) {
return false;
}
for (let index = 1; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code < 48 || code > 57) {
return false;
}
}
return true;
}
export function resolveVercelAiGatewayBaseUrl(
configuredBaseUrl: string | undefined,
defaultBaseUrl: string,
): string {
const baseUrl = trimTrailingSlashes(configuredBaseUrl ?? defaultBaseUrl);
if (baseUrl.endsWith("/v4/ai")) {
return baseUrl;
}
const versionEnd = baseUrl.endsWith("/ai")
? baseUrl.length - "/ai".length
: baseUrl.length;
const versionStart = baseUrl.lastIndexOf("/", versionEnd - 1);
if (
versionStart >= 0 &&
isVersionSegment(baseUrl.slice(versionStart + 1, versionEnd))
) {
return `${baseUrl.slice(0, versionStart)}/v4/ai`;
}
return `${baseUrl}/v4/ai`;
}
+296
View File
@@ -0,0 +1,296 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createOpenAIMock, openAITranscriptionModel, transcribeMock } =
vi.hoisted(() => {
const openAITranscriptionModel = {
provider: "openai",
specificationVersion: "v3",
};
return {
createOpenAIMock: vi.fn(() => ({
transcription: vi.fn(() => openAITranscriptionModel),
})),
openAITranscriptionModel,
transcribeMock: vi.fn(),
};
});
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: createOpenAIMock,
}));
vi.mock("ai", () => ({
experimental_transcribe: transcribeMock,
}));
import {
createStreamingAudioTranscriptionSession,
resolveAudioTranscriptionRoute,
transcribeAudio,
} from "./transcription";
describe("transcribeAudio", () => {
beforeEach(() => {
createOpenAIMock.mockClear();
transcribeMock.mockReset().mockResolvedValue({
text: "hello world",
language: "en",
durationInSeconds: 1.5,
});
});
it("resolves provider-specific transcription routes", () => {
expect(
resolveAudioTranscriptionRoute({
providerId: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh/v1/",
}),
).toEqual({
kind: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh/v4/ai",
endpoint: "https://ai-gateway.vercel.sh/v4/ai/transcription-model",
});
expect(
resolveAudioTranscriptionRoute({
providerId: "elevenlabs",
baseUrl: "https://api.elevenlabs.io/v1/",
}),
).toMatchObject({
kind: "elevenlabs",
endpoint: "https://api.elevenlabs.io/v1/speech-to-text",
});
expect(
resolveAudioTranscriptionRoute({
providerId: "groq",
baseUrl: "https://api.groq.com/openai/v1/",
}),
).toMatchObject({
kind: "openai-compatible",
endpoint: "https://api.groq.com/openai/v1/audio/transcriptions",
});
});
it("uses provider credentials, endpoint, headers, and the selected model", async () => {
const fetchImpl = vi.fn<typeof fetch>();
await expect(
transcribeAudio({
providerConfig: {
providerId: "groq",
modelId: "chat-model",
apiKey: "secret",
baseUrl: "https://api.groq.test/openai/v1",
headers: { "X-Test": "value" },
timeoutMs: 5_000,
fetch: fetchImpl,
},
modelId: "whisper-large-v3-turbo",
audio: new Uint8Array([1, 2, 3]),
maxRetries: 0,
}),
).resolves.toEqual({
text: "hello world",
language: "en",
durationInSeconds: 1.5,
});
expect(createOpenAIMock).toHaveBeenCalledWith({
apiKey: "secret",
baseURL: "https://api.groq.test/openai/v1",
fetch: fetchImpl,
headers: { "X-Test": "value" },
});
expect(transcribeMock).toHaveBeenCalledWith(
expect.objectContaining({
model: openAITranscriptionModel,
audio: new Uint8Array([1, 2, 3]),
maxRetries: 0,
abortSignal: expect.any(AbortSignal),
}),
);
});
it("uses Vercel AI Gateway's native transcription model transport", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
expect(input).toBe(
"https://ai-gateway.vercel.sh/v4/ai/transcription-model",
);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer gateway-secret");
expect(headers.get("ai-gateway-protocol-version")).toBe("0.0.1");
expect(headers.get("ai-gateway-auth-method")).toBe("api-key");
expect(headers.get("ai-transcription-model-specification-version")).toBe(
"4",
);
expect(headers.get("ai-model-id")).toBe("openai/whisper-1");
expect(headers.get("content-type")).toBe("application/json");
expect(JSON.parse(String(init?.body))).toEqual({
audio: "AQID",
mediaType: "audio/mp4",
});
return new Response(
JSON.stringify({
text: "gateway transcript",
language: "en",
durationInSeconds: 1.5,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
await expect(
transcribeAudio({
providerConfig: {
providerId: "vercel-ai-gateway",
modelId: "chat-model",
apiKey: "gateway-secret",
baseUrl: "https://ai-gateway.vercel.sh/v1",
headers: { "X-Test": "value" },
fetch: fetchImpl,
},
modelId: "openai/whisper-1",
audio: new Uint8Array([1, 2, 3]),
mediaType: "audio/mp4; codecs=mp4a.40.2",
maxRetries: 0,
}),
).resolves.toEqual({
text: "gateway transcript",
language: "en",
durationInSeconds: 1.5,
});
expect(fetchImpl).toHaveBeenCalledOnce();
expect(transcribeMock).not.toHaveBeenCalled();
expect(createOpenAIMock).not.toHaveBeenCalled();
});
it("mints a short-lived Vercel streaming transcription session", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
expect(input).toBe(
"https://ai-gateway.vercel.sh/v1/realtime/client-secrets",
);
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer gateway-secret");
expect(headers.get("ai-gateway-protocol-version")).toBe("0.0.1");
expect(headers.get("ai-gateway-auth-method")).toBe("api-key");
expect(JSON.parse(String(init?.body))).toEqual({
model: "openai/gpt-realtime-whisper",
routeKind: "transcription",
expiresIn: 120,
});
return new Response(
JSON.stringify({
token: "vcst_short_lived",
expiresAt: 1_800_000_000,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
await expect(
createStreamingAudioTranscriptionSession({
providerConfig: {
providerId: "vercel-ai-gateway",
modelId: "chat-model",
apiKey: "gateway-secret",
baseUrl: "https://ai-gateway.vercel.sh/v1",
fetch: fetchImpl,
},
modelId: "openai/gpt-realtime-whisper",
expiresAfterSeconds: 120,
}),
).resolves.toEqual({
token: "vcst_short_lived",
url: "wss://ai-gateway.vercel.sh/v4/ai/transcription-model?ai-model-id=openai%2Fgpt-realtime-whisper",
expiresAt: 1_800_000_000,
});
});
it("rejects a streaming-only Vercel model on the recorded-audio path", async () => {
const fetchImpl = vi.fn<typeof fetch>();
await expect(
transcribeAudio({
providerConfig: {
providerId: "vercel-ai-gateway",
modelId: "chat-model",
apiKey: "gateway-secret",
fetch: fetchImpl,
},
modelId: "openai/gpt-realtime-whisper",
audio: new Uint8Array([1, 2, 3]),
}),
).rejects.toThrow("requires streaming transcription");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("uses ElevenLabs' native speech-to-text endpoint", async () => {
const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
expect(input).toBe("https://api.elevenlabs.test/v1/speech-to-text");
const headers = new Headers(init?.headers);
expect(headers.get("xi-api-key")).toBe("eleven-secret");
expect(headers.get("content-type")).toBeNull();
const body = init?.body;
expect(body).toBeInstanceOf(FormData);
const formData = body as FormData;
expect(formData.get("model_id")).toBe("scribe_v2");
const file = formData.get("file");
expect(file).toBeInstanceOf(Blob);
expect((file as Blob).type).toBe("audio/webm");
return new Response(
JSON.stringify({
text: "native ElevenLabs transcript",
language_code: "eng",
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
});
await expect(
transcribeAudio({
providerConfig: {
providerId: "elevenlabs",
modelId: "scribe_v2",
apiKey: "eleven-secret",
baseUrl: "https://api.elevenlabs.test/v1/",
headers: { "content-type": "application/json" },
fetch: fetchImpl,
},
modelId: "scribe_v2",
audio: new Uint8Array([1, 2, 3]),
mediaType: "audio/webm;codecs=opus",
}),
).resolves.toEqual({
text: "native ElevenLabs transcript",
language: "eng",
});
expect(createOpenAIMock).not.toHaveBeenCalled();
});
it("rejects empty audio and missing credentials before making a request", async () => {
await expect(
transcribeAudio({
providerConfig: {
providerId: "groq",
modelId: "chat-model",
apiKey: "secret",
},
modelId: "whisper-large-v3",
audio: new Uint8Array(),
}),
).rejects.toThrow("Recorded audio is empty");
await expect(
transcribeAudio({
providerConfig: {
providerId: "groq",
modelId: "chat-model",
},
modelId: "whisper-large-v3",
audio: new Uint8Array([1]),
}),
).rejects.toThrow('Provider "groq" is missing credentials');
expect(transcribeMock).not.toHaveBeenCalled();
});
});
+418
View File
@@ -0,0 +1,418 @@
import { createOpenAI } from "@ai-sdk/openai";
import { experimental_transcribe as transcribe } from "ai";
import type { ProviderConfig } from "./providers/config";
import {
resolveVercelAiGatewayBaseUrl,
trimTrailingSlashes,
} from "./providers/url";
export const DEFAULT_TRANSCRIPTION_TIMEOUT_MS = 120_000;
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io/v1";
const DEFAULT_VERCEL_AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v4/ai";
const VERCEL_AI_GATEWAY_PROTOCOL_VERSION = "0.0.1";
const VERCEL_AI_GATEWAY_TRANSCRIPTION_SPECIFICATION_VERSION = "4";
export interface AudioTranscriptionRequest {
providerConfig: ProviderConfig;
modelId: string;
audio: Uint8Array;
mediaType?: string;
abortSignal?: AbortSignal;
maxRetries?: number;
}
export interface AudioTranscriptionResult {
text: string;
language?: string;
durationInSeconds?: number;
}
export interface StreamingAudioTranscriptionSessionRequest {
providerConfig: ProviderConfig;
modelId: string;
expiresAfterSeconds?: number;
abortSignal?: AbortSignal;
}
export interface StreamingAudioTranscriptionSession {
token: string;
url: string;
expiresAt?: number;
}
export interface AudioTranscriptionRoute {
kind: "elevenlabs" | "vercel-ai-gateway" | "openai-compatible";
baseUrl: string;
endpoint: string;
}
/**
* Resolve the provider-specific transport used for audio transcription.
*
* Vercel AI Gateway's AI SDK protocol is not the OpenAI-compatible REST
* surface: transcription requests go to `/v4/ai/transcription-model`.
*/
export function resolveAudioTranscriptionRoute(
config: Pick<ProviderConfig, "providerId" | "baseUrl">,
): AudioTranscriptionRoute {
if (config.providerId === "elevenlabs") {
const baseUrl = trimTrailingSlashes(
config.baseUrl ?? DEFAULT_ELEVENLABS_BASE_URL,
);
return {
kind: "elevenlabs",
baseUrl,
endpoint: `${baseUrl}/speech-to-text`,
};
}
if (config.providerId === "vercel-ai-gateway") {
const baseUrl = resolveVercelAiGatewayBaseUrl(
config.baseUrl,
DEFAULT_VERCEL_AI_GATEWAY_BASE_URL,
);
return {
kind: "vercel-ai-gateway",
baseUrl,
endpoint: `${baseUrl}/transcription-model`,
};
}
const baseUrl = trimTrailingSlashes(
config.baseUrl ?? DEFAULT_OPENAI_BASE_URL,
);
return {
kind: "openai-compatible",
baseUrl,
endpoint: `${baseUrl}/audio/transcriptions`,
};
}
function resolveApiKey(
config: Pick<ProviderConfig, "apiKey" | "accessToken">,
): string | undefined {
return config.apiKey?.trim() || config.accessToken?.trim() || undefined;
}
export function isStreamingTranscriptionModelId(modelId: string): boolean {
return /(?:^|[/_.-])realtime(?:$|[/_.-])/.test(modelId.trim().toLowerCase());
}
function resolveAbortSignal(
config: ProviderConfig,
requestSignal: AbortSignal | undefined,
): AbortSignal {
const timeoutSignal = AbortSignal.timeout(
config.timeoutMs ?? DEFAULT_TRANSCRIPTION_TIMEOUT_MS,
);
const signals = [requestSignal, config.abortSignal, timeoutSignal].filter(
(signal): signal is AbortSignal => signal !== undefined,
);
return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
}
function resolveAudioFileExtension(mediaType: string | undefined): string {
switch (mediaType?.split(";", 1)[0]?.trim().toLowerCase()) {
case "audio/mpeg":
case "audio/mp3":
return "mp3";
case "audio/mp4":
case "audio/m4a":
case "audio/x-m4a":
return "m4a";
case "audio/ogg":
return "ogg";
case "audio/wav":
case "audio/wave":
case "audio/x-wav":
return "wav";
default:
return "webm";
}
}
async function readErrorBody(response: Response): Promise<string> {
const body = await response.text().catch(() => "");
if (!body) return "";
try {
const parsed = JSON.parse(body) as unknown;
if (parsed && typeof parsed === "object") {
const record = parsed as {
detail?: unknown;
error?: unknown;
message?: unknown;
};
if (typeof record.message === "string") return record.message;
if (record.error && typeof record.error === "object") {
const message = (record.error as { message?: unknown }).message;
if (typeof message === "string") return message;
}
const detail = record.detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object") {
const message = (detail as { message?: unknown }).message;
if (typeof message === "string") return message;
}
}
} catch {
// Use the response body below when it is not JSON.
}
return body;
}
async function transcribeVercelAIGatewayAudio(
request: AudioTranscriptionRequest,
apiKey: string,
): Promise<AudioTranscriptionResult> {
if (isStreamingTranscriptionModelId(request.modelId)) {
throw new Error(
`Model "${request.modelId}" requires streaming transcription and cannot transcribe a completed recording`,
);
}
const route = resolveAudioTranscriptionRoute(request.providerConfig);
const headers = new Headers(request.providerConfig.headers);
if (!headers.has("authorization")) {
headers.set("authorization", `Bearer ${apiKey}`);
}
headers.set(
"ai-gateway-protocol-version",
VERCEL_AI_GATEWAY_PROTOCOL_VERSION,
);
headers.set("ai-gateway-auth-method", "api-key");
headers.set(
"ai-transcription-model-specification-version",
VERCEL_AI_GATEWAY_TRANSCRIPTION_SPECIFICATION_VERSION,
);
headers.set("ai-model-id", request.modelId.trim());
headers.set("content-type", "application/json");
const mediaType =
request.mediaType?.split(";", 1)[0]?.trim().toLowerCase() || "audio/webm";
const fetchImpl = request.providerConfig.fetch ?? fetch;
const response = await fetchImpl(route.endpoint, {
method: "POST",
headers,
body: JSON.stringify({
audio: Buffer.from(request.audio).toString("base64"),
mediaType,
}),
signal: resolveAbortSignal(request.providerConfig, request.abortSignal),
});
if (!response.ok) {
const detail = await readErrorBody(response);
throw new Error(
`Vercel AI Gateway transcription failed (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
const result = (await response.json()) as {
text?: unknown;
language?: unknown;
durationInSeconds?: unknown;
};
if (typeof result.text !== "string" || !result.text.trim()) {
throw new Error("Vercel AI Gateway transcription returned no text");
}
return {
text: result.text,
language: typeof result.language === "string" ? result.language : undefined,
durationInSeconds:
typeof result.durationInSeconds === "number"
? result.durationInSeconds
: undefined,
};
}
/**
* Mint a short-lived credential for a browser transcription WebSocket.
*
* The long-lived provider credential remains on the trusted SDK/sidecar side;
* only the transcription-bound client secret is returned to the webview.
*/
export async function createStreamingAudioTranscriptionSession(
request: StreamingAudioTranscriptionSessionRequest,
): Promise<StreamingAudioTranscriptionSession> {
const modelId = request.modelId.trim();
if (!modelId) {
throw new Error("A streaming transcription model is required");
}
if (request.providerConfig.providerId !== "vercel-ai-gateway") {
throw new Error(
`Provider "${request.providerConfig.providerId}" does not support browser streaming transcription`,
);
}
const expiresAfterSeconds = request.expiresAfterSeconds ?? 300;
if (
!Number.isInteger(expiresAfterSeconds) ||
expiresAfterSeconds < 1 ||
expiresAfterSeconds > 300
) {
throw new Error(
"Streaming transcription session lifetime must be between 1 and 300 seconds",
);
}
const apiKey = resolveApiKey(request.providerConfig);
if (!apiKey) {
throw new Error(
`Provider "${request.providerConfig.providerId}" is missing credentials`,
);
}
const route = resolveAudioTranscriptionRoute(request.providerConfig);
const mintEndpoint = new URL(
"/v1/realtime/client-secrets",
route.baseUrl,
).toString();
const headers = new Headers(request.providerConfig.headers);
if (!headers.has("authorization")) {
headers.set("authorization", `Bearer ${apiKey}`);
}
headers.set(
"ai-gateway-protocol-version",
VERCEL_AI_GATEWAY_PROTOCOL_VERSION,
);
headers.set("ai-gateway-auth-method", "api-key");
headers.set("content-type", "application/json");
const fetchImpl = request.providerConfig.fetch ?? fetch;
const response = await fetchImpl(mintEndpoint, {
method: "POST",
headers,
body: JSON.stringify({
model: modelId,
routeKind: "transcription",
expiresIn: expiresAfterSeconds,
}),
signal: resolveAbortSignal(request.providerConfig, request.abortSignal),
});
if (!response.ok) {
const detail = await readErrorBody(response);
throw new Error(
`Vercel AI Gateway streaming transcription setup failed (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
const result = (await response.json()) as {
token?: unknown;
expiresAt?: unknown;
};
if (typeof result.token !== "string" || !result.token.trim()) {
throw new Error(
"Vercel AI Gateway streaming transcription setup returned no token",
);
}
const url = new URL(route.endpoint);
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
url.searchParams.set("ai-model-id", modelId);
return {
token: result.token,
url: url.toString(),
expiresAt:
typeof result.expiresAt === "number" ? result.expiresAt : undefined,
};
}
async function transcribeElevenLabsAudio(
request: AudioTranscriptionRequest,
apiKey: string,
): Promise<AudioTranscriptionResult> {
const route = resolveAudioTranscriptionRoute(request.providerConfig);
const headers = new Headers(request.providerConfig.headers);
headers.delete("content-type");
headers.set("xi-api-key", apiKey);
const mediaType =
request.mediaType?.split(";", 1)[0]?.trim().toLowerCase() || "audio/webm";
const formData = new FormData();
formData.append("model_id", request.modelId.trim());
formData.append(
"file",
new Blob([new Uint8Array(request.audio).buffer], { type: mediaType }),
`audio.${resolveAudioFileExtension(mediaType)}`,
);
const fetchImpl = request.providerConfig.fetch ?? fetch;
const response = await fetchImpl(route.endpoint, {
method: "POST",
headers,
body: formData,
signal: resolveAbortSignal(request.providerConfig, request.abortSignal),
});
if (!response.ok) {
const detail = await readErrorBody(response);
throw new Error(
`ElevenLabs transcription failed (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
const result = (await response.json()) as {
text?: unknown;
language_code?: unknown;
};
if (typeof result.text !== "string" || !result.text.trim()) {
throw new Error("ElevenLabs transcription returned no text");
}
return {
text: result.text,
language:
typeof result.language_code === "string"
? result.language_code
: undefined,
};
}
/**
* Transcribe recorded audio through the selected provider's transcription
* endpoint. Provider credentials and endpoints come from the same
* ProviderConfig used by the rest of the SDK.
*/
export async function transcribeAudio(
request: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const modelId = request.modelId.trim();
if (!modelId) {
throw new Error("A transcription model is required");
}
if (request.audio.byteLength === 0) {
throw new Error("Recorded audio is empty");
}
const apiKey = resolveApiKey(request.providerConfig);
if (!apiKey) {
throw new Error(
`Provider "${request.providerConfig.providerId}" is missing credentials`,
);
}
if (request.providerConfig.providerId === "elevenlabs") {
return transcribeElevenLabsAudio(request, apiKey);
}
if (request.providerConfig.providerId === "vercel-ai-gateway") {
return transcribeVercelAIGatewayAudio(request, apiKey);
}
const route = resolveAudioTranscriptionRoute(request.providerConfig);
const provider = createOpenAI({
apiKey,
baseURL: route.baseUrl,
fetch: request.providerConfig.fetch,
headers: request.providerConfig.headers,
});
const result = await transcribe({
model: provider.transcription(modelId),
audio: request.audio,
abortSignal: resolveAbortSignal(
request.providerConfig,
request.abortSignal,
),
maxRetries: request.maxRetries,
});
return {
text: result.text,
language: result.language,
durationInSeconds: result.durationInSeconds,
};
}
+5
View File
@@ -179,6 +179,10 @@ export {
ModelInfoSchema,
type ModelMetadata,
ModelMetadataSchema,
type ModelModalities,
ModelModalitiesSchema,
type ModelModality,
ModelModalitySchema,
type ModelPricing,
ModelPricingSchema,
type ModelStatus,
@@ -359,6 +363,7 @@ export type {
ProviderSettingsActionRequest,
RuntimeLoggerConfig,
SaveProviderSettingsActionRequest,
VoiceInputSelection,
} from "./rpc/runtime";
export {
ProviderCapabilitySchema,
+5
View File
@@ -204,6 +204,10 @@ export {
ModelInfoSchema,
type ModelMetadata,
ModelMetadataSchema,
type ModelModalities,
ModelModalitiesSchema,
type ModelModality,
ModelModalitySchema,
type ModelPricing,
ModelPricingSchema,
type ModelStatus,
@@ -405,6 +409,7 @@ export type {
ProviderSettingsActionRequest,
RuntimeLoggerConfig,
SaveProviderSettingsActionRequest,
VoiceInputSelection,
} from "./rpc/runtime";
export {
ProviderCapabilitySchema,
@@ -32,6 +32,7 @@ export const ModelCapabilitySchema = z.enum([
"structured_output",
"temperature",
"files",
"transcription-streaming",
]);
export type ModelCapability = z.infer<typeof ModelCapabilitySchema>;
@@ -71,6 +72,23 @@ export const ModelMetadataSchema = z
export type ModelMetadata = z.infer<typeof ModelMetadataSchema>;
export const ModelModalitySchema = z.enum([
"text",
"image",
"audio",
"video",
"pdf",
]);
export type ModelModality = z.infer<typeof ModelModalitySchema>;
export const ModelModalitiesSchema = z.object({
input: z.array(ModelModalitySchema),
output: z.array(ModelModalitySchema),
});
export type ModelModalities = z.infer<typeof ModelModalitiesSchema>;
export const ModelInfoSchema = z.object({
id: z.string(),
name: z.string().optional(),
@@ -91,6 +109,7 @@ export const ModelInfoSchema = z.object({
releaseDate: z.string().optional(),
deprecationDate: z.string().optional(),
family: z.string().optional(),
modalities: ModelModalitiesSchema.optional(),
metadata: ModelMetadataSchema.optional(),
});
+10
View File
@@ -1,5 +1,6 @@
import z from "zod";
import type { HubToolExecutorName } from "../hub";
import type { ModelModality } from "../llms/model-info";
import type { ReasoningLevel } from "../llms/reasoning-options";
import type {
RuntimeConfigExtensionKind,
@@ -149,6 +150,9 @@ export interface ProviderModel {
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
supportsStreamingTranscription?: boolean;
inputModalities?: ModelModality[];
outputModalities?: ModelModality[];
}
export type ProviderConfigFieldType =
@@ -200,9 +204,15 @@ export interface ProviderListItem {
family?: string;
}
export interface VoiceInputSelection {
providerId: string;
modelId: string;
}
export interface ProviderCatalogResponse {
providers: ProviderListItem[];
settingsPath: string;
voiceInput?: VoiceInputSelection;
}
export interface ProviderModelsResponse {