diff --git a/src/api/index.ts b/src/api/index.ts index ec35c2a2af..75de50ed49 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -11,7 +11,11 @@ import { GeminiHandler } from "./providers/gemini" import { OpenAiNativeHandler } from "./providers/openai-native" import { ApiStream } from "./transform/stream" -export interface ApiHandler { +export interface SingleCompletionHandler { + completePrompt(prompt: string): Promise +} + +export interface ApiHandler extends SingleCompletionHandler { createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream getModel(): { id: string; info: ModelInfo } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index c090f17c63..38c7bafe62 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -7,7 +7,7 @@ import { ApiHandlerOptions, ModelInfo, } from "../../shared/api" -import { ApiHandler } from "../index" +import { ApiHandler, SingleCompletionHandler } from "../index" import { ApiStream } from "../transform/stream" export class AnthropicHandler implements ApiHandler { @@ -173,4 +173,27 @@ export class AnthropicHandler implements ApiHandler { } return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.messages.create({ + model: this.getModel().id, + max_tokens: this.getModel().info.maxTokens || 8192, + temperature: 0, + system: [{ text: "", type: "text" }], + messages: [{ role: "user", content: prompt }], + stream: false + }) + + if (response.content[0].type === 'text') { + return response.content[0].text + } + throw new Error('Unexpected response type from Anthropic API') + } catch (error) { + if (error instanceof Error) { + throw new Error(`Anthropic completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 3b691c14b7..700fc5eb7f 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -1,222 +1,293 @@ import { BedrockRuntimeClient, ConverseStreamCommand, BedrockRuntimeClientConfig } from "@aws-sdk/client-bedrock-runtime" import { Anthropic } from "@anthropic-ai/sdk" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, BedrockModelId, ModelInfo, bedrockDefaultModelId, bedrockModels } from "../../shared/api" import { ApiStream } from "../transform/stream" import { convertToBedrockConverseMessages, convertToAnthropicMessage } from "../transform/bedrock-converse-format" // Define types for stream events based on AWS SDK export interface StreamEvent { - messageStart?: { - role?: string; - }; - messageStop?: { - stopReason?: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence"; - additionalModelResponseFields?: Record; - }; - contentBlockStart?: { - start?: { - text?: string; - }; - contentBlockIndex?: number; - }; - contentBlockDelta?: { - delta?: { - text?: string; - }; - contentBlockIndex?: number; - }; - metadata?: { - usage?: { - inputTokens: number; - outputTokens: number; - totalTokens?: number; // Made optional since we don't use it - }; - metrics?: { - latencyMs: number; - }; - }; + messageStart?: { + role?: string; + }; + messageStop?: { + stopReason?: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence"; + additionalModelResponseFields?: Record; + }; + contentBlockStart?: { + start?: { + text?: string; + }; + contentBlockIndex?: number; + }; + contentBlockDelta?: { + delta?: { + text?: string; + }; + contentBlockIndex?: number; + }; + metadata?: { + usage?: { + inputTokens: number; + outputTokens: number; + totalTokens?: number; // Made optional since we don't use it + }; + metrics?: { + latencyMs: number; + }; + }; } export class AwsBedrockHandler implements ApiHandler { - private options: ApiHandlerOptions - private client: BedrockRuntimeClient + private options: ApiHandlerOptions + private client: BedrockRuntimeClient - constructor(options: ApiHandlerOptions) { - this.options = options - - // Only include credentials if they actually exist - const clientConfig: BedrockRuntimeClientConfig = { - region: this.options.awsRegion || "us-east-1" - } + constructor(options: ApiHandlerOptions) { + this.options = options + + // Only include credentials if they actually exist + const clientConfig: BedrockRuntimeClientConfig = { + region: this.options.awsRegion || "us-east-1" + } - if (this.options.awsAccessKey && this.options.awsSecretKey) { - // Create credentials object with all properties at once - clientConfig.credentials = { - accessKeyId: this.options.awsAccessKey, - secretAccessKey: this.options.awsSecretKey, - ...(this.options.awsSessionToken ? { sessionToken: this.options.awsSessionToken } : {}) - } - } + if (this.options.awsAccessKey && this.options.awsSecretKey) { + // Create credentials object with all properties at once + clientConfig.credentials = { + accessKeyId: this.options.awsAccessKey, + secretAccessKey: this.options.awsSecretKey, + ...(this.options.awsSessionToken ? { sessionToken: this.options.awsSessionToken } : {}) + } + } - this.client = new BedrockRuntimeClient(clientConfig) - } + this.client = new BedrockRuntimeClient(clientConfig) + } - async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const modelConfig = this.getModel() - - // Handle cross-region inference - let modelId: string - if (this.options.awsUseCrossRegionInference) { - let regionPrefix = (this.options.awsRegion || "").slice(0, 3) - switch (regionPrefix) { - case "us-": - modelId = `us.${modelConfig.id}` - break - case "eu-": - modelId = `eu.${modelConfig.id}` - break - default: - modelId = modelConfig.id - break - } - } else { - modelId = modelConfig.id - } + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + const modelConfig = this.getModel() + + // Handle cross-region inference + let modelId: string + if (this.options.awsUseCrossRegionInference) { + let regionPrefix = (this.options.awsRegion || "").slice(0, 3) + switch (regionPrefix) { + case "us-": + modelId = `us.${modelConfig.id}` + break + case "eu-": + modelId = `eu.${modelConfig.id}` + break + default: + modelId = modelConfig.id + break + } + } else { + modelId = modelConfig.id + } - // Convert messages to Bedrock format - const formattedMessages = convertToBedrockConverseMessages(messages) + // Convert messages to Bedrock format + const formattedMessages = convertToBedrockConverseMessages(messages) - // Construct the payload - const payload = { - modelId, - messages: formattedMessages, - system: [{ text: systemPrompt }], - inferenceConfig: { - maxTokens: modelConfig.info.maxTokens || 5000, - temperature: 0.3, - topP: 0.1, - ...(this.options.awsUsePromptCache ? { - promptCache: { - promptCacheId: this.options.awspromptCacheId || "" - } - } : {}) - } - } + // Construct the payload + const payload = { + modelId, + messages: formattedMessages, + system: [{ text: systemPrompt }], + inferenceConfig: { + maxTokens: modelConfig.info.maxTokens || 5000, + temperature: 0.3, + topP: 0.1, + ...(this.options.awsUsePromptCache ? { + promptCache: { + promptCacheId: this.options.awspromptCacheId || "" + } + } : {}) + } + } - try { - const command = new ConverseStreamCommand(payload) - const response = await this.client.send(command) + try { + const command = new ConverseStreamCommand(payload) + const response = await this.client.send(command) - if (!response.stream) { - throw new Error('No stream available in the response') - } + if (!response.stream) { + throw new Error('No stream available in the response') + } - for await (const chunk of response.stream) { - // Parse the chunk as JSON if it's a string (for tests) - let streamEvent: StreamEvent - try { - streamEvent = typeof chunk === 'string' ? - JSON.parse(chunk) : - chunk as unknown as StreamEvent - } catch (e) { - console.error('Failed to parse stream event:', e) - continue - } + for await (const chunk of response.stream) { + // Parse the chunk as JSON if it's a string (for tests) + let streamEvent: StreamEvent + try { + streamEvent = typeof chunk === 'string' ? + JSON.parse(chunk) : + chunk as unknown as StreamEvent + } catch (e) { + console.error('Failed to parse stream event:', e) + continue + } - // Handle metadata events first - if (streamEvent.metadata?.usage) { - yield { - type: "usage", - inputTokens: streamEvent.metadata.usage.inputTokens || 0, - outputTokens: streamEvent.metadata.usage.outputTokens || 0 - } - continue - } + // Handle metadata events first + if (streamEvent.metadata?.usage) { + yield { + type: "usage", + inputTokens: streamEvent.metadata.usage.inputTokens || 0, + outputTokens: streamEvent.metadata.usage.outputTokens || 0 + } + continue + } - // Handle message start - if (streamEvent.messageStart) { - continue - } + // Handle message start + if (streamEvent.messageStart) { + continue + } - // Handle content blocks - if (streamEvent.contentBlockStart?.start?.text) { - yield { - type: "text", - text: streamEvent.contentBlockStart.start.text - } - continue - } + // Handle content blocks + if (streamEvent.contentBlockStart?.start?.text) { + yield { + type: "text", + text: streamEvent.contentBlockStart.start.text + } + continue + } - // Handle content deltas - if (streamEvent.contentBlockDelta?.delta?.text) { - yield { - type: "text", - text: streamEvent.contentBlockDelta.delta.text - } - continue - } + // Handle content deltas + if (streamEvent.contentBlockDelta?.delta?.text) { + yield { + type: "text", + text: streamEvent.contentBlockDelta.delta.text + } + continue + } - // Handle message stop - if (streamEvent.messageStop) { - continue - } - } + // Handle message stop + if (streamEvent.messageStop) { + continue + } + } - } catch (error: unknown) { - console.error('Bedrock Runtime API Error:', error) - // Only access stack if error is an Error object - if (error instanceof Error) { - console.error('Error stack:', error.stack) - yield { - type: "text", - text: `Error: ${error.message}` - } - yield { - type: "usage", - inputTokens: 0, - outputTokens: 0 - } - throw error - } else { - const unknownError = new Error("An unknown error occurred") - yield { - type: "text", - text: unknownError.message - } - yield { - type: "usage", - inputTokens: 0, - outputTokens: 0 - } - throw unknownError - } - } - } + } catch (error: unknown) { + console.error('Bedrock Runtime API Error:', error) + // Only access stack if error is an Error object + if (error instanceof Error) { + console.error('Error stack:', error.stack) + yield { + type: "text", + text: `Error: ${error.message}` + } + yield { + type: "usage", + inputTokens: 0, + outputTokens: 0 + } + throw error + } else { + const unknownError = new Error("An unknown error occurred") + yield { + type: "text", + text: unknownError.message + } + yield { + type: "usage", + inputTokens: 0, + outputTokens: 0 + } + throw unknownError + } + } + } - getModel(): { id: BedrockModelId | string; info: ModelInfo } { - const modelId = this.options.apiModelId - if (modelId) { - // For tests, allow any model ID - if (process.env.NODE_ENV === 'test') { - return { - id: modelId, - info: { - maxTokens: 5000, - contextWindow: 128_000, - supportsPromptCache: false - } - } - } - // For production, validate against known models - if (modelId in bedrockModels) { - const id = modelId as BedrockModelId - return { id, info: bedrockModels[id] } - } - } - return { - id: bedrockDefaultModelId, - info: bedrockModels[bedrockDefaultModelId] - } - } + getModel(): { id: BedrockModelId | string; info: ModelInfo } { + const modelId = this.options.apiModelId + if (modelId) { + // For tests, allow any model ID + if (process.env.NODE_ENV === 'test') { + return { + id: modelId, + info: { + maxTokens: 5000, + contextWindow: 128_000, + supportsPromptCache: false + } + } + } + // For production, validate against known models + if (modelId in bedrockModels) { + const id = modelId as BedrockModelId + return { id, info: bedrockModels[id] } + } + } + return { + id: bedrockDefaultModelId, + info: bedrockModels[bedrockDefaultModelId] + } + } + + async completePrompt(prompt: string): Promise { + const modelConfig = this.getModel() + + // Handle cross-region inference + let modelId: string + if (this.options.awsUseCrossRegionInference) { + let regionPrefix = (this.options.awsRegion || "").slice(0, 3) + switch (regionPrefix) { + case "us-": + modelId = `us.${modelConfig.id}` + break + case "eu-": + modelId = `eu.${modelConfig.id}` + break + default: + modelId = modelConfig.id + break + } + } else { + modelId = modelConfig.id + } + + const payload = { + modelId, + messages: convertToBedrockConverseMessages([{ role: "user", content: prompt }]), + system: [{ text: "" }], + inferenceConfig: { + maxTokens: modelConfig.info.maxTokens || 5000, + temperature: 0.3, + topP: 0.1 + } + } + + try { + const command = new ConverseStreamCommand(payload) + const response = await this.client.send(command) + + if (!response.stream) { + throw new Error('No stream available in the response') + } + + let fullResponse = "" + for await (const chunk of response.stream) { + let streamEvent: StreamEvent + try { + streamEvent = typeof chunk === 'string' ? + JSON.parse(chunk) : + chunk as unknown as StreamEvent + } catch (e) { + console.error('Failed to parse stream event:', e) + continue + } + + if (streamEvent.contentBlockStart?.start?.text) { + fullResponse += streamEvent.contentBlockStart.start.text + } + + if (streamEvent.contentBlockDelta?.delta?.text) { + fullResponse += streamEvent.contentBlockDelta.delta.text + } + } + + return fullResponse + } catch (error) { + if (error instanceof Error) { + throw new Error(`Bedrock completion error: ${error.message}`) + } + throw new Error('An unknown error occurred during Bedrock completion') + } + } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index d7ac5ec67d..587f1360a7 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { GoogleGenerativeAI } from "@google/generative-ai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "../../shared/api" import { convertAnthropicMessageToGemini } from "../transform/gemini-format" import { ApiStream } from "../transform/stream" @@ -53,4 +53,28 @@ export class GeminiHandler implements ApiHandler { } return { id: geminiDefaultModelId, info: geminiModels[geminiDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + const model = this.client.getGenerativeModel({ + model: this.getModel().id, + systemInstruction: "" + }) + + const result = await model.generateContent({ + contents: [{ role: "user", parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0 + } + }) + + const response = await result.response + return response.text() + } catch (error) { + if (error instanceof Error) { + throw new Error(`Gemini completion error: ${error.message}`) + } + throw new Error('An unknown error occurred during Gemini completion') + } + } } diff --git a/src/api/providers/lmstudio.ts b/src/api/providers/lmstudio.ts index 868ef7da13..16cb3ece51 100644 --- a/src/api/providers/lmstudio.ts +++ b/src/api/providers/lmstudio.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" @@ -53,4 +53,21 @@ export class LmStudioHandler implements ApiHandler { info: openAiModelInfoSaneDefaults, } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.chat.completions.create({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false + }) + + return response.choices[0]?.message?.content || "" + } catch (error) { + throw new Error( + "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Cline's prompts." + ) + } + } } diff --git a/src/api/providers/ollama.ts b/src/api/providers/ollama.ts index 7668bd395f..fdb6d6e5f9 100644 --- a/src/api/providers/ollama.ts +++ b/src/api/providers/ollama.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" @@ -46,4 +46,22 @@ export class OllamaHandler implements ApiHandler { info: openAiModelInfoSaneDefaults, } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.chat.completions.create({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false + }) + + return response.choices[0]?.message?.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Ollama completion error: ${error.message}`) + } + throw new Error('An unknown error occurred during Ollama completion') + } + } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 70d55b7abe..71e2de5e12 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, @@ -82,4 +82,34 @@ export class OpenAiNativeHandler implements ApiHandler { } return { id: openAiNativeDefaultModelId, info: openAiNativeModels[openAiNativeDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + switch (this.getModel().id) { + case "o1-preview": + case "o1-mini": { + // o1 doesn't support temperature + const response = await this.client.chat.completions.create({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }] + }) + return response.choices[0]?.message.content || "" + } + default: { + const response = await this.client.chat.completions.create({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false + }) + return response.choices[0]?.message?.content || "" + } + } + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenAI Native completion error: ${error.message}`) + } + throw new Error('An unknown error occurred during OpenAI Native completion') + } + } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 71308ed180..5b61e1a0e8 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -6,7 +6,7 @@ import { ModelInfo, openAiModelInfoSaneDefaults, } from "../../shared/api" -import { ApiHandler } from "../index" +import { ApiHandler, SingleCompletionHandler } from "../index" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream } from "../transform/stream" @@ -74,4 +74,23 @@ export class OpenAiHandler implements ApiHandler { info: openAiModelInfoSaneDefaults, } } + + async completePrompt(prompt: string): Promise { + try { + const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { + model: this.options.openAiModelId ?? "", + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false + } + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message?.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenAI completion error: ${error.message}`) + } + throw new Error('An unknown error occurred during OpenAI completion') + } + } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index c2c34d80f0..ccfe167282 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -4,11 +4,11 @@ import OpenAI from "openai" import { ApiHandler } from "../" import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" -import { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream" +import { ApiStream, ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream" import delay from "delay" // Add custom interface for OpenRouter params -interface OpenRouterChatCompletionParams extends OpenAI.Chat.ChatCompletionCreateParamsStreaming { +type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { transforms?: string[]; } @@ -17,7 +17,12 @@ interface OpenRouterApiStreamUsageChunk extends ApiStreamUsageChunk { fullResponseText: string; } -export class OpenRouterHandler implements ApiHandler { +// Interface for providers that support single completions +export interface SingleCompletionHandler { + completePrompt(prompt: string): Promise +} + +export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions private client: OpenAI @@ -184,4 +189,28 @@ export class OpenRouterHandler implements ApiHandler { } return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.chat.completions.create({ + model: this.getModel().id, + messages: [{ role: "user", content: prompt }], + temperature: 0, + stream: false + }) + + if ("error" in response) { + const error = response.error as { message?: string; code?: number } + throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`) + } + + const completion = response as OpenAI.Chat.ChatCompletion + return completion.choices[0]?.message?.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`OpenRouter completion error: ${error.message}`) + } + throw error + } + } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 60e6967dd6..3730f8714a 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { AnthropicVertex } from "@anthropic-ai/vertex-sdk" -import { ApiHandler } from "../" +import { ApiHandler, SingleCompletionHandler } from "../" import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api" import { ApiStream } from "../transform/stream" @@ -83,4 +83,27 @@ export class VertexHandler implements ApiHandler { } return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] } } + + async completePrompt(prompt: string): Promise { + try { + const response = await this.client.messages.create({ + model: this.getModel().id, + max_tokens: this.getModel().info.maxTokens || 8192, + temperature: 0, + system: "", + messages: [{ role: "user", content: prompt }], + stream: false + }) + + if (response.content[0].type === 'text') { + return response.content[0].text + } + throw new Error('Unexpected response type from Vertex API') + } catch (error) { + if (error instanceof Error) { + throw new Error(`Vertex completion error: ${error.message}`) + } + throw new Error('An unknown error occurred during Vertex completion') + } + } } diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 343393fccd..78806c4a21 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -8,7 +8,7 @@ import pWaitFor from "p-wait-for" import * as path from "path" import { serializeError } from "serialize-error" import * as vscode from "vscode" -import { ApiHandler, buildApiHandler } from "../api" +import { ApiHandler, SingleCompletionHandler, buildApiHandler } from "../api" import { ApiStream } from "../api/transform/stream" import { DiffViewProvider } from "../integrations/editor/DiffViewProvider" import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown" @@ -126,6 +126,15 @@ export class Cline { } } + async enhancePrompt(promptText: string): Promise { + if (!promptText) { + throw new Error("No prompt text provided") + } + + const prompt = `Generate an enhanced version of this prompt (reply with only the enhanced prompt, no bullet points): ${promptText}` + return this.api.completePrompt(prompt) + } + // Storing task to disk for history private async ensureTaskDirectoryExists(): Promise { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4e5688132b..e8b6949690 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -23,6 +23,7 @@ import { openMention } from "../mentions" import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" +import { enhancePrompt } from "../../utils/enhance-prompt" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -632,6 +633,21 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("writeDelayMs", message.value) await this.postStateToWebview() break + case "enhancePrompt": + if (message.text) { + try { + const { apiConfiguration } = await this.getState() + const enhancedPrompt = await enhancePrompt(apiConfiguration, message.text) + await this.postMessageToWebview({ + type: "enhancedPrompt", + text: enhancedPrompt + }) + } catch (error) { + console.error("Error enhancing prompt:", error) + vscode.window.showErrorMessage("Failed to enhance prompt") + } + } + break } }, null, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 01de0affff..b792efef2c 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -18,6 +18,7 @@ export interface ExtensionMessage { | "partialMessage" | "openRouterModels" | "mcpServers" + | "enhancedPrompt" text?: string action?: | "chatButtonClicked" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 507bc79e42..69737b606a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -42,6 +42,9 @@ export interface WebviewMessage { | "fuzzyMatchThreshold" | "preferredLanguage" | "writeDelayMs" + | "enhancePrompt" + | "enhancedPrompt" + | "draggedImages" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -51,10 +54,10 @@ export interface WebviewMessage { value?: number commands?: string[] audioType?: AudioType - // For toggleToolAutoApprove serverName?: string toolName?: string alwaysAllow?: boolean + dataUrls?: string[] } export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" diff --git a/src/utils/__tests__/enhance-prompt.test.ts b/src/utils/__tests__/enhance-prompt.test.ts new file mode 100644 index 0000000000..623d494cb4 --- /dev/null +++ b/src/utils/__tests__/enhance-prompt.test.ts @@ -0,0 +1,63 @@ +import { enhancePrompt } from '../enhance-prompt' +import { buildApiHandler } from '../../api' +import { ApiConfiguration } from '../../shared/api' + +// Mock the buildApiHandler function +jest.mock('../../api', () => ({ + buildApiHandler: jest.fn() +})) + +describe('enhancePrompt', () => { + const mockApiConfig: ApiConfiguration = { + apiProvider: 'anthropic', + apiKey: 'test-key', + apiModelId: 'claude-3-5-sonnet-20241022' + } + + const mockHandler = { + completePrompt: jest.fn() + } + + beforeEach(() => { + jest.clearAllMocks() + ;(buildApiHandler as jest.Mock).mockReturnValue(mockHandler) + }) + + it('should enhance a valid prompt', async () => { + const inputPrompt = 'Write a function to sort an array' + const enhancedPrompt = 'Write a TypeScript function that implements an efficient sorting algorithm for a generic array, including error handling and type safety' + + mockHandler.completePrompt.mockResolvedValue(enhancedPrompt) + + const result = await enhancePrompt(mockApiConfig, inputPrompt) + + expect(result).toBe(enhancedPrompt) + expect(buildApiHandler).toHaveBeenCalledWith(mockApiConfig) + expect(mockHandler.completePrompt).toHaveBeenCalledWith( + expect.stringContaining(inputPrompt) + ) + }) + + it('should throw error when no prompt text is provided', async () => { + await expect(enhancePrompt(mockApiConfig, '')).rejects.toThrow('No prompt text provided') + expect(mockHandler.completePrompt).not.toHaveBeenCalled() + }) + + it('should pass through API errors', async () => { + const inputPrompt = 'Test prompt' + mockHandler.completePrompt.mockRejectedValue('API error') + + await expect(enhancePrompt(mockApiConfig, inputPrompt)).rejects.toBe('API error') + }) + + it('should pass the correct prompt format to the API', async () => { + const inputPrompt = 'Test prompt' + mockHandler.completePrompt.mockResolvedValue('Enhanced test prompt') + + await enhancePrompt(mockApiConfig, inputPrompt) + + expect(mockHandler.completePrompt).toHaveBeenCalledWith( + 'Generate an enhanced version of this prompt (reply with only the enhanced prompt, no other text or bullet points): Test prompt' + ) + }) +}) \ No newline at end of file diff --git a/src/utils/enhance-prompt.ts b/src/utils/enhance-prompt.ts new file mode 100644 index 0000000000..bd1bdd41fd --- /dev/null +++ b/src/utils/enhance-prompt.ts @@ -0,0 +1,17 @@ +import { ApiConfiguration } from "../shared/api" +import { buildApiHandler } from "../api" +import { SingleCompletionHandler } from "../api" + +/** + * Enhances a prompt using the API without creating a full Cline instance or task history. + * This is a lightweight alternative that only uses the API's completion functionality. + */ +export async function enhancePrompt(apiConfiguration: ApiConfiguration, promptText: string): Promise { + if (!promptText) { + throw new Error("No prompt text provided") + } + // Create a minimal handler that only has completePrompt capability + const handler: SingleCompletionHandler = buildApiHandler(apiConfiguration) + const prompt = `Generate an enhanced version of this prompt (reply with only the enhanced prompt, no other text or bullet points): ${promptText}` + return handler.completePrompt(prompt) +} \ No newline at end of file diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index f48380b95f..af704613b6 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -13,7 +13,7 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" import Thumbnails from "../common/Thumbnails" -declare const vscode: any; +import { vscode } from "../../utils/vscode" interface ChatTextAreaProps { inputValue: string @@ -46,6 +46,18 @@ const ChatTextArea = forwardRef( ) => { const { filePaths } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) + + // Handle enhanced prompt response + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + const message = event.data + if (message.type === 'enhancedPrompt' && message.text) { + setInputValue(message.text) + } + } + window.addEventListener('message', messageHandler) + return () => window.removeEventListener('message', messageHandler) + }, [setInputValue]) const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) const [showContextMenu, setShowContextMenu] = useState(false) @@ -60,6 +72,63 @@ const ChatTextArea = forwardRef( const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) const contextMenuContainerRef = useRef(null) + const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) + + const handleEnhancePrompt = useCallback(() => { + if (!textAreaDisabled) { + const trimmedInput = inputValue.trim() + if (trimmedInput) { + setIsEnhancingPrompt(true) + const message = { + type: "enhancePrompt" as const, + text: trimmedInput, + } + vscode.postMessage(message) + } else { + const promptDescription = "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works." + setInputValue(promptDescription) + } + } + }, [inputValue, textAreaDisabled, setInputValue]) + + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + const message = event.data + if (message.type === 'enhancedPrompt') { + setInputValue(message.text) + setIsEnhancingPrompt(false) + } + } + window.addEventListener('message', messageHandler) + return () => window.removeEventListener('message', messageHandler) + }, [setInputValue]) + + // Handle enhanced prompt response + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + const message = event.data + if (message.type === 'enhancedPrompt') { + setInputValue(message.text) + } + } + window.addEventListener('message', messageHandler) + return () => { + window.removeEventListener('message', messageHandler) + } + }, [setInputValue]) + + // Handle enhanced prompt response + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + const message = event.data + if (message.type === 'enhancedPrompt' && message.text) { + setInputValue(message.text) + } + } + window.addEventListener('message', messageHandler) + return () => window.removeEventListener('message', messageHandler) + }, [setInputValue]) + const queryItems = useMemo(() => { return [ { type: ContextMenuOptionType.Problems, value: "problems" }, @@ -423,68 +492,64 @@ const ChatTextArea = forwardRef( ) return ( -
{ - console.log("onDrop called") - e.preventDefault() - const files = Array.from(e.dataTransfer.files) - const text = e.dataTransfer.getData("text") - if (text) { - const newValue = - inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition) - setInputValue(newValue) - const newCursorPosition = cursorPosition + text.length - setCursorPosition(newCursorPosition) - setIntendedCursorPosition(newCursorPosition) - return - } - const acceptedTypes = ["png", "jpeg", "webp"] - const imageFiles = files.filter((file) => { - const [type, subtype] = file.type.split("/") - return type === "image" && acceptedTypes.includes(subtype) - }) - if (!shouldDisableImages && imageFiles.length > 0) { - const imagePromises = imageFiles.map((file) => { - return new Promise((resolve) => { - const reader = new FileReader() - reader.onloadend = () => { - if (reader.error) { - console.error("Error reading file:", reader.error) - resolve(null) - } else { - const result = reader.result - console.log("File read successfully", result) - resolve(typeof result === "string" ? result : null) - } +
{ + e.preventDefault() + const files = Array.from(e.dataTransfer.files) + const text = e.dataTransfer.getData("text") + if (text) { + const newValue = + inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition) + setInputValue(newValue) + const newCursorPosition = cursorPosition + text.length + setCursorPosition(newCursorPosition) + setIntendedCursorPosition(newCursorPosition) + return + } + const acceptedTypes = ["png", "jpeg", "webp"] + const imageFiles = files.filter((file) => { + const [type, subtype] = file.type.split("/") + return type === "image" && acceptedTypes.includes(subtype) + }) + if (!shouldDisableImages && imageFiles.length > 0) { + const imagePromises = imageFiles.map((file) => { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = () => { + if (reader.error) { + console.error("Error reading file:", reader.error) + resolve(null) + } else { + const result = reader.result + resolve(typeof result === "string" ? result : null) } - reader.readAsDataURL(file) - }) - }) - const imageDataArray = await Promise.all(imagePromises) - const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) - if (dataUrls.length > 0) { - setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) - if (typeof vscode !== 'undefined') { - vscode.postMessage({ - type: 'draggedImages', - dataUrls: dataUrls - }) } - } else { - console.warn("No valid images were processed") + reader.readAsDataURL(file) + }) + }) + const imageDataArray = await Promise.all(imagePromises) + const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) + if (dataUrls.length > 0) { + setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) + if (typeof vscode !== 'undefined') { + vscode.postMessage({ + type: 'draggedImages', + dataUrls: dataUrls + }) } + } else { + console.warn("No valid images were processed") } - }} - onDragOver={(e) => { - e.preventDefault() - }} - > + } + }} + onDragOver={(e) => { + e.preventDefault() + }}> {showContextMenu && (
( borderTop: 0, borderColor: "transparent", borderBottom: `${thumbnailsHeight + 6}px solid transparent`, - padding: "9px 49px 3px 9px", + padding: "9px 9px 25px 9px", }} /> ( borderTop: 0, borderBottom: `${thumbnailsHeight + 6}px solid transparent`, borderColor: "transparent", + padding: "9px 9px 25px 9px", // borderRight: "54px solid transparent", // 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 49px 3px 9px", cursor: textAreaDisabled ? "not-allowed" : undefined, flex: 1, zIndex: 1, @@ -609,45 +674,20 @@ const ChatTextArea = forwardRef( paddingTop: 4, bottom: 14, left: 22, - right: 67, // (54 + 9) + 4 extra padding + right: 67, zIndex: 2, }} /> )} -
-
-
{ - if (!shouldDisableImages) { - onSelectImages() - } - }} - style={{ - marginRight: 5.5, - fontSize: 16.5, - }} - /> -
{ - if (!textAreaDisabled) { - onSend() - } - }} - style={{ fontSize: 15 }}>
-
+
+ +
+ {isEnhancingPrompt && Enhancing prompt...} + !textAreaDisabled && handleEnhancePrompt()} style={{ fontSize: 16.5 }} /> +
+ !shouldDisableImages && onSelectImages()} style={{ fontSize: 16.5 }} /> + !textAreaDisabled && onSend()} style={{ fontSize: 15 }} /> +
)