diff --git a/proto/build-proto.js b/proto/build-proto.js index 975d5a745..a0ebfeee7 100755 --- a/proto/build-proto.js +++ b/proto/build-proto.js @@ -12,11 +12,27 @@ const require = createRequire(import.meta.url) const protoc = path.join(require.resolve("grpc-tools"), "../bin/protoc") const tsProtoPlugin = require.resolve("ts-proto/protoc-gen-ts_proto") -// Get script directory and root directory const __filename = fileURLToPath(import.meta.url) const SCRIPT_DIR = path.dirname(__filename) const ROOT_DIR = path.resolve(SCRIPT_DIR, "..") +// List of gRPC services +// To add a new service, simply add it to this map and run this script +// The service handler will be automatically discovered and used by grpc-handler.ts +const serviceNameMap = { + account: "cline.AccountService", + browser: "cline.BrowserService", + checkpoints: "cline.CheckpointsService", + file: "cline.FileService", + mcp: "cline.McpService", + state: "cline.StateService", + task: "cline.TaskService", + web: "cline.WebService", + models: "cline.ModelsService", + // Add new services here - no other code changes needed! +} +const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey)) + async function main() { console.log(chalk.bold.blue("Starting Protocol Buffer code generation...")) @@ -33,6 +49,9 @@ async function main() { await fs.unlink(path.join(TS_OUT_DIR, file)) } + // Check for missing proto files for services in serviceNameMap + await ensureProtoFilesExist() + // Process all proto files console.log(chalk.cyan("Processing proto files from"), SCRIPT_DIR) const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR }) @@ -64,42 +83,135 @@ async function main() { console.log(chalk.green("Protocol Buffer code generation completed successfully.")) console.log(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`)) - // Generate method registration files await generateMethodRegistrations() + await generateServiceConfig() + await generateGrpcClientConfig() +} - // Make the script executable - try { - await fs.chmod(path.join(SCRIPT_DIR, "build-proto.js"), 0o755) - } catch (error) { - console.warn(chalk.yellow("Warning: Could not make script executable:"), error) +/** + * Generate a gRPC client configuration file for the webview + * This eliminates the need for manual imports and client creation in grpc-client.ts + */ +async function generateGrpcClientConfig() { + console.log(chalk.cyan("Generating gRPC client configuration...")) + + const serviceImports = [] + const serviceClientCreations = [] + const serviceExports = [] + + // Process each service in the serviceNameMap + for (const [dirName, fullServiceName] of Object.entries(serviceNameMap)) { + const capitalizedName = dirName.charAt(0).toUpperCase() + dirName.slice(1) + + // Add import statement + serviceImports.push(`import { ${capitalizedName}ServiceDefinition } from "@shared/proto/${dirName}"`) + + // Add client creation + serviceClientCreations.push( + `const ${capitalizedName}ServiceClient = createGrpcClient(${capitalizedName}ServiceDefinition)`, + ) + + // Add to exports + serviceExports.push(`${capitalizedName}ServiceClient`) } + + // Generate the file content + const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createGrpcClient } from "./grpc-client-base" +${serviceImports.join("\n")} + +${serviceClientCreations.join("\n")} + +export { + ${serviceExports.join(",\n\t")} +}` + + const configPath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts") + await fs.writeFile(configPath, content) + console.log(chalk.green(`Generated gRPC client at ${configPath}`)) +} + +/** + * Parse proto files to extract streaming method information + * @param protoFiles Array of proto file names + * @param scriptDir Directory containing proto files + * @returns Map of service names to their streaming methods + */ +async function parseProtoForStreamingMethods(protoFiles, scriptDir) { + console.log(chalk.cyan("Parsing proto files for streaming methods...")) + + // Map of service name to array of streaming method names + const streamingMethodsMap = new Map() + + for (const protoFile of protoFiles) { + const content = await fs.readFile(path.join(scriptDir, protoFile), "utf8") + + // Extract package name + const packageMatch = content.match(/package\s+([^;]+);/) + const packageName = packageMatch ? packageMatch[1].trim() : "unknown" + + // Extract service definitions + const serviceMatches = Array.from(content.matchAll(/service\s+(\w+)\s*\{([^}]+)\}/g)) + for (const serviceMatch of serviceMatches) { + const serviceName = serviceMatch[1] + const serviceBody = serviceMatch[2] + const fullServiceName = `${packageName}.${serviceName}` + + // Extract method definitions with streaming + const methodMatches = Array.from( + serviceBody.matchAll(/rpc\s+(\w+)\s*\(\s*(stream\s+)?(\w+)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w+)\s*\)/g), + ) + + const streamingMethods = [] + for (const methodMatch of methodMatches) { + const methodName = methodMatch[1] + const isRequestStreaming = !!methodMatch[2] + const requestType = methodMatch[3] + const isResponseStreaming = !!methodMatch[4] + const responseType = methodMatch[5] + + if (isResponseStreaming) { + streamingMethods.push({ + name: methodName, + requestType, + responseType, + isRequestStreaming, + }) + } + } + + if (streamingMethods.length > 0) { + streamingMethodsMap.set(fullServiceName, streamingMethods) + } + } + } + + return streamingMethodsMap } async function generateMethodRegistrations() { console.log(chalk.cyan("Generating method registration files...")) - const serviceDirs = [ - path.join(ROOT_DIR, "src", "core", "controller", "account"), - path.join(ROOT_DIR, "src", "core", "controller", "browser"), - path.join(ROOT_DIR, "src", "core", "controller", "checkpoints"), - path.join(ROOT_DIR, "src", "core", "controller", "file"), - path.join(ROOT_DIR, "src", "core", "controller", "mcp"), - path.join(ROOT_DIR, "src", "core", "controller", "models"), - path.join(ROOT_DIR, "src", "core", "controller", "task"), - path.join(ROOT_DIR, "src", "core", "controller", "web-content"), - // Add more service directories here as needed - ] + // Parse proto files for streaming methods + const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR }) + const streamingMethodsMap = await parseProtoForStreamingMethods(protoFiles, SCRIPT_DIR) for (const serviceDir of serviceDirs) { try { await fs.access(serviceDir) } catch (error) { - console.log(chalk.gray(`Skipping ${serviceDir} - directory does not exist`)) - continue + console.log(chalk.cyan(`Creating directory ${serviceDir} for new service`)) + await fs.mkdir(serviceDir, { recursive: true }) } const serviceName = path.basename(serviceDir) const registryFile = path.join(serviceDir, "methods.ts") + const indexFile = path.join(serviceDir, "index.ts") + + const fullServiceName = serviceNameMap[serviceName] + const streamingMethods = streamingMethodsMap.get(fullServiceName) || [] console.log(chalk.cyan(`Generating method registrations for ${serviceName}...`)) @@ -109,8 +221,8 @@ async function generateMethodRegistrations() { // Filter out index.ts and methods.ts const implementationFiles = files.filter((file) => file !== "index.ts" && file !== "methods.ts") - // Create the output file with header - let content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY + // Create the methods.ts file with header + let methodsContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY // Generated by proto/build-proto.js // Import all method implementations @@ -119,31 +231,177 @@ import { registerMethod } from "./index"\n` // Add imports for all implementation files for (const file of implementationFiles) { const baseName = path.basename(file, ".ts") - content += `import { ${baseName} } from "./${baseName}"\n` + methodsContent += `import { ${baseName} } from "./${baseName}"\n` + } + + // Add streaming methods information + if (streamingMethods.length > 0) { + methodsContent += `\n// Streaming methods for this service +export const streamingMethods = ${JSON.stringify( + streamingMethods.map((m) => m.name), + null, + 2, + )}\n` } // Add registration function - content += `\n// Register all ${serviceName} service methods + methodsContent += `\n// Register all ${serviceName} service methods export function registerAllMethods(): void { \t// Register each method with the registry\n` // Add registration statements for (const file of implementationFiles) { const baseName = path.basename(file, ".ts") - content += `\tregisterMethod("${baseName}", ${baseName})\n` + const isStreaming = streamingMethods.some((m) => m.name === baseName) + + if (isStreaming) { + methodsContent += `\tregisterMethod("${baseName}", ${baseName}, { isStreaming: true })\n` + } else { + methodsContent += `\tregisterMethod("${baseName}", ${baseName})\n` + } } // Close the function - content += `}` + methodsContent += `}` - // Write the file - await fs.writeFile(registryFile, content) + // Write the methods.ts file + await fs.writeFile(registryFile, methodsContent) console.log(chalk.green(`Generated ${registryFile}`)) + + // Generate index.ts file + const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1) + const indexContent = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" +import { registerAllMethods } from "./methods" + +// Create ${serviceName} service registry +const ${serviceName}Service = createServiceRegistry("${serviceName}") + +// Export the method handler types and registration function +export type ${capitalizedServiceName}MethodHandler = ServiceMethodHandler +export type ${capitalizedServiceName}StreamingMethodHandler = StreamingMethodHandler +export const registerMethod = ${serviceName}Service.registerMethod + +// Export the request handlers +export const handle${capitalizedServiceName}ServiceRequest = ${serviceName}Service.handleRequest +export const handle${capitalizedServiceName}ServiceStreamingRequest = ${serviceName}Service.handleStreamingRequest +export const isStreamingMethod = ${serviceName}Service.isStreamingMethod + +// Register all ${serviceName} methods +registerAllMethods()` + + // Write the index.ts file + await fs.writeFile(indexFile, indexContent) + console.log(chalk.green(`Generated ${indexFile}`)) } console.log(chalk.green("Method registration files generated successfully.")) } +/** + * Generate a service configuration file that maps service names to their handlers + * This eliminates the need for manual switch/case statements in grpc-handler.ts + */ +async function generateServiceConfig() { + console.log(chalk.cyan("Generating service configuration file...")) + + const serviceImports = [] + const serviceConfigs = [] + + // Add all services from the serviceNameMap + for (const [dirName, fullServiceName] of Object.entries(serviceNameMap)) { + const capitalizedName = dirName.charAt(0).toUpperCase() + dirName.slice(1) + serviceImports.push( + `import { handle${capitalizedName}ServiceRequest, handle${capitalizedName}ServiceStreamingRequest } from "./${dirName}/index"`, + ) + serviceConfigs.push(` + "${fullServiceName}": { + requestHandler: handle${capitalizedName}ServiceRequest, + streamingHandler: handle${capitalizedName}ServiceStreamingRequest + }`) + } + + const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { Controller } from "./index" +import { StreamingResponseHandler } from "./grpc-handler" +${serviceImports.join("\n")} + +/** + * Configuration for a service handler + */ +export interface ServiceHandlerConfig { + requestHandler: (controller: Controller, method: string, message: any) => Promise; + streamingHandler: (controller: Controller, method: string, message: any, responseStream: StreamingResponseHandler, requestId?: string) => Promise; +} + +/** + * Map of service names to their handler configurations + */ +export const serviceHandlers: Record = {${serviceConfigs.join(",")} +};` + + const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts") + await fs.writeFile(configPath, content) + console.log(chalk.green(`Generated service configuration at ${configPath}`)) +} + +/** + * Ensure that a .proto file exists for each service in the serviceNameMap + * If a .proto file doesn't exist, create a template file + */ +async function ensureProtoFilesExist() { + console.log(chalk.cyan("Checking for missing proto files...")) + + // Get existing proto files + const existingProtoFiles = await globby("*.proto", { cwd: SCRIPT_DIR }) + const existingProtoServices = existingProtoFiles.map((file) => path.basename(file, ".proto")) + + // Check each service in serviceNameMap + for (const [serviceName, fullServiceName] of Object.entries(serviceNameMap)) { + if (!existingProtoServices.includes(serviceName)) { + console.log(chalk.yellow(`Creating template proto file for ${serviceName}...`)) + + // Extract service class name from full name (e.g., "cline.ModelsService" -> "ModelsService") + const serviceClassName = fullServiceName.split(".").pop() + + // Create template proto file + const protoContent = `syntax = "proto3"; + +package cline; +option java_package = "bot.cline.proto"; +option java_multiple_files = true; + +import "common.proto"; + +// ${serviceClassName} provides methods for managing ${serviceName} +service ${serviceClassName} { + // Add your RPC methods here + // Example (String is from common.proto, responses should be generic types): + // rpc YourMethod(YourRequest) returns (String); +} + +// Add your message definitions here +// Example (Requests must always start with Metadata): +// message YourRequest { +// Metadata metadata = 1; +// string stringField = 2; +// int32 int32Field = 3; +// } +` + + // Write the template proto file + const protoFilePath = path.join(SCRIPT_DIR, `${serviceName}.proto`) + await fs.writeFile(protoFilePath, protoContent) + console.log(chalk.green(`Created template proto file at ${protoFilePath}`)) + } + } +} + // Run the main function main().catch((error) => { console.error(chalk.red("Error:"), error) diff --git a/proto/state.proto b/proto/state.proto new file mode 100644 index 000000000..b3667cb12 --- /dev/null +++ b/proto/state.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; +package cline; + +import "common.proto"; + +service StateService { + rpc getLatestState(EmptyRequest) returns (State); + rpc subscribeToState(EmptyRequest) returns (stream State); +} + +message State { + string state_json = 1; +} diff --git a/proto/web_content.proto b/proto/web.proto similarity index 90% rename from proto/web_content.proto rename to proto/web.proto index 79a1c4f1f..fe24087a4 100644 --- a/proto/web_content.proto +++ b/proto/web.proto @@ -6,7 +6,7 @@ option java_multiple_files = true; import "common.proto"; -service WebContentService { +service WebService { rpc checkIsImageUrl(StringRequest) returns (IsImageUrl); } diff --git a/src/core/controller/account/index.ts b/src/core/controller/account/index.ts index 29d4a36c7..6ec7c2da1 100644 --- a/src/core/controller/account/index.ts +++ b/src/core/controller/account/index.ts @@ -1,14 +1,22 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" import { registerAllMethods } from "./methods" +// Create account service registry const accountService = createServiceRegistry("account") -// Export the method handler type and registration function +// Export the method handler types and registration function export type AccountMethodHandler = ServiceMethodHandler +export type AccountStreamingMethodHandler = StreamingMethodHandler export const registerMethod = accountService.registerMethod -// Export the request handler +// Export the request handlers export const handleAccountServiceRequest = accountService.handleRequest +export const handleAccountServiceStreamingRequest = accountService.handleStreamingRequest +export const isStreamingMethod = accountService.isStreamingMethod // Register all account methods registerAllMethods() diff --git a/src/core/controller/browser/index.ts b/src/core/controller/browser/index.ts index 5e67c4c24..6edfd242d 100644 --- a/src/core/controller/browser/index.ts +++ b/src/core/controller/browser/index.ts @@ -1,15 +1,22 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" import { registerAllMethods } from "./methods" // Create browser service registry const browserService = createServiceRegistry("browser") -// Export the method handler type and registration function +// Export the method handler types and registration function export type BrowserMethodHandler = ServiceMethodHandler +export type BrowserStreamingMethodHandler = StreamingMethodHandler export const registerMethod = browserService.registerMethod -// Export the request handler +// Export the request handlers export const handleBrowserServiceRequest = browserService.handleRequest +export const handleBrowserServiceStreamingRequest = browserService.handleStreamingRequest +export const isStreamingMethod = browserService.isStreamingMethod // Register all browser methods registerAllMethods() diff --git a/src/core/controller/checkpoints/index.ts b/src/core/controller/checkpoints/index.ts index 5bc7c25e4..33fc69538 100644 --- a/src/core/controller/checkpoints/index.ts +++ b/src/core/controller/checkpoints/index.ts @@ -1,15 +1,22 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" import { registerAllMethods } from "./methods" // Create checkpoints service registry const checkpointsService = createServiceRegistry("checkpoints") -// Export the method handler type and registration function +// Export the method handler types and registration function export type CheckpointsMethodHandler = ServiceMethodHandler +export type CheckpointsStreamingMethodHandler = StreamingMethodHandler export const registerMethod = checkpointsService.registerMethod -// Export the request handler +// Export the request handlers export const handleCheckpointsServiceRequest = checkpointsService.handleRequest +export const handleCheckpointsServiceStreamingRequest = checkpointsService.handleStreamingRequest +export const isStreamingMethod = checkpointsService.isStreamingMethod // Register all checkpoints methods registerAllMethods() diff --git a/src/core/controller/file/index.ts b/src/core/controller/file/index.ts index d2c07ef9a..daa6e632b 100644 --- a/src/core/controller/file/index.ts +++ b/src/core/controller/file/index.ts @@ -1,15 +1,22 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" import { registerAllMethods } from "./methods" // Create file service registry const fileService = createServiceRegistry("file") -// Export the method handler type and registration function +// Export the method handler types and registration function export type FileMethodHandler = ServiceMethodHandler +export type FileStreamingMethodHandler = StreamingMethodHandler export const registerMethod = fileService.registerMethod -// Export the request handler +// Export the request handlers export const handleFileServiceRequest = fileService.handleRequest +export const handleFileServiceStreamingRequest = fileService.handleStreamingRequest +export const isStreamingMethod = fileService.isStreamingMethod // Register all file methods registerAllMethods() diff --git a/src/core/controller/grpc-handler.ts b/src/core/controller/grpc-handler.ts index 9911a0806..f6668e7bf 100644 --- a/src/core/controller/grpc-handler.ts +++ b/src/core/controller/grpc-handler.ts @@ -1,12 +1,11 @@ import { Controller } from "./index" -import { handleAccountServiceRequest } from "./account" -import { handleBrowserServiceRequest } from "./browser/index" -import { handleFileServiceRequest } from "./file" -import { handleTaskServiceRequest } from "./task" -import { handleCheckpointsServiceRequest } from "./checkpoints" -import { handleMcpServiceRequest } from "./mcp" -import { handleWebContentServiceRequest } from "./web-content" -import { handleModelsServiceRequest } from "./models" +import { serviceHandlers } from "./grpc-service-config" +import { GrpcRequestRegistry } from "./grpc-request-registry" + +/** + * Type definition for a streaming response handler + */ +export type StreamingResponseHandler = (response: any, isLast?: boolean, sequenceNumber?: number) => Promise /** * Handles gRPC requests from the webview @@ -20,62 +19,37 @@ export class GrpcHandler { * @param method The method name * @param message The request message * @param requestId The request ID for response correlation - * @returns The response message or error + * @param isStreaming Whether this is a streaming request + * @returns The response message or error for unary requests, void for streaming requests */ async handleRequest( service: string, method: string, message: any, requestId: string, + isStreaming: boolean = false, ): Promise<{ message?: any error?: string request_id: string - }> { + } | void> { try { - switch (service) { - case "cline.AccountService": - return { - message: await handleAccountServiceRequest(this.controller, method, message), - request_id: requestId, - } - case "cline.BrowserService": - return { - message: await handleBrowserServiceRequest(this.controller, method, message), - request_id: requestId, - } - case "cline.CheckpointsService": - return { - message: await handleCheckpointsServiceRequest(this.controller, method, message), - request_id: requestId, - } - case "cline.FileService": - return { - message: await handleFileServiceRequest(this.controller, method, message), - request_id: requestId, - } - case "cline.TaskService": - return { - message: await handleTaskServiceRequest(this.controller, method, message), - request_id: requestId, - } - case "cline.McpService": - return { - message: await handleMcpServiceRequest(this.controller, method, message), - request_id: requestId, - } - case "cline.WebContentService": - return { - message: await handleWebContentServiceRequest(this.controller, method, message), - request_id: requestId, - } - case "cline.ModelsService": - return { - message: await handleModelsServiceRequest(this.controller, method, message), - request_id: requestId, - } - default: - throw new Error(`Unknown service: ${service}`) + // If this is a streaming request, use the streaming handler + if (isStreaming) { + await this.handleStreamingRequest(service, method, message, requestId) + return + } + + // Get the service handler from the config + const serviceConfig = serviceHandlers[service] + if (!serviceConfig) { + throw new Error(`Unknown service: ${service}`) + } + + // Handle unary request + return { + message: await serviceConfig.requestHandler(this.controller, method, message), + request_id: requestId, } } catch (error) { return { @@ -84,8 +58,66 @@ export class GrpcHandler { } } } + + /** + * Handle a streaming gRPC request + * @param service The service name + * @param method The method name + * @param message The request message + * @param requestId The request ID for response correlation + */ + private async handleStreamingRequest(service: string, method: string, message: any, requestId: string): Promise { + // Create a response stream function + const responseStream: StreamingResponseHandler = async ( + response: any, + isLast: boolean = false, + sequenceNumber?: number, + ) => { + await this.controller.postMessageToWebview({ + type: "grpc_response", + grpc_response: { + message: response, + request_id: requestId, + is_streaming: !isLast, + sequence_number: sequenceNumber, + }, + }) + } + + try { + // Get the service handler from the config + const serviceConfig = serviceHandlers[service] + if (!serviceConfig) { + throw new Error(`Unknown service: ${service}`) + } + + // Check if the service supports streaming + if (!serviceConfig.streamingHandler) { + throw new Error(`Service ${service} does not support streaming`) + } + + // Handle streaming request and pass the requestId to all streaming handlers + await serviceConfig.streamingHandler(this.controller, method, message, responseStream, requestId) + + // Don't send a final message here - the stream should stay open for future updates + // The stream will be closed when the client disconnects or when the service explicitly ends it + } catch (error) { + // Send error response + await this.controller.postMessageToWebview({ + type: "grpc_response", + grpc_response: { + error: error instanceof Error ? error.message : String(error), + request_id: requestId, + is_streaming: false, + }, + }) + } + } } +// Registry to track active gRPC requests and their cleanup functions +const requestRegistry = new GrpcRequestRegistry() + /** * Handle a gRPC request from the webview * @param controller The controller instance @@ -98,11 +130,35 @@ export async function handleGrpcRequest( method: string message: any request_id: string + is_streaming?: boolean }, ) { try { const grpcHandler = new GrpcHandler(controller) - const response = await grpcHandler.handleRequest(request.service, request.method, request.message, request.request_id) + + // For streaming requests, handleRequest handles sending responses directly + if (request.is_streaming) { + try { + await grpcHandler.handleRequest(request.service, request.method, request.message, request.request_id, true) + } finally { + // Note: We don't automatically clean up here anymore + // The request will be cleaned up when it completes or is cancelled + } + return + } + + // For unary requests, we get a response and send it back + const response = (await grpcHandler.handleRequest( + request.service, + request.method, + request.message, + request.request_id, + false, + )) as { + message?: any + error?: string + request_id: string + } // Send the response back to the webview await controller.postMessageToWebview({ @@ -120,3 +176,39 @@ export async function handleGrpcRequest( }) } } + +/** + * Handle a gRPC request cancellation from the webview + * @param controller The controller instance + * @param request The cancellation request + */ +export async function handleGrpcRequestCancel( + controller: Controller, + request: { + request_id: string + }, +) { + const cancelled = requestRegistry.cancelRequest(request.request_id) + + if (cancelled) { + // Send a cancellation confirmation + await controller.postMessageToWebview({ + type: "grpc_response", + grpc_response: { + message: { cancelled: true }, + request_id: request.request_id, + is_streaming: false, + }, + }) + } else { + console.log(`[DEBUG] Request not found for cancellation: ${request.request_id}`) + } +} + +/** + * Get the request registry instance + * This allows other parts of the code to access the registry + */ +export function getRequestRegistry(): GrpcRequestRegistry { + return requestRegistry +} diff --git a/src/core/controller/grpc-request-registry.ts b/src/core/controller/grpc-request-registry.ts new file mode 100644 index 000000000..e8585aa9d --- /dev/null +++ b/src/core/controller/grpc-request-registry.ts @@ -0,0 +1,124 @@ +import { StreamingResponseHandler } from "./grpc-handler" + +/** + * Information about a registered gRPC request + */ +export interface RequestInfo { + /** + * Function to clean up resources when the request is cancelled or completed + */ + cleanup: () => void + + /** + * Optional metadata about the request + */ + metadata?: any + + /** + * Timestamp when the request was registered + */ + timestamp: Date + + /** + * The streaming response handler for this request + */ + responseStream?: StreamingResponseHandler +} + +/** + * Registry for managing gRPC request lifecycles + * This class provides a centralized way to track active requests and their cleanup functions + */ +export class GrpcRequestRegistry { + /** + * Map of request IDs to request information + */ + private activeRequests = new Map() + + /** + * Register a new request with its cleanup function + * @param requestId The unique ID of the request + * @param cleanup Function to clean up resources when the request is cancelled + * @param metadata Optional metadata about the request + * @param responseStream Optional streaming response handler + */ + public registerRequest( + requestId: string, + cleanup: () => void, + metadata?: any, + responseStream?: StreamingResponseHandler, + ): void { + this.activeRequests.set(requestId, { + cleanup, + metadata, + timestamp: new Date(), + responseStream, + }) + console.log(`[DEBUG] Registered request: ${requestId}`) + } + + /** + * Cancel a request and clean up its resources + * @param requestId The ID of the request to cancel + * @returns True if the request was found and cancelled, false otherwise + */ + public cancelRequest(requestId: string): boolean { + const requestInfo = this.activeRequests.get(requestId) + if (requestInfo) { + try { + requestInfo.cleanup() + console.log(`[DEBUG] Cleaned up request: ${requestId}`) + } catch (error) { + console.error(`Error cleaning up request ${requestId}:`, error) + } + this.activeRequests.delete(requestId) + return true + } + return false + } + + /** + * Get information about a request + * @param requestId The ID of the request + * @returns The request information, or undefined if not found + */ + public getRequestInfo(requestId: string): RequestInfo | undefined { + return this.activeRequests.get(requestId) + } + + /** + * Check if a request exists in the registry + * @param requestId The ID of the request + * @returns True if the request exists, false otherwise + */ + public hasRequest(requestId: string): boolean { + return this.activeRequests.has(requestId) + } + + /** + * Get all active requests + * @returns An array of [requestId, requestInfo] pairs + */ + public getAllRequests(): [string, RequestInfo][] { + return Array.from(this.activeRequests.entries()) + } + + /** + * Clean up stale requests that have been active for too long + * @param maxAgeMs Maximum age in milliseconds before a request is considered stale + * @returns The number of requests that were cleaned up + */ + public cleanupStaleRequests(maxAgeMs: number): number { + const now = new Date() + let cleanedCount = 0 + + for (const [requestId, info] of this.activeRequests.entries()) { + if (now.getTime() - info.timestamp.getTime() > maxAgeMs) { + this.cancelRequest(requestId) + cleanedCount++ + } + } + + return cleanedCount + } +} diff --git a/src/core/controller/grpc-service-config.ts b/src/core/controller/grpc-service-config.ts new file mode 100644 index 000000000..48aff0238 --- /dev/null +++ b/src/core/controller/grpc-service-config.ts @@ -0,0 +1,70 @@ +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { Controller } from "./index" +import { StreamingResponseHandler } from "./grpc-handler" +import { handleAccountServiceRequest, handleAccountServiceStreamingRequest } from "./account/index" +import { handleBrowserServiceRequest, handleBrowserServiceStreamingRequest } from "./browser/index" +import { handleCheckpointsServiceRequest, handleCheckpointsServiceStreamingRequest } from "./checkpoints/index" +import { handleFileServiceRequest, handleFileServiceStreamingRequest } from "./file/index" +import { handleMcpServiceRequest, handleMcpServiceStreamingRequest } from "./mcp/index" +import { handleStateServiceRequest, handleStateServiceStreamingRequest } from "./state/index" +import { handleTaskServiceRequest, handleTaskServiceStreamingRequest } from "./task/index" +import { handleWebServiceRequest, handleWebServiceStreamingRequest } from "./web/index" +import { handleModelsServiceRequest, handleModelsServiceStreamingRequest } from "./models/index" + +/** + * Configuration for a service handler + */ +export interface ServiceHandlerConfig { + requestHandler: (controller: Controller, method: string, message: any) => Promise + streamingHandler: ( + controller: Controller, + method: string, + message: any, + responseStream: StreamingResponseHandler, + requestId?: string, + ) => Promise +} + +/** + * Map of service names to their handler configurations + */ +export const serviceHandlers: Record = { + "cline.AccountService": { + requestHandler: handleAccountServiceRequest, + streamingHandler: handleAccountServiceStreamingRequest, + }, + "cline.BrowserService": { + requestHandler: handleBrowserServiceRequest, + streamingHandler: handleBrowserServiceStreamingRequest, + }, + "cline.CheckpointsService": { + requestHandler: handleCheckpointsServiceRequest, + streamingHandler: handleCheckpointsServiceStreamingRequest, + }, + "cline.FileService": { + requestHandler: handleFileServiceRequest, + streamingHandler: handleFileServiceStreamingRequest, + }, + "cline.McpService": { + requestHandler: handleMcpServiceRequest, + streamingHandler: handleMcpServiceStreamingRequest, + }, + "cline.StateService": { + requestHandler: handleStateServiceRequest, + streamingHandler: handleStateServiceStreamingRequest, + }, + "cline.TaskService": { + requestHandler: handleTaskServiceRequest, + streamingHandler: handleTaskServiceStreamingRequest, + }, + "cline.WebService": { + requestHandler: handleWebServiceRequest, + streamingHandler: handleWebServiceStreamingRequest, + }, + "cline.ModelsService": { + requestHandler: handleModelsServiceRequest, + streamingHandler: handleModelsServiceStreamingRequest, + }, +} diff --git a/src/core/controller/grpc-service.ts b/src/core/controller/grpc-service.ts index 876f6afd7..23cb54ba5 100644 --- a/src/core/controller/grpc-service.ts +++ b/src/core/controller/grpc-service.ts @@ -1,16 +1,36 @@ import { Controller } from "./index" +import { StreamingResponseHandler } from "./grpc-handler" /** * Generic type for service method handlers */ export type ServiceMethodHandler = (controller: Controller, message: any) => Promise +/** + * Type for streaming method handlers + */ +export type StreamingMethodHandler = ( + controller: Controller, + message: any, + responseStream: StreamingResponseHandler, + requestId?: string, +) => Promise + +/** + * Method metadata including streaming information + */ +export interface MethodMetadata { + isStreaming: boolean +} + /** * Generic service registry for gRPC services */ export class ServiceRegistry { private serviceName: string private methodRegistry: Record = {} + private streamingMethodRegistry: Record = {} + private methodMetadata: Record = {} /** * Create a new service registry @@ -24,10 +44,37 @@ export class ServiceRegistry { * Register a method handler * @param methodName The name of the method to register * @param handler The handler function for the method + * @param metadata Optional metadata about the method */ - registerMethod(methodName: string, handler: ServiceMethodHandler): void { - this.methodRegistry[methodName] = handler - console.log(`Registered ${this.serviceName} method: ${methodName}`) + registerMethod(methodName: string, handler: ServiceMethodHandler | StreamingMethodHandler, metadata?: MethodMetadata): void { + const isStreaming = metadata?.isStreaming || false + + if (isStreaming) { + this.streamingMethodRegistry[methodName] = handler as StreamingMethodHandler + } else { + this.methodRegistry[methodName] = handler as ServiceMethodHandler + } + + this.methodMetadata[methodName] = { isStreaming, ...metadata } + console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`) + } + + /** + * Check if a method is a streaming method + * @param method The method name + * @returns True if the method is a streaming method + */ + isStreamingMethod(method: string): boolean { + return this.methodMetadata[method]?.isStreaming || false + } + + /** + * Get a streaming method handler + * @param method The method name + * @returns The streaming method handler or undefined if not found + */ + getStreamingHandler(method: string): StreamingMethodHandler | undefined { + return this.streamingMethodRegistry[method] } /** @@ -41,11 +88,41 @@ export class ServiceRegistry { const handler = this.methodRegistry[method] if (!handler) { + if (this.isStreamingMethod(method)) { + throw new Error(`Method ${method} is a streaming method and should be handled with handleStreamingRequest`) + } throw new Error(`Unknown ${this.serviceName} method: ${method}`) } return handler(controller, message) } + + /** + * Handle a streaming service request + * @param controller The controller instance + * @param method The method name + * @param message The request message + * @param responseStream The streaming response handler + * @param requestId The request ID for correlation and cleanup + */ + async handleStreamingRequest( + controller: Controller, + method: string, + message: any, + responseStream: StreamingResponseHandler, + requestId?: string, + ): Promise { + const handler = this.streamingMethodRegistry[method] + + if (!handler) { + if (this.methodRegistry[method]) { + throw new Error(`Method ${method} is not a streaming method and should be handled with handleRequest`) + } + throw new Error(`Unknown ${this.serviceName} streaming method: ${method}`) + } + + await handler(controller, message, responseStream, requestId) + } } /** @@ -57,9 +134,20 @@ export function createServiceRegistry(serviceName: string) { const registry = new ServiceRegistry(serviceName) return { - registerMethod: (methodName: string, handler: ServiceMethodHandler) => registry.registerMethod(methodName, handler), + registerMethod: (methodName: string, handler: ServiceMethodHandler | StreamingMethodHandler, metadata?: MethodMetadata) => + registry.registerMethod(methodName, handler, metadata), handleRequest: (controller: Controller, method: string, message: any) => registry.handleRequest(controller, method, message), + + handleStreamingRequest: ( + controller: Controller, + method: string, + message: any, + responseStream: StreamingResponseHandler, + requestId?: string, + ) => registry.handleStreamingRequest(controller, method, message, responseStream, requestId), + + isStreamingMethod: (method: string) => registry.isStreamingMethod(method), } } diff --git a/src/core/controller/index.ts b/src/core/controller/index.ts index 917c3a61d..f94fef24b 100644 --- a/src/core/controller/index.ts +++ b/src/core/controller/index.ts @@ -7,7 +7,7 @@ import { setTimeout as setTimeoutPromise } from "node:timers/promises" import pWaitFor from "p-wait-for" import * as path from "path" import * as vscode from "vscode" -import { handleGrpcRequest } from "./grpc-handler" +import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler" import { buildApiHandler } from "@api/index" import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration" import { downloadTask } from "@integrations/misc/export-markdown" @@ -48,6 +48,7 @@ import { } from "../storage/state" import { Task, cwd } from "../task" import { ClineRulesToggles } from "@shared/cline-rules" +import { sendStateUpdate } from "./state/subscribeToState" import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules" import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules" @@ -377,9 +378,6 @@ export class Controller { } break } - case "getLatestState": - await this.postStateToWebview() - break case "accountLogoutClicked": { await this.handleSignOut() break @@ -653,6 +651,12 @@ export class Controller { } break } + case "grpc_request_cancel": { + if (message.grpc_request_cancel) { + await handleGrpcRequestCancel(this, message.grpc_request_cancel) + } + break + } case "copyToClipboard": { try { @@ -1654,7 +1658,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont async postStateToWebview() { const state = await this.getStateToPostToWebview() - this.postMessageToWebview({ type: "state", state }) + await sendStateUpdate(state) } async getStateToPostToWebview(): Promise { diff --git a/src/core/controller/mcp/index.ts b/src/core/controller/mcp/index.ts index 74725e8ad..af2a8775a 100644 --- a/src/core/controller/mcp/index.ts +++ b/src/core/controller/mcp/index.ts @@ -1,15 +1,22 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" import { registerAllMethods } from "./methods" -// Create MCP service registry +// Create mcp service registry const mcpService = createServiceRegistry("mcp") -// Export the method handler type and registration function +// Export the method handler types and registration function export type McpMethodHandler = ServiceMethodHandler +export type McpStreamingMethodHandler = StreamingMethodHandler export const registerMethod = mcpService.registerMethod -// Export the request handler +// Export the request handlers export const handleMcpServiceRequest = mcpService.handleRequest +export const handleMcpServiceStreamingRequest = mcpService.handleStreamingRequest +export const isStreamingMethod = mcpService.isStreamingMethod // Register all mcp methods registerAllMethods() diff --git a/src/core/controller/models/index.ts b/src/core/controller/models/index.ts index d58a396cf..c2b269f7a 100644 --- a/src/core/controller/models/index.ts +++ b/src/core/controller/models/index.ts @@ -1,15 +1,22 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" import { registerAllMethods } from "./methods" // Create models service registry const modelsService = createServiceRegistry("models") -// Export the method handler type and registration function +// Export the method handler types and registration function export type ModelsMethodHandler = ServiceMethodHandler +export type ModelsStreamingMethodHandler = StreamingMethodHandler export const registerMethod = modelsService.registerMethod -// Export the request handler +// Export the request handlers export const handleModelsServiceRequest = modelsService.handleRequest +export const handleModelsServiceStreamingRequest = modelsService.handleStreamingRequest +export const isStreamingMethod = modelsService.isStreamingMethod // Register all models methods registerAllMethods() diff --git a/src/core/controller/state/getLatestState.ts b/src/core/controller/state/getLatestState.ts new file mode 100644 index 000000000..3e24663c0 --- /dev/null +++ b/src/core/controller/state/getLatestState.ts @@ -0,0 +1,24 @@ +import * as vscode from "vscode" +import { Controller } from "../index" +import { EmptyRequest } from "../../../shared/proto/common" +import { State } from "../../../shared/proto/state" +import { ExtensionState } from "../../../shared/ExtensionMessage" + +/** + * Get the latest extension state + * @param controller The controller instance + * @param request The empty request + * @returns The current extension state + */ +export async function getLatestState(controller: Controller, request: EmptyRequest): Promise { + // Get the state using the existing method + const state = await controller.getStateToPostToWebview() + + // Convert the state to a JSON string + const stateJson = JSON.stringify(state) + + // Return the state as a JSON string + return { + stateJson, + } +} diff --git a/src/core/controller/state/index.ts b/src/core/controller/state/index.ts new file mode 100644 index 000000000..1ba9cbbbf --- /dev/null +++ b/src/core/controller/state/index.ts @@ -0,0 +1,22 @@ +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" +import { registerAllMethods } from "./methods" + +// Create state service registry +const stateService = createServiceRegistry("state") + +// Export the method handler types and registration function +export type StateMethodHandler = ServiceMethodHandler +export type StateStreamingMethodHandler = StreamingMethodHandler +export const registerMethod = stateService.registerMethod + +// Export the request handlers +export const handleStateServiceRequest = stateService.handleRequest +export const handleStateServiceStreamingRequest = stateService.handleStreamingRequest +export const isStreamingMethod = stateService.isStreamingMethod + +// Register all state methods +registerAllMethods() diff --git a/src/core/controller/state/methods.ts b/src/core/controller/state/methods.ts new file mode 100644 index 000000000..1d92cc921 --- /dev/null +++ b/src/core/controller/state/methods.ts @@ -0,0 +1,17 @@ +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +// Import all method implementations +import { registerMethod } from "./index" +import { getLatestState } from "./getLatestState" +import { subscribeToState } from "./subscribeToState" + +// Streaming methods for this service +export const streamingMethods = ["subscribeToState"] + +// Register all state service methods +export function registerAllMethods(): void { + // Register each method with the registry + registerMethod("getLatestState", getLatestState) + registerMethod("subscribeToState", subscribeToState, { isStreaming: true }) +} diff --git a/src/core/controller/state/subscribeToState.ts b/src/core/controller/state/subscribeToState.ts new file mode 100644 index 000000000..c92096d7e --- /dev/null +++ b/src/core/controller/state/subscribeToState.ts @@ -0,0 +1,74 @@ +import * as vscode from "vscode" +import { Controller } from "../index" +import { EmptyRequest } from "../../../shared/proto/common" +import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler" + +// Keep track of active state subscriptions +const activeStateSubscriptions = new Set() + +/** + * Subscribe to state updates + * @param controller The controller instance + * @param request The empty request + * @param responseStream The streaming response handler + * @param requestId The ID of the request (passed by the gRPC handler) + */ +export async function subscribeToState( + controller: Controller, + request: EmptyRequest, + responseStream: StreamingResponseHandler, + requestId?: string, +): Promise { + // Send the initial state + const initialState = await controller.getStateToPostToWebview() + const initialStateJson = JSON.stringify(initialState) + + console.log("[DEBUG] set up state subscription") + + await responseStream({ + stateJson: initialStateJson, + }) + + // Add this subscription to the active subscriptions + activeStateSubscriptions.add(responseStream) + + // Register cleanup when the connection is closed + const cleanup = () => { + activeStateSubscriptions.delete(responseStream) + console.log("[DEBUG] Cleaned up state subscription") + } + + // Register the cleanup function with the request registry if we have a requestId + if (requestId) { + getRequestRegistry().registerRequest(requestId, cleanup, { type: "state_subscription" }, responseStream) + } +} + +/** + * Send a state update to all active subscribers + * @param state The state to send + */ +export async function sendStateUpdate(state: any): Promise { + const stateJson = JSON.stringify(state) + + // Send the update to all active subscribers + const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => { + try { + // The issue might be that we're not properly formatting the response + // Let's ensure we're sending a properly formatted State message + await responseStream( + { + stateJson, + }, + false, // Not the last message + ) + console.log("[DEBUG] sending followup state", stateJson.length, "chars") + } catch (error) { + console.error("Error sending state update:", error) + // Remove the subscription if there was an error + activeStateSubscriptions.delete(responseStream) + } + }) + + await Promise.all(promises) +} diff --git a/src/core/controller/task/index.ts b/src/core/controller/task/index.ts index 4fda3bd73..e50616fe7 100644 --- a/src/core/controller/task/index.ts +++ b/src/core/controller/task/index.ts @@ -1,15 +1,22 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" import { registerAllMethods } from "./methods" // Create task service registry const taskService = createServiceRegistry("task") -// Export the method handler type and registration function +// Export the method handler types and registration function export type TaskMethodHandler = ServiceMethodHandler +export type TaskStreamingMethodHandler = StreamingMethodHandler export const registerMethod = taskService.registerMethod -// Export the request handler +// Export the request handlers export const handleTaskServiceRequest = taskService.handleRequest +export const handleTaskServiceStreamingRequest = taskService.handleStreamingRequest +export const isStreamingMethod = taskService.isStreamingMethod // Register all task methods registerAllMethods() diff --git a/src/core/controller/web-content/index.ts b/src/core/controller/web-content/index.ts deleted file mode 100644 index 5f25f1fff..000000000 --- a/src/core/controller/web-content/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createServiceRegistry, ServiceMethodHandler } from "../grpc-service" -import { registerAllMethods } from "./methods" - -// Create web content service registry -const webContentService = createServiceRegistry("web-content") - -// Export the method handler type and registration function -export type WebContentMethodHandler = ServiceMethodHandler -export const registerMethod = webContentService.registerMethod - -// Export the request handler -export const handleWebContentServiceRequest = webContentService.handleRequest - -// Register all web content methods -registerAllMethods() diff --git a/src/core/controller/web-content/checkIsImageUrl.ts b/src/core/controller/web/checkIsImageUrl.ts similarity index 92% rename from src/core/controller/web-content/checkIsImageUrl.ts rename to src/core/controller/web/checkIsImageUrl.ts index 66b422589..d9928561a 100644 --- a/src/core/controller/web-content/checkIsImageUrl.ts +++ b/src/core/controller/web/checkIsImageUrl.ts @@ -1,6 +1,6 @@ import { Controller } from "../index" import { StringRequest } from "../../../shared/proto/common" -import { IsImageUrl } from "../../../shared/proto/web_content" +import { IsImageUrl } from "../../../shared/proto/web" import { detectImageUrl } from "@integrations/misc/link-preview" /** diff --git a/src/core/controller/web/index.ts b/src/core/controller/web/index.ts new file mode 100644 index 000000000..5c2ad8bbc --- /dev/null +++ b/src/core/controller/web/index.ts @@ -0,0 +1,22 @@ +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service" +import { StreamingResponseHandler } from "../grpc-handler" +import { registerAllMethods } from "./methods" + +// Create web service registry +const webService = createServiceRegistry("web") + +// Export the method handler types and registration function +export type WebMethodHandler = ServiceMethodHandler +export type WebStreamingMethodHandler = StreamingMethodHandler +export const registerMethod = webService.registerMethod + +// Export the request handlers +export const handleWebServiceRequest = webService.handleRequest +export const handleWebServiceStreamingRequest = webService.handleStreamingRequest +export const isStreamingMethod = webService.isStreamingMethod + +// Register all web methods +registerAllMethods() diff --git a/src/core/controller/web-content/methods.ts b/src/core/controller/web/methods.ts similarity index 89% rename from src/core/controller/web-content/methods.ts rename to src/core/controller/web/methods.ts index 8bcb9675b..c3420b15e 100644 --- a/src/core/controller/web-content/methods.ts +++ b/src/core/controller/web/methods.ts @@ -5,7 +5,7 @@ import { registerMethod } from "./index" import { checkIsImageUrl } from "./checkIsImageUrl" -// Register all web-content service methods +// Register all web service methods export function registerAllMethods(): void { // Register each method with the registry registerMethod("checkIsImageUrl", checkIsImageUrl) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 89a326c4e..48a139f83 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -105,6 +105,8 @@ export interface ExtensionMessage { message?: any // JSON serialized protobuf message request_id: string // Same ID as the request error?: string // Optional error message + is_streaming?: boolean // Whether this is part of a streaming response + sequence_number?: number // For ordering chunks in streaming responses } } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index c70799b28..e3f53a335 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -36,7 +36,6 @@ export interface WebviewMessage { | "openExtensionSettings" | "requestVsCodeLmModels" | "toggleToolAutoApprove" - | "getLatestState" | "accountLogoutClicked" | "showAccountViewClicked" | "authStateChanged" @@ -61,6 +60,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleFavoriteModel" | "grpc_request" + | "grpc_request_cancel" | "toggleClineRule" | "toggleCursorRule" | "toggleWindsurfRule" @@ -109,6 +109,10 @@ export interface WebviewMessage { method: string message: any // JSON serialized protobuf message request_id: string // For correlating requests and responses + is_streaming?: boolean // Whether this is a streaming request + } + grpc_request_cancel?: { + request_id: string // ID of the request to cancel } // For cline rules isGlobal?: boolean diff --git a/src/shared/proto/state.ts b/src/shared/proto/state.ts new file mode 100644 index 000000000..c565ee3f6 --- /dev/null +++ b/src/shared/proto/state.ts @@ -0,0 +1,127 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.7.0 +// protoc v3.19.1 +// source: state.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire" +import { EmptyRequest } from "./common" + +export const protobufPackage = "cline" + +export interface State { + stateJson: string +} + +function createBaseState(): State { + return { stateJson: "" } +} + +export const State: MessageFns = { + encode(message: State, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.stateJson !== "") { + writer.uint32(10).string(message.stateJson) + } + return writer + }, + + decode(input: BinaryReader | Uint8Array, length?: number): State { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input) + let end = length === undefined ? reader.len : reader.pos + length + const message = createBaseState() + while (reader.pos < end) { + const tag = reader.uint32() + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break + } + + message.stateJson = reader.string() + continue + } + } + if ((tag & 7) === 4 || tag === 0) { + break + } + reader.skip(tag & 7) + } + return message + }, + + fromJSON(object: any): State { + return { stateJson: isSet(object.stateJson) ? globalThis.String(object.stateJson) : "" } + }, + + toJSON(message: State): unknown { + const obj: any = {} + if (message.stateJson !== "") { + obj.stateJson = message.stateJson + } + return obj + }, + + create, I>>(base?: I): State { + return State.fromPartial(base ?? ({} as any)) + }, + fromPartial, I>>(object: I): State { + const message = createBaseState() + message.stateJson = object.stateJson ?? "" + return message + }, +} + +export type StateServiceDefinition = typeof StateServiceDefinition +export const StateServiceDefinition = { + name: "StateService", + fullName: "cline.StateService", + methods: { + getLatestState: { + name: "getLatestState", + requestType: EmptyRequest, + requestStream: false, + responseType: State, + responseStream: false, + options: {}, + }, + subscribeToState: { + name: "subscribeToState", + requestType: EmptyRequest, + requestStream: false, + responseType: State, + responseStream: true, + options: {}, + }, + }, +} as const + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined + +export type DeepPartial = T extends Builtin + ? T + : T extends globalThis.Array + ? globalThis.Array> + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends {} + ? { [K in keyof T]?: DeepPartial } + : Partial + +type KeysOfUnion = T extends T ? keyof T : never +export type Exact = P extends Builtin + ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never } + +function isSet(value: any): boolean { + return value !== null && value !== undefined +} + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter + decode(input: BinaryReader | Uint8Array, length?: number): T + fromJSON(object: any): T + toJSON(message: T): unknown + create, I>>(base?: I): T + fromPartial, I>>(object: I): T +} diff --git a/src/shared/proto/web_content.ts b/src/shared/proto/web.ts similarity index 94% rename from src/shared/proto/web_content.ts rename to src/shared/proto/web.ts index f49657e64..f001b2d61 100644 --- a/src/shared/proto/web_content.ts +++ b/src/shared/proto/web.ts @@ -2,7 +2,7 @@ // versions: // protoc-gen-ts_proto v2.7.0 // protoc v3.19.1 -// source: web_content.proto +// source: web.proto /* eslint-disable */ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire" @@ -91,10 +91,10 @@ export const IsImageUrl: MessageFns = { }, } -export type WebContentServiceDefinition = typeof WebContentServiceDefinition -export const WebContentServiceDefinition = { - name: "WebContentService", - fullName: "cline.WebContentService", +export type WebServiceDefinition = typeof WebServiceDefinition +export const WebServiceDefinition = { + name: "WebService", + fullName: "cline.WebService", methods: { checkIsImageUrl: { name: "checkIsImageUrl", diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 84a4301c6..310e2289f 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -27,7 +27,8 @@ import { import { useMetaKeyDetection, useShortcut } from "@/utils/hooks" import { validateApiConfiguration, validateModelId } from "@/utils/validate" import { vscode } from "@/utils/vscode" -import { FileServiceClient } from "@/services/grpc-client" +import { EmptyRequest } from "@shared/proto/common" +import { FileServiceClient, StateServiceClient } from "@/services/grpc-client" import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock" import Thumbnails from "@/components/common/Thumbnails" import Tooltip from "@/components/common/Tooltip" @@ -875,7 +876,13 @@ const ChatTextArea = forwardRef( if (!apiValidationResult && !modelIdValidationResult) { vscode.postMessage({ type: "apiConfiguration", apiConfiguration }) } else { - vscode.postMessage({ type: "getLatestState" }) + StateServiceClient.getLatestState(EmptyRequest.create()) + .then(() => { + console.log("State refreshed") + }) + .catch((error) => { + console.error("Error refreshing state:", error) + }) } }, [apiConfiguration, openRouterModels]) diff --git a/webview-ui/src/components/mcp/chat-display/utils/mcpRichUtil.ts b/webview-ui/src/components/mcp/chat-display/utils/mcpRichUtil.ts index f20d313ba..fb3917025 100644 --- a/webview-ui/src/components/mcp/chat-display/utils/mcpRichUtil.ts +++ b/webview-ui/src/components/mcp/chat-display/utils/mcpRichUtil.ts @@ -1,4 +1,4 @@ -import { WebContentServiceClient } from "@/services/grpc-client" +import { WebServiceClient } from "@/services/grpc-client" // Safely create a URL object with error handling and ensure HTTPS export const safeCreateUrl = (url: string): URL | null => { @@ -145,7 +145,7 @@ export const checkIfImageUrl = async (url: string): Promise => { }) // Create the actual service call - const servicePromise = WebContentServiceClient.checkIsImageUrl({ value: url }) + const servicePromise = WebServiceClient.checkIsImageUrl({ value: url }) .then((result) => result.isImage) .catch((error) => { console.error("Error checking if URL is an image via gRPC:", error) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 731f99f56..a5154de5a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,5 +1,7 @@ -import React, { createContext, useCallback, useContext, useEffect, useState } from "react" +import React, { createContext, useCallback, useContext, useEffect, useState, useRef } from "react" import { useEvent } from "react-use" +import { StateServiceClient } from "../services/grpc-client" +import { EmptyRequest } from "@shared/proto/common" import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings" import { ExtensionMessage, ExtensionState, DEFAULT_PLATFORM } from "@shared/ExtensionMessage" import { @@ -92,50 +94,6 @@ export const ExtensionStateContextProvider: React.FC<{ const handleMessage = useCallback((event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { - case "state": { - setState((prevState) => { - const incoming = message.state! - // Versioning logic for autoApprovalSettings - const incomingVersion = incoming.autoApprovalSettings?.version ?? 1 - const currentVersion = prevState.autoApprovalSettings?.version ?? 1 - const shouldUpdateAutoApproval = incomingVersion > currentVersion - return { - ...incoming, - autoApprovalSettings: shouldUpdateAutoApproval - ? incoming.autoApprovalSettings - : prevState.autoApprovalSettings, - } - }) - const config = message.state?.apiConfiguration - const hasKey = config - ? [ - config.apiKey, - config.openRouterApiKey, - config.awsRegion, - config.vertexProjectId, - config.openAiApiKey, - config.ollamaModelId, - config.lmStudioModelId, - config.liteLlmApiKey, - config.geminiApiKey, - config.openAiNativeApiKey, - config.deepSeekApiKey, - config.requestyApiKey, - config.togetherApiKey, - config.qwenApiKey, - config.doubaoApiKey, - config.mistralApiKey, - config.vsCodeLmModelSelector, - config.clineApiKey, - config.asksageApiKey, - config.xaiApiKey, - config.sambanovaApiKey, - ].some((key) => key !== undefined) - : false - setShowWelcome(!hasKey) - setDidHydrateState(true) - break - } case "theme": { if (message.text) { setTheme(convertTextMateToHljs(JSON.parse(message.text))) @@ -201,8 +159,95 @@ export const ExtensionStateContextProvider: React.FC<{ useEvent("message", handleMessage) + // Reference to store the state subscription cancellation function + const stateSubscriptionRef = useRef<(() => void) | null>(null) + + // Subscribe to state updates using the new gRPC streaming API useEffect(() => { + // Set up state subscription + stateSubscriptionRef.current = StateServiceClient.subscribeToState( + {}, + { + onResponse: (response) => { + console.log("[DEBUG] got state update via subscription", response) + if (response.stateJson) { + try { + const stateData = JSON.parse(response.stateJson) as ExtensionState + console.log("[DEBUG] parsed state JSON, updating state") + setState((prevState) => { + // Versioning logic for autoApprovalSettings + const incomingVersion = stateData.autoApprovalSettings?.version ?? 1 + const currentVersion = prevState.autoApprovalSettings?.version ?? 1 + const shouldUpdateAutoApproval = incomingVersion > currentVersion + + const newState = { + ...stateData, + autoApprovalSettings: shouldUpdateAutoApproval + ? stateData.autoApprovalSettings + : prevState.autoApprovalSettings, + } + + // Update welcome screen state based on API configuration + const config = stateData.apiConfiguration + const hasKey = config + ? [ + config.apiKey, + config.openRouterApiKey, + config.awsRegion, + config.vertexProjectId, + config.openAiApiKey, + config.ollamaModelId, + config.lmStudioModelId, + config.liteLlmApiKey, + config.geminiApiKey, + config.openAiNativeApiKey, + config.deepSeekApiKey, + config.requestyApiKey, + config.togetherApiKey, + config.qwenApiKey, + config.doubaoApiKey, + config.mistralApiKey, + config.vsCodeLmModelSelector, + config.clineApiKey, + config.asksageApiKey, + config.xaiApiKey, + config.sambanovaApiKey, + ].some((key) => key !== undefined) + : false + + setShowWelcome(!hasKey) + setDidHydrateState(true) + + console.log("[DEBUG] returning new state in ESC") + + return newState + }) + } catch (error) { + console.error("Error parsing state JSON:", error) + console.log("[DEBUG] ERR getting state", error) + } + } + console.log('[DEBUG] ended "got subscribed state"') + }, + onError: (error) => { + console.error("Error in state subscription:", error) + }, + onComplete: () => { + console.log("State subscription completed") + }, + }, + ) + + // Still send the webviewDidLaunch message for other initialization vscode.postMessage({ type: "webviewDidLaunch" }) + + // Clean up subscription when component unmounts + return () => { + if (stateSubscriptionRef.current) { + stateSubscriptionRef.current() + stateSubscriptionRef.current = null + } + } }, []) const contextValue: ExtensionStateContextType = { diff --git a/webview-ui/src/services/grpc-client-base.ts b/webview-ui/src/services/grpc-client-base.ts new file mode 100644 index 000000000..7261c90a1 --- /dev/null +++ b/webview-ui/src/services/grpc-client-base.ts @@ -0,0 +1,179 @@ +import { vscode } from "../utils/vscode" +import { v4 as uuidv4 } from "uuid" + +// Generic type for any protobuf service definition +export type ProtoService = { + name: string + fullName: string + methods: { + [key: string]: { + name: string + requestType: any + responseType: any + requestStream: boolean + responseStream: boolean + options: any + } + } +} + +// Define a unified client type that handles both unary and streaming methods +export type GrpcClientType = { + [K in keyof T["methods"]]: T["methods"][K]["responseStream"] extends true + ? ( + request: InstanceType, + options: { + onResponse: (response: InstanceType) => void + onError?: (error: Error) => void + onComplete?: () => void + }, + ) => () => void // Returns a cancel function + : (request: InstanceType) => Promise> +} + +/** + * Helper function to encode request objects + */ +function encodeRequest(request: any): any { + if (request === null || request === undefined) { + return {} + } else if (typeof request.toJSON === "function") { + return request.toJSON() + } else if (typeof request === "object") { + return { ...request } + } else { + return { value: request } + } +} + +// Create a client for any protobuf service with inferred types +export function createGrpcClient(service: T): GrpcClientType { + const client = {} as GrpcClientType + + // For each method in the service + Object.values(service.methods).forEach((method) => { + if (method.responseStream) { + // Streaming method implementation + client[method.name as keyof GrpcClientType] = (( + request: any, + options: { + onResponse: (response: any) => void + onError?: (error: Error) => void + onComplete?: () => void + }, + ) => { + const requestId = uuidv4() + + // Set up listener for streaming responses + const handleResponse = (event: MessageEvent) => { + const message = event.data + if (message.type === "grpc_response" && message.grpc_response?.request_id === requestId) { + if (message.grpc_response.error) { + // Handle error + if (options.onError) { + options.onError(new Error(message.grpc_response.error)) + } + // Only remove the event listener on error + window.removeEventListener("message", handleResponse) + } else if (message.grpc_response.is_streaming === false) { + // End of stream + if (message.grpc_response.message) { + // Process final message if present + const responseType = method.responseType + const response = responseType.fromJSON(message.grpc_response.message) + options.onResponse(response) + } + + if (options.onComplete) { + options.onComplete() + } + // Only remove the event listener when the stream is explicitly ended + window.removeEventListener("message", handleResponse) + } else { + // Process streaming message + if (message.grpc_response.message) { + const responseType = method.responseType + const response = responseType.fromJSON(message.grpc_response.message) + console.log("[DEBUG] Received streaming response:", message.grpc_response.message) + options.onResponse(response) + } + } + } + } + + window.addEventListener("message", handleResponse) + + // Send the streaming request + const encodedRequest = encodeRequest(request) + + vscode.postMessage({ + type: "grpc_request", + grpc_request: { + service: service.fullName, + method: method.name, + message: encodedRequest, + request_id: requestId, + is_streaming: true, + }, + }) + + // Return a function to cancel the stream + return () => { + window.removeEventListener("message", handleResponse) + // Send cancellation message + vscode.postMessage({ + type: "grpc_request_cancel", + grpc_request_cancel: { + request_id: requestId, + }, + }) + console.log(`[DEBUG] Sent cancellation for request: ${requestId}`) + } + }) as any + } else { + // Unary method implementation + client[method.name as keyof GrpcClientType] = ((request: any) => { + return new Promise((resolve, reject) => { + const requestId = uuidv4() + + // Set up one-time listener for this specific request + const handleResponse = (event: MessageEvent) => { + const message = event.data + if (message.type === "grpc_response" && message.grpc_response?.request_id === requestId) { + // Remove listener once we get our response + window.removeEventListener("message", handleResponse) + + if (message.grpc_response.error) { + reject(new Error(message.grpc_response.error)) + } else { + // Convert JSON back to protobuf message + const responseType = method.responseType + const response = responseType.fromJSON(message.grpc_response.message) + console.log("[DEBUG] grpc-client sending response:", response) + resolve(response) + } + } + } + + window.addEventListener("message", handleResponse) + + // Send the request + const encodedRequest = encodeRequest(request) + + vscode.postMessage({ + type: "grpc_request", + grpc_request: { + service: service.fullName, + method: method.name, + message: encodedRequest, + request_id: requestId, + is_streaming: false, + }, + }) + }) + }) as any + } + }) + + return client +} diff --git a/webview-ui/src/services/grpc-client.ts b/webview-ui/src/services/grpc-client.ts index 92ccdddb9..6f29f2fd2 100644 --- a/webview-ui/src/services/grpc-client.ts +++ b/webview-ui/src/services/grpc-client.ts @@ -1,119 +1,35 @@ -import { vscode } from "../utils/vscode" -import { v4 as uuidv4 } from "uuid" +// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +// Generated by proto/build-proto.js + +import { createGrpcClient } from "./grpc-client-base" import { AccountServiceDefinition } from "@shared/proto/account" import { BrowserServiceDefinition } from "@shared/proto/browser" import { CheckpointsServiceDefinition } from "@shared/proto/checkpoints" -import { EmptyRequest } from "@shared/proto/common" import { FileServiceDefinition } from "@shared/proto/file" import { McpServiceDefinition } from "@shared/proto/mcp" -import { ModelsServiceDefinition } from "@shared/proto/models" +import { StateServiceDefinition } from "@shared/proto/state" import { TaskServiceDefinition } from "@shared/proto/task" -import { WebContentServiceDefinition } from "@shared/proto/web_content" -// Generic type for any protobuf service definition -type ProtoService = { - name: string - fullName: string - methods: { - [key: string]: { - name: string - requestType: any - responseType: any - requestStream: boolean - responseStream: boolean - options: any - } - } -} - -// Define a generic type that extracts method signatures from a service definition -type GrpcClientType = { - [K in keyof T["methods"]]: ( - request: InstanceType, - ) => Promise> -} - -// Create a client for any protobuf service with inferred types -function createGrpcClient(service: T): GrpcClientType { - const client = {} as GrpcClientType - - // For each method in the service - Object.values(service.methods).forEach((method) => { - // Create a function that matches the method signature - client[method.name as keyof GrpcClientType] = ((request: any) => { - return new Promise((resolve, reject) => { - const requestId = uuidv4() - - // Set up one-time listener for this specific request - const handleResponse = (event: MessageEvent) => { - const message = event.data - if (message.type === "grpc_response" && message.grpc_response?.request_id === requestId) { - // Remove listener once we get our response - window.removeEventListener("message", handleResponse) - - if (message.grpc_response.error) { - reject(new Error(message.grpc_response.error)) - } else { - // Convert JSON back to protobuf message - const responseType = method.responseType - const response = responseType.fromJSON(message.grpc_response.message) - console.log("[DEBUG] grpc-client sending response:", response) - resolve(response) - } - } - } - - window.addEventListener("message", handleResponse) - - let encodedRequest = {} - - // Handle different types of requests - if (request === null || request === undefined) { - // Empty request - encodedRequest = {} - } else if (typeof request.toJSON === "function") { - // Proper protobuf object - encodedRequest = request.toJSON() - } else if (typeof request === "object") { - // Plain JavaScript object - encodedRequest = { ...request } - } else { - // Fallback - encodedRequest = { value: request } - } - - // Send the request - vscode.postMessage({ - type: "grpc_request", - grpc_request: { - service: service.fullName, - method: method.name, - message: encodedRequest, // Convert protobuf to JSON - request_id: requestId, - }, - }) - }) - }) as any - }) - - return client -} +import { WebServiceDefinition } from "@shared/proto/web" +import { ModelsServiceDefinition } from "@shared/proto/models" const AccountServiceClient = createGrpcClient(AccountServiceDefinition) const BrowserServiceClient = createGrpcClient(BrowserServiceDefinition) const CheckpointsServiceClient = createGrpcClient(CheckpointsServiceDefinition) const FileServiceClient = createGrpcClient(FileServiceDefinition) const McpServiceClient = createGrpcClient(McpServiceDefinition) -const ModelsServiceClient = createGrpcClient(ModelsServiceDefinition) +const StateServiceClient = createGrpcClient(StateServiceDefinition) const TaskServiceClient = createGrpcClient(TaskServiceDefinition) -const WebContentServiceClient = createGrpcClient(WebContentServiceDefinition) +const WebServiceClient = createGrpcClient(WebServiceDefinition) +const ModelsServiceClient = createGrpcClient(ModelsServiceDefinition) export { AccountServiceClient, BrowserServiceClient, CheckpointsServiceClient, FileServiceClient, - TaskServiceClient, McpServiceClient, + StateServiceClient, + TaskServiceClient, + WebServiceClient, ModelsServiceClient, - WebContentServiceClient, }