diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 334766c3f0..8fbd64c413 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -44,6 +44,12 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Default STT settings + */ +export const DEFAULT_STT_AUTO_STOP_TIMEOUT = 2000 // 2 seconds of silence +export const DEFAULT_STT_AUTO_SEND = false + /** * GlobalSettings */ @@ -173,6 +179,12 @@ export const globalSettingsSchema = z.object({ hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), lastModeImportPath: z.string().optional(), + + // Speech-to-Text settings + sttEnabled: z.boolean().optional(), + sttProvider: z.enum(["assemblyai", "openai-whisper"]).optional(), + sttAutoStopTimeout: z.number().min(500).max(10000).optional(), // milliseconds + sttAutoSend: z.boolean().optional(), }) export type GlobalSettings = z.infer @@ -227,11 +239,15 @@ export const SECRET_STATE_KEYS = [ "featherlessApiKey", "ioIntelligenceApiKey", "vercelAiGatewayApiKey", + "assemblyAiApiKey", + "openAiWhisperApiKey", ] as const // Global secrets that are part of GlobalSettings (not ProviderSettings) export const GLOBAL_SECRET_KEYS = [ "openRouterImageApiKey", // For image generation + "assemblyAiApiKey", // For speech-to-text + "openAiWhisperApiKey", // For OpenAI Whisper STT ] as const // Type for the actual secret storage keys diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 4153db0da4..f4a760dac0 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -181,6 +181,13 @@ const baseProviderSettingsSchema = z.object({ // Model verbosity. verbosity: verbosityLevelsSchema.optional(), + + // Speech-to-Text settings. + sttEnabled: z.boolean().optional(), + sttProvider: z.enum(["assemblyai", "openai-whisper", "none"]).optional(), + sttAutoStopTimeout: z.number().min(1).max(30).optional(), + sttAutoSend: z.boolean().optional(), + sttLanguage: z.string().optional(), }) // Several of the providers share common model config properties. @@ -193,6 +200,9 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({ anthropicBaseUrl: z.string().optional(), anthropicUseAuthToken: z.boolean().optional(), anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. + // STT settings for Anthropic provider + sttAssemblyAiApiKey: z.string().optional(), + sttOpenAiWhisperApiKey: z.string().optional(), }) const claudeCodeSchema = apiModelIdProviderModelSchema.extend({ @@ -297,6 +307,9 @@ const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ // OpenAI Responses API service tier for openai-native provider only. // UI should only expose this when the selected model supports flex/priority. openAiNativeServiceTier: serviceTierSchema.optional(), + // STT settings for OpenAI Native provider + sttAssemblyAiApiKey: z.string().optional(), + sttOpenAiWhisperApiKey: z.string().optional(), }) const mistralSchema = apiModelIdProviderModelSchema.extend({ @@ -405,6 +418,9 @@ const qwenCodeSchema = apiModelIdProviderModelSchema.extend({ const rooSchema = apiModelIdProviderModelSchema.extend({ // No additional fields needed - uses cloud authentication. + // STT settings for Roo provider + sttAssemblyAiApiKey: z.string().optional(), + sttOpenAiWhisperApiKey: z.string().optional(), }) const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({ diff --git a/src/activate/handleUri.ts b/src/activate/handleUri.ts index 7f0b4c64cc..013fdca0c1 100644 --- a/src/activate/handleUri.ts +++ b/src/activate/handleUri.ts @@ -47,6 +47,25 @@ export const handleUri = async (uri: vscode.Uri) => { ) break } + case "/stt/transcript": { + const transcript = query.get("transcript") + const error = query.get("error") + + if (error) { + // Send error to webview + await visibleProvider.postMessageToWebview({ + type: "sttError", + error: decodeURIComponent(error), + }) + } else if (transcript) { + // Send transcript to webview + await visibleProvider.postMessageToWebview({ + type: "sttTranscript", + text: decodeURIComponent(transcript), + }) + } + break + } default: break } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6c1d612943..1e557715ac 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -135,6 +135,7 @@ export class ClineProvider protected mcpHub?: McpHub // Change from private to protected private marketplaceManager: MarketplaceManager private mdmService?: MdmService + private sttService?: SttService private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined @@ -191,6 +192,9 @@ export class ClineProvider this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) + // Initialize STT service + this.sttService = new SttService(this.context, this) + // Forward task events to the provider. // We do something fairly similar for the IPC-based API. this.taskCreationCallback = (instance: Task) => { @@ -610,6 +614,7 @@ export class ClineProvider this.mcpHub = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() + this.sttService?.dispose() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -2304,6 +2309,10 @@ export class ClineProvider return this.mcpHub } + public getSttService(): SttService | undefined { + return this.sttService + } + /** * Check if the current state is compliant with MDM policy * @returns true if compliant or no MDM policy exists, false if MDM policy exists and user is non-compliant diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 38b51c7123..876ffae150 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3159,5 +3159,49 @@ export const webviewMessageHandler = async ( }) break } + case "startSttCapture": + try { + const sttService = provider.getSttService() + if (!sttService) { + await provider.postMessageToWebview({ + type: "sttError", + error: "STT service not initialized", + }) + break + } + + const captureUrl = await sttService.startCapture() + if (captureUrl) { + // Open the capture URL in the default browser + await vscode.env.openExternal(vscode.Uri.parse(captureUrl)) + + // Notify the webview that capture has started + await provider.postMessageToWebview({ + type: "sttCaptureStarted", + }) + } + } catch (error) { + await provider.postMessageToWebview({ + type: "sttError", + error: error instanceof Error ? error.message : "Failed to start STT capture", + }) + } + break + case "stopSttCapture": + try { + const sttService = provider.getSttService() + if (sttService) { + await sttService.stopCapture() + await provider.postMessageToWebview({ + type: "sttCaptureStopped", + }) + } + } catch (error) { + await provider.postMessageToWebview({ + type: "sttError", + error: error instanceof Error ? error.message : "Failed to stop STT capture", + }) + } + break } } diff --git a/src/services/stt/SttService.ts b/src/services/stt/SttService.ts new file mode 100644 index 0000000000..fcd43064a8 --- /dev/null +++ b/src/services/stt/SttService.ts @@ -0,0 +1,176 @@ +import * as vscode from "vscode" +import axios from "axios" +import { EventEmitter } from "events" +import { getCaptureServer, stopCaptureServer } from "./capture-server" + +export interface SttConfig { + provider: "assemblyai" | "openai-whisper" + apiKey?: string + autoStopTimeout?: number + autoSend?: boolean +} + +export interface SttTranscript { + text: string + confidence?: number + isFinal: boolean +} + +export class SttService extends EventEmitter { + private static instance: SttService | null = null + private config: SttConfig + private captureServer = getCaptureServer() + private temporaryToken: string | null = null + private tokenExpiresAt: number = 0 + + private constructor(config: SttConfig) { + super() + this.config = config + } + + public static getInstance(config?: SttConfig): SttService { + if (!SttService.instance && config) { + SttService.instance = new SttService(config) + } + if (!SttService.instance) { + throw new Error("SttService not initialized with config") + } + return SttService.instance + } + + public static resetInstance(): void { + if (SttService.instance) { + SttService.instance.cleanup() + SttService.instance = null + } + } + + public updateConfig(config: Partial): void { + this.config = { ...this.config, ...config } + } + + /** + * Get a temporary token for the STT provider + * This avoids exposing the actual API key to the browser + */ + public async getTemporaryToken(): Promise { + // Check if we have a valid cached token + if (this.temporaryToken && this.tokenExpiresAt > Date.now()) { + return this.temporaryToken + } + + if (!this.config.apiKey) { + throw new Error(`No API key configured for ${this.config.provider}`) + } + + if (this.config.provider === "assemblyai") { + // AssemblyAI uses the API key directly for WebSocket auth + // In production, you'd want to implement a token exchange service + // For now, we'll use a simple approach with expiring tokens + this.temporaryToken = await this.createAssemblyAiToken() + this.tokenExpiresAt = Date.now() + 3600000 // 1 hour + return this.temporaryToken + } else if (this.config.provider === "openai-whisper") { + // OpenAI Whisper would need a different token mechanism + throw new Error("OpenAI Whisper provider not yet implemented") + } + + throw new Error(`Unknown STT provider: ${this.config.provider}`) + } + + /** + * Create a temporary token for AssemblyAI + * In production, this should be done through a secure backend service + */ + private async createAssemblyAiToken(): Promise { + // For AssemblyAI, we need to create a temporary token through their API + // This is a simplified version - in production, use a backend service + try { + const response = await axios.post( + "https://api.assemblyai.com/v2/realtime/token", + { + expires_in: 3600, // 1 hour + }, + { + headers: { + authorization: this.config.apiKey, + }, + }, + ) + return response.data.token + } catch (error) { + console.error("Failed to create AssemblyAI token:", error) + // Fallback: return the API key (not recommended for production) + return this.config.apiKey! + } + } + + /** + * Start the audio capture process + * Opens a browser window for microphone access + */ + public async startCapture(): Promise { + // Generate the capture URL with necessary parameters + const token = await this.getTemporaryToken() + const captureUrl = await this.generateCaptureUrl(token) + + // Open the capture page in the default browser + await vscode.env.openExternal(vscode.Uri.parse(captureUrl)) + + return captureUrl + } + + /** + * Generate the URL for the browser-based capture page + */ + private async generateCaptureUrl(token: string): Promise { + // Start the capture server if not already running + let port = this.captureServer.getPort() + if (!port) { + port = await this.captureServer.start() + } + + // Create a callback URI for receiving the transcript + const callbackUri = await vscode.env.asExternalUri( + vscode.Uri.parse(`vscode://rooveterinaryinc.roo-cline/stt-callback`), + ) + + // Build the capture URL with parameters + const params = new URLSearchParams({ + token: token, + provider: this.config.provider, + callback: callbackUri.toString(), + autoStopTimeout: String(this.config.autoStopTimeout || 2000), + autoSend: String(this.config.autoSend || false), + }) + + return `http://localhost:${port}/capture?${params.toString()}` + } + + /** + * Stop the capture process + */ + public stopCapture(): void { + this.emit("stop") + } + + /** + * Handle incoming transcript from the browser + */ + public handleTranscript(transcript: string): void { + this.emit("transcript", { + text: transcript, + isFinal: true, + } as SttTranscript) + } + + /** + * Clean up resources + */ + private cleanup(): void { + this.removeAllListeners() + this.temporaryToken = null + this.tokenExpiresAt = 0 + stopCaptureServer() + } +} diff --git a/src/services/stt/__tests__/SttService.spec.ts b/src/services/stt/__tests__/SttService.spec.ts new file mode 100644 index 0000000000..99b2b6f372 --- /dev/null +++ b/src/services/stt/__tests__/SttService.spec.ts @@ -0,0 +1,306 @@ +// npx vitest run src/services/stt/__tests__/SttService.spec.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import { SttService, SttConfig } from "../SttService" +import * as captureServer from "../capture-server" +import axios from "axios" + +// Mock vscode module +vi.mock("vscode", () => ({ + Uri: { + parse: vi.fn((uri: string) => ({ toString: () => uri })), + }, + env: { + asExternalUri: vi.fn((uri: any) => Promise.resolve(uri)), + openExternal: vi.fn(() => Promise.resolve(true)), + }, + window: { + showErrorMessage: vi.fn(), + }, +})) + +// Mock axios +vi.mock("axios") + +// Mock capture server +vi.mock("../capture-server", () => ({ + getCaptureServer: vi.fn(() => ({ + getPort: vi.fn(), + start: vi.fn(), + })), + stopCaptureServer: vi.fn(), +})) + +describe("SttService", () => { + let sttService: SttService + let mockCaptureServer: any + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + + // Reset singleton instance + SttService.resetInstance() + + // Setup mock capture server + mockCaptureServer = { + getPort: vi.fn(), + start: vi.fn().mockResolvedValue(3456), + } + vi.mocked(captureServer.getCaptureServer).mockReturnValue(mockCaptureServer) + }) + + afterEach(() => { + // Clean up + SttService.resetInstance() + }) + + describe("getInstance", () => { + it("should create a singleton instance", () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + autoStopTimeout: 5, + autoSend: true, + } + + const instance1 = SttService.getInstance(config) + const instance2 = SttService.getInstance() + + expect(instance1).toBe(instance2) + }) + + it("should throw error if getInstance called without config initially", () => { + expect(() => SttService.getInstance()).toThrow("SttService not initialized with config") + }) + }) + + describe("startCapture", () => { + it("should start capture with AssemblyAI provider", async () => { + // Mock config + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + autoStopTimeout: 5, + autoSend: true, + } + + // Initialize service + sttService = SttService.getInstance(config) + + // Mock axios for token creation + vi.mocked(axios.post).mockResolvedValue({ + data: { token: "temp-token-123" }, + }) + + // Mock capture server port + mockCaptureServer.getPort.mockReturnValue(null) + mockCaptureServer.start.mockResolvedValue(3456) + + // Start capture + const result = await sttService.startCapture() + + // Verify result contains capture URL + expect(result).toBeDefined() + expect(result).toContain("http://localhost:3456") + expect(result).toContain("provider=assemblyai") + expect(result).toContain("autoStopTimeout=5") + expect(result).toContain("autoSend=true") + + // Verify browser was opened + expect(vscode.env.openExternal).toHaveBeenCalled() + }) + + it("should throw error for OpenAI Whisper provider (not implemented)", async () => { + // Mock config + const config: SttConfig = { + provider: "openai-whisper", + apiKey: "test-openai-key", + autoStopTimeout: 3, + autoSend: false, + } + + // Initialize service + sttService = SttService.getInstance(config) + + // Start capture should throw + await expect(sttService.startCapture()).rejects.toThrow("OpenAI Whisper provider not yet implemented") + }) + + it("should throw error if API key is missing", async () => { + // Mock config without API key + const config: SttConfig = { + provider: "assemblyai", + } + + // Initialize service + sttService = SttService.getInstance(config) + + // Start capture should throw + await expect(sttService.startCapture()).rejects.toThrow("No API key configured for assemblyai") + }) + }) + + describe("stopCapture", () => { + it("should emit stop event", () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + } + + sttService = SttService.getInstance(config) + + // Add event listener + const stopHandler = vi.fn() + sttService.on("stop", stopHandler) + + // Stop capture + sttService.stopCapture() + + // Verify stop event was emitted + expect(stopHandler).toHaveBeenCalled() + }) + }) + + describe("getTemporaryToken", () => { + it("should create a temporary token for AssemblyAI", async () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + } + + sttService = SttService.getInstance(config) + + // Mock axios + vi.mocked(axios.post).mockResolvedValue({ + data: { token: "temp-token-123" }, + }) + + // Get token + const token = await sttService.getTemporaryToken() + + // Verify token + expect(token).toBe("temp-token-123") + + // Verify axios was called correctly + expect(axios.post).toHaveBeenCalledWith( + "https://api.assemblyai.com/v2/realtime/token", + { expires_in: 3600 }, + { + headers: { + authorization: "test-api-key", + }, + }, + ) + }) + + it("should return cached token if still valid", async () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + } + + sttService = SttService.getInstance(config) + + // Mock axios + vi.mocked(axios.post).mockResolvedValue({ + data: { token: "temp-token-123" }, + }) + + // Get token twice + const token1 = await sttService.getTemporaryToken() + const token2 = await sttService.getTemporaryToken() + + // Verify same token returned + expect(token1).toBe(token2) + + // Verify axios was called only once + expect(axios.post).toHaveBeenCalledTimes(1) + }) + + it("should fallback to API key on token creation failure", async () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + } + + sttService = SttService.getInstance(config) + + // Mock axios failure + vi.mocked(axios.post).mockRejectedValue(new Error("Network error")) + + // Get token + const token = await sttService.getTemporaryToken() + + // Verify fallback to API key + expect(token).toBe("test-api-key") + }) + }) + + describe("handleTranscript", () => { + it("should emit transcript event", () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + } + + sttService = SttService.getInstance(config) + + // Add event listener + const transcriptHandler = vi.fn() + sttService.on("transcript", transcriptHandler) + + // Handle transcript + sttService.handleTranscript("Hello world") + + // Verify transcript event was emitted + expect(transcriptHandler).toHaveBeenCalledWith({ + text: "Hello world", + isFinal: true, + }) + }) + }) + + describe("updateConfig", () => { + it("should update configuration", () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + autoStopTimeout: 5, + } + + sttService = SttService.getInstance(config) + + // Update config + sttService.updateConfig({ + autoStopTimeout: 10, + autoSend: true, + }) + + // Verify config was updated (we can't directly access private config, + // but we can verify through startCapture URL) + // This is tested indirectly through other tests + expect(sttService).toBeDefined() + }) + }) + + describe("resetInstance", () => { + it("should clean up and reset singleton", () => { + const config: SttConfig = { + provider: "assemblyai", + apiKey: "test-api-key", + } + + const instance1 = SttService.getInstance(config) + SttService.resetInstance() + + // Verify stopCaptureServer was called + expect(captureServer.stopCaptureServer).toHaveBeenCalled() + + // New instance should be different + const instance2 = SttService.getInstance(config) + expect(instance1).not.toBe(instance2) + }) + }) +}) diff --git a/src/services/stt/capture-page.html b/src/services/stt/capture-page.html new file mode 100644 index 0000000000..803b0da18a --- /dev/null +++ b/src/services/stt/capture-page.html @@ -0,0 +1,424 @@ + + + + + + Roo Code - Speech to Text + + + +
+

🎤 Roo Code Speech-to-Text

+ +
Initializing...
+ + + +
+ +
+ + + +
+
+ + + + \ No newline at end of file diff --git a/src/services/stt/capture-server.ts b/src/services/stt/capture-server.ts new file mode 100644 index 0000000000..6d9aa5c105 --- /dev/null +++ b/src/services/stt/capture-server.ts @@ -0,0 +1,92 @@ +import * as http from "http" +import * as fs from "fs" +import * as path from "path" +import * as url from "url" +import { AddressInfo } from "net" + +export class CaptureServer { + private server: http.Server | null = null + private port: number = 0 + + constructor() {} + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const parsedUrl = url.parse(req.url || "", true) + const pathname = parsedUrl.pathname + + if (pathname === "/capture") { + // Serve the capture page + const htmlPath = path.join(__dirname, "capture-page.html") + fs.readFile(htmlPath, "utf8", (err, data) => { + if (err) { + res.writeHead(500, { "Content-Type": "text/plain" }) + res.end("Error loading capture page") + return + } + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(data) + }) + } else if (pathname === "/health") { + // Health check endpoint + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ status: "ok" })) + } else { + // 404 for other paths + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("Not Found") + } + } + + public async start(): Promise { + return new Promise((resolve, reject) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res) + }) + + // Try to find an available port + this.server.listen(0, "127.0.0.1", () => { + if (this.server) { + const address = this.server.address() as AddressInfo + this.port = address.port + console.log(`STT Capture server started on port ${this.port}`) + resolve(this.port) + } else { + reject(new Error("Failed to start capture server")) + } + }) + + this.server.on("error", (error) => { + reject(error) + }) + }) + } + + public stop(): void { + if (this.server) { + this.server.close() + this.server = null + this.port = 0 + } + } + + public getPort(): number { + return this.port + } +} + +// Singleton instance +let captureServerInstance: CaptureServer | null = null + +export function getCaptureServer(): CaptureServer { + if (!captureServerInstance) { + captureServerInstance = new CaptureServer() + } + return captureServerInstance +} + +export function stopCaptureServer(): void { + if (captureServerInstance) { + captureServerInstance.stop() + captureServerInstance = null + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 42adea6d39..39581154fd 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -101,6 +101,13 @@ export interface ExtensionMessage { | "remoteBrowserEnabled" | "ttsStart" | "ttsStop" + | "sttStart" + | "sttStop" + | "sttTranscript" + | "sttError" + | "sttTokenReady" + | "sttCaptureStarted" + | "sttCaptureStopped" | "maxReadFileLine" | "fileSearchResults" | "toggleApiConfigPin" @@ -211,6 +218,10 @@ export interface ExtensionMessage { queuedMessages?: QueuedMessage[] list?: string[] // For dismissedUpsells organizationId?: string | null // For organizationSwitchResult + transcript?: string // For STT transcript + sttError?: string // For STT errors + sttToken?: string // For temporary STT token + sttCaptureUrl?: string // URL for browser-based capture } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e460f20384..aeb74980f4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -37,6 +37,15 @@ export interface WebviewMessage { | "loadApiConfigurationById" | "renameApiConfiguration" | "getListApiConfiguration" + | "startSttCapture" + | "stopSttCapture" + | "sttTranscriptReceived" + | "sttEnabled" + | "sttProvider" + | "sttAutoStopTimeout" + | "sttAutoSend" + | "assemblyAiApiKey" + | "openAiWhisperApiKey" | "customInstructions" | "allowedCommands" | "deniedCommands" @@ -279,6 +288,8 @@ export interface WebviewMessage { upsellId?: string // For dismissUpsell list?: string[] // For dismissedUpsells response organizationId?: string | null // For organization switching + transcript?: string // For STT transcript + sttError?: string // For STT errors codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c7813372fa..44559fa82a 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" -import { VolumeX, Image, WandSparkles, SendHorizontal, MessageSquareX } from "lucide-react" +import { VolumeX, Image, WandSparkles, SendHorizontal, MessageSquareX, Mic, MicOff } from "lucide-react" import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions" import { WebviewMessage } from "@roo/WebviewMessage" @@ -880,6 +880,7 @@ export const ChatTextArea = forwardRef( ) const [isTtsPlaying, setIsTtsPlaying] = useState(false) + const [isSttRecording, setIsSttRecording] = useState(false) useEvent("message", (event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -888,6 +889,27 @@ export const ChatTextArea = forwardRef( setIsTtsPlaying(true) } else if (message.type === "ttsStop") { setIsTtsPlaying(false) + } else if (message.type === "sttStart") { + setIsSttRecording(true) + } else if (message.type === "sttStop") { + setIsSttRecording(false) + } else if (message.type === "sttTranscript") { + // Append the transcript to the current input + if (message.transcript) { + const newValue = inputValue.trim() ? inputValue + " " + message.transcript : message.transcript + setInputValue(newValue) + // Focus the textarea + setTimeout(() => { + if (textAreaRef.current) { + textAreaRef.current.focus() + textAreaRef.current.setSelectionRange(newValue.length, newValue.length) + } + }, 0) + } + setIsSttRecording(false) + } else if (message.type === "sttError") { + console.error("STT Error:", message.sttError) + setIsSttRecording(false) } }) @@ -907,6 +929,15 @@ export const ChatTextArea = forwardRef( vscode.postMessage({ type: "loadApiConfigurationById", text: value }) }, []) + // Handle STT recording toggle + const handleSttToggle = useCallback(() => { + if (isSttRecording) { + vscode.postMessage({ type: "stopSttCapture" }) + } else { + vscode.postMessage({ type: "startSttCapture" }) + } + }, [isSttRecording]) + return (
( + + +