mirror of
https://github.com/cline/cline.git
synced 2026-08-30 17:20:20 +08:00
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:
@@ -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 () => {});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
+198
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+144
-6
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -17,6 +17,10 @@ export {
|
||||
ModelInfoSchema,
|
||||
type ModelMetadata,
|
||||
ModelMetadataSchema,
|
||||
type ModelModalities,
|
||||
ModelModalitiesSchema,
|
||||
type ModelModality,
|
||||
ModelModalitySchema,
|
||||
type ModelPricing,
|
||||
ModelPricingSchema,
|
||||
type ModelStatus,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user