mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d8eaa8eee | ||
|
|
61eac3614c | ||
|
|
5885a3cc1d | ||
|
|
759ef873ae | ||
|
|
782e4ff6e0 | ||
|
|
4bb00241bf | ||
|
|
c325faf8db | ||
|
|
20f8f9c9cf | ||
|
|
5be163f49d | ||
|
|
7843ab937a | ||
|
|
5ed4319d21 | ||
|
|
a8971b807a | ||
|
|
51c4e0aceb | ||
|
|
1cf62941cd |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
improve visibility for mode switch background color on different themes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add caching support for Bedrock inferences using SAP AI Core and minor refactor
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support sending context to active webview when editor panels are opened.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
remove unused parseAssistantmessageV1
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix credit error tests
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix LiteLLM Proxy Provider Cost Tracking
|
||||
@@ -219,6 +219,9 @@ EOF
|
||||
|
||||
## Basic PR Commands
|
||||
```bash
|
||||
# Get current PR number
|
||||
gh pr view --json number -q .number
|
||||
|
||||
# List open PRs
|
||||
gh pr list
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [3.22.0]
|
||||
|
||||
- Implemented a retry strategy for Cerebras to handle rate limit issues due to its generation speed
|
||||
- Add support for GPT-5 models to SAP AI Core Provider
|
||||
- Support sending context to active webview when editor panels are opened.
|
||||
- Fix bug where running out of credits on Cline accounts would show '402 empty body' response instead of 'buy credits' component
|
||||
- Fix LiteLLM Proxy Provider Cost Tracking
|
||||
|
||||
## [3.21.0]
|
||||
|
||||
- Add support for GPT-5 model family including GPT-5, GPT-5 Mini, and GPT-5 Nano with prompt caching support and set GPT-5 as the new default model
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -274,6 +274,8 @@ function createHandlerForProvider(
|
||||
sapAiResourceGroup: options.sapAiResourceGroup,
|
||||
sapAiCoreBaseUrl: options.sapAiCoreBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "claude-code":
|
||||
return new ClaudeCodeHandler({
|
||||
|
||||
@@ -39,7 +39,11 @@ export class CerebrasHandler implements ApiHandler {
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
@withRetry({
|
||||
maxRetries: 6, // More retries to be patient with rate limits
|
||||
baseDelay: 5000, // Start with 5 second delay
|
||||
maxDelay: 60000, // Allow up to 60 second delays to respect rate limits
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
|
||||
@@ -170,7 +174,25 @@ export class CerebrasHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Enhanced error handling for Cerebras API
|
||||
if (error?.status === 429 || error?.code === "rate_limit_exceeded") {
|
||||
// Rate limit error - will be handled by retry decorator with patient backoff
|
||||
const limits = this.getRateLimits()
|
||||
throw new Error(`Cerebras API rate limit exceeded.`)
|
||||
} else if (error?.status === 401) {
|
||||
throw new Error("Cerebras API authentication failed. Please check your API key.")
|
||||
} else if (error?.status === 403) {
|
||||
throw new Error("Cerebras API access denied. Please check your API key permissions.")
|
||||
} else if (error?.status >= 500) {
|
||||
// Server errors - retryable
|
||||
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
|
||||
} else if (error?.status === 400) {
|
||||
// Client errors - not retryable
|
||||
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
|
||||
}
|
||||
|
||||
// Re-throw original error for other cases
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -193,6 +215,35 @@ export class CerebrasHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit information for the current model
|
||||
*
|
||||
* These limits are used for informational purposes and to calculate appropriate
|
||||
* retry delays. Since Cerebras inference is extremely fast, users hit these limits
|
||||
* quickly, so we need to be patient with retries to maximize usage efficiency.
|
||||
*
|
||||
* @returns Rate limit configuration for the model
|
||||
*/
|
||||
private getRateLimits(): { requestsPerMinute: number; tokensPerMinute: number } {
|
||||
const modelId = this.getModel().id
|
||||
|
||||
switch (modelId) {
|
||||
case "qwen-3-coder-480b":
|
||||
case "qwen-3-coder-480b-free":
|
||||
return { requestsPerMinute: 10, tokensPerMinute: 150_000 }
|
||||
case "qwen-3-235b-a22b-instruct-2507":
|
||||
case "qwen-3-235b-a22b-thinking-2507":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
|
||||
case "llama-3.3-70b":
|
||||
case "gpt-oss-120b":
|
||||
case "qwen-3-32b":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
|
||||
default:
|
||||
// Default rate limits for unknown models
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
|
||||
}
|
||||
}
|
||||
|
||||
private calculateCost({ inputTokens, outputTokens }: { inputTokens: number; outputTokens: number }): number {
|
||||
const model = this.getModel()
|
||||
const inputPrice = model.info.inputPrice || 0
|
||||
|
||||
+365
-144
@@ -5,6 +5,11 @@ import { ApiHandler } from "../"
|
||||
import { ModelInfo, sapAiCoreDefaultModelId, SapAiCoreModelId, sapAiCoreModels } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import {
|
||||
type Message as BedrockMessage,
|
||||
type ContentBlock as BedrockContentBlock,
|
||||
ConversationRole as BedrockConversationRole,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
interface SapAiCoreHandlerOptions {
|
||||
sapAiCoreClientId?: string
|
||||
@@ -13,6 +18,7 @@ interface SapAiCoreHandlerOptions {
|
||||
sapAiResourceGroup?: string
|
||||
sapAiCoreBaseUrl?: string
|
||||
apiModelId?: string
|
||||
thinkingBudgetTokens?: number
|
||||
}
|
||||
|
||||
interface Deployment {
|
||||
@@ -27,6 +33,307 @@ interface Token {
|
||||
token_type: string
|
||||
expires_at: number
|
||||
}
|
||||
|
||||
// Bedrock namespace containing caching-related functions
|
||||
namespace Bedrock {
|
||||
// Define cache point type for AWS Bedrock
|
||||
interface CachePointContentBlock {
|
||||
cachePoint: {
|
||||
type: "default"
|
||||
}
|
||||
}
|
||||
|
||||
// Define types for supported content types
|
||||
type SupportedContentType = "text" | "image" | "thinking"
|
||||
|
||||
interface ContentItem {
|
||||
type: SupportedContentType
|
||||
text?: string
|
||||
source?: {
|
||||
data: string | Buffer | Uint8Array
|
||||
media_type?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares system messages with optional caching support
|
||||
*/
|
||||
export function prepareSystemMessages(systemPrompt: string, enableCaching: boolean): any[] | undefined {
|
||||
if (!systemPrompt) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (enableCaching) {
|
||||
return [{ text: systemPrompt }, { cachePoint: { type: "default" } }]
|
||||
}
|
||||
|
||||
return [{ text: systemPrompt }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies cache control to messages for prompt caching using AWS Bedrock's cachePoint system
|
||||
* AWS Bedrock uses cachePoint objects instead of Anthropic's cache_control approach
|
||||
*/
|
||||
export function applyCacheControlToMessages(
|
||||
messages: BedrockMessage[],
|
||||
lastUserMsgIndex: number,
|
||||
secondLastMsgUserIndex: number,
|
||||
): BedrockMessage[] {
|
||||
return messages.map((message, index) => {
|
||||
// Add cachePoint to the last user message and second-to-last user message
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
// Clone the message to avoid modifying the original
|
||||
const messageWithCache = { ...message }
|
||||
|
||||
if (messageWithCache.content && Array.isArray(messageWithCache.content)) {
|
||||
// Add cachePoint to the end of the content array
|
||||
messageWithCache.content = [
|
||||
...messageWithCache.content,
|
||||
{
|
||||
cachePoint: {
|
||||
type: "default",
|
||||
},
|
||||
} as CachePointContentBlock, // Properly typed cache point for AWS SDK
|
||||
]
|
||||
}
|
||||
|
||||
return messageWithCache
|
||||
}
|
||||
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats messages for models using the Converse API specification
|
||||
* Used by both Anthropic and Nova models to avoid code duplication
|
||||
*/
|
||||
export function formatMessagesForConverseAPI(messages: Anthropic.Messages.MessageParam[]): BedrockMessage[] {
|
||||
return messages.map((message) => {
|
||||
// Determine role (user or assistant)
|
||||
const role = message.role === "user" ? BedrockConversationRole.USER : BedrockConversationRole.ASSISTANT
|
||||
|
||||
// Process content based on type
|
||||
let content: BedrockContentBlock[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// Simple text content
|
||||
content = [{ text: message.content }]
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Convert Anthropic content format to Converse API content format
|
||||
const processedContent = message.content
|
||||
.map((item) => {
|
||||
// Text content
|
||||
if (item.type === "text") {
|
||||
return { text: item.text }
|
||||
}
|
||||
|
||||
// Image content
|
||||
if (item.type === "image") {
|
||||
return processImageContent(item)
|
||||
}
|
||||
|
||||
// Log unsupported content types for debugging
|
||||
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
|
||||
return null
|
||||
})
|
||||
.filter((item): item is BedrockContentBlock => item !== null)
|
||||
|
||||
content = processedContent
|
||||
}
|
||||
|
||||
// Return formatted message
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes image content with proper error handling and user notification
|
||||
*/
|
||||
function processImageContent(item: any): BedrockContentBlock | null {
|
||||
let imageData: Uint8Array
|
||||
let format: "png" | "jpeg" | "gif" | "webp" = "jpeg" // default format
|
||||
|
||||
// Extract format from media_type if available
|
||||
if (item.source.media_type) {
|
||||
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
|
||||
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
|
||||
if (formatMatch && formatMatch[1]) {
|
||||
const extractedFormat = formatMatch[1]
|
||||
// Ensure format is one of the allowed values
|
||||
if (["png", "jpeg", "gif", "webp"].includes(extractedFormat)) {
|
||||
format = extractedFormat as "png" | "jpeg" | "gif" | "webp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get image data with improved error handling
|
||||
try {
|
||||
if (typeof item.source.data === "string") {
|
||||
// Handle base64 encoded data
|
||||
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
|
||||
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
|
||||
} else if (item.source.data && typeof item.source.data === "object") {
|
||||
// Try to convert to Uint8Array
|
||||
imageData = new Uint8Array(Buffer.from(item.source.data as Buffer | Uint8Array))
|
||||
} else {
|
||||
throw new Error("Unsupported image data format")
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: imageData,
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process image content:", error)
|
||||
// Return a text content indicating the error instead of null
|
||||
// This ensures users are aware of the issue
|
||||
return {
|
||||
text: `[ERROR: Failed to process image - ${error instanceof Error ? error.message : "Unknown error"}]`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini namespace containing caching-related functions and types
|
||||
namespace Gemini {
|
||||
/**
|
||||
* Process Gemini streaming response with enhanced thinking content support and caching awareness
|
||||
*/
|
||||
export function processStreamChunk(data: any): {
|
||||
text?: string
|
||||
reasoning?: string
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
cachedContentTokenCount?: number
|
||||
}
|
||||
} {
|
||||
const result: ReturnType<typeof processStreamChunk> = {}
|
||||
|
||||
// Handle thinking content from Gemini's response
|
||||
const candidateForThoughts = data?.candidates?.[0]
|
||||
const partsForThoughts = candidateForThoughts?.content?.parts
|
||||
let thoughts = ""
|
||||
|
||||
if (partsForThoughts) {
|
||||
for (const part of partsForThoughts) {
|
||||
const { thought, text } = part
|
||||
if (thought && text) {
|
||||
thoughts += text + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (thoughts.trim() !== "") {
|
||||
result.reasoning = thoughts.trim()
|
||||
}
|
||||
|
||||
// Handle regular text content
|
||||
if (data.text) {
|
||||
result.text = data.text
|
||||
}
|
||||
|
||||
// Handle content parts for non-thought text
|
||||
if (data.candidates && data.candidates[0]?.content?.parts) {
|
||||
let nonThoughtText = ""
|
||||
for (const part of data.candidates[0].content.parts) {
|
||||
if (part.text && !part.thought) {
|
||||
nonThoughtText += part.text
|
||||
}
|
||||
}
|
||||
if (nonThoughtText && !result.text) {
|
||||
result.text = nonThoughtText
|
||||
}
|
||||
}
|
||||
|
||||
// Handle usage metadata with caching support
|
||||
if (data.usageMetadata) {
|
||||
result.usageMetadata = {
|
||||
promptTokenCount: data.usageMetadata.promptTokenCount,
|
||||
candidatesTokenCount: data.usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokenCount: data.usageMetadata.thoughtsTokenCount,
|
||||
cachedContentTokenCount: data.usageMetadata.cachedContentTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
|
||||
const role = message.role === "assistant" ? "model" : "user"
|
||||
const parts = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
parts.push({ text: message.content })
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
parts.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { role, parts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare Gemini request payload with thinking configuration and implicit caching support
|
||||
*/
|
||||
export function prepareRequestPayload(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
model: { id: SapAiCoreModelId; info: ModelInfo },
|
||||
thinkingBudgetTokens?: number,
|
||||
): any {
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
|
||||
const payload = {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
parts: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
generationConfig: {
|
||||
maxOutputTokens: model.info.maxTokens,
|
||||
temperature: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
// Add thinking config if the model supports it and budget is provided
|
||||
const thinkingBudget = thinkingBudgetTokens ?? 0
|
||||
const maxBudget = model.info.thinkingConfig?.maxBudget ?? 0
|
||||
|
||||
if (thinkingBudget > 0 && model.info.thinkingConfig) {
|
||||
// Add thinking configuration to the payload
|
||||
;(payload as any).thinkingConfig = {
|
||||
thinkingBudget: thinkingBudget,
|
||||
includeThoughts: true,
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
export class SapAiCoreHandler implements ApiHandler {
|
||||
private options: SapAiCoreHandlerOptions
|
||||
private token?: Token
|
||||
@@ -142,7 +449,20 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
"anthropic--claude-3-opus",
|
||||
]
|
||||
|
||||
const openAIModels = ["gpt-4o", "gpt-4", "gpt-4o-mini", "o1", "gpt-4.1", "gpt-4.1-nano", "o3-mini", "o3", "o4-mini"]
|
||||
const openAIModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4",
|
||||
"gpt-4o-mini",
|
||||
"o1",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-5",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-mini",
|
||||
"o3-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
]
|
||||
|
||||
const geminiModels = ["gemini-2.5-flash", "gemini-2.5-pro"]
|
||||
|
||||
@@ -151,21 +471,47 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
if (anthropicModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
|
||||
|
||||
// Format messages for Converse API. Note that the Invoke API has
|
||||
// the same format for messages as the Converse API.
|
||||
const formattedMessages = Bedrock.formatMessagesForConverseAPI(messages)
|
||||
|
||||
// Get message indices for caching
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
// Use converse-stream endpoint with caching support
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
|
||||
|
||||
// Apply caching controls to messages (enabled by default)
|
||||
const messagesWithCache = Bedrock.applyCacheControlToMessages(
|
||||
formattedMessages,
|
||||
lastUserMsgIndex,
|
||||
secondLastMsgUserIndex,
|
||||
)
|
||||
|
||||
// Prepare system message with caching support (enabled by default)
|
||||
const systemMessages = Bedrock.prepareSystemMessages(systemPrompt, true)
|
||||
|
||||
payload = {
|
||||
inferenceConfig: {
|
||||
maxTokens: model.info.maxTokens,
|
||||
temperature: 0.0,
|
||||
},
|
||||
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
|
||||
messages: this.formatAnthropicMessages(messages),
|
||||
system: systemMessages,
|
||||
messages: messagesWithCache,
|
||||
}
|
||||
} else {
|
||||
// Use invoke-with-response-stream endpoint
|
||||
// TODO: add caching support using Anthropic-native cache_control blocks
|
||||
payload = {
|
||||
max_tokens: model.info.maxTokens,
|
||||
system: systemPrompt,
|
||||
@@ -191,7 +537,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
|
||||
if (["o1", "o3-mini", "o3", "o4-mini"].includes(model.id)) {
|
||||
if (["o1", "o3-mini", "o3", "o4-mini", "gpt-5", "gpt-5-nano", "gpt-5-mini"].includes(model.id)) {
|
||||
delete payload.max_tokens
|
||||
delete payload.temperature
|
||||
}
|
||||
@@ -202,7 +548,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
} else if (geminiModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/models/${model.id}:streamGenerateContent`
|
||||
payload = this.convertToGeminiFormat(systemPrompt, messages)
|
||||
payload = Gemini.prepareRequestPayload(systemPrompt, messages, model, this.options.thinkingBudgetTokens)
|
||||
} else {
|
||||
throw new Error(`Unsupported model: ${model.id}`)
|
||||
}
|
||||
@@ -493,50 +839,31 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const jsonData = line.slice(6)
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
const candidateForThoughts = data?.candidates?.[0]
|
||||
const partsForThoughts = candidateForThoughts?.content?.parts
|
||||
let thoughts = ""
|
||||
|
||||
if (partsForThoughts) {
|
||||
for (const part of partsForThoughts) {
|
||||
const { thought, text } = part
|
||||
if (thought && text) {
|
||||
thoughts += text + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use Gemini namespace to process the chunk
|
||||
const processed = Gemini.processStreamChunk(data)
|
||||
|
||||
if (thoughts.trim() !== "") {
|
||||
// Yield reasoning if present
|
||||
if (processed.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: thoughts.trim(),
|
||||
reasoning: processed.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.text) {
|
||||
// Yield text if present
|
||||
if (processed.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: data.text,
|
||||
text: processed.text,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.candidates && data.candidates[0]?.content?.parts) {
|
||||
for (const part of data.candidates[0].content.parts) {
|
||||
if (part.text && !part.thought) {
|
||||
// Only non-thought text
|
||||
yield {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.usageMetadata) {
|
||||
promptTokens = data.usageMetadata.promptTokenCount ?? promptTokens
|
||||
outputTokens = data.usageMetadata.candidatesTokenCount ?? outputTokens
|
||||
thoughtsTokenCount = data.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
|
||||
cacheReadTokens = data.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
|
||||
if (processed.usageMetadata) {
|
||||
promptTokens = processed.usageMetadata.promptTokenCount ?? promptTokens
|
||||
outputTokens = processed.usageMetadata.candidatesTokenCount ?? outputTokens
|
||||
thoughtsTokenCount = processed.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
|
||||
cacheReadTokens = processed.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
@@ -544,6 +871,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
outputTokens,
|
||||
thoughtsTokenCount,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -581,111 +909,4 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
return { id: sapAiCoreDefaultModelId, info: sapAiCoreModels[sapAiCoreDefaultModelId] }
|
||||
}
|
||||
|
||||
private getValidImageFormat(mediaType: string): string {
|
||||
const format = mediaType.split("/")[1]?.toLowerCase()
|
||||
const validFormats = ["png", "jpeg", "gif", "webp"]
|
||||
|
||||
if (validFormats.includes(format)) {
|
||||
return format
|
||||
}
|
||||
throw new Error(`Unsupported image format: ${format}`)
|
||||
}
|
||||
|
||||
private convertToGeminiFormat(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]) {
|
||||
const contents = messages.map(this.convertAnthropicMessageToGemini)
|
||||
|
||||
const payload = {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
parts: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
generationConfig: {
|
||||
maxOutputTokens: this.getModel().info.maxTokens,
|
||||
temperature: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
private convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
|
||||
const role = message.role === "assistant" ? "model" : "user"
|
||||
const parts = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
parts.push({ text: message.content })
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
parts.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { role, parts }
|
||||
}
|
||||
private formatAnthropicMessages(messages: Anthropic.Messages.MessageParam[]): any[] {
|
||||
return messages.map((m) => {
|
||||
const contentBlocks: any[] = []
|
||||
|
||||
if (typeof m.content === "string") {
|
||||
contentBlocks.push({ text: m.content })
|
||||
} else if (Array.isArray(m.content)) {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "text") {
|
||||
if (!block.text) {
|
||||
throw new Error('Text block is missing the "text" field.')
|
||||
}
|
||||
contentBlocks.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
if (!block.source) {
|
||||
throw new Error('Image block is missing the "source" field.')
|
||||
}
|
||||
|
||||
const { type, media_type, data } = block.source
|
||||
|
||||
if (!type || !media_type || !data) {
|
||||
throw new Error('Image source must have "type", "media_type", and "data" fields.')
|
||||
}
|
||||
|
||||
if (type !== "base64") {
|
||||
throw new Error(`Unsupported image source type: ${type}. Only "base64" is supported.`)
|
||||
}
|
||||
|
||||
const format = this.getValidImageFormat(media_type)
|
||||
|
||||
contentBlocks.push({
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: data,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
throw new Error(`Unsupported content block type: ${block.type}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unsupported content format.")
|
||||
}
|
||||
|
||||
return {
|
||||
role: m.role,
|
||||
content: contentBlocks,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Controller } from "../index"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { EmptyRequest, String } from "@shared/proto/cline/common"
|
||||
|
||||
const authService = AuthService.getInstance()
|
||||
|
||||
/**
|
||||
* Handles the user clicking the login link in the UI.
|
||||
* Generates a secure nonce for state validation, stores it in secrets,
|
||||
@@ -13,5 +11,5 @@ const authService = AuthService.getInstance()
|
||||
* @returns The login URL as a string.
|
||||
*/
|
||||
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
|
||||
return await authService.createAuthRequest()
|
||||
return await AuthService.getInstance().createAuthRequest()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Empty } from "@shared/proto/cline/common"
|
||||
import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
const authService = AuthService.getInstance()
|
||||
/**
|
||||
* Handles the account logout action
|
||||
* @param controller The controller instance
|
||||
@@ -12,6 +11,6 @@ const authService = AuthService.getInstance()
|
||||
*/
|
||||
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
await controller.handleSignOut()
|
||||
await authService.handleDeauth()
|
||||
await AuthService.getInstance().handleDeauth()
|
||||
return Empty.create({})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { AuthService } from "../../../services/auth/AuthService"
|
||||
import { AuthState, EmptyRequest } from "@/shared/proto/index.cline"
|
||||
import { AuthService } from "@services/auth/AuthService"
|
||||
import { Controller } from ".."
|
||||
import { StreamingResponseHandler } from "../grpc-handler"
|
||||
|
||||
const authService = AuthService.getInstance()
|
||||
export const subscribeToAuthStatusUpdate = authService.subscribeToAuthStatusUpdate.bind(authService)
|
||||
export const sendAuthStatusUpdateEvent = authService.sendAuthStatusUpdate.bind(authService)
|
||||
export async function subscribeToAuthStatusUpdate(
|
||||
controller: Controller,
|
||||
request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler<AuthState>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
return AuthService.getInstance().subscribeToAuthStatusUpdate(controller, request, responseStream, requestId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import axios from "axios"
|
||||
import * as vscode from "vscode"
|
||||
import { getNonce } from "./getNonce"
|
||||
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
import { Controller } from "@core/controller/index"
|
||||
import { findLast } from "@shared/array"
|
||||
import { readFile } from "fs/promises"
|
||||
import path from "node:path"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import { Uri } from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
|
||||
export abstract class WebviewProvider {
|
||||
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
|
||||
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
|
||||
private static activeInstances: Set<WebviewProvider> = new Set()
|
||||
private static clientIdMap = new Map<WebviewProvider, string>()
|
||||
controller: Controller
|
||||
private clientId: string
|
||||
|
||||
private static lastActiveControllerId: string | null = null
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
|
||||
private readonly providerType: WebviewProviderType,
|
||||
) {
|
||||
WebviewProvider.activeInstances.add(this)
|
||||
this.clientId = uuidv4()
|
||||
WebviewProvider.clientIdMap.set(this, this.clientId)
|
||||
|
||||
// Create controller with cache service
|
||||
this.controller = new Controller(context, this.clientId)
|
||||
WebviewProvider.setLastActiveControllerId(this.controller.id)
|
||||
}
|
||||
|
||||
// Add a method to get the client ID
|
||||
public getClientId(): string {
|
||||
return this.clientId
|
||||
}
|
||||
|
||||
// Add a static method to get the client ID for a specific instance
|
||||
public static getClientIdForInstance(instance: WebviewProvider): string | undefined {
|
||||
return WebviewProvider.clientIdMap.get(instance)
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.controller.dispose()
|
||||
WebviewProvider.activeInstances.delete(this)
|
||||
// Remove from client ID map
|
||||
WebviewProvider.clientIdMap.delete(this)
|
||||
}
|
||||
|
||||
public static getVisibleInstance(): WebviewProvider | undefined {
|
||||
return findLast(Array.from(WebviewProvider.activeInstances), (instance) => instance.isVisible() === true)
|
||||
}
|
||||
|
||||
public static getActiveInstance(): WebviewProvider | undefined {
|
||||
return Array.from(WebviewProvider.activeInstances).find((instance) => {
|
||||
const webview = instance.getWebview()
|
||||
if (webview && webview.viewType === "claude-dev.TabPanelProvider" && "active" in webview) {
|
||||
return webview.active === true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
public static getAllInstances(): WebviewProvider[] {
|
||||
return Array.from(WebviewProvider.activeInstances)
|
||||
}
|
||||
|
||||
public static getSidebarInstance() {
|
||||
return Array.from(WebviewProvider.activeInstances).find(
|
||||
(instance) => instance.providerType === WebviewProviderType.SIDEBAR,
|
||||
)
|
||||
}
|
||||
|
||||
public static getTabInstances(): WebviewProvider[] {
|
||||
return Array.from(WebviewProvider.activeInstances).filter((instance) => instance.providerType === WebviewProviderType.TAB)
|
||||
}
|
||||
|
||||
public static getLastActiveInstance(): WebviewProvider | undefined {
|
||||
const lastActiveId = WebviewProvider.getLastActiveControllerId()
|
||||
if (!lastActiveId) {
|
||||
return undefined
|
||||
}
|
||||
return Array.from(WebviewProvider.activeInstances).find((instance) => instance.controller.id === lastActiveId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last active controller ID with performance optimization
|
||||
* @returns The last active controller ID or null
|
||||
*/
|
||||
public static getLastActiveControllerId(): string | null {
|
||||
return WebviewProvider.lastActiveControllerId || WebviewProvider.getSidebarInstance()?.controller.id || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last active controller ID with validation and performance optimization
|
||||
* @param controllerId The controller ID to set as last active
|
||||
*/
|
||||
public static setLastActiveControllerId(controllerId: string | null): void {
|
||||
// Only update if the value is actually different to avoid unnecessary operations
|
||||
if (WebviewProvider.lastActiveControllerId !== controllerId) {
|
||||
WebviewProvider.lastActiveControllerId = controllerId
|
||||
}
|
||||
}
|
||||
|
||||
public static async disposeAllInstances() {
|
||||
const instances = Array.from(WebviewProvider.activeInstances)
|
||||
for (const instance of instances) {
|
||||
await instance.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and sets up the webview when it's first created.
|
||||
*
|
||||
* @param webviewView - The webview view or panel instance to be resolved
|
||||
* @returns A promise that resolves when the webview has been fully initialized
|
||||
*/
|
||||
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
|
||||
|
||||
/**
|
||||
* Gets the current webview instance.
|
||||
*
|
||||
* @returns The webview instance (WebviewView, WebviewPanel, or similar)
|
||||
*/
|
||||
abstract getWebview(): any
|
||||
|
||||
/**
|
||||
* Converts a local URI to a webview URI that can be used within the webview.
|
||||
*
|
||||
* @param uri - The local URI to convert
|
||||
* @returns A URI that can be used within the webview
|
||||
*/
|
||||
abstract getWebviewUri(uri: Uri): Uri
|
||||
|
||||
/**
|
||||
* Gets the Content Security Policy source for the webview.
|
||||
*
|
||||
* @returns The CSP source string to be used in the webview's Content-Security-Policy
|
||||
*/
|
||||
abstract getCspSource(): string
|
||||
|
||||
/**
|
||||
* Checks if the webview is currently visible to the user.
|
||||
*
|
||||
* @returns True if the webview is visible, false otherwise
|
||||
*/
|
||||
abstract isVisible(): boolean
|
||||
|
||||
/**
|
||||
* Defines and returns the HTML that should be rendered within the webview panel.
|
||||
*
|
||||
* @remarks This is also the place where references to the React webview build files
|
||||
* are created and inserted into the webview HTML.
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @param extensionUri The URI of the directory containing the extension
|
||||
* @returns A template string literal containing the HTML that should be
|
||||
* rendered within the webview panel
|
||||
*/
|
||||
public getHtmlContent(): string {
|
||||
// Get the local path to main script run in the webview,
|
||||
// then convert it to a uri we can use in the webview.
|
||||
|
||||
// The CSS file from the React build output
|
||||
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
|
||||
// The JS file from the React build output
|
||||
const scriptUri = this.getExtensionUri("webview-ui", "build", "assets", "index.js")
|
||||
|
||||
// The codicon font from the React build output
|
||||
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
|
||||
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
|
||||
// don't forget to add font-src ${webview.cspSource};
|
||||
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
|
||||
|
||||
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
|
||||
|
||||
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
|
||||
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
|
||||
|
||||
// // Same for stylesheet
|
||||
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
|
||||
|
||||
// Use a nonce to only allow a specific script to be run.
|
||||
/*
|
||||
content security policy of your webview to only allow scripts that have a specific nonce
|
||||
create a content security policy meta tag so that only loading scripts with a nonce is allowed
|
||||
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
|
||||
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
|
||||
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
|
||||
|
||||
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
|
||||
*/
|
||||
const nonce = getNonce()
|
||||
|
||||
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
|
||||
return /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
|
||||
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
|
||||
font-src ${this.getCspSource()} data:;
|
||||
style-src ${this.getCspSource()} 'unsafe-inline';
|
||||
img-src ${this.getCspSource()} https: data:;
|
||||
script-src 'nonce-${nonce}' 'unsafe-eval';">
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="text/javascript" nonce="${nonce}">
|
||||
// Inject the provider type
|
||||
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
|
||||
|
||||
// Inject the client ID
|
||||
window.clineClientId = "${this.clientId}";
|
||||
</script>
|
||||
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
|
||||
<script src="http://localhost:8097"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the Vite dev server port from the generated port file to avoid conflicts
|
||||
* Returns a Promise that resolves to the port number
|
||||
* If the file doesn't exist or can't be read, it resolves to the default port
|
||||
*/
|
||||
private getDevServerPort(): Promise<number> {
|
||||
const DEFAULT_PORT = 25463
|
||||
|
||||
const portFilePath = path.join(__dirname, "..", "webview-ui", ".vite-port")
|
||||
|
||||
return readFile(portFilePath, "utf8")
|
||||
.then((portFile) => {
|
||||
const port = parseInt(portFile.trim()) || DEFAULT_PORT
|
||||
console.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
|
||||
|
||||
return port
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
`[getDevServerPort] Port file not found or couldn't be read at ${portFilePath}, using default port: ${DEFAULT_PORT}`,
|
||||
)
|
||||
return DEFAULT_PORT
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @returns A template string literal containing the HTML that should be
|
||||
* rendered within the webview panel
|
||||
*/
|
||||
protected async getHMRHtmlContent(): Promise<string> {
|
||||
const localPort = await this.getDevServerPort()
|
||||
const localServerUrl = `localhost:${localPort}`
|
||||
|
||||
// Check if local dev server is running.
|
||||
try {
|
||||
await axios.get(`http://${localServerUrl}`)
|
||||
} catch (error) {
|
||||
// Only show the error message when in development mode.
|
||||
if (process.env.IS_DEV) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message:
|
||||
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
|
||||
})
|
||||
}
|
||||
|
||||
return this.getHtmlContent()
|
||||
}
|
||||
|
||||
const nonce = getNonce()
|
||||
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
|
||||
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
|
||||
|
||||
const scriptEntrypoint = "src/main.tsx"
|
||||
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
|
||||
|
||||
const reactRefresh = /*html*/ `
|
||||
<script nonce="${nonce}" type="module">
|
||||
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
`
|
||||
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`font-src ${this.getCspSource()}`,
|
||||
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`img-src ${this.getCspSource()} https: data:`,
|
||||
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
]
|
||||
|
||||
return /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/javascript" nonce="${nonce}">
|
||||
// Inject the provider type
|
||||
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
|
||||
|
||||
// Inject the client ID
|
||||
window.clineClientId = "${this.clientId}";
|
||||
</script>
|
||||
${reactRefresh}
|
||||
<script type="module" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
/**
|
||||
* A helper function which will get the webview URI of a given file or resource in the extension directory.
|
||||
*
|
||||
* @remarks This URI can be used within a webview's HTML as a link to the
|
||||
* given file/resource.
|
||||
*
|
||||
* @param pathList An array of strings representing the path to a file/resource in the extension directory.
|
||||
* @returns A URI pointing to the file/resource
|
||||
*/
|
||||
private getExtensionUri(...pathList: string[]): Uri {
|
||||
if (!this.getWebview()) {
|
||||
throw Error("webview is not initialized.")
|
||||
}
|
||||
return this.getWebviewUri(Uri.joinPath(this.context.extensionUri, ...pathList))
|
||||
}
|
||||
}
|
||||
+1
-358
@@ -1,358 +1 @@
|
||||
import axios from "axios"
|
||||
import * as vscode from "vscode"
|
||||
import { getNonce } from "./getNonce"
|
||||
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
import { Controller } from "@core/controller/index"
|
||||
import { findLast } from "@shared/array"
|
||||
import { readFile } from "fs/promises"
|
||||
import path from "node:path"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import { Uri } from "vscode"
|
||||
import { ExtensionMessage } from "@/shared/ExtensionMessage"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
|
||||
export abstract class WebviewProvider {
|
||||
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
|
||||
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
|
||||
private static activeInstances: Set<WebviewProvider> = new Set()
|
||||
private static clientIdMap = new Map<WebviewProvider, string>()
|
||||
controller: Controller
|
||||
private clientId: string
|
||||
|
||||
private static lastActiveControllerId: string | null = null
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
|
||||
private readonly providerType: WebviewProviderType,
|
||||
) {
|
||||
WebviewProvider.activeInstances.add(this)
|
||||
this.clientId = uuidv4()
|
||||
WebviewProvider.clientIdMap.set(this, this.clientId)
|
||||
|
||||
// Create controller with cache service
|
||||
this.controller = new Controller(context, this.clientId)
|
||||
WebviewProvider.setLastActiveControllerId(this.controller.id)
|
||||
}
|
||||
|
||||
// Add a method to get the client ID
|
||||
public getClientId(): string {
|
||||
return this.clientId
|
||||
}
|
||||
|
||||
// Add a static method to get the client ID for a specific instance
|
||||
public static getClientIdForInstance(instance: WebviewProvider): string | undefined {
|
||||
return WebviewProvider.clientIdMap.get(instance)
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.controller.dispose()
|
||||
WebviewProvider.activeInstances.delete(this)
|
||||
// Remove from client ID map
|
||||
WebviewProvider.clientIdMap.delete(this)
|
||||
}
|
||||
|
||||
public static getVisibleInstance(): WebviewProvider | undefined {
|
||||
return findLast(Array.from(WebviewProvider.activeInstances), (instance) => instance.isVisible() === true)
|
||||
}
|
||||
|
||||
public static getActiveInstance(): WebviewProvider | undefined {
|
||||
return Array.from(WebviewProvider.activeInstances).find((instance) => {
|
||||
const webview = instance.getWebview()
|
||||
if (webview && webview.viewType === "claude-dev.TabPanelProvider" && "active" in webview) {
|
||||
return webview.active === true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
public static getAllInstances(): WebviewProvider[] {
|
||||
return Array.from(WebviewProvider.activeInstances)
|
||||
}
|
||||
|
||||
public static getSidebarInstance() {
|
||||
return Array.from(WebviewProvider.activeInstances).find(
|
||||
(instance) => instance.providerType === WebviewProviderType.SIDEBAR,
|
||||
)
|
||||
}
|
||||
|
||||
public static getTabInstances(): WebviewProvider[] {
|
||||
return Array.from(WebviewProvider.activeInstances).filter((instance) => instance.providerType === WebviewProviderType.TAB)
|
||||
}
|
||||
|
||||
public static getLastActiveInstance(): WebviewProvider | undefined {
|
||||
const lastActiveId = WebviewProvider.getLastActiveControllerId()
|
||||
if (!lastActiveId) {
|
||||
return undefined
|
||||
}
|
||||
return Array.from(WebviewProvider.activeInstances).find((instance) => instance.controller.id === lastActiveId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last active controller ID with performance optimization
|
||||
* @returns The last active controller ID or null
|
||||
*/
|
||||
public static getLastActiveControllerId(): string | null {
|
||||
return WebviewProvider.lastActiveControllerId || WebviewProvider.getSidebarInstance()?.controller.id || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last active controller ID with validation and performance optimization
|
||||
* @param controllerId The controller ID to set as last active
|
||||
*/
|
||||
public static setLastActiveControllerId(controllerId: string | null): void {
|
||||
// Only update if the value is actually different to avoid unnecessary operations
|
||||
if (WebviewProvider.lastActiveControllerId !== controllerId) {
|
||||
WebviewProvider.lastActiveControllerId = controllerId
|
||||
}
|
||||
}
|
||||
|
||||
public static async disposeAllInstances() {
|
||||
const instances = Array.from(WebviewProvider.activeInstances)
|
||||
for (const instance of instances) {
|
||||
await instance.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes and sets up the webview when it's first created.
|
||||
*
|
||||
* @param webviewView - The webview view or panel instance to be resolved
|
||||
* @returns A promise that resolves when the webview has been fully initialized
|
||||
*/
|
||||
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
|
||||
|
||||
/**
|
||||
* Gets the current webview instance.
|
||||
*
|
||||
* @returns The webview instance (WebviewView, WebviewPanel, or similar)
|
||||
*/
|
||||
abstract getWebview(): vscode.WebviewPanel | vscode.WebviewView | undefined
|
||||
|
||||
/**
|
||||
* Converts a local URI to a webview URI that can be used within the webview.
|
||||
*
|
||||
* @param uri - The local URI to convert
|
||||
* @returns A URI that can be used within the webview
|
||||
*/
|
||||
abstract getWebviewUri(uri: Uri): Uri
|
||||
|
||||
/**
|
||||
* Gets the Content Security Policy source for the webview.
|
||||
*
|
||||
* @returns The CSP source string to be used in the webview's Content-Security-Policy
|
||||
*/
|
||||
abstract getCspSource(): string
|
||||
|
||||
/**
|
||||
* Checks if the webview is currently visible to the user.
|
||||
*
|
||||
* @returns True if the webview is visible, false otherwise
|
||||
*/
|
||||
abstract isVisible(): boolean
|
||||
|
||||
/**
|
||||
* Defines and returns the HTML that should be rendered within the webview panel.
|
||||
*
|
||||
* @remarks This is also the place where references to the React webview build files
|
||||
* are created and inserted into the webview HTML.
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @param extensionUri The URI of the directory containing the extension
|
||||
* @returns A template string literal containing the HTML that should be
|
||||
* rendered within the webview panel
|
||||
*/
|
||||
public getHtmlContent(): string {
|
||||
// Get the local path to main script run in the webview,
|
||||
// then convert it to a uri we can use in the webview.
|
||||
|
||||
// The CSS file from the React build output
|
||||
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
|
||||
// The JS file from the React build output
|
||||
const scriptUri = this.getExtensionUri("webview-ui", "build", "assets", "index.js")
|
||||
|
||||
// The codicon font from the React build output
|
||||
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
|
||||
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
|
||||
// don't forget to add font-src ${webview.cspSource};
|
||||
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
|
||||
|
||||
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
|
||||
|
||||
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
|
||||
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
|
||||
|
||||
// // Same for stylesheet
|
||||
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
|
||||
|
||||
// Use a nonce to only allow a specific script to be run.
|
||||
/*
|
||||
content security policy of your webview to only allow scripts that have a specific nonce
|
||||
create a content security policy meta tag so that only loading scripts with a nonce is allowed
|
||||
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
|
||||
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
|
||||
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
|
||||
|
||||
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
|
||||
*/
|
||||
const nonce = getNonce()
|
||||
|
||||
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
|
||||
return /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
|
||||
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
|
||||
font-src ${this.getCspSource()} data:;
|
||||
style-src ${this.getCspSource()} 'unsafe-inline';
|
||||
img-src ${this.getCspSource()} https: data:;
|
||||
script-src 'nonce-${nonce}' 'unsafe-eval';">
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="text/javascript" nonce="${nonce}">
|
||||
// Inject the provider type
|
||||
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
|
||||
|
||||
// Inject the client ID
|
||||
window.clineClientId = "${this.clientId}";
|
||||
</script>
|
||||
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
|
||||
<script src="http://localhost:8097"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the Vite dev server port from the generated port file to avoid conflicts
|
||||
* Returns a Promise that resolves to the port number
|
||||
* If the file doesn't exist or can't be read, it resolves to the default port
|
||||
*/
|
||||
private getDevServerPort(): Promise<number> {
|
||||
const DEFAULT_PORT = 25463
|
||||
|
||||
const portFilePath = path.join(__dirname, "..", "webview-ui", ".vite-port")
|
||||
|
||||
return readFile(portFilePath, "utf8")
|
||||
.then((portFile) => {
|
||||
const port = parseInt(portFile.trim()) || DEFAULT_PORT
|
||||
console.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
|
||||
|
||||
return port
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
`[getDevServerPort] Port file not found or couldn't be read at ${portFilePath}, using default port: ${DEFAULT_PORT}`,
|
||||
)
|
||||
return DEFAULT_PORT
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @returns A template string literal containing the HTML that should be
|
||||
* rendered within the webview panel
|
||||
*/
|
||||
protected async getHMRHtmlContent(): Promise<string> {
|
||||
const localPort = await this.getDevServerPort()
|
||||
const localServerUrl = `localhost:${localPort}`
|
||||
|
||||
// Check if local dev server is running.
|
||||
try {
|
||||
await axios.get(`http://${localServerUrl}`)
|
||||
} catch (error) {
|
||||
// Only show the error message when in development mode.
|
||||
if (process.env.IS_DEV) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message:
|
||||
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
|
||||
})
|
||||
}
|
||||
|
||||
return this.getHtmlContent()
|
||||
}
|
||||
|
||||
const nonce = getNonce()
|
||||
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
|
||||
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
|
||||
|
||||
const scriptEntrypoint = "src/main.tsx"
|
||||
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
|
||||
|
||||
const reactRefresh = /*html*/ `
|
||||
<script nonce="${nonce}" type="module">
|
||||
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
`
|
||||
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`font-src ${this.getCspSource()}`,
|
||||
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`img-src ${this.getCspSource()} https: data:`,
|
||||
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
]
|
||||
|
||||
return /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/javascript" nonce="${nonce}">
|
||||
// Inject the provider type
|
||||
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
|
||||
|
||||
// Inject the client ID
|
||||
window.clineClientId = "${this.clientId}";
|
||||
</script>
|
||||
${reactRefresh}
|
||||
<script type="module" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
/**
|
||||
* A helper function which will get the webview URI of a given file or resource in the extension directory.
|
||||
*
|
||||
* @remarks This URI can be used within a webview's HTML as a link to the
|
||||
* given file/resource.
|
||||
*
|
||||
* @param pathList An array of strings representing the path to a file/resource in the extension directory.
|
||||
* @returns A URI pointing to the file/resource
|
||||
*/
|
||||
private getExtensionUri(...pathList: string[]): Uri {
|
||||
if (!this.getWebview()) {
|
||||
throw Error("webview is not initialized.")
|
||||
}
|
||||
return this.getWebviewUri(Uri.joinPath(this.context.extensionUri, ...pathList))
|
||||
}
|
||||
}
|
||||
export { WebviewProvider } from "./WebviewProvider"
|
||||
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
import { ExtensionMessage } from "@/shared/ExtensionMessage"
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import * as vscode from "vscode"
|
||||
import { URI } from "vscode-uri"
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
|
||||
export class ExternalWebviewProvider extends WebviewProvider {
|
||||
// This hostname cannot be changed without updating the external webview handler.
|
||||
@@ -25,7 +24,7 @@ export class ExternalWebviewProvider extends WebviewProvider {
|
||||
return true
|
||||
}
|
||||
override getWebview() {
|
||||
return undefined
|
||||
return {}
|
||||
}
|
||||
|
||||
override resolveWebviewView(_: any): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { HostBridgeClientProvider } from "./host-provider-types"
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
|
||||
@@ -129,7 +129,7 @@ export class ClineError extends Error {
|
||||
const { code, status, details } = err._error
|
||||
|
||||
// Check balance error first (most specific)
|
||||
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
|
||||
if (status === 402 || (code === "insufficient_credits" && typeof details?.current_balance === "number")) {
|
||||
return ClineErrorType.Balance
|
||||
}
|
||||
|
||||
|
||||
+27
-3
@@ -2782,21 +2782,21 @@ export const sapAiCoreModels = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-4-opus": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-3.7-sonnet": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-3.5-sonnet": {
|
||||
@@ -2832,6 +2832,9 @@ export const sapAiCoreModels = {
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
thinkingConfig: {
|
||||
maxBudget: 32767,
|
||||
},
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
@@ -2879,6 +2882,27 @@ export const sapAiCoreModels = {
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gpt-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gpt-5-nano": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gpt-5-mini": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
o1: {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
|
||||
@@ -12,10 +12,11 @@ log("Running standalone cline ", VERSION)
|
||||
|
||||
const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
const DATA_DIR = path.join(CLINE_DIR, "data")
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR || path.join(CLINE_DIR, "core", VERSION)
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
|
||||
const EXTENSION_DIR = path.join(CLINE_DIR, "core", VERSION, "extension")
|
||||
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
const extension: Extension<void> = {
|
||||
|
||||
@@ -44,6 +44,7 @@ import { validateApiConfiguration, validateModelId } from "@/utils/validate"
|
||||
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
|
||||
import ServersToggleModal from "./ServersToggleModal"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { isSafari } from "@/utils/platformUtils"
|
||||
|
||||
const { MAX_IMAGES_AND_FILES_PER_MESSAGE } = CHAT_CONSTANTS
|
||||
|
||||
@@ -92,7 +93,8 @@ interface GitCommit {
|
||||
description: string
|
||||
}
|
||||
|
||||
const PLAN_MODE_COLOR = "var(--vscode-inputValidation-warningBorder)"
|
||||
const PLAN_MODE_COLOR = "var(--vscode-activityWarningBadge-background)"
|
||||
const ACT_MODE_COLOR = "var(--vscode-focusBorder)"
|
||||
|
||||
const SwitchOption = styled.div.withConfig({
|
||||
shouldForwardProp: (prop) => !["isActive"].includes(prop),
|
||||
@@ -131,7 +133,7 @@ const Slider = styled.div.withConfig({
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : "var(--vscode-focusBorder)")};
|
||||
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : ACT_MODE_COLOR)};
|
||||
transition: transform 0.2s ease;
|
||||
transform: translateX(${(props) => (props.isAct ? "100%" : "0%")});
|
||||
`
|
||||
@@ -567,7 +569,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
}
|
||||
}
|
||||
|
||||
const isComposing = event.nativeEvent?.isComposing ?? false
|
||||
// Safari does not support InputEvent.isComposing (always false), so we need to fallback to keyCode === 229 for it
|
||||
const isComposing = isSafari ? event.nativeEvent.keyCode === 229 : (event.nativeEvent?.isComposing ?? false)
|
||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||
event.preventDefault()
|
||||
|
||||
|
||||
@@ -6,23 +6,30 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
|
||||
import React from "react"
|
||||
import { useClineAuth } from "@/context/ClineAuthContext"
|
||||
|
||||
interface CreditLimitErrorProps {
|
||||
currentBalance: number
|
||||
totalSpent?: number
|
||||
totalPromotions?: number
|
||||
message: string
|
||||
buyCreditsUrl?: string
|
||||
// buyCreditsUrl?: string
|
||||
}
|
||||
|
||||
const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
|
||||
currentBalance = 0,
|
||||
totalSpent = 0,
|
||||
totalPromotions = 0,
|
||||
message = "You have run out of credit.",
|
||||
buyCreditsUrl = "https://app.cline.bot/dashboard/account?tab=credits&redirect=true",
|
||||
message = "You have run out of credits.",
|
||||
// buyCreditsUrl = "https://app.cline.bot/dashboard/account?tab=credits&redirect=true",
|
||||
}) => {
|
||||
const { uriScheme } = useExtensionState()
|
||||
const { activeOrganization } = useClineAuth()
|
||||
|
||||
const isPersonal = !activeOrganization?.organizationId
|
||||
const buyCreditsUrl = isPersonal
|
||||
? "https://app.cline.bot/dashboard/account?tab=credits&redirect=true"
|
||||
: "https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
|
||||
|
||||
const callbackUrl = `${uriScheme || "vscode"}://saoudrizwan.claude-dev`
|
||||
const fullPurchaseUrl = new URL(buyCreditsUrl)
|
||||
@@ -33,13 +40,13 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
|
||||
<div className="p-2 border-none rounded-md mb-2 bg-[var(--vscode-textBlockQuote-background)]">
|
||||
<div className="mb-3 font-azeret-mono">
|
||||
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "8px" }}>{message}</div>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
{/* <div style={{ marginBottom: "12px" }}>
|
||||
<div style={{ color: "var(--vscode-foreground)" }}>
|
||||
Current Balance: <span style={{ fontWeight: "bold" }}>{currentBalance.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style={{ color: "var(--vscode-foreground)" }}>Total Spent: {totalSpent.toFixed(2)}</div>
|
||||
<div style={{ color: "var(--vscode-foreground)" }}>Total Promotions: {totalPromotions.toFixed(2)}</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<VSCodeButtonLink
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("ErrorRow", () => {
|
||||
current_balance: 0,
|
||||
total_spent: 10.5,
|
||||
total_promotions: 5.0,
|
||||
message: "You have run out of credit.",
|
||||
message: "You have run out of credits.",
|
||||
buy_credits_url: "https://app.cline.bot/dashboard",
|
||||
},
|
||||
},
|
||||
@@ -99,7 +99,7 @@ describe("ErrorRow", () => {
|
||||
render(<ErrorRow message={mockMessage} errorType="error" apiRequestFailedMessage="Insufficient credits error" />)
|
||||
|
||||
expect(screen.getByTestId("credit-limit-error")).toBeInTheDocument()
|
||||
expect(screen.getByText("You have run out of credit.")).toBeInTheDocument()
|
||||
expect(screen.getByText("You have run out of credits.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders rate limit error with request ID", async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
|
||||
totalSpent={errorDetails?.total_spent}
|
||||
totalPromotions={errorDetails?.total_promotions}
|
||||
message={errorDetails?.message}
|
||||
buyCreditsUrl={errorDetails?.buy_credits_url}
|
||||
// buyCreditsUrl={errorDetails?.buy_credits_url}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,3 +34,9 @@ export const detectMetaKeyChar = (platform: string) => {
|
||||
return "CMD"
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = navigator?.userAgent || ""
|
||||
|
||||
export const isChrome = userAgent.indexOf("Chrome") >= 0
|
||||
|
||||
export const isSafari = !isChrome && userAgent.indexOf("Safari") >= 0
|
||||
|
||||
Reference in New Issue
Block a user