mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235e3ad665 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add voice dictation feature for Cline account users
|
||||
@@ -79,6 +79,7 @@
|
||||
"features/drag-and-drop",
|
||||
"features/plan-and-act",
|
||||
"features/slash-commands/workflows",
|
||||
"features/voice-recording",
|
||||
"features/editing-messages",
|
||||
{
|
||||
"group": "@ Mentions",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Voice Recording
|
||||
description:
|
||||
---
|
||||
|
||||
Cline lets you record audio messages in chat, which are transcribed using Cline's transcription service.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Enable dictation** in Feature Settings (it's on by default).
|
||||
2. **Click the microphone** in the chat input.
|
||||
3. **Speak** - the button turns red while recording.
|
||||
4. **Click stop** when done.
|
||||
5. **Wait for transcription** - "[Transcribing...]" will appear and then the finished transcription will appear.
|
||||
|
||||
## Settings
|
||||
|
||||
Enable or disable voice recording in Feature Settings by toggling "Enable Dictation." You can also change the transcription language to one of the supported languages.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Audio Recording Tools
|
||||
|
||||
Cline uses FFmpeg to capture your voice across all platforms:
|
||||
|
||||
- **macOS**: FFmpeg (via Homebrew: `brew install ffmpeg`)
|
||||
- **Linux**: FFmpeg (via apt: `sudo apt-get install ffmpeg`)
|
||||
- **Windows**: FFmpeg (via winget: `winget install Gyan.FFmpeg`)
|
||||
|
||||
If you don't have FFmpeg installed, Cline will automatically detect this and prompt you to install it with a single click.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Independent from Chat Provider
|
||||
|
||||
The voice transcription feature works completely independently from whatever chat provider you're using. You can use Claude, GPT-4, or any other model for your conversations, and voice transcription will always use Cline's own transcription service. As long as you have a valid Cline account with enough credits, voice mode will work regardless of your chat model choice.
|
||||
|
||||
### Audio Format
|
||||
|
||||
Voice recordings are captured in WebM format using the Opus codec for optimal compression. The system records in mono (single channel) at a 16kHz sample rate, which is specifically optimized for voice recognition. The bitrate is set to 32kbps to keep file sizes efficient while maintaining good audio quality.
|
||||
|
||||
### Privacy & Security
|
||||
|
||||
Your audio is recorded locally on your machine and only the audio file itself is sent to Cline's transcription service for processing. No audio is stored anywhere after transcription is complete, and all temporary files are automatically cleaned up to protect your privacy.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`Failed to start recording` - Audio recording tools aren't installed. Cline will prompt you to install FFmpeg - just follow the chat instructions.
|
||||
|
||||
`Invalid audio format or request data` - Usually an audio recording issue. Make sure FFmpeg is properly installed and working.
|
||||
|
||||
`Authentication failed` - You need to reauthenticate your Cline account. Sign out and back in, then check your internet connection.
|
||||
|
||||
`Insufficient credits for transcription service` - Your Cline account doesn't have enough credits. Check your balance and purchase more if needed.
|
||||
|
||||
`Cannot connect to transcription service` - Connection issue. Check your internet connection and firewall settings aren't blocking Cline's servers.
|
||||
|
||||
## API Usage
|
||||
|
||||
Voice transcription uses Cline's transcription service, which requires credits from your Cline account. Currently, voice transcription is billed at $0.006 per minute of audio.
|
||||
|
||||
**Note:** We are still experimenting with this feature and pricing may change in the future.
|
||||
Generated
+8770
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.20.5",
|
||||
"version": "3.20.8",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.20.5",
|
||||
"version": "3.20.8",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
syntax = "proto3";
|
||||
|
||||
import "cline/common.proto";
|
||||
|
||||
package cline;
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
service VoiceService {
|
||||
rpc startRecording(StartRecordingRequest) returns (RecordingResult);
|
||||
rpc stopRecording(StopRecordingRequest) returns (RecordedAudio);
|
||||
rpc getRecordingStatus(GetRecordingStatusRequest) returns (RecordingStatus);
|
||||
rpc transcribeAudio(TranscribeAudioRequest) returns (Transcription);
|
||||
}
|
||||
|
||||
// Request messages
|
||||
message StartRecordingRequest {
|
||||
Metadata metadata = 1;
|
||||
// Could add options later
|
||||
}
|
||||
|
||||
message StopRecordingRequest {
|
||||
Metadata metadata = 1;
|
||||
}
|
||||
|
||||
message GetRecordingStatusRequest {
|
||||
Metadata metadata = 1;
|
||||
}
|
||||
|
||||
message TranscribeAudioRequest {
|
||||
Metadata metadata = 1;
|
||||
string audio_base64 = 2;
|
||||
string language = 3; // optional language hint
|
||||
}
|
||||
|
||||
// Plain, reusable response types
|
||||
message RecordingResult {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message RecordedAudio {
|
||||
bool success = 1;
|
||||
string audio_base64 = 2;
|
||||
string error = 3;
|
||||
}
|
||||
|
||||
message RecordingStatus {
|
||||
bool is_recording = 1;
|
||||
double duration_seconds = 2;
|
||||
string error = 3;
|
||||
}
|
||||
|
||||
message Transcription {
|
||||
string text = 1;
|
||||
string error = 2;
|
||||
}
|
||||
@@ -731,6 +731,7 @@ export class Controller {
|
||||
localWindsurfRulesToggles,
|
||||
localCursorRulesToggles,
|
||||
localWorkflowToggles,
|
||||
dictationSettings,
|
||||
} = await getAllExtensionState(this.context)
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
@@ -785,6 +786,7 @@ export class Controller {
|
||||
welcomeViewCompleted: welcomeViewCompleted as boolean, // Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
|
||||
mcpResponsesCollapsed,
|
||||
terminalOutputLineLimit,
|
||||
dictationSettings,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RecordingStatus } from "@shared/proto/cline/voice"
|
||||
import { GetRecordingStatusRequest } from "@shared/proto/cline/voice"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Gets the current recording status
|
||||
* @param controller The controller instance
|
||||
* @param request The request (unused but required for consistency)
|
||||
* @returns RecordingStatus with current status
|
||||
*/
|
||||
export async function getRecordingStatus(controller: Controller, request: GetRecordingStatusRequest): Promise<RecordingStatus> {
|
||||
try {
|
||||
// TODO: Implement actual audio recording service
|
||||
// For now, return a default status
|
||||
return RecordingStatus.create({
|
||||
isRecording: false,
|
||||
durationSeconds: 0,
|
||||
error: "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error getting recording status:", error)
|
||||
return RecordingStatus.create({
|
||||
isRecording: false,
|
||||
durationSeconds: 0,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller } from ".."
|
||||
import { StartRecordingRequest, RecordingResult } from "@shared/proto/cline/voice"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
|
||||
/**
|
||||
* Starts audio recording using the Extension Host
|
||||
* @param controller The controller instance
|
||||
* @param request StartRecordingRequest
|
||||
* @returns RecordingResult with success status
|
||||
*/
|
||||
export const startRecording = async (controller: Controller, _request: StartRecordingRequest): Promise<RecordingResult> => {
|
||||
const taskId = controller.task?.taskId
|
||||
|
||||
try {
|
||||
const userInfo = AuthService.getInstance().getInfo()
|
||||
if (!userInfo?.user?.uid) {
|
||||
throw new Error("User is not authenticated. Please log in first.")
|
||||
}
|
||||
|
||||
const result = await audioRecordingService.startRecording()
|
||||
|
||||
// Capture telemetry for recording start
|
||||
if (result.success) {
|
||||
telemetryService.captureVoiceRecordingStarted(taskId, process.platform)
|
||||
}
|
||||
|
||||
return RecordingResult.create({
|
||||
success: result.success,
|
||||
error: result.error || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error starting recording:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Voice recording error: ${errorMessage}`,
|
||||
})
|
||||
return RecordingResult.create({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Controller } from ".."
|
||||
import { StopRecordingRequest, RecordedAudio } from "@shared/proto/cline/voice"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
|
||||
/**
|
||||
* Stops audio recording and returns the recorded audio
|
||||
* @param controller The controller instance
|
||||
* @param request StopRecordingRequest
|
||||
* @returns RecordedAudio with audio data
|
||||
*/
|
||||
export const stopRecording = async (controller: Controller, _request: StopRecordingRequest): Promise<RecordedAudio> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const recordingStatus = audioRecordingService.getRecordingStatus()
|
||||
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
|
||||
|
||||
try {
|
||||
const result = await audioRecordingService.stopRecording()
|
||||
|
||||
// Capture telemetry for recording stop
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, result.success, process.platform)
|
||||
|
||||
// Calculate audio size if available
|
||||
|
||||
return RecordedAudio.create({
|
||||
success: result.success,
|
||||
audioBase64: result.audioBase64 || "",
|
||||
error: result.error || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error stopping recording:", error)
|
||||
|
||||
// Capture telemetry for recording failure
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
|
||||
|
||||
return RecordedAudio.create({
|
||||
success: false,
|
||||
audioBase64: "",
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Controller } from ".."
|
||||
import { TranscribeAudioRequest, Transcription } from "@shared/proto/cline/voice"
|
||||
import { voiceTranscriptionService } from "@/services/dictation/VoiceTranscriptionService"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
|
||||
/**
|
||||
* Transcribes audio using Cline transcription service
|
||||
* @param controller The controller instance
|
||||
* @param request TranscribeAudioRequest containing base64 audio data
|
||||
* @returns Transcription with transcribed text or error
|
||||
*/
|
||||
export const transcribeAudio = async (controller: Controller, request: TranscribeAudioRequest): Promise<Transcription> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const startTime = Date.now()
|
||||
|
||||
// Calculate audio size from base64
|
||||
const audioSizeBytes = Math.ceil((request.audioBase64.length * 3) / 4)
|
||||
|
||||
// Capture telemetry for transcription start
|
||||
telemetryService.captureVoiceTranscriptionStarted(taskId, audioSizeBytes, request.language || "en")
|
||||
|
||||
try {
|
||||
// Transcribe the audio
|
||||
const result = await voiceTranscriptionService.transcribeAudio(request.audioBase64, request.language || undefined)
|
||||
const durationMs = Date.now() - startTime
|
||||
|
||||
// Handle transcription result
|
||||
if (result.error) {
|
||||
// Determine error type for telemetry
|
||||
let errorType = "api_error"
|
||||
if (result.error.includes("Authentication failed")) {
|
||||
errorType = "invalid_jwt_token"
|
||||
} else if (result.error.includes("Insufficient credits")) {
|
||||
errorType = "insufficient_credits"
|
||||
} else if (result.error.includes("Invalid audio format")) {
|
||||
errorType = "invalid_audio_format"
|
||||
} else if (result.error.includes("Cannot connect")) {
|
||||
errorType = "connection_error"
|
||||
} else if (result.error.includes("Network error")) {
|
||||
errorType = "network_error"
|
||||
}
|
||||
|
||||
// Capture telemetry for transcription error
|
||||
telemetryService.captureVoiceTranscriptionError(taskId, errorType, result.error, durationMs)
|
||||
|
||||
let errorMessage = ""
|
||||
// Show error notification if transcription failed
|
||||
if (result.error.includes("Authentication failed")) {
|
||||
errorMessage = "Authentication failed. Please log in again."
|
||||
} else if (result.error.includes("Insufficient credits")) {
|
||||
errorMessage = "Insufficient credits for transcription service."
|
||||
} else if (result.error.includes("Cannot connect")) {
|
||||
errorMessage = "Cannot connect to transcription service."
|
||||
} else {
|
||||
errorMessage = `Voice transcription failed: ${result.error}`
|
||||
}
|
||||
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
} else if (result.text) {
|
||||
// Capture telemetry for successful transcription
|
||||
telemetryService.captureVoiceTranscriptionCompleted(taskId, result.text.length, durationMs, request.language || "en")
|
||||
}
|
||||
|
||||
// Return the response
|
||||
return Transcription.create({
|
||||
text: result.text || "",
|
||||
error: result.error || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error transcribing audio:", error)
|
||||
const durationMs = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
|
||||
// Capture telemetry for unexpected error
|
||||
telemetryService.captureVoiceTranscriptionError(taskId, "unexpected_error", errorMessage, durationMs)
|
||||
|
||||
return Transcription.create({
|
||||
text: "",
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export type GlobalStateKey =
|
||||
// Settings around plan/act and ephemeral model configuration
|
||||
| "preferredLanguage"
|
||||
| "openaiReasoningEffort"
|
||||
| "dictationSettings"
|
||||
| "mode"
|
||||
// Plan mode configurations
|
||||
| "planModeApiProvider"
|
||||
|
||||
@@ -2,11 +2,13 @@ import * as vscode from "vscode"
|
||||
import { Mode, OpenaiReasoningEffort } from "@shared/storage/types"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { DEFAULT_DICTATION_SETTINGS } from "@shared/DictationSettings"
|
||||
import { GlobalStateKey, LocalStateKey, SecretKey } from "./state-keys"
|
||||
import { ApiConfiguration, ApiProvider, BedrockModelId, ModelInfo } from "@shared/api"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "@shared/BrowserSettings"
|
||||
import { DictationSettings } from "@shared/DictationSettings"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { UserInfo } from "@shared/UserInfo"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
@@ -338,6 +340,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
actModeHuggingFaceModelInfo,
|
||||
actModeHuaweiCloudMaasModelId,
|
||||
actModeHuaweiCloudMaasModelInfo,
|
||||
dictationSettings,
|
||||
] = await Promise.all([
|
||||
getGlobalState(context, "preferredLanguage") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openaiReasoningEffort") as Promise<OpenaiReasoningEffort | undefined>,
|
||||
@@ -397,6 +400,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getGlobalState(context, "actModeHuggingFaceModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "actModeHuaweiCloudMaasModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "actModeHuaweiCloudMaasModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "dictationSettings") as Promise<DictationSettings | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
@@ -579,6 +583,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
localWindsurfRulesToggles: localWindsurfRulesToggles || {},
|
||||
localCursorRulesToggles: localCursorRulesToggles || {},
|
||||
localWorkflowToggles: localWorkflowToggles || {},
|
||||
dictationSettings: dictationSettings || DEFAULT_DICTATION_SETTINGS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,4 +240,22 @@ export class ClineAccountService {
|
||||
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribes audio using the Cline transcription service
|
||||
* @param audioBase64 - Base64 encoded audio data
|
||||
* @param language - Optional language hint for transcription
|
||||
* @returns Promise with transcribed text or error
|
||||
*/
|
||||
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text: string }> {
|
||||
const response = await this.authenticatedRequest<{ text: string }>(`/api/v1/chat/transcriptions`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
audioData: audioBase64,
|
||||
language: language || "en",
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioRecordingService {
|
||||
private recordingProcess: ChildProcess | null = null
|
||||
private isRecording: boolean = false
|
||||
private startTime: number = 0
|
||||
private outputFile: string = ""
|
||||
|
||||
constructor() {}
|
||||
|
||||
async startRecording(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (this.isRecording) {
|
||||
return { success: false, error: "Already recording" }
|
||||
}
|
||||
|
||||
// Check if recording software is available
|
||||
const checkResult = this.checkRecordingDependencies()
|
||||
if (!checkResult.available) {
|
||||
return { success: false, error: checkResult.error }
|
||||
}
|
||||
|
||||
// Create temporary file for audio output
|
||||
const tempDir = os.tmpdir()
|
||||
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
|
||||
|
||||
Logger.info("Starting audio recording...")
|
||||
|
||||
// Get the recording program path
|
||||
const recordProgram = this.getRecordProgram()
|
||||
if (!recordProgram) {
|
||||
return { success: false, error: "Recording program not found" }
|
||||
}
|
||||
Logger.info(`Using recording program: ${recordProgram.path}`)
|
||||
|
||||
// Set up recording arguments
|
||||
const args = recordProgram.getArgs(this.outputFile)
|
||||
|
||||
// Spawn the recording process
|
||||
this.recordingProcess = spawn(recordProgram.path, args)
|
||||
this.isRecording = true
|
||||
this.startTime = Date.now()
|
||||
|
||||
// Handle process errors
|
||||
this.recordingProcess.on("error", (error) => {
|
||||
Logger.error(`Recording process error: ${error.message}`)
|
||||
this.isRecording = false
|
||||
})
|
||||
|
||||
// Handle process exit
|
||||
this.recordingProcess.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
Logger.warn(`Recording process exited with code: ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
this.recordingProcess.stderr?.on("data", (data) => {
|
||||
const message = data.toString().trim()
|
||||
if (message && !message.includes("In:") && !message.includes("Out:")) {
|
||||
Logger.info(`Recording stderr: ${message}`)
|
||||
}
|
||||
})
|
||||
|
||||
Logger.info("Audio recording started successfully")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to start audio recording: " + errorMessage)
|
||||
return { success: false, error: `Failed to start recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording(): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
||||
try {
|
||||
if (!this.isRecording || !this.recordingProcess) {
|
||||
return { success: false, error: "Not currently recording" }
|
||||
}
|
||||
|
||||
Logger.info("Stopping audio recording...")
|
||||
|
||||
// Send SIGINT to stop recording gracefully (like Ctrl+C)
|
||||
this.recordingProcess.kill("SIGINT")
|
||||
|
||||
// Wait for the process to finish
|
||||
await new Promise<void>((resolve) => {
|
||||
if (this.recordingProcess) {
|
||||
// Timeout after 5 seconds
|
||||
const timeoutId = setTimeout(() => {
|
||||
resolve()
|
||||
}, 5000)
|
||||
|
||||
this.recordingProcess.on("exit", (code) => {
|
||||
clearTimeout(timeoutId) // Clear the timeout since process exited
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
this.recordingProcess = null
|
||||
this.isRecording = false
|
||||
|
||||
// Wait a moment for file to be fully written
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// Read the audio file and convert to base64
|
||||
if (!fs.existsSync(this.outputFile)) {
|
||||
return { success: false, error: "Recording file not found" }
|
||||
}
|
||||
|
||||
const audioBuffer = fs.readFileSync(this.outputFile)
|
||||
const audioBase64 = audioBuffer.toString("base64")
|
||||
|
||||
// Clean up temporary file
|
||||
try {
|
||||
fs.unlinkSync(this.outputFile)
|
||||
} catch (cleanupError) {
|
||||
Logger.warn(
|
||||
"Failed to cleanup temporary audio file: " +
|
||||
(cleanupError instanceof Error ? cleanupError.message : String(cleanupError)),
|
||||
)
|
||||
}
|
||||
|
||||
Logger.info("Audio recording stopped and converted to base64")
|
||||
return { success: true, audioBase64 }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to stop audio recording: " + errorMessage)
|
||||
return { success: false, error: `Failed to stop recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
getRecordingStatus(): { isRecording: boolean; durationSeconds: number; error?: string } {
|
||||
const durationSeconds = this.isRecording ? (Date.now() - this.startTime) / 1000 : 0
|
||||
return {
|
||||
isRecording: this.isRecording,
|
||||
durationSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
private checkRecordingDependencies(): { available: boolean; error?: string } {
|
||||
const program = this.getRecordProgram()
|
||||
if (!program) {
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
const error = config ? config.error : `Audio recording is not supported on platform: ${platform}`
|
||||
return { available: false, error }
|
||||
}
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
private getRecordProgram(): { path: string; getArgs: (outputFile: string) => string[] } | undefined {
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
|
||||
if (!config) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 1. Check if the command is in the system's PATH
|
||||
const pathDirs = (process.env.PATH || "").split(path.delimiter)
|
||||
for (const dir of pathDirs) {
|
||||
const fullPath = path.join(dir, config.command)
|
||||
if (fs.existsSync(fullPath) && isExecutable(fullPath)) {
|
||||
return { path: fullPath, getArgs: config.getArgs }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check fallback paths if not in PATH
|
||||
for (const p of config.fallbackPaths) {
|
||||
if (fs.existsSync(p) && isExecutable(p)) {
|
||||
return { path: p, getArgs: config.getArgs }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Cleanup method
|
||||
cleanup(): void {
|
||||
if (this.isRecording && this.recordingProcess) {
|
||||
try {
|
||||
this.recordingProcess.kill("SIGINT")
|
||||
this.recordingProcess = null
|
||||
this.isRecording = false
|
||||
} catch (error) {
|
||||
Logger.error("Error during cleanup: " + (error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any leftover temp files
|
||||
if (this.outputFile && fs.existsSync(this.outputFile)) {
|
||||
try {
|
||||
fs.unlinkSync(this.outputFile)
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
"Failed to cleanup temp file during service cleanup: " +
|
||||
(error instanceof Error ? error.message : String(error)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const audioRecordingService = new AudioRecordingService()
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import axios from "axios"
|
||||
|
||||
export class VoiceTranscriptionService {
|
||||
private clineAccountService: ClineAccountService
|
||||
|
||||
constructor() {
|
||||
this.clineAccountService = ClineAccountService.getInstance()
|
||||
}
|
||||
|
||||
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
|
||||
try {
|
||||
Logger.info("Transcribing audio with Cline transcription service...")
|
||||
|
||||
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
|
||||
|
||||
Logger.info("Transcription successful")
|
||||
|
||||
return { text: result.text }
|
||||
} catch (error) {
|
||||
Logger.error("Voice transcription error:", error)
|
||||
|
||||
// Handle axios errors with proper status code mapping
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message || error.message
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
return { error: "Authentication failed. Please reauthenticate your Cline account" }
|
||||
case 402:
|
||||
return { error: "Insufficient credits for transcription service." }
|
||||
case 400:
|
||||
return { error: "Invalid audio format or request data." }
|
||||
case 500:
|
||||
return { error: "Transcription server error. Please try again later." }
|
||||
default:
|
||||
return { error: `Transcription failed: ${message}` }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle network errors
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (errorMessage.includes("ECONNREFUSED") || errorMessage.includes("Network Error")) {
|
||||
return { error: "Cannot connect to transcription service." }
|
||||
}
|
||||
|
||||
return { error: `Network error: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceTranscriptionService = new VoiceTranscriptionService()
|
||||
@@ -21,7 +21,7 @@ import { ClineAccountUserInfo } from "@/services/auth/AuthService"
|
||||
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
|
||||
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
|
||||
*/
|
||||
type TelemetryCategory = "checkpoints" | "browser"
|
||||
type TelemetryCategory = "checkpoints" | "browser" | "voice"
|
||||
|
||||
/**
|
||||
* Maximum length for error messages to prevent excessive data
|
||||
@@ -33,6 +33,7 @@ class TelemetryService {
|
||||
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
|
||||
["checkpoints", false], // Checkpoints telemetry disabled
|
||||
["browser", true], // Browser telemetry enabled
|
||||
["voice", true], // Voice telemetry enabled
|
||||
])
|
||||
|
||||
// Event constants for tracking user interactions and system events
|
||||
@@ -92,6 +93,20 @@ class TelemetryService {
|
||||
// Tracks when a button is clicked
|
||||
BUTTON_CLICKED: "ui.button_clicked",
|
||||
},
|
||||
// Voice-related events for tracking voice recording and transcription usage
|
||||
VOICE: {
|
||||
// Tracks when voice recording is started
|
||||
RECORDING_STARTED: "voice.recording_started",
|
||||
// Tracks when voice recording is stopped
|
||||
RECORDING_STOPPED: "voice.recording_stopped",
|
||||
// Tracks when voice transcription is started
|
||||
TRANSCRIPTION_STARTED: "voice.transcription_started",
|
||||
// Tracks when voice transcription is completed successfully
|
||||
TRANSCRIPTION_COMPLETED: "voice.transcription_completed",
|
||||
// Tracks when voice transcription fails
|
||||
TRANSCRIPTION_ERROR: "voice.transcription_error",
|
||||
// Tracks when voice feature is enabled or disabled in settings
|
||||
},
|
||||
}
|
||||
|
||||
/** Singleton instance of the TelemetryService */
|
||||
@@ -193,8 +208,9 @@ class TelemetryService {
|
||||
/**
|
||||
* Captures a telemetry event if telemetry is enabled
|
||||
* @param event The event to capture with its properties
|
||||
* @param collect Optional flag to determine if the event should be collected for batch sending (unused for now)
|
||||
*/
|
||||
public capture(event: { event: string; properties?: any }): void {
|
||||
public capture(event: { event: string; properties?: any }, collect?: boolean): void {
|
||||
if (!this.telemetryEnabled) {
|
||||
return
|
||||
}
|
||||
@@ -678,6 +694,164 @@ class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
// Voice events
|
||||
/**
|
||||
* Records when voice recording is started
|
||||
* @param taskId Optional task identifier if recording was started during a task
|
||||
* @param platform The platform where recording is happening (macOS, Windows, Linux)
|
||||
* @param collect If true, collect event instead of sending
|
||||
*/
|
||||
public captureVoiceRecordingStarted(taskId?: string, platform?: string, collect: boolean = false) {
|
||||
if (!this.isCategoryEnabled("voice")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture(
|
||||
{
|
||||
event: TelemetryService.EVENTS.VOICE.RECORDING_STARTED,
|
||||
properties: {
|
||||
taskId,
|
||||
platform: platform || process.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
collect,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice recording is stopped
|
||||
* @param taskId Optional task identifier if recording was stopped during a task
|
||||
* @param durationMs Duration of the recording in milliseconds
|
||||
* @param success Whether the recording was successful
|
||||
* @param platform The platform where recording happened
|
||||
* @param collect If true, collect event instead of sending
|
||||
*/
|
||||
public captureVoiceRecordingStopped(
|
||||
taskId?: string,
|
||||
durationMs?: number,
|
||||
success?: boolean,
|
||||
platform?: string,
|
||||
collect?: boolean,
|
||||
) {
|
||||
if (!this.isCategoryEnabled("voice")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture(
|
||||
{
|
||||
event: TelemetryService.EVENTS.VOICE.RECORDING_STOPPED,
|
||||
properties: {
|
||||
taskId,
|
||||
durationMs,
|
||||
success,
|
||||
platform: platform || process.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
collect || false,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription is started
|
||||
* @param taskId Optional task identifier if transcription was started during a task
|
||||
* @param audioSizeBytes Size of the audio data being transcribed
|
||||
* @param language Language hint provided for transcription
|
||||
* @param collect If true, collect event instead of sending
|
||||
*/
|
||||
public captureVoiceTranscriptionStarted(
|
||||
taskId?: string,
|
||||
audioSizeBytes?: number,
|
||||
language?: string,
|
||||
collect: boolean = false,
|
||||
) {
|
||||
if (!this.isCategoryEnabled("voice")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture(
|
||||
{
|
||||
event: TelemetryService.EVENTS.VOICE.TRANSCRIPTION_STARTED,
|
||||
properties: {
|
||||
taskId,
|
||||
audioSizeBytes,
|
||||
language,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
collect,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription is completed successfully
|
||||
* @param taskId Optional task identifier if transcription was completed during a task
|
||||
* @param transcriptionLength Length of the transcribed text
|
||||
* @param durationMs Time taken for transcription in milliseconds
|
||||
* @param language Language used for transcription
|
||||
* @param collect If true, collect event instead of sending
|
||||
*/
|
||||
public captureVoiceTranscriptionCompleted(
|
||||
taskId?: string,
|
||||
transcriptionLength?: number,
|
||||
durationMs?: number,
|
||||
language?: string,
|
||||
collect: boolean = false,
|
||||
) {
|
||||
if (!this.isCategoryEnabled("voice")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture(
|
||||
{
|
||||
event: TelemetryService.EVENTS.VOICE.TRANSCRIPTION_COMPLETED,
|
||||
properties: {
|
||||
taskId,
|
||||
transcriptionLength,
|
||||
durationMs,
|
||||
language,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
collect,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription fails
|
||||
* @param taskId Optional task identifier if transcription failed during a task
|
||||
* @param errorType Type of error that occurred (e.g., "no_openai_key", "api_error", "network_error")
|
||||
* @param errorMessage The error message
|
||||
* @param durationMs Time taken before failure in milliseconds
|
||||
* @param collect If true, collect event instead of sending
|
||||
*/
|
||||
public captureVoiceTranscriptionError(
|
||||
taskId?: string,
|
||||
errorType?: string,
|
||||
errorMessage?: string,
|
||||
durationMs?: number,
|
||||
collect: boolean = false,
|
||||
) {
|
||||
if (!this.isCategoryEnabled("voice")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture(
|
||||
{
|
||||
event: TelemetryService.EVENTS.VOICE.TRANSCRIPTION_ERROR,
|
||||
properties: {
|
||||
taskId,
|
||||
errorType,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
collect,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if telemetry is enabled
|
||||
* @returns Boolean indicating whether telemetry is enabled
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export interface DictationSettings {
|
||||
voiceRecordingEnabled: boolean
|
||||
dictationLanguage: string
|
||||
}
|
||||
|
||||
export const DEFAULT_DICTATION_SETTINGS: DictationSettings = {
|
||||
voiceRecordingEnabled: true,
|
||||
dictationLanguage: "en",
|
||||
}
|
||||
|
||||
export interface LanguageItem {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export const SUPPORTED_DICTATION_LANGUAGES: LanguageItem[] = [
|
||||
{ name: "English", code: "en" },
|
||||
{ name: "Spanish (Español)", code: "es" },
|
||||
{ name: "Chinese (中文)", code: "zh" },
|
||||
{ name: "Japanese (日本語)", code: "ja" },
|
||||
{ name: "Afrikaans", code: "af" },
|
||||
{ name: "Arabic (العربية)", code: "ar" },
|
||||
{ name: "Armenian (Հայերեն)", code: "hy" },
|
||||
{ name: "Azerbaijani (Azərbaycan)", code: "az" },
|
||||
{ name: "Belarusian (Беларуская)", code: "be" },
|
||||
{ name: "Bosnian (Bosanski)", code: "bs" },
|
||||
{ name: "Bulgarian (Български)", code: "bg" },
|
||||
{ name: "Catalan (Català)", code: "ca" },
|
||||
{ name: "Croatian (Hrvatski)", code: "hr" },
|
||||
{ name: "Czech (Čeština)", code: "cs" },
|
||||
{ name: "Danish (Dansk)", code: "da" },
|
||||
{ name: "Dutch (Nederlands)", code: "nl" },
|
||||
{ name: "Estonian (Eesti)", code: "et" },
|
||||
{ name: "Finnish (Suomi)", code: "fi" },
|
||||
{ name: "French (Français)", code: "fr" },
|
||||
{ name: "Galician (Galego)", code: "gl" },
|
||||
{ name: "German (Deutsch)", code: "de" },
|
||||
{ name: "Greek (Ελληνικά)", code: "el" },
|
||||
{ name: "Hebrew (עברית)", code: "he" },
|
||||
{ name: "Hindi (हिन्दी)", code: "hi" },
|
||||
{ name: "Hungarian (Magyar)", code: "hu" },
|
||||
{ name: "Icelandic (Íslenska)", code: "is" },
|
||||
{ name: "Indonesian (Bahasa Indonesia)", code: "id" },
|
||||
{ name: "Italian (Italiano)", code: "it" },
|
||||
{ name: "Kannada (ಕನ್ನಡ)", code: "kn" },
|
||||
{ name: "Kazakh (Қазақша)", code: "kk" },
|
||||
{ name: "Korean (한국어)", code: "ko" },
|
||||
{ name: "Latvian (Latviešu)", code: "lv" },
|
||||
{ name: "Lithuanian (Lietuvių)", code: "lt" },
|
||||
{ name: "Macedonian (Македонски)", code: "mk" },
|
||||
{ name: "Malay (Bahasa Melayu)", code: "ms" },
|
||||
{ name: "Marathi (मराठी)", code: "mr" },
|
||||
{ name: "Maori (Te Reo Māori)", code: "mi" },
|
||||
{ name: "Nepali (नेपाली)", code: "ne" },
|
||||
{ name: "Norwegian (Norsk)", code: "no" },
|
||||
{ name: "Persian (فارسی)", code: "fa" },
|
||||
{ name: "Polish (Polski)", code: "pl" },
|
||||
{ name: "Portuguese (Português)", code: "pt" },
|
||||
{ name: "Romanian (Română)", code: "ro" },
|
||||
{ name: "Russian (Русский)", code: "ru" },
|
||||
{ name: "Serbian (Српски)", code: "sr" },
|
||||
{ name: "Slovak (Slovenčina)", code: "sk" },
|
||||
{ name: "Slovenian (Slovenščina)", code: "sl" },
|
||||
{ name: "Swahili (Kiswahili)", code: "sw" },
|
||||
{ name: "Swedish (Svenska)", code: "sv" },
|
||||
{ name: "Tagalog", code: "tl" },
|
||||
{ name: "Tamil (தமிழ்)", code: "ta" },
|
||||
{ name: "Thai (ไทย)", code: "th" },
|
||||
{ name: "Turkish (Türkçe)", code: "tr" },
|
||||
{ name: "Ukrainian (Українська)", code: "uk" },
|
||||
{ name: "Urdu (اردو)", code: "ur" },
|
||||
{ name: "Vietnamese (Tiếng Việt)", code: "vi" },
|
||||
{ name: "Welsh (Cymraeg)", code: "cy" },
|
||||
]
|
||||
@@ -3,6 +3,7 @@ import { ApiConfiguration } from "./api"
|
||||
import { AutoApprovalSettings } from "./AutoApprovalSettings"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { Mode, OpenaiReasoningEffort } from "./storage/types"
|
||||
import { DictationSettings } from "./DictationSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { ClineRulesToggles } from "./cline-rules"
|
||||
@@ -33,6 +34,7 @@ export interface ExtensionState {
|
||||
apiConfiguration?: ApiConfiguration
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
dictationSettings: DictationSettings
|
||||
remoteBrowserHost?: string
|
||||
preferredLanguage?: string
|
||||
openaiReasoningEffort?: OpenaiReasoningEffort
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export const AUDIO_PROGRAM_CONFIG = {
|
||||
darwin: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"avfoundation",
|
||||
"-i",
|
||||
":default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
error: "It looks like your system is missing the 'FFmpeg' utility, which is required for voice recording. To install it, please ensure you are in **Act Mode** and then send this message. I will handle the installation for you.\n\n**Installation command:** `brew install ffmpeg`",
|
||||
},
|
||||
linux: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg"],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"alsa",
|
||||
"-i",
|
||||
"default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
error: "It looks like your system is missing the 'FFmpeg' utility, which is required for voice recording. To install it, please ensure you are in **Act Mode** and then send this message. I will handle the installation for you.\n\n**Installation command:** `sudo apt-get install ffmpeg`",
|
||||
},
|
||||
win32: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: ["C:\\ffmpeg\\bin\\ffmpeg.exe", "C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe"],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"wasapi",
|
||||
"-i",
|
||||
"audio=default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
error: "It looks like your system is missing the 'FFmpeg' utility, which is required for voice recording. To install it, please ensure you are in **Act Mode** and then send this message. I will handle the installation for you.\n\n**Installation command:** `winget install Gyan.FFmpeg`",
|
||||
},
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import ServersToggleModal from "./ServersToggleModal"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
|
||||
const { MAX_IMAGES_AND_FILES_PER_MESSAGE } = CHAT_CONSTANTS
|
||||
import VoiceRecorder from "./VoiceRecorder"
|
||||
|
||||
const getImageDimensions = (dataUrl: string): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -277,8 +278,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { filePaths, mode, apiConfiguration, openRouterModels, platform, localWorkflowToggles, globalWorkflowToggles } =
|
||||
useExtensionState()
|
||||
const {
|
||||
filePaths,
|
||||
apiConfiguration,
|
||||
openRouterModels,
|
||||
platform,
|
||||
localWorkflowToggles,
|
||||
globalWorkflowToggles,
|
||||
dictationSettings,
|
||||
mode,
|
||||
} = useExtensionState()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
const [gitCommits, setGitCommits] = useState<GitCommit[]>([])
|
||||
@@ -1567,7 +1576,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
// borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead
|
||||
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
|
||||
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
|
||||
padding: "9px 28px 9px 9px",
|
||||
padding: `9px ${dictationSettings?.voiceRecordingEnabled ? "48" : "28"}px 9px 9px`,
|
||||
cursor: "text",
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
@@ -1608,10 +1617,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
position: "absolute",
|
||||
right: 23,
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
alignItems: "center",
|
||||
height: textAreaBaseHeight || 31,
|
||||
bottom: 9.5, // should be 10 but doesn't look good on mac
|
||||
paddingBottom: "8px",
|
||||
zIndex: 2,
|
||||
}}>
|
||||
<div
|
||||
@@ -1620,6 +1628,35 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
{dictationSettings?.voiceRecordingEnabled === true && (
|
||||
<VoiceRecorder
|
||||
onTranscription={(text) => {
|
||||
// Remove any processing text first
|
||||
const cleanedValue = inputValue.replace(/\s*\[Transcribing\.\.\.\]$/, "")
|
||||
// Append the transcribed text to the cleaned input
|
||||
const newValue = cleanedValue + (cleanedValue ? " " : "") + text
|
||||
setInputValue(newValue)
|
||||
// Focus the textarea and move cursor to end
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.focus()
|
||||
const length = newValue.length
|
||||
textAreaRef.current.setSelectionRange(length, length)
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
onProcessingStateChange={(isProcessing, message) => {
|
||||
if (isProcessing && message) {
|
||||
// Show processing message in input
|
||||
const processingText = inputValue + (inputValue ? " " : "") + `[${message}]`
|
||||
setInputValue(processingText)
|
||||
}
|
||||
// When processing is done, the onTranscription callback will handle the final text
|
||||
}}
|
||||
disabled={sendingDisabled}
|
||||
language={dictationSettings?.dictationLanguage || "en"}
|
||||
/>
|
||||
)}
|
||||
{/* <div
|
||||
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react"
|
||||
import { VoiceServiceClient } from "@/services/grpc-client"
|
||||
import {
|
||||
StartRecordingRequest,
|
||||
StopRecordingRequest,
|
||||
TranscribeAudioRequest,
|
||||
GetRecordingStatusRequest,
|
||||
} from "@shared/proto/cline/voice"
|
||||
|
||||
import HeroTooltip from "../common/HeroTooltip"
|
||||
import { formatSeconds } from "@/utils/format"
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onTranscription: (text: string) => void
|
||||
onProcessingStateChange?: (isProcessing: boolean, message?: string) => void
|
||||
disabled?: boolean
|
||||
language?: string
|
||||
}
|
||||
|
||||
const MAX_DURATION = 5 * 60 // 5 minutes in seconds
|
||||
|
||||
const VoiceRecorder: React.FC<VoiceRecorderProps> = ({
|
||||
onTranscription,
|
||||
onProcessingStateChange,
|
||||
disabled = false,
|
||||
language = "en",
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [recordingDuration, setRecordingDuration] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
setIsRecording(true)
|
||||
setError(null) // Clear any previous errors
|
||||
onProcessingStateChange?.(false) // Clear any previous processing state
|
||||
setRecordingDuration(0) // Reset recording duration
|
||||
|
||||
// Call Extension Host to start recording
|
||||
const response = await VoiceServiceClient.startRecording(StartRecordingRequest.create({}))
|
||||
|
||||
if (!response.success) {
|
||||
console.error("Failed to start recording:", response.error)
|
||||
setIsRecording(false)
|
||||
setError(response.error || "Failed to start recording")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Recording started successfully")
|
||||
} catch (error) {
|
||||
console.error("Error starting recording:", error)
|
||||
setIsRecording(false)
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to start recording"
|
||||
setError(errorMessage)
|
||||
}
|
||||
}, [onProcessingStateChange])
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
try {
|
||||
setIsRecording(false)
|
||||
setIsProcessing(true)
|
||||
onProcessingStateChange?.(true, "Processing...")
|
||||
|
||||
// Call Extension Host to stop recording and get audio
|
||||
const response = await VoiceServiceClient.stopRecording(StopRecordingRequest.create({}))
|
||||
|
||||
if (!response.success) {
|
||||
console.error("Failed to stop recording:", response.error)
|
||||
setIsProcessing(false)
|
||||
const errorMessage = response.error || "Failed to stop recording"
|
||||
setError(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.audioBase64) {
|
||||
console.error("No audio data received")
|
||||
setIsProcessing(false)
|
||||
const errorMessage = "No audio data received"
|
||||
setError(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Update processing state for transcription
|
||||
onProcessingStateChange?.(true, "Transcribing...")
|
||||
|
||||
// Transcribe the audio using OpenAI Whisper
|
||||
const transcriptionResponse = await VoiceServiceClient.transcribeAudio(
|
||||
TranscribeAudioRequest.create({
|
||||
audioBase64: response.audioBase64,
|
||||
language: language,
|
||||
}),
|
||||
)
|
||||
|
||||
if (transcriptionResponse.error) {
|
||||
console.error("Transcription error:", transcriptionResponse.error)
|
||||
setError(transcriptionResponse.error)
|
||||
// Clear the error after a delay
|
||||
setTimeout(() => {
|
||||
setError(null)
|
||||
onProcessingStateChange?.(false)
|
||||
}, 5000)
|
||||
} else if (transcriptionResponse.text) {
|
||||
setError(null)
|
||||
onTranscription(transcriptionResponse.text)
|
||||
onProcessingStateChange?.(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error stopping recording:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : "An error occurred"
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [onTranscription, onProcessingStateChange])
|
||||
|
||||
// Poll recording status while recording to update duration
|
||||
useEffect(() => {
|
||||
const pollRecordingStatus = async () => {
|
||||
try {
|
||||
const statusResponse = await VoiceServiceClient.getRecordingStatus(GetRecordingStatusRequest.create({}))
|
||||
if (statusResponse.isRecording) {
|
||||
setRecordingDuration(Math.floor(statusResponse.durationSeconds))
|
||||
|
||||
// Auto-stop if max duration reached
|
||||
if (statusResponse.durationSeconds >= MAX_DURATION) {
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error polling recording status:", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecording && !isProcessing) {
|
||||
// Start polling immediately, then every second
|
||||
pollRecordingStatus()
|
||||
pollingIntervalRef.current = setInterval(pollRecordingStatus, 1000)
|
||||
} else {
|
||||
// Clear polling when not recording
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current)
|
||||
pollingIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current)
|
||||
pollingIntervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isRecording, isProcessing, stopRecording])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (disabled || isProcessing) return
|
||||
|
||||
if (error) return setError(null)
|
||||
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}, [isRecording, startRecording, stopRecording, disabled, isProcessing, error])
|
||||
|
||||
const getIconClass = () => {
|
||||
if (isProcessing) return "codicon-loading"
|
||||
if (isRecording) return "codicon-stop-circle"
|
||||
if (error) return "codicon-error"
|
||||
return "codicon-mic"
|
||||
}
|
||||
|
||||
const getIconColor = () => {
|
||||
if (isRecording) return "var(--vscode-errorForeground)"
|
||||
if (error) return "var(--vscode-errorForeground)"
|
||||
return ""
|
||||
}
|
||||
|
||||
const getIconAnimation = () => {
|
||||
if (isProcessing) return "animate-spin"
|
||||
if (isRecording) return "animate-pulse"
|
||||
return ""
|
||||
}
|
||||
|
||||
const getIconAdjustment = () => {
|
||||
if (isProcessing) return "mt-0"
|
||||
if (isRecording) return "mt-1"
|
||||
if (error) return "mt-1"
|
||||
return "mt-0.5"
|
||||
}
|
||||
|
||||
const getRecTooltipContent = () => {
|
||||
if (isProcessing) return "Transcribing..."
|
||||
if (isRecording) return `Stop Recording (${formatSeconds(recordingDuration)}/${formatSeconds(MAX_DURATION)})`
|
||||
if (error) return `Error: ${error}`
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HeroTooltip content={getRecTooltipContent()} placement="top">
|
||||
<div
|
||||
className={`input-icon-button mr-1.5 text-base ${getIconAdjustment()} ${getIconAnimation()} ${disabled || isProcessing ? "disabled" : ""}`}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
color: getIconColor(),
|
||||
}}>
|
||||
<span className={`codicon ${getIconClass()}`} />
|
||||
</div>
|
||||
</HeroTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceRecorder
|
||||
@@ -0,0 +1,16 @@
|
||||
import styled from "styled-components"
|
||||
|
||||
const CollapsibleContent = styled.div<{ isOpen: boolean }>`
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.3s ease-in-out,
|
||||
opacity 0.3s ease-in-out,
|
||||
margin-top 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out;
|
||||
max-height: ${({ isOpen }) => (isOpen ? "1000px" : "0")};
|
||||
opacity: ${({ isOpen }) => (isOpen ? 1 : 0)};
|
||||
margin-top: ${({ isOpen }) => (isOpen ? "15px" : "0")};
|
||||
visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")};
|
||||
`
|
||||
|
||||
export default CollapsibleContent
|
||||
@@ -5,6 +5,7 @@ import { BROWSER_VIEWPORT_PRESETS } from "../../../../../src/shared/BrowserSetti
|
||||
import { useExtensionState } from "../../../context/ExtensionStateContext"
|
||||
import { BrowserServiceClient } from "../../../services/grpc-client"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import CollapsibleContent from "../CollapsibleContent"
|
||||
import { updateBrowserSetting } from "../utils/settingsHandlers"
|
||||
import { DebouncedTextField } from "../common/DebouncedTextField"
|
||||
import Section from "../Section"
|
||||
@@ -43,19 +44,6 @@ const ConnectionStatusIndicator = ({
|
||||
)
|
||||
}
|
||||
|
||||
const CollapsibleContent = styled.div<{ isOpen: boolean }>`
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.3s ease-in-out,
|
||||
opacity 0.3s ease-in-out,
|
||||
margin-top 0.3s ease-in-out,
|
||||
visibility 0.3s ease-in-out;
|
||||
max-height: ${({ isOpen }) => (isOpen ? "1000px" : "0")}; // Sufficiently large height
|
||||
opacity: ${({ isOpen }) => (isOpen ? 1 : 0)};
|
||||
margin-top: ${({ isOpen }) => (isOpen ? "15px" : "0")};
|
||||
visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")};
|
||||
`
|
||||
|
||||
export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({ renderSectionHeader }) => {
|
||||
const { browserSettings } = useExtensionState()
|
||||
const [isCheckingConnection, setIsCheckingConnection] = useState(false)
|
||||
|
||||
@@ -106,7 +106,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
<VSCodeOption value="high">High</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
|
||||
Reasoning effort for the OpenAI family of models(applies to all OpenAI model providers)
|
||||
Reasoning effort for the OpenAI family of models (applies to all OpenAI model providers)
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import "../../../src/shared/webview/types"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
|
||||
import { DEFAULT_DICTATION_SETTINGS, type DictationSettings } from "@shared/DictationSettings"
|
||||
import { DEFAULT_PLATFORM, type ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { DEFAULT_MCP_DISPLAY_MODE } from "@shared/McpDisplayMode"
|
||||
import type { UserInfo } from "@shared/proto/cline/account"
|
||||
@@ -70,6 +71,7 @@ interface ExtensionStateContextType extends ExtensionState {
|
||||
setGlobalWorkflowToggles: (toggles: Record<string, boolean>) => void
|
||||
setMcpMarketplaceCatalog: (value: McpMarketplaceCatalog) => void
|
||||
setTotalTasksSize: (value: number | null) => void
|
||||
setDictationSettings: (value: DictationSettings) => void
|
||||
|
||||
// Refresh functions
|
||||
refreshOpenRouterModels: () => void
|
||||
@@ -174,8 +176,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
shouldShowAnnouncement: false,
|
||||
autoApprovalSettings: DEFAULT_AUTO_APPROVAL_SETTINGS,
|
||||
browserSettings: DEFAULT_BROWSER_SETTINGS,
|
||||
preferredLanguage: "English",
|
||||
openaiReasoningEffort: "medium",
|
||||
dictationSettings: DEFAULT_DICTATION_SETTINGS,
|
||||
mode: "act",
|
||||
platform: DEFAULT_PLATFORM,
|
||||
telemetrySetting: "unset",
|
||||
@@ -266,6 +267,9 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
|
||||
const shouldUpdateAutoApproval = incomingVersion > currentVersion
|
||||
|
||||
// Always preserve chat settings from the current state
|
||||
// This prevents the backend from overwriting user changes
|
||||
|
||||
const newState = {
|
||||
...stateData,
|
||||
autoApprovalSettings: shouldUpdateAutoApproval
|
||||
@@ -276,9 +280,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
// Update welcome screen state based on API configuration
|
||||
setShowWelcome(!newState.welcomeViewCompleted)
|
||||
setDidHydrateState(true)
|
||||
|
||||
console.log("[DEBUG] returning new state in ESC")
|
||||
|
||||
return newState
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -722,6 +724,11 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
refreshOpenRouterModels,
|
||||
onRelinquishControl,
|
||||
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
|
||||
setDictationSettings: (value: DictationSettings) =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
dictationSettings: value,
|
||||
})),
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
||||
@@ -62,3 +62,16 @@ export function formatSize(bytes?: number) {
|
||||
|
||||
return prettyBytes(bytes)
|
||||
}
|
||||
|
||||
export function formatSeconds(seconds?: number): string {
|
||||
if (seconds === undefined) {
|
||||
return "--:--"
|
||||
}
|
||||
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, "0")
|
||||
|
||||
return `${mins}:${secs}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user