mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge branch 'main' of https://github.com/RooVetGit/Roo-Code into sbc_add_subtasks
This commit is contained in:
commit
4095c16d32
54 changed files with 2598 additions and 1665 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -1,5 +1,21 @@
|
|||
# Roo Code Changelog
|
||||
|
||||
## [3.7.5]
|
||||
|
||||
- Fix context window truncation math (see [#1173](https://github.com/RooVetGit/Roo-Code/issues/1173))
|
||||
- Fix various issues with the model picker (thanks @System233!)
|
||||
- Fix model input / output cost parsing (thanks @System233!)
|
||||
- Add drag-and-drop for files
|
||||
- Enable the "Thinking Budget" slider for Claude 3.7 Sonnet on OpenRouter
|
||||
|
||||
## [3.7.4]
|
||||
|
||||
- Fix a bug that prevented the "Thinking" setting from properly updating when switching profiles.
|
||||
|
||||
## [3.7.3]
|
||||
|
||||
- Support for ["Thinking"](https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking) Sonnet 3.7 when using the Anthropic provider.
|
||||
|
||||
## [3.7.2]
|
||||
|
||||
- Fix computer use and prompt caching for OpenRouter's `anthropic/claude-3.7-sonnet:beta` (thanks @cte!)
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "roo-cline",
|
||||
"version": "3.7.2",
|
||||
"version": "3.7.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "roo-cline",
|
||||
"version": "3.7.2",
|
||||
"version": "3.7.5",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"name": "roo-cline",
|
||||
"displayName": "Roo Code (prev. Roo Cline)",
|
||||
"description": "An AI-powered autonomous coding agent that lives in your editor.",
|
||||
"description": "A whole dev team of AI agents in your editor.",
|
||||
"publisher": "RooVeterinaryInc",
|
||||
"version": "3.7.2",
|
||||
"version": "3.7.5",
|
||||
"icon": "assets/icons/rocket.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ import { ApiStream } from "../transform/stream"
|
|||
|
||||
const ANTHROPIC_DEFAULT_TEMPERATURE = 0
|
||||
|
||||
const THINKING_MODELS = ["claude-3-7-sonnet-20250219"]
|
||||
|
||||
export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Anthropic
|
||||
|
|
@ -32,16 +30,19 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
|
|||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
let stream: AnthropicStream<Anthropic.Messages.RawMessageStreamEvent>
|
||||
const cacheControl: CacheControlEphemeral = { type: "ephemeral" }
|
||||
const modelId = this.getModel().id
|
||||
const maxTokens = this.getModel().info.maxTokens || 8192
|
||||
let { id: modelId, info: modelInfo } = this.getModel()
|
||||
const maxTokens = modelInfo.maxTokens || 8192
|
||||
let temperature = this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE
|
||||
let thinking: BetaThinkingConfigParam | undefined = undefined
|
||||
|
||||
if (THINKING_MODELS.includes(modelId)) {
|
||||
thinking = this.options.anthropicThinking
|
||||
? { type: "enabled", budget_tokens: this.options.anthropicThinking }
|
||||
: { type: "disabled" }
|
||||
|
||||
// Anthropic "Thinking" models require a temperature of 1.0.
|
||||
if (modelId === "claude-3-7-sonnet-20250219:thinking") {
|
||||
// The `:thinking` variant is a virtual identifier for the
|
||||
// `claude-3-7-sonnet-20250219` model with a thinking budget.
|
||||
// We can handle this more elegantly in the future.
|
||||
modelId = "claude-3-7-sonnet-20250219"
|
||||
const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024)
|
||||
thinking = { type: "enabled", budget_tokens: budgetTokens }
|
||||
temperature = 1.0
|
||||
}
|
||||
|
||||
|
|
@ -114,8 +115,8 @@ export class AnthropicHandler implements ApiHandler, SingleCompletionHandler {
|
|||
default: {
|
||||
stream = (await this.client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
temperature: this.options.modelTemperature ?? ANTHROPIC_DEFAULT_TEMPERATURE,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
system: [{ text: systemPrompt, type: "text" }],
|
||||
messages,
|
||||
// tools,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
|
||||
import { ApiHandlerOptions, ModelInfo, glamaDefaultModelId, glamaDefaultModelInfo } from "../../shared/api"
|
||||
import { parseApiPrice } from "../../utils/cost"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
|
||||
const GLAMA_DEFAULT_TEMPERATURE = 0
|
||||
|
||||
|
|
@ -69,7 +71,7 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
|
|||
let maxTokens: number | undefined
|
||||
|
||||
if (this.getModel().id.startsWith("anthropic/")) {
|
||||
maxTokens = 8_192
|
||||
maxTokens = this.getModel().info.maxTokens
|
||||
}
|
||||
|
||||
const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = {
|
||||
|
|
@ -177,7 +179,7 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
|
||||
if (this.getModel().id.startsWith("anthropic/")) {
|
||||
requestOptions.max_tokens = 8192
|
||||
requestOptions.max_tokens = this.getModel().info.maxTokens
|
||||
}
|
||||
|
||||
const response = await this.client.chat.completions.create(requestOptions)
|
||||
|
|
@ -190,3 +192,44 @@ export class GlamaHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGlamaModels() {
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
|
||||
try {
|
||||
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
|
||||
const rawModels = response.data
|
||||
|
||||
for (const rawModel of rawModels) {
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: rawModel.maxTokensOutput,
|
||||
contextWindow: rawModel.maxTokensInput,
|
||||
supportsImages: rawModel.capabilities?.includes("input:image"),
|
||||
supportsComputerUse: rawModel.capabilities?.includes("computer_use"),
|
||||
supportsPromptCache: rawModel.capabilities?.includes("caching"),
|
||||
inputPrice: parseApiPrice(rawModel.pricePerToken?.input),
|
||||
outputPrice: parseApiPrice(rawModel.pricePerToken?.output),
|
||||
description: undefined,
|
||||
cacheWritesPrice: parseApiPrice(rawModel.pricePerToken?.cacheWrite),
|
||||
cacheReadsPrice: parseApiPrice(rawModel.pricePerToken?.cacheRead),
|
||||
}
|
||||
|
||||
switch (rawModel.id) {
|
||||
case rawModel.id.startsWith("anthropic/claude-3-7-sonnet"):
|
||||
modelInfo.maxTokens = 16384
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/"):
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Glama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import axios from "axios"
|
||||
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
|
|
@ -72,3 +74,17 @@ export class LmStudioHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLmStudioModels(baseUrl = "http://localhost:1234") {
|
||||
try {
|
||||
if (!URL.canParse(baseUrl)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const response = await axios.get(`${baseUrl}/v1/models`)
|
||||
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
|
||||
return [...new Set<string>(modelsArray)]
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import axios from "axios"
|
||||
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
|
|
@ -88,3 +90,17 @@ export class OllamaHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOllamaModels(baseUrl = "http://localhost:11434") {
|
||||
try {
|
||||
if (!URL.canParse(baseUrl)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const response = await axios.get(`${baseUrl}/api/tags`)
|
||||
const modelsArray = response.data?.models?.map((model: any) => model.name) || []
|
||||
return [...new Set<string>(modelsArray)]
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI, { AzureOpenAI } from "openai"
|
||||
import axios from "axios"
|
||||
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
|
|
@ -166,3 +167,27 @@ export class OpenAiHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOpenAiModels(baseUrl?: string, apiKey?: string) {
|
||||
try {
|
||||
if (!baseUrl) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!URL.canParse(baseUrl)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const config: Record<string, any> = {}
|
||||
|
||||
if (apiKey) {
|
||||
config["headers"] = { Authorization: `Bearer ${apiKey}` }
|
||||
}
|
||||
|
||||
const response = await axios.get(`${baseUrl}/models`, config)
|
||||
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
|
||||
return [...new Set<string>(modelsArray)]
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,31 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta"
|
||||
import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import delay from "delay"
|
||||
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { parseApiPrice } from "../../utils/cost"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import delay from "delay"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { DEEP_SEEK_DEFAULT_TEMPERATURE } from "./openai"
|
||||
import { ApiHandler, SingleCompletionHandler } from ".."
|
||||
|
||||
const OPENROUTER_DEFAULT_TEMPERATURE = 0
|
||||
|
||||
// Add custom interface for OpenRouter params
|
||||
// Add custom interface for OpenRouter params.
|
||||
type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
|
||||
transforms?: string[]
|
||||
include_reasoning?: boolean
|
||||
thinking?: BetaThinkingConfigParam
|
||||
}
|
||||
|
||||
// Add custom interface for OpenRouter usage chunk
|
||||
// Add custom interface for OpenRouter usage chunk.
|
||||
interface OpenRouterApiStreamUsageChunk extends ApiStreamUsageChunk {
|
||||
fullResponseText: string
|
||||
}
|
||||
|
||||
import { SingleCompletionHandler } from ".."
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
|
|
@ -52,22 +54,12 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const { id: modelId, info: modelInfo } = this.getModel()
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
switch (this.getModel().id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
switch (true) {
|
||||
case modelId.startsWith("anthropic/"):
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
|
|
@ -103,31 +95,11 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
break
|
||||
}
|
||||
|
||||
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
|
||||
// (models usually default to max tokens allowed)
|
||||
let maxTokens: number | undefined
|
||||
switch (this.getModel().id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
maxTokens = 8_192
|
||||
break
|
||||
}
|
||||
|
||||
let defaultTemperature = OPENROUTER_DEFAULT_TEMPERATURE
|
||||
let topP: number | undefined = undefined
|
||||
|
||||
// Handle models based on deepseek-r1
|
||||
if (
|
||||
this.getModel().id.startsWith("deepseek/deepseek-r1") ||
|
||||
this.getModel().id === "perplexity/sonar-reasoning"
|
||||
) {
|
||||
if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") {
|
||||
// Recommended temperature for DeepSeek reasoning models
|
||||
defaultTemperature = DEEP_SEEK_DEFAULT_TEMPERATURE
|
||||
// DeepSeek highly recommends using user instead of system role
|
||||
|
|
@ -136,24 +108,38 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
topP = 0.95
|
||||
}
|
||||
|
||||
let temperature = this.options.modelTemperature ?? defaultTemperature
|
||||
let thinking: BetaThinkingConfigParam | undefined = undefined
|
||||
|
||||
if (modelInfo.thinking) {
|
||||
const maxTokens = modelInfo.maxTokens || 8192
|
||||
const budgetTokens = this.options.anthropicThinking ?? Math.max(maxTokens * 0.8, 1024)
|
||||
thinking = { type: "enabled", budget_tokens: budgetTokens }
|
||||
temperature = 1.0
|
||||
}
|
||||
|
||||
// https://openrouter.ai/docs/transforms
|
||||
let fullResponseText = ""
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
max_tokens: maxTokens,
|
||||
temperature: this.options.modelTemperature ?? defaultTemperature,
|
||||
|
||||
const completionParams: OpenRouterChatCompletionParams = {
|
||||
model: modelId,
|
||||
max_tokens: modelInfo.maxTokens,
|
||||
temperature,
|
||||
thinking, // OpenRouter is temporarily supporting this.
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
include_reasoning: true,
|
||||
// This way, the transforms field will only be included in the parameters when openRouterUseMiddleOutTransform is true.
|
||||
...(this.options.openRouterUseMiddleOutTransform && { transforms: ["middle-out"] }),
|
||||
} as OpenRouterChatCompletionParams)
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create(completionParams)
|
||||
|
||||
let genId: string | undefined
|
||||
|
||||
for await (const chunk of stream as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as { message?: string; code?: number }
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
|
|
@ -165,12 +151,14 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: delta.reasoning,
|
||||
} as ApiStreamChunk
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
fullResponseText += delta.content
|
||||
yield {
|
||||
|
|
@ -178,6 +166,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
text: delta.content,
|
||||
} as ApiStreamChunk
|
||||
}
|
||||
|
||||
// if (chunk.usage) {
|
||||
// yield {
|
||||
// type: "usage",
|
||||
|
|
@ -187,10 +176,12 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
// }
|
||||
}
|
||||
|
||||
// retry fetching generation details
|
||||
// Retry fetching generation details.
|
||||
let attempt = 0
|
||||
|
||||
while (attempt++ < 10) {
|
||||
await delay(200) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
|
||||
try {
|
||||
const response = await axios.get(`https://openrouter.ai/api/v1/generation?id=${genId}`, {
|
||||
headers: {
|
||||
|
|
@ -200,7 +191,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
})
|
||||
|
||||
const generation = response.data?.data
|
||||
console.log("OpenRouter generation details:", response.data)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
// cacheWriteTokens: 0,
|
||||
|
|
@ -211,6 +202,7 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
totalCost: generation?.total_cost || 0,
|
||||
fullResponseText,
|
||||
} as OpenRouterApiStreamUsageChunk
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
|
|
@ -218,13 +210,13 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
|
||||
getModel() {
|
||||
const modelId = this.options.openRouterModelId
|
||||
const modelInfo = this.options.openRouterModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
|
||||
return modelId && modelInfo
|
||||
? { id: modelId, info: modelInfo }
|
||||
: { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
|
|
@ -247,7 +239,81 @@ export class OpenRouterHandler implements ApiHandler, SingleCompletionHandler {
|
|||
if (error instanceof Error) {
|
||||
throw new Error(`OpenRouter completion error: ${error.message}`)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOpenRouterModels() {
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
|
||||
try {
|
||||
const response = await axios.get("https://openrouter.ai/api/v1/models")
|
||||
const rawModels = response.data.data
|
||||
|
||||
for (const rawModel of rawModels) {
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: rawModel.top_provider?.max_completion_tokens,
|
||||
contextWindow: rawModel.context_length,
|
||||
supportsImages: rawModel.architecture?.modality?.includes("image"),
|
||||
supportsPromptCache: false,
|
||||
inputPrice: parseApiPrice(rawModel.pricing?.prompt),
|
||||
outputPrice: parseApiPrice(rawModel.pricing?.completion),
|
||||
description: rawModel.description,
|
||||
thinking: rawModel.id === "anthropic/claude-3.7-sonnet:thinking",
|
||||
}
|
||||
|
||||
// NOTE: this needs to be synced with api.ts/openrouter default model info.
|
||||
switch (true) {
|
||||
case rawModel.id.startsWith("anthropic/claude-3.7-sonnet"):
|
||||
modelInfo.supportsComputerUse = true
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
modelInfo.maxTokens = 16384
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet-20240620"):
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/claude-3.5-sonnet"):
|
||||
modelInfo.supportsComputerUse = true
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/claude-3-5-haiku"):
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 1.25
|
||||
modelInfo.cacheReadsPrice = 0.1
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/claude-3-opus"):
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 18.75
|
||||
modelInfo.cacheReadsPrice = 1.5
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/claude-3-haiku"):
|
||||
default:
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 0.3
|
||||
modelInfo.cacheReadsPrice = 0.03
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
|
||||
import axios from "axios"
|
||||
|
||||
import { ModelInfo, requestyModelInfoSaneDefaults, requestyDefaultModelId } from "../../shared/api"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { parseApiPrice } from "../../utils/cost"
|
||||
import { ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { OpenAiHandler, OpenAiHandlerOptions } from "./openai"
|
||||
|
||||
export class RequestyHandler extends OpenAiHandler {
|
||||
constructor(options: OpenAiHandlerOptions) {
|
||||
|
|
@ -38,3 +41,65 @@ export class RequestyHandler extends OpenAiHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRequestyModels() {
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
|
||||
try {
|
||||
const response = await axios.get("https://router.requesty.ai/v1/models")
|
||||
const rawModels = response.data.data
|
||||
|
||||
for (const rawModel of rawModels) {
|
||||
// {
|
||||
// id: "anthropic/claude-3-5-sonnet-20240620",
|
||||
// object: "model",
|
||||
// created: 1740552655,
|
||||
// owned_by: "system",
|
||||
// input_price: 0.0000028,
|
||||
// caching_price: 0.00000375,
|
||||
// cached_price: 3e-7,
|
||||
// output_price: 0.000015,
|
||||
// max_output_tokens: 8192,
|
||||
// context_window: 200000,
|
||||
// supports_caching: true,
|
||||
// description:
|
||||
// "Anthropic's previous most intelligent model. High level of intelligence and capability. Excells in coding.",
|
||||
// }
|
||||
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: rawModel.max_output_tokens,
|
||||
contextWindow: rawModel.context_window,
|
||||
supportsPromptCache: rawModel.supports_caching,
|
||||
inputPrice: parseApiPrice(rawModel.input_price),
|
||||
outputPrice: parseApiPrice(rawModel.output_price),
|
||||
description: rawModel.description,
|
||||
cacheWritesPrice: parseApiPrice(rawModel.caching_price),
|
||||
cacheReadsPrice: parseApiPrice(rawModel.cached_price),
|
||||
}
|
||||
|
||||
switch (rawModel.id) {
|
||||
case rawModel.id.startsWith("anthropic/claude-3-7-sonnet"):
|
||||
modelInfo.supportsComputerUse = true
|
||||
modelInfo.supportsImages = true
|
||||
modelInfo.maxTokens = 16384
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/claude-3-5-sonnet-20241022"):
|
||||
modelInfo.supportsComputerUse = true
|
||||
modelInfo.supportsImages = true
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
case rawModel.id.startsWith("anthropic/"):
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
|
||||
import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
|
||||
interface UnboundUsage extends OpenAI.CompletionUsage {
|
||||
cache_creation_input_tokens?: number
|
||||
|
|
@ -71,7 +73,7 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
|
|||
let maxTokens: number | undefined
|
||||
|
||||
if (this.getModel().id.startsWith("anthropic/")) {
|
||||
maxTokens = 8_192
|
||||
maxTokens = this.getModel().info.maxTokens
|
||||
}
|
||||
|
||||
const { data: completion, response } = await this.client.chat.completions
|
||||
|
|
@ -150,7 +152,7 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
|
||||
if (this.getModel().id.startsWith("anthropic/")) {
|
||||
requestOptions.max_tokens = 8192
|
||||
requestOptions.max_tokens = this.getModel().info.maxTokens
|
||||
}
|
||||
|
||||
const response = await this.client.chat.completions.create(requestOptions)
|
||||
|
|
@ -163,3 +165,46 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUnboundModels() {
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
|
||||
try {
|
||||
const response = await axios.get("https://api.getunbound.ai/models")
|
||||
|
||||
if (response.data) {
|
||||
const rawModels: Record<string, any> = response.data
|
||||
|
||||
for (const [modelId, model] of Object.entries(rawModels)) {
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: model?.maxTokens ? parseInt(model.maxTokens) : undefined,
|
||||
contextWindow: model?.contextWindow ? parseInt(model.contextWindow) : 0,
|
||||
supportsImages: model?.supportsImages ?? false,
|
||||
supportsPromptCache: model?.supportsPromptCaching ?? false,
|
||||
supportsComputerUse: model?.supportsComputerUse ?? false,
|
||||
inputPrice: model?.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined,
|
||||
outputPrice: model?.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined,
|
||||
cacheWritesPrice: model?.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined,
|
||||
cacheReadsPrice: model?.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined,
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case modelId.startsWith("anthropic/claude-3-7-sonnet"):
|
||||
modelInfo.maxTokens = 16384
|
||||
break
|
||||
case modelId.startsWith("anthropic/"):
|
||||
modelInfo.maxTokens = 8192
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
models[modelId] = modelInfo
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
import { calculateApiCost } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
|
@ -545,3 +546,15 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVsCodeLmModels() {
|
||||
try {
|
||||
const models = await vscode.lm.selectChatModels({})
|
||||
return models || []
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ import {
|
|||
import { getApiMetrics } from "../shared/getApiMetrics"
|
||||
import { HistoryItem } from "../shared/HistoryItem"
|
||||
import { ClineAskResponse } from "../shared/WebviewMessage"
|
||||
import { GlobalFileNames } from "../shared/globalFileNames"
|
||||
import { defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
|
||||
import { calculateApiCost } from "../utils/cost"
|
||||
import { fileExistsAtPath } from "../utils/fs"
|
||||
import { arePathsEqual, getReadablePath } from "../utils/path"
|
||||
|
|
@ -54,12 +56,10 @@ import { parseMentions } from "./mentions"
|
|||
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
|
||||
import { formatResponse } from "./prompts/responses"
|
||||
import { SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { modes, defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
|
||||
import { truncateConversationIfNeeded } from "./sliding-window"
|
||||
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
|
||||
import { ClineProvider } from "./webview/ClineProvider"
|
||||
import { detectCodeOmission } from "../integrations/editor/detect-omission"
|
||||
import { BrowserSession } from "../services/browser/BrowserSession"
|
||||
import { OpenRouterHandler } from "../api/providers/openrouter"
|
||||
import { McpHub } from "../services/mcp/McpHub"
|
||||
import crypto from "crypto"
|
||||
import { insertGroups } from "./diff/insert-groups"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// npx jest src/core/config/__tests__/CustomModesManager.test.ts
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
|
|
@ -15,9 +17,10 @@ describe("CustomModesManager", () => {
|
|||
let mockOnUpdate: jest.Mock
|
||||
let mockWorkspaceFolders: { uri: { fsPath: string } }[]
|
||||
|
||||
const mockStoragePath = "/mock/settings"
|
||||
// Use path.sep to ensure correct path separators for the current platform
|
||||
const mockStoragePath = `${path.sep}mock${path.sep}settings`
|
||||
const mockSettingsPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
const mockRoomodes = "/mock/workspace/.roomodes"
|
||||
const mockRoomodes = `${path.sep}mock${path.sep}workspace${path.sep}.roomodes`
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnUpdate = jest.fn()
|
||||
|
|
@ -243,7 +246,15 @@ describe("CustomModesManager", () => {
|
|||
await manager.updateCustomMode("project-mode", projectMode)
|
||||
|
||||
// Verify .roomodes was created with the project mode
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(mockRoomodes, expect.stringContaining("project-mode"), "utf-8")
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String), // Don't check exact path as it may have different separators on different platforms
|
||||
expect.stringContaining("project-mode"),
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
// Verify the path is correct regardless of separators
|
||||
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
|
||||
expect(path.normalize(writeCall[0])).toBe(path.normalize(mockRoomodes))
|
||||
|
||||
// Verify the content written to .roomodes
|
||||
expect(roomodesContent).toEqual({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
|||
import { ModelInfo } from "../../../shared/api"
|
||||
import { truncateConversation, truncateConversationIfNeeded } from "../index"
|
||||
|
||||
/**
|
||||
* Tests for the truncateConversation function
|
||||
*/
|
||||
describe("truncateConversation", () => {
|
||||
it("should retain the first message", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
|
|
@ -91,6 +94,86 @@ describe("truncateConversation", () => {
|
|||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests for the getMaxTokens function (private but tested through truncateConversationIfNeeded)
|
||||
*/
|
||||
describe("getMaxTokens", () => {
|
||||
// We'll test this indirectly through truncateConversationIfNeeded
|
||||
const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({
|
||||
contextWindow,
|
||||
supportsPromptCache: true, // Not relevant for getMaxTokens
|
||||
maxTokens,
|
||||
})
|
||||
|
||||
// Reuse across tests for consistency
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "First message" },
|
||||
{ role: "assistant", content: "Second message" },
|
||||
{ role: "user", content: "Third message" },
|
||||
{ role: "assistant", content: "Fourth message" },
|
||||
{ role: "user", content: "Fifth message" },
|
||||
]
|
||||
|
||||
it("should use maxTokens as buffer when specified", () => {
|
||||
const modelInfo = createModelInfo(100000, 50000)
|
||||
// Max tokens = 100000 - 50000 = 50000
|
||||
|
||||
// Below max tokens - no truncation
|
||||
const result1 = truncateConversationIfNeeded(messages, 49999, modelInfo)
|
||||
expect(result1).toEqual(messages)
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = truncateConversationIfNeeded(messages, 50001, modelInfo)
|
||||
expect(result2).not.toEqual(messages)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
})
|
||||
|
||||
it("should use 20% of context window as buffer when maxTokens is undefined", () => {
|
||||
const modelInfo = createModelInfo(100000, undefined)
|
||||
// Max tokens = 100000 - (100000 * 0.2) = 80000
|
||||
|
||||
// Below max tokens - no truncation
|
||||
const result1 = truncateConversationIfNeeded(messages, 79999, modelInfo)
|
||||
expect(result1).toEqual(messages)
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = truncateConversationIfNeeded(messages, 80001, modelInfo)
|
||||
expect(result2).not.toEqual(messages)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
})
|
||||
|
||||
it("should handle small context windows appropriately", () => {
|
||||
const modelInfo = createModelInfo(50000, 10000)
|
||||
// Max tokens = 50000 - 10000 = 40000
|
||||
|
||||
// Below max tokens - no truncation
|
||||
const result1 = truncateConversationIfNeeded(messages, 39999, modelInfo)
|
||||
expect(result1).toEqual(messages)
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = truncateConversationIfNeeded(messages, 40001, modelInfo)
|
||||
expect(result2).not.toEqual(messages)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
})
|
||||
|
||||
it("should handle large context windows appropriately", () => {
|
||||
const modelInfo = createModelInfo(200000, 30000)
|
||||
// Max tokens = 200000 - 30000 = 170000
|
||||
|
||||
// Below max tokens - no truncation
|
||||
const result1 = truncateConversationIfNeeded(messages, 169999, modelInfo)
|
||||
expect(result1).toEqual(messages)
|
||||
|
||||
// Above max tokens - truncate
|
||||
const result2 = truncateConversationIfNeeded(messages, 170001, modelInfo)
|
||||
expect(result2).not.toEqual(messages)
|
||||
expect(result2.length).toBe(3) // Truncated with 0.5 fraction
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests for the truncateConversationIfNeeded function
|
||||
*/
|
||||
describe("truncateConversationIfNeeded", () => {
|
||||
const createModelInfo = (contextWindow: number, supportsPromptCache: boolean, maxTokens?: number): ModelInfo => ({
|
||||
contextWindow,
|
||||
|
|
@ -106,25 +189,43 @@ describe("truncateConversationIfNeeded", () => {
|
|||
{ role: "user", content: "Fifth message" },
|
||||
]
|
||||
|
||||
it("should not truncate if tokens are below threshold for prompt caching models", () => {
|
||||
const modelInfo = createModelInfo(200000, true, 50000)
|
||||
const totalTokens = 100000 // Below threshold
|
||||
it("should not truncate if tokens are below max tokens threshold", () => {
|
||||
const modelInfo = createModelInfo(100000, true, 30000)
|
||||
const maxTokens = 100000 - 30000 // 70000
|
||||
const totalTokens = 69999 // Below threshold
|
||||
|
||||
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
|
||||
expect(result).toEqual(messages)
|
||||
expect(result).toEqual(messages) // No truncation occurs
|
||||
})
|
||||
|
||||
it("should not truncate if tokens are below threshold for non-prompt caching models", () => {
|
||||
const modelInfo = createModelInfo(200000, false)
|
||||
const totalTokens = 100000 // Below threshold
|
||||
it("should truncate if tokens are above max tokens threshold", () => {
|
||||
const modelInfo = createModelInfo(100000, true, 30000)
|
||||
const maxTokens = 100000 - 30000 // 70000
|
||||
const totalTokens = 70001 // Above threshold
|
||||
|
||||
// When truncating, always uses 0.5 fraction
|
||||
// With 4 messages after the first, 0.5 fraction means remove 2 messages
|
||||
const expectedResult = [messages[0], messages[3], messages[4]]
|
||||
|
||||
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
|
||||
expect(result).toEqual(messages)
|
||||
expect(result).toEqual(expectedResult)
|
||||
})
|
||||
|
||||
it("should use 80% of context window as threshold if it's greater than (contextWindow - buffer)", () => {
|
||||
const modelInfo = createModelInfo(50000, true) // Small context window
|
||||
const totalTokens = 40001 // Above 80% threshold (40000)
|
||||
const mockResult = [messages[0], messages[3], messages[4]]
|
||||
const result = truncateConversationIfNeeded(messages, totalTokens, modelInfo)
|
||||
expect(result).toEqual(mockResult)
|
||||
it("should work with non-prompt caching models the same as prompt caching models", () => {
|
||||
// The implementation no longer differentiates between prompt caching and non-prompt caching models
|
||||
const modelInfo1 = createModelInfo(100000, true, 30000)
|
||||
const modelInfo2 = createModelInfo(100000, false, 30000)
|
||||
|
||||
// Test below threshold
|
||||
const belowThreshold = 69999
|
||||
expect(truncateConversationIfNeeded(messages, belowThreshold, modelInfo1)).toEqual(
|
||||
truncateConversationIfNeeded(messages, belowThreshold, modelInfo2),
|
||||
)
|
||||
|
||||
// Test above threshold
|
||||
const aboveThreshold = 70001
|
||||
expect(truncateConversationIfNeeded(messages, aboveThreshold, modelInfo1)).toEqual(
|
||||
truncateConversationIfNeeded(messages, aboveThreshold, modelInfo2),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,13 +28,9 @@ export function truncateConversation(
|
|||
/**
|
||||
* Conditionally truncates the conversation messages if the total token count exceeds the model's limit.
|
||||
*
|
||||
* Depending on whether the model supports prompt caching, different maximum token thresholds
|
||||
* and truncation fractions are used. If the current total tokens exceed the threshold,
|
||||
* the conversation is truncated using the appropriate fraction.
|
||||
*
|
||||
* @param {Anthropic.Messages.MessageParam[]} messages - The conversation messages.
|
||||
* @param {number} totalTokens - The total number of tokens in the conversation.
|
||||
* @param {ModelInfo} modelInfo - Model metadata including context window size and prompt cache support.
|
||||
* @param {ModelInfo} modelInfo - Model metadata including context window size.
|
||||
* @returns {Anthropic.Messages.MessageParam[]} The original or truncated conversation messages.
|
||||
*/
|
||||
export function truncateConversationIfNeeded(
|
||||
|
|
@ -42,61 +38,16 @@ export function truncateConversationIfNeeded(
|
|||
totalTokens: number,
|
||||
modelInfo: ModelInfo,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
return totalTokens < getMaxTokensForPromptCachingModels(modelInfo)
|
||||
? messages
|
||||
: truncateConversation(messages, getTruncFractionForPromptCachingModels(modelInfo))
|
||||
} else {
|
||||
return totalTokens < getMaxTokensForNonPromptCachingModels(modelInfo)
|
||||
? messages
|
||||
: truncateConversation(messages, getTruncFractionForNonPromptCachingModels(modelInfo))
|
||||
}
|
||||
return totalTokens < getMaxTokens(modelInfo) ? messages : truncateConversation(messages, 0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the maximum allowed tokens for models that support prompt caching.
|
||||
*
|
||||
* The maximum is computed as the greater of (contextWindow - buffer) and 80% of the contextWindow.
|
||||
* Calculates the maximum allowed tokens
|
||||
*
|
||||
* @param {ModelInfo} modelInfo - The model information containing the context window size.
|
||||
* @returns {number} The maximum number of tokens allowed for prompt caching models.
|
||||
* @returns {number} The maximum number of tokens allowed
|
||||
*/
|
||||
function getMaxTokensForPromptCachingModels(modelInfo: ModelInfo): number {
|
||||
// The buffer needs to be at least as large as `modelInfo.maxTokens`.
|
||||
const buffer = modelInfo.maxTokens ? Math.max(40_000, modelInfo.maxTokens) : 40_000
|
||||
return Math.max(modelInfo.contextWindow - buffer, modelInfo.contextWindow * 0.8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the fraction of messages to remove for models that support prompt caching.
|
||||
*
|
||||
* @param {ModelInfo} modelInfo - The model information (unused in current implementation).
|
||||
* @returns {number} The truncation fraction for prompt caching models (fixed at 0.5).
|
||||
*/
|
||||
function getTruncFractionForPromptCachingModels(modelInfo: ModelInfo): number {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the maximum allowed tokens for models that do not support prompt caching.
|
||||
*
|
||||
* The maximum is computed as the greater of (contextWindow - 40000) and 80% of the contextWindow.
|
||||
*
|
||||
* @param {ModelInfo} modelInfo - The model information containing the context window size.
|
||||
* @returns {number} The maximum number of tokens allowed for non-prompt caching models.
|
||||
*/
|
||||
function getMaxTokensForNonPromptCachingModels(modelInfo: ModelInfo): number {
|
||||
// The buffer needs to be at least as large as `modelInfo.maxTokens`.
|
||||
const buffer = modelInfo.maxTokens ? Math.max(40_000, modelInfo.maxTokens) : 40_000
|
||||
return Math.max(modelInfo.contextWindow - buffer, modelInfo.contextWindow * 0.8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the fraction of messages to remove for models that do not support prompt caching.
|
||||
*
|
||||
* @param {ModelInfo} modelInfo - The model information.
|
||||
* @returns {number} The truncation fraction for non-prompt caching models (fixed at 0.1).
|
||||
*/
|
||||
function getTruncFractionForNonPromptCachingModels(modelInfo: ModelInfo): number {
|
||||
return Math.min(40_000 / modelInfo.contextWindow, 0.2)
|
||||
function getMaxTokens(modelInfo: ModelInfo): number {
|
||||
// The buffer needs to be at least as large as `modelInfo.maxTokens`, or 20% of the context window if for some reason it's not set.
|
||||
return modelInfo.contextWindow - (modelInfo.maxTokens || modelInfo.contextWindow * 0.2)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,138 +8,51 @@ import * as path from "path"
|
|||
import * as vscode from "vscode"
|
||||
import simpleGit from "simple-git"
|
||||
|
||||
import { buildApiHandler } from "../../api"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
|
||||
import { GlobalFileNames } from "../../shared/globalFileNames"
|
||||
import type { SecretKey, GlobalStateKey } from "../../shared/globalState"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
|
||||
import { checkExistKey } from "../../shared/checkExistApiConfig"
|
||||
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
|
||||
import { downloadTask } from "../../integrations/misc/export-markdown"
|
||||
import { openFile, openImage } from "../../integrations/misc/open-file"
|
||||
import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import { getDiffStrategy } from "../diff/DiffStrategy"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug } from "../../shared/modes"
|
||||
import { SYSTEM_PROMPT } from "../prompts/system"
|
||||
import { McpServerManager } from "../../services/mcp/McpServerManager"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
||||
import { singleCompletionHandler } from "../../utils/single-completion-handler"
|
||||
import { searchCommits } from "../../utils/git"
|
||||
import { getDiffStrategy } from "../diff/DiffStrategy"
|
||||
import { SYSTEM_PROMPT } from "../prompts/system"
|
||||
import { ConfigManager } from "../config/ConfigManager"
|
||||
import { CustomModesManager } from "../config/CustomModesManager"
|
||||
import { buildApiHandler } from "../../api"
|
||||
import { getOpenRouterModels } from "../../api/providers/openrouter"
|
||||
import { getGlamaModels } from "../../api/providers/glama"
|
||||
import { getUnboundModels } from "../../api/providers/unbound"
|
||||
import { getRequestyModels } from "../../api/providers/requesty"
|
||||
import { getOpenAiModels } from "../../api/providers/openai"
|
||||
import { getOllamaModels } from "../../api/providers/ollama"
|
||||
import { getVsCodeLmModels } from "../../api/providers/vscode-lm"
|
||||
import { getLmStudioModels } from "../../api/providers/lmstudio"
|
||||
import { ACTION_NAMES } from "../CodeActionProvider"
|
||||
import { Cline } from "../Cline"
|
||||
import { openMention } from "../mentions"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
||||
import { checkExistKey } from "../../shared/checkExistApiConfig"
|
||||
import { singleCompletionHandler } from "../../utils/single-completion-handler"
|
||||
import { searchCommits } from "../../utils/git"
|
||||
import { ConfigManager } from "../config/ConfigManager"
|
||||
import { CustomModesManager } from "../config/CustomModesManager"
|
||||
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
|
||||
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
|
||||
|
||||
import { ACTION_NAMES } from "../CodeActionProvider"
|
||||
import { McpServerManager } from "../../services/mcp/McpServerManager"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
|
||||
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
|
||||
*/
|
||||
|
||||
type SecretKey =
|
||||
| "apiKey"
|
||||
| "glamaApiKey"
|
||||
| "openRouterApiKey"
|
||||
| "awsAccessKey"
|
||||
| "awsSecretKey"
|
||||
| "awsSessionToken"
|
||||
| "openAiApiKey"
|
||||
| "geminiApiKey"
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
| "mistralApiKey"
|
||||
| "unboundApiKey"
|
||||
| "requestyApiKey"
|
||||
type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
| "glamaModelId"
|
||||
| "glamaModelInfo"
|
||||
| "awsRegion"
|
||||
| "awsUseCrossRegionInference"
|
||||
| "awsProfile"
|
||||
| "awsUseProfile"
|
||||
| "vertexProjectId"
|
||||
| "vertexRegion"
|
||||
| "lastShownAnnouncementId"
|
||||
| "customInstructions"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "alwaysAllowWrite"
|
||||
| "alwaysAllowExecute"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysAllowMcp"
|
||||
| "alwaysAllowModeSwitch"
|
||||
| "taskHistory"
|
||||
| "openAiBaseUrl"
|
||||
| "openAiModelId"
|
||||
| "openAiCustomModelInfo"
|
||||
| "openAiUseAzure"
|
||||
| "ollamaModelId"
|
||||
| "ollamaBaseUrl"
|
||||
| "lmStudioModelId"
|
||||
| "lmStudioBaseUrl"
|
||||
| "anthropicBaseUrl"
|
||||
| "anthropicThinking"
|
||||
| "azureApiVersion"
|
||||
| "openAiStreamingEnabled"
|
||||
| "openRouterModelId"
|
||||
| "openRouterModelInfo"
|
||||
| "openRouterBaseUrl"
|
||||
| "openRouterUseMiddleOutTransform"
|
||||
| "allowedCommands"
|
||||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "diffEnabled"
|
||||
| "checkpointsEnabled"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "fuzzyMatchThreshold"
|
||||
| "preferredLanguage" // Language setting for Cline's communication
|
||||
| "writeDelayMs"
|
||||
| "terminalOutputLineLimit"
|
||||
| "mcpEnabled"
|
||||
| "enableMcpServerCreation"
|
||||
| "alwaysApproveResubmit"
|
||||
| "requestDelaySeconds"
|
||||
| "rateLimitSeconds"
|
||||
| "currentApiConfigName"
|
||||
| "listApiConfigMeta"
|
||||
| "vsCodeLmModelSelector"
|
||||
| "mode"
|
||||
| "modeApiConfigs"
|
||||
| "customModePrompts"
|
||||
| "customSupportPrompts"
|
||||
| "enhancementApiConfigId"
|
||||
| "experiments" // Map of experiment IDs to their enabled state
|
||||
| "autoApprovalEnabled"
|
||||
| "customModes" // Array of custom modes
|
||||
| "unboundModelId"
|
||||
| "requestyModelId"
|
||||
| "requestyModelInfo"
|
||||
| "unboundModelInfo"
|
||||
| "modelTemperature"
|
||||
| "mistralCodestralUrl"
|
||||
| "maxOpenTabsContext"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
glamaModels: "glama_models.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
requestyModels: "requesty_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
unboundModels: "unbound_models.json",
|
||||
}
|
||||
/**
|
||||
* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
* https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
|
||||
*/
|
||||
|
||||
export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly sideBarId = "roo-cline.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.
|
||||
|
|
@ -702,15 +615,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
|
||||
this.postStateToWebview()
|
||||
this.workspaceTracker?.initializeFilePaths() // don't await
|
||||
|
||||
getTheme().then((theme) =>
|
||||
this.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) }),
|
||||
)
|
||||
// post last cached models in case the call to endpoint fails
|
||||
this.readOpenRouterModels().then((openRouterModels) => {
|
||||
if (openRouterModels) {
|
||||
this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
|
||||
}
|
||||
})
|
||||
|
||||
// If MCP Hub is already initialized, update the webview with current server list
|
||||
if (this.mcpHub) {
|
||||
|
|
@ -720,13 +628,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
})
|
||||
}
|
||||
|
||||
// gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
|
||||
// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
|
||||
// (see normalizeApiConfiguration > openrouter)
|
||||
this.refreshOpenRouterModels().then(async (openRouterModels) => {
|
||||
const cacheDir = await this.ensureCacheDirectoryExists()
|
||||
|
||||
// Post last cached models in case the call to endpoint fails.
|
||||
this.readModelsFromCache(GlobalFileNames.openRouterModels).then((openRouterModels) => {
|
||||
if (openRouterModels) {
|
||||
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
|
||||
}
|
||||
})
|
||||
|
||||
// GUI relies on model info to be up-to-date to provide
|
||||
// the most accurate pricing, so we need to fetch the
|
||||
// latest details on launch.
|
||||
// We do this for all users since many users switch
|
||||
// between api providers and if they were to switch back
|
||||
// to OpenRouter it would be showing outdated model info
|
||||
// if we hadn't retrieved the latest at this point
|
||||
// (see normalizeApiConfiguration > openrouter).
|
||||
getOpenRouterModels().then(async (openRouterModels) => {
|
||||
if (Object.keys(openRouterModels).length > 0) {
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.openRouterModels),
|
||||
JSON.stringify(openRouterModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
|
||||
|
||||
// Update model info in state (this needs to be
|
||||
// done here since we don't want to update state
|
||||
// while settings is open, and we may refresh
|
||||
// models there).
|
||||
const { apiConfiguration } = await this.getState()
|
||||
|
||||
if (apiConfiguration.openRouterModelId) {
|
||||
await this.updateGlobalState(
|
||||
"openRouterModelInfo",
|
||||
|
|
@ -736,15 +668,23 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
}
|
||||
})
|
||||
this.readGlamaModels().then((glamaModels) => {
|
||||
|
||||
this.readModelsFromCache(GlobalFileNames.glamaModels).then((glamaModels) => {
|
||||
if (glamaModels) {
|
||||
this.postMessageToWebview({ type: "glamaModels", glamaModels })
|
||||
}
|
||||
})
|
||||
this.refreshGlamaModels().then(async (glamaModels) => {
|
||||
if (glamaModels) {
|
||||
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
|
||||
getGlamaModels().then(async (glamaModels) => {
|
||||
if (Object.keys(glamaModels).length > 0) {
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.glamaModels),
|
||||
JSON.stringify(glamaModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "glamaModels", glamaModels })
|
||||
|
||||
const { apiConfiguration } = await this.getState()
|
||||
|
||||
if (apiConfiguration.glamaModelId) {
|
||||
await this.updateGlobalState(
|
||||
"glamaModelInfo",
|
||||
|
|
@ -755,14 +695,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
})
|
||||
|
||||
this.readUnboundModels().then((unboundModels) => {
|
||||
this.readModelsFromCache(GlobalFileNames.unboundModels).then((unboundModels) => {
|
||||
if (unboundModels) {
|
||||
this.postMessageToWebview({ type: "unboundModels", unboundModels })
|
||||
}
|
||||
})
|
||||
this.refreshUnboundModels().then(async (unboundModels) => {
|
||||
if (unboundModels) {
|
||||
|
||||
getUnboundModels().then(async (unboundModels) => {
|
||||
if (Object.keys(unboundModels).length > 0) {
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.unboundModels),
|
||||
JSON.stringify(unboundModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "unboundModels", unboundModels })
|
||||
|
||||
const { apiConfiguration } = await this.getState()
|
||||
|
||||
if (apiConfiguration?.unboundModelId) {
|
||||
await this.updateGlobalState(
|
||||
"unboundModelInfo",
|
||||
|
|
@ -773,15 +721,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
})
|
||||
|
||||
this.readRequestyModels().then((requestyModels) => {
|
||||
this.readModelsFromCache(GlobalFileNames.requestyModels).then((requestyModels) => {
|
||||
if (requestyModels) {
|
||||
this.postMessageToWebview({ type: "requestyModels", requestyModels })
|
||||
}
|
||||
})
|
||||
this.refreshRequestyModels().then(async (requestyModels) => {
|
||||
if (requestyModels) {
|
||||
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
|
||||
|
||||
getRequestyModels().then(async (requestyModels) => {
|
||||
if (Object.keys(requestyModels).length > 0) {
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.requestyModels),
|
||||
JSON.stringify(requestyModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "requestyModels", requestyModels })
|
||||
|
||||
const { apiConfiguration } = await this.getState()
|
||||
|
||||
if (apiConfiguration.requestyModelId) {
|
||||
await this.updateGlobalState(
|
||||
"requestyModelInfo",
|
||||
|
|
@ -928,41 +883,82 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
case "resetState":
|
||||
await this.resetState()
|
||||
break
|
||||
case "requestOllamaModels":
|
||||
const ollamaModels = await this.getOllamaModels(message.text)
|
||||
this.postMessageToWebview({ type: "ollamaModels", ollamaModels })
|
||||
break
|
||||
case "requestLmStudioModels":
|
||||
const lmStudioModels = await this.getLmStudioModels(message.text)
|
||||
this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
|
||||
break
|
||||
case "requestVsCodeLmModels":
|
||||
const vsCodeLmModels = await this.getVsCodeLmModels()
|
||||
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
|
||||
case "refreshOpenRouterModels":
|
||||
const openRouterModels = await getOpenRouterModels()
|
||||
|
||||
if (Object.keys(openRouterModels).length > 0) {
|
||||
const cacheDir = await this.ensureCacheDirectoryExists()
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.openRouterModels),
|
||||
JSON.stringify(openRouterModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "openRouterModels", openRouterModels })
|
||||
}
|
||||
|
||||
break
|
||||
case "refreshGlamaModels":
|
||||
await this.refreshGlamaModels()
|
||||
const glamaModels = await getGlamaModels()
|
||||
|
||||
if (Object.keys(glamaModels).length > 0) {
|
||||
const cacheDir = await this.ensureCacheDirectoryExists()
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.glamaModels),
|
||||
JSON.stringify(glamaModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "glamaModels", glamaModels })
|
||||
}
|
||||
|
||||
break
|
||||
case "refreshOpenRouterModels":
|
||||
await this.refreshOpenRouterModels()
|
||||
case "refreshUnboundModels":
|
||||
const unboundModels = await getUnboundModels()
|
||||
|
||||
if (Object.keys(unboundModels).length > 0) {
|
||||
const cacheDir = await this.ensureCacheDirectoryExists()
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.unboundModels),
|
||||
JSON.stringify(unboundModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "unboundModels", unboundModels })
|
||||
}
|
||||
|
||||
break
|
||||
case "refreshRequestyModels":
|
||||
const requestyModels = await getRequestyModels()
|
||||
|
||||
if (Object.keys(requestyModels).length > 0) {
|
||||
const cacheDir = await this.ensureCacheDirectoryExists()
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, GlobalFileNames.requestyModels),
|
||||
JSON.stringify(requestyModels),
|
||||
)
|
||||
await this.postMessageToWebview({ type: "requestyModels", requestyModels })
|
||||
}
|
||||
|
||||
break
|
||||
case "refreshOpenAiModels":
|
||||
if (message?.values?.baseUrl && message?.values?.apiKey) {
|
||||
const openAiModels = await this.getOpenAiModels(
|
||||
const openAiModels = await getOpenAiModels(
|
||||
message?.values?.baseUrl,
|
||||
message?.values?.apiKey,
|
||||
)
|
||||
this.postMessageToWebview({ type: "openAiModels", openAiModels })
|
||||
}
|
||||
|
||||
break
|
||||
case "refreshUnboundModels":
|
||||
await this.refreshUnboundModels()
|
||||
case "requestOllamaModels":
|
||||
const ollamaModels = await getOllamaModels(message.text)
|
||||
// TODO: Cache like we do for OpenRouter, etc?
|
||||
this.postMessageToWebview({ type: "ollamaModels", ollamaModels })
|
||||
break
|
||||
case "refreshRequestyModels":
|
||||
if (message?.values?.apiKey) {
|
||||
const requestyModels = await this.refreshRequestyModels(message?.values?.apiKey)
|
||||
this.postMessageToWebview({ type: "requestyModels", requestyModels: requestyModels })
|
||||
}
|
||||
case "requestLmStudioModels":
|
||||
const lmStudioModels = await getLmStudioModels(message.text)
|
||||
// TODO: Cache like we do for OpenRouter, etc?
|
||||
this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
|
||||
break
|
||||
case "requestVsCodeLmModels":
|
||||
const vsCodeLmModels = await getVsCodeLmModels()
|
||||
// TODO: Cache like we do for OpenRouter, etc?
|
||||
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
|
||||
break
|
||||
case "openImage":
|
||||
openImage(message.text!)
|
||||
|
|
@ -1883,159 +1879,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
return settingsDir
|
||||
}
|
||||
|
||||
// Ollama
|
||||
|
||||
async getOllamaModels(baseUrl?: string) {
|
||||
try {
|
||||
if (!baseUrl) {
|
||||
baseUrl = "http://localhost:11434"
|
||||
}
|
||||
if (!URL.canParse(baseUrl)) {
|
||||
return []
|
||||
}
|
||||
const response = await axios.get(`${baseUrl}/api/tags`)
|
||||
const modelsArray = response.data?.models?.map((model: any) => model.name) || []
|
||||
const models = [...new Set<string>(modelsArray)]
|
||||
return models
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
private async ensureCacheDirectoryExists() {
|
||||
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
|
||||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
return cacheDir
|
||||
}
|
||||
|
||||
// LM Studio
|
||||
private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | undefined> {
|
||||
const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
|
||||
async getLmStudioModels(baseUrl?: string) {
|
||||
try {
|
||||
if (!baseUrl) {
|
||||
baseUrl = "http://localhost:1234"
|
||||
}
|
||||
if (!URL.canParse(baseUrl)) {
|
||||
return []
|
||||
}
|
||||
const response = await axios.get(`${baseUrl}/v1/models`)
|
||||
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
|
||||
const models = [...new Set<string>(modelsArray)]
|
||||
return models
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// VSCode LM API
|
||||
private async getVsCodeLmModels() {
|
||||
try {
|
||||
const models = await vscode.lm.selectChatModels({})
|
||||
return models || []
|
||||
} catch (error) {
|
||||
this.outputChannel.appendLine(
|
||||
`Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAi
|
||||
|
||||
async getOpenAiModels(baseUrl?: string, apiKey?: string) {
|
||||
try {
|
||||
if (!baseUrl) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!URL.canParse(baseUrl)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const config: Record<string, any> = {}
|
||||
if (apiKey) {
|
||||
config["headers"] = { Authorization: `Bearer ${apiKey}` }
|
||||
}
|
||||
|
||||
const response = await axios.get(`${baseUrl}/models`, config)
|
||||
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
|
||||
const models = [...new Set<string>(modelsArray)]
|
||||
return models
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Requesty
|
||||
async readRequestyModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
const requestyModelsFilePath = path.join(
|
||||
await this.ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.requestyModels,
|
||||
)
|
||||
const fileExists = await fileExistsAtPath(requestyModelsFilePath)
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(requestyModelsFilePath, "utf8")
|
||||
const fileContents = await fs.readFile(filePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async refreshRequestyModels(apiKey?: string) {
|
||||
const requestyModelsFilePath = path.join(
|
||||
await this.ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.requestyModels,
|
||||
)
|
||||
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const config: Record<string, any> = {}
|
||||
if (!apiKey) {
|
||||
apiKey = (await this.getSecret("requestyApiKey")) as string
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
this.outputChannel.appendLine("No Requesty API key found")
|
||||
return models
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
config["headers"] = { Authorization: `Bearer ${apiKey}` }
|
||||
}
|
||||
|
||||
const response = await axios.get("https://router.requesty.ai/v1/models", config)
|
||||
|
||||
if (response.data) {
|
||||
const rawModels = response.data.data
|
||||
const parsePrice = (price: any) => {
|
||||
if (price) {
|
||||
return parseFloat(price) * 1_000_000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
for (const rawModel of rawModels) {
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: rawModel.max_output_tokens,
|
||||
contextWindow: rawModel.context_window,
|
||||
supportsImages: rawModel.support_image,
|
||||
supportsComputerUse: rawModel.support_computer_use,
|
||||
supportsPromptCache: rawModel.supports_caching,
|
||||
inputPrice: parsePrice(rawModel.input_price),
|
||||
outputPrice: parsePrice(rawModel.output_price),
|
||||
description: rawModel.description,
|
||||
cacheWritesPrice: parsePrice(rawModel.caching_price),
|
||||
cacheReadsPrice: parsePrice(rawModel.cached_price),
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
} else {
|
||||
this.outputChannel.appendLine("Invalid response from Requesty API")
|
||||
}
|
||||
await fs.writeFile(requestyModelsFilePath, JSON.stringify(models))
|
||||
} catch (error) {
|
||||
this.outputChannel.appendLine(
|
||||
`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
}
|
||||
|
||||
await this.postMessageToWebview({ type: "requestyModels", requestyModels: models })
|
||||
return models
|
||||
}
|
||||
|
||||
// OpenRouter
|
||||
|
||||
async handleOpenRouterCallback(code: string) {
|
||||
|
|
@ -2064,11 +1925,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
|
||||
}
|
||||
|
||||
private async ensureCacheDirectoryExists(): Promise<string> {
|
||||
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
|
||||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
return cacheDir
|
||||
}
|
||||
// Glama
|
||||
|
||||
async handleGlamaCallback(code: string) {
|
||||
let apiKey: string
|
||||
|
|
@ -2099,225 +1956,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
|
||||
}
|
||||
|
||||
private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | undefined> {
|
||||
const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(filePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async readGlamaModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
return this.readModelsFromCache(GlobalFileNames.glamaModels)
|
||||
}
|
||||
|
||||
async refreshGlamaModels() {
|
||||
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
|
||||
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
|
||||
/*
|
||||
{
|
||||
"added": "2024-12-24T15:12:49.324Z",
|
||||
"capabilities": [
|
||||
"adjustable_safety_settings",
|
||||
"caching",
|
||||
"code_execution",
|
||||
"function_calling",
|
||||
"json_mode",
|
||||
"json_schema",
|
||||
"system_instructions",
|
||||
"tuning",
|
||||
"input:audio",
|
||||
"input:image",
|
||||
"input:text",
|
||||
"input:video",
|
||||
"output:text"
|
||||
],
|
||||
"id": "google-vertex/gemini-1.5-flash-002",
|
||||
"maxTokensInput": 1048576,
|
||||
"maxTokensOutput": 8192,
|
||||
"pricePerToken": {
|
||||
"cacheRead": null,
|
||||
"cacheWrite": null,
|
||||
"input": "0.000000075",
|
||||
"output": "0.0000003"
|
||||
}
|
||||
}
|
||||
*/
|
||||
if (response.data) {
|
||||
const rawModels = response.data
|
||||
const parsePrice = (price: any) => {
|
||||
if (price) {
|
||||
return parseFloat(price) * 1_000_000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
for (const rawModel of rawModels) {
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: rawModel.maxTokensOutput,
|
||||
contextWindow: rawModel.maxTokensInput,
|
||||
supportsImages: rawModel.capabilities?.includes("input:image"),
|
||||
supportsComputerUse: rawModel.capabilities?.includes("computer_use"),
|
||||
supportsPromptCache: rawModel.capabilities?.includes("caching"),
|
||||
inputPrice: parsePrice(rawModel.pricePerToken?.input),
|
||||
outputPrice: parsePrice(rawModel.pricePerToken?.output),
|
||||
description: undefined,
|
||||
cacheWritesPrice: parsePrice(rawModel.pricePerToken?.cacheWrite),
|
||||
cacheReadsPrice: parsePrice(rawModel.pricePerToken?.cacheRead),
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
} else {
|
||||
this.outputChannel.appendLine("Invalid response from Glama API")
|
||||
}
|
||||
await fs.writeFile(glamaModelsFilePath, JSON.stringify(models))
|
||||
} catch (error) {
|
||||
this.outputChannel.appendLine(
|
||||
`Error fetching Glama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
}
|
||||
|
||||
await this.postMessageToWebview({ type: "glamaModels", glamaModels: models })
|
||||
return models
|
||||
}
|
||||
|
||||
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
return this.readModelsFromCache(GlobalFileNames.openRouterModels)
|
||||
}
|
||||
|
||||
async refreshOpenRouterModels() {
|
||||
const openRouterModelsFilePath = path.join(
|
||||
await this.ensureCacheDirectoryExists(),
|
||||
GlobalFileNames.openRouterModels,
|
||||
)
|
||||
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
|
||||
try {
|
||||
const response = await axios.get("https://openrouter.ai/api/v1/models")
|
||||
|
||||
if (response.data?.data) {
|
||||
const rawModels = response.data.data
|
||||
const parsePrice = (price: any) => {
|
||||
if (price) {
|
||||
return parseFloat(price) * 1_000_000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const rawModel of rawModels) {
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: rawModel.top_provider?.max_completion_tokens,
|
||||
contextWindow: rawModel.context_length,
|
||||
supportsImages: rawModel.architecture?.modality?.includes("image"),
|
||||
supportsPromptCache: false,
|
||||
inputPrice: parsePrice(rawModel.pricing?.prompt),
|
||||
outputPrice: parsePrice(rawModel.pricing?.completion),
|
||||
description: rawModel.description,
|
||||
}
|
||||
|
||||
switch (rawModel.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
// NOTE: this needs to be synced with api.ts/openrouter default model info.
|
||||
modelInfo.supportsComputerUse = true
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
break
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 3.75
|
||||
modelInfo.cacheReadsPrice = 0.3
|
||||
break
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3.5-haiku":
|
||||
case "anthropic/claude-3.5-haiku:beta":
|
||||
case "anthropic/claude-3.5-haiku-20241022":
|
||||
case "anthropic/claude-3.5-haiku-20241022:beta":
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 1.25
|
||||
modelInfo.cacheReadsPrice = 0.1
|
||||
break
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 18.75
|
||||
modelInfo.cacheReadsPrice = 1.5
|
||||
break
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 0.3
|
||||
modelInfo.cacheReadsPrice = 0.03
|
||||
break
|
||||
}
|
||||
|
||||
models[rawModel.id] = modelInfo
|
||||
}
|
||||
} else {
|
||||
this.outputChannel.appendLine("Invalid response from OpenRouter API")
|
||||
}
|
||||
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
|
||||
} catch (error) {
|
||||
this.outputChannel.appendLine(
|
||||
`Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
}
|
||||
|
||||
await this.postMessageToWebview({ type: "openRouterModels", openRouterModels: models })
|
||||
return models
|
||||
}
|
||||
|
||||
async readUnboundModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
return this.readModelsFromCache(GlobalFileNames.unboundModels)
|
||||
}
|
||||
|
||||
async refreshUnboundModels() {
|
||||
const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
|
||||
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const response = await axios.get("https://api.getunbound.ai/models")
|
||||
|
||||
if (response.data) {
|
||||
const rawModels: Record<string, any> = response.data
|
||||
for (const [modelId, model] of Object.entries(rawModels)) {
|
||||
models[modelId] = {
|
||||
maxTokens: model?.maxTokens ? parseInt(model.maxTokens) : undefined,
|
||||
contextWindow: model?.contextWindow ? parseInt(model.contextWindow) : 0,
|
||||
supportsImages: model?.supportsImages ?? false,
|
||||
supportsPromptCache: model?.supportsPromptCaching ?? false,
|
||||
supportsComputerUse: model?.supportsComputerUse ?? false,
|
||||
inputPrice: model?.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined,
|
||||
outputPrice: model?.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined,
|
||||
cacheWritesPrice: model?.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined,
|
||||
cacheReadsPrice: model?.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
await fs.writeFile(unboundModelsFilePath, JSON.stringify(models))
|
||||
} catch (error) {
|
||||
this.outputChannel.appendLine(
|
||||
`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
}
|
||||
|
||||
await this.postMessageToWebview({ type: "unboundModels", unboundModels: models })
|
||||
return models
|
||||
}
|
||||
|
||||
// Task history
|
||||
|
||||
async getTaskWithId(id: string): Promise<{
|
||||
|
|
@ -2460,6 +2098,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
|
||||
const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get<string[]>("allowedCommands") || []
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || ""
|
||||
|
||||
return {
|
||||
version: this.context.extension?.packageJSON?.version ?? "",
|
||||
apiConfiguration,
|
||||
|
|
@ -2506,6 +2146,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
experiments: experiments ?? experimentDefault,
|
||||
mcpServers: this.mcpHub?.getAllServers() ?? [],
|
||||
maxOpenTabsContext: maxOpenTabsContext ?? 20,
|
||||
cwd: cwd,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2879,26 +2520,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
return await this.context.globalState.get(key)
|
||||
}
|
||||
|
||||
// workspace
|
||||
|
||||
private async updateWorkspaceState(key: string, value: any) {
|
||||
await this.context.workspaceState.update(key, value)
|
||||
}
|
||||
|
||||
private async getWorkspaceState(key: string) {
|
||||
return await this.context.workspaceState.get(key)
|
||||
}
|
||||
|
||||
// private async clearState() {
|
||||
// this.context.workspaceState.keys().forEach((key) => {
|
||||
// this.context.workspaceState.update(key, undefined)
|
||||
// })
|
||||
// this.context.globalState.keys().forEach((key) => {
|
||||
// this.context.globalState.update(key, undefined)
|
||||
// })
|
||||
// this.context.secrets.delete("apiKey")
|
||||
// }
|
||||
|
||||
// secrets
|
||||
|
||||
public async storeSecret(key: SecretKey, value?: string) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import * as fs from "fs/promises"
|
|||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
|
||||
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { GlobalFileNames } from "../../shared/globalFileNames"
|
||||
import {
|
||||
McpResource,
|
||||
McpResourceResponse,
|
||||
|
|
|
|||
|
|
@ -27,10 +27,11 @@ export interface ExtensionMessage {
|
|||
| "workspaceUpdated"
|
||||
| "invoke"
|
||||
| "partialMessage"
|
||||
| "glamaModels"
|
||||
| "openRouterModels"
|
||||
| "openAiModels"
|
||||
| "glamaModels"
|
||||
| "unboundModels"
|
||||
| "requestyModels"
|
||||
| "openAiModels"
|
||||
| "mcpServers"
|
||||
| "enhancedPrompt"
|
||||
| "commitSearchResults"
|
||||
|
|
@ -43,8 +44,6 @@ export interface ExtensionMessage {
|
|||
| "autoApprovalEnabled"
|
||||
| "updateCustomMode"
|
||||
| "deleteCustomMode"
|
||||
| "unboundModels"
|
||||
| "refreshUnboundModels"
|
||||
| "currentCheckpointUpdated"
|
||||
text?: string
|
||||
action?:
|
||||
|
|
@ -67,11 +66,11 @@ export interface ExtensionMessage {
|
|||
path?: string
|
||||
}>
|
||||
partialMessage?: ClineMessage
|
||||
glamaModels?: Record<string, ModelInfo>
|
||||
requestyModels?: Record<string, ModelInfo>
|
||||
openRouterModels?: Record<string, ModelInfo>
|
||||
openAiModels?: string[]
|
||||
glamaModels?: Record<string, ModelInfo>
|
||||
unboundModels?: Record<string, ModelInfo>
|
||||
requestyModels?: Record<string, ModelInfo>
|
||||
openAiModels?: string[]
|
||||
mcpServers?: McpServer[]
|
||||
commits?: GitCommit[]
|
||||
listApiConfig?: ApiConfigMeta[]
|
||||
|
|
@ -129,6 +128,7 @@ export interface ExtensionState {
|
|||
customModes: ModeConfig[]
|
||||
toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled)
|
||||
maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
|
||||
cwd?: string // Current working directory
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
|
|
|
|||
|
|
@ -40,11 +40,11 @@ export interface WebviewMessage {
|
|||
| "openFile"
|
||||
| "openMention"
|
||||
| "cancelTask"
|
||||
| "refreshGlamaModels"
|
||||
| "refreshOpenRouterModels"
|
||||
| "refreshOpenAiModels"
|
||||
| "refreshGlamaModels"
|
||||
| "refreshUnboundModels"
|
||||
| "refreshRequestyModels"
|
||||
| "refreshOpenAiModels"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysAllowMcp"
|
||||
| "alwaysAllowModeSwitch"
|
||||
|
|
@ -71,7 +71,6 @@ export interface WebviewMessage {
|
|||
| "mcpEnabled"
|
||||
| "enableMcpServerCreation"
|
||||
| "searchCommits"
|
||||
| "refreshGlamaModels"
|
||||
| "alwaysApproveResubmit"
|
||||
| "requestDelaySeconds"
|
||||
| "rateLimitSeconds"
|
||||
|
|
|
|||
|
|
@ -89,6 +89,13 @@ export interface ModelInfo {
|
|||
cacheReadsPrice?: number
|
||||
description?: string
|
||||
reasoningEffort?: "low" | "medium" | "high"
|
||||
thinking?: boolean
|
||||
}
|
||||
|
||||
export const THINKING_BUDGET = {
|
||||
step: 1024,
|
||||
min: 1024,
|
||||
default: 8 * 1024,
|
||||
}
|
||||
|
||||
// Anthropic
|
||||
|
|
@ -96,8 +103,8 @@ export interface ModelInfo {
|
|||
export type AnthropicModelId = keyof typeof anthropicModels
|
||||
export const anthropicDefaultModelId: AnthropicModelId = "claude-3-7-sonnet-20250219"
|
||||
export const anthropicModels = {
|
||||
"claude-3-7-sonnet-20250219": {
|
||||
maxTokens: 64_000,
|
||||
"claude-3-7-sonnet-20250219:thinking": {
|
||||
maxTokens: 16384,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
|
|
@ -106,6 +113,19 @@ export const anthropicModels = {
|
|||
outputPrice: 15.0, // $15 per million output tokens
|
||||
cacheWritesPrice: 3.75, // $3.75 per million tokens
|
||||
cacheReadsPrice: 0.3, // $0.30 per million tokens
|
||||
thinking: true,
|
||||
},
|
||||
"claude-3-7-sonnet-20250219": {
|
||||
maxTokens: 16384,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0, // $3 per million input tokens
|
||||
outputPrice: 15.0, // $15 per million output tokens
|
||||
cacheWritesPrice: 3.75, // $3.75 per million tokens
|
||||
cacheReadsPrice: 0.3, // $0.30 per million tokens
|
||||
thinking: false,
|
||||
},
|
||||
"claude-3-5-sonnet-20241022": {
|
||||
maxTokens: 8192,
|
||||
|
|
|
|||
9
src/shared/globalFileNames.ts
Normal file
9
src/shared/globalFileNames.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
glamaModels: "glama_models.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
requestyModels: "requesty_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
unboundModels: "unbound_models.json",
|
||||
}
|
||||
85
src/shared/globalState.ts
Normal file
85
src/shared/globalState.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
export type SecretKey =
|
||||
| "apiKey"
|
||||
| "glamaApiKey"
|
||||
| "openRouterApiKey"
|
||||
| "awsAccessKey"
|
||||
| "awsSecretKey"
|
||||
| "awsSessionToken"
|
||||
| "openAiApiKey"
|
||||
| "geminiApiKey"
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
| "mistralApiKey"
|
||||
| "unboundApiKey"
|
||||
| "requestyApiKey"
|
||||
|
||||
export type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
| "glamaModelId"
|
||||
| "glamaModelInfo"
|
||||
| "awsRegion"
|
||||
| "awsUseCrossRegionInference"
|
||||
| "awsProfile"
|
||||
| "awsUseProfile"
|
||||
| "vertexProjectId"
|
||||
| "vertexRegion"
|
||||
| "lastShownAnnouncementId"
|
||||
| "customInstructions"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "alwaysAllowWrite"
|
||||
| "alwaysAllowExecute"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysAllowMcp"
|
||||
| "alwaysAllowModeSwitch"
|
||||
| "taskHistory"
|
||||
| "openAiBaseUrl"
|
||||
| "openAiModelId"
|
||||
| "openAiCustomModelInfo"
|
||||
| "openAiUseAzure"
|
||||
| "ollamaModelId"
|
||||
| "ollamaBaseUrl"
|
||||
| "lmStudioModelId"
|
||||
| "lmStudioBaseUrl"
|
||||
| "anthropicBaseUrl"
|
||||
| "anthropicThinking"
|
||||
| "azureApiVersion"
|
||||
| "openAiStreamingEnabled"
|
||||
| "openRouterModelId"
|
||||
| "openRouterModelInfo"
|
||||
| "openRouterBaseUrl"
|
||||
| "openRouterUseMiddleOutTransform"
|
||||
| "allowedCommands"
|
||||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "diffEnabled"
|
||||
| "checkpointsEnabled"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "fuzzyMatchThreshold"
|
||||
| "preferredLanguage" // Language setting for Cline's communication
|
||||
| "writeDelayMs"
|
||||
| "terminalOutputLineLimit"
|
||||
| "mcpEnabled"
|
||||
| "enableMcpServerCreation"
|
||||
| "alwaysApproveResubmit"
|
||||
| "requestDelaySeconds"
|
||||
| "rateLimitSeconds"
|
||||
| "currentApiConfigName"
|
||||
| "listApiConfigMeta"
|
||||
| "vsCodeLmModelSelector"
|
||||
| "mode"
|
||||
| "modeApiConfigs"
|
||||
| "customModePrompts"
|
||||
| "customSupportPrompts"
|
||||
| "enhancementApiConfigId"
|
||||
| "experiments" // Map of experiment IDs to their enabled state
|
||||
| "autoApprovalEnabled"
|
||||
| "customModes" // Array of custom modes
|
||||
| "unboundModelId"
|
||||
| "requestyModelId"
|
||||
| "requestyModelInfo"
|
||||
| "unboundModelInfo"
|
||||
| "modelTemperature"
|
||||
| "mistralCodestralUrl"
|
||||
| "maxOpenTabsContext"
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { arePathsEqual, getReadablePath } from "../path"
|
||||
import * as path from "path"
|
||||
// npx jest src/utils/__tests__/path.test.ts
|
||||
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
import { arePathsEqual, getReadablePath } from "../path"
|
||||
|
||||
describe("Path Utilities", () => {
|
||||
const originalPlatform = process.platform
|
||||
|
|
@ -92,22 +95,24 @@ describe("Path Utilities", () => {
|
|||
describe("getReadablePath", () => {
|
||||
const homeDir = os.homedir()
|
||||
const desktop = path.join(homeDir, "Desktop")
|
||||
const cwd = process.platform === "win32" ? "C:\\Users\\test\\project" : "/Users/test/project"
|
||||
|
||||
it("should return basename when path equals cwd", () => {
|
||||
const cwd = "/Users/test/project"
|
||||
expect(getReadablePath(cwd, cwd)).toBe("project")
|
||||
})
|
||||
|
||||
it("should return relative path when inside cwd", () => {
|
||||
const cwd = "/Users/test/project"
|
||||
const filePath = "/Users/test/project/src/file.txt"
|
||||
const filePath =
|
||||
process.platform === "win32"
|
||||
? "C:\\Users\\test\\project\\src\\file.txt"
|
||||
: "/Users/test/project/src/file.txt"
|
||||
expect(getReadablePath(cwd, filePath)).toBe("src/file.txt")
|
||||
})
|
||||
|
||||
it("should return absolute path when outside cwd", () => {
|
||||
const cwd = "/Users/test/project"
|
||||
const filePath = "/Users/test/other/file.txt"
|
||||
expect(getReadablePath(cwd, filePath)).toBe("/Users/test/other/file.txt")
|
||||
const filePath =
|
||||
process.platform === "win32" ? "C:\\Users\\test\\other\\file.txt" : "/Users/test/other/file.txt"
|
||||
expect(getReadablePath(cwd, filePath)).toBe(filePath.toPosix())
|
||||
})
|
||||
|
||||
it("should handle Desktop as cwd", () => {
|
||||
|
|
@ -116,19 +121,20 @@ describe("Path Utilities", () => {
|
|||
})
|
||||
|
||||
it("should handle undefined relative path", () => {
|
||||
const cwd = "/Users/test/project"
|
||||
expect(getReadablePath(cwd)).toBe("project")
|
||||
})
|
||||
|
||||
it("should handle parent directory traversal", () => {
|
||||
const cwd = "/Users/test/project"
|
||||
const filePath = "../../other/file.txt"
|
||||
expect(getReadablePath(cwd, filePath)).toBe("/Users/other/file.txt")
|
||||
const filePath =
|
||||
process.platform === "win32" ? "C:\\Users\\test\\other\\file.txt" : "/Users/test/other/file.txt"
|
||||
expect(getReadablePath(cwd, filePath)).toBe(filePath.toPosix())
|
||||
})
|
||||
|
||||
it("should normalize paths with redundant segments", () => {
|
||||
const cwd = "/Users/test/project"
|
||||
const filePath = "/Users/test/project/./src/../src/file.txt"
|
||||
const filePath =
|
||||
process.platform === "win32"
|
||||
? "C:\\Users\\test\\project\\src\\file.txt"
|
||||
: "/Users/test/project/./src/../src/file.txt"
|
||||
expect(getReadablePath(cwd, filePath)).toBe("src/file.txt")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,3 +22,5 @@ export function calculateApiCost(
|
|||
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
|
||||
return totalCost
|
||||
}
|
||||
|
||||
export const parseApiPrice = (price: any) => (price ? parseFloat(price) * 1_000_000 : undefined)
|
||||
|
|
|
|||
2
webview-ui/package-lock.json
generated
2
webview-ui/package-lock.json
generated
|
|
@ -3674,6 +3674,7 @@
|
|||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
|
||||
"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
|
|
@ -4719,6 +4720,7 @@
|
|||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
|
|
|
|||
6
webview-ui/src/__mocks__/lucide-react.ts
Normal file
6
webview-ui/src/__mocks__/lucide-react.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import React from "react"
|
||||
|
||||
export const Check = () => React.createElement("div")
|
||||
export const ChevronsUpDown = () => React.createElement("div")
|
||||
export const Loader = () => React.createElement("div")
|
||||
export const X = () => React.createElement("div")
|
||||
|
|
@ -8,6 +8,9 @@ export const Dropdown = ({ children, value, onChange }: any) =>
|
|||
|
||||
export const Pane = ({ children }: any) => React.createElement("div", { "data-testid": "mock-pane" }, children)
|
||||
|
||||
export const Button = ({ children, ...props }: any) =>
|
||||
React.createElement("div", { "data-testid": "mock-button", ...props }, children)
|
||||
|
||||
export type DropdownOption = {
|
||||
label: string
|
||||
value: string
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { vscode } from "../../utils/vscode"
|
|||
import { WebviewMessage } from "../../../../src/shared/WebviewMessage"
|
||||
import { Mode, getAllModes } from "../../../../src/shared/modes"
|
||||
import { CaretIcon } from "../common/CaretIcon"
|
||||
import { convertToMentionPath } from "../../utils/path-mentions"
|
||||
|
||||
interface ChatTextAreaProps {
|
||||
inputValue: string
|
||||
|
|
@ -50,7 +51,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
},
|
||||
ref,
|
||||
) => {
|
||||
const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState()
|
||||
const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState()
|
||||
const [gitCommits, setGitCommits] = useState<any[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
|
|
@ -589,18 +590,24 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
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)
|
||||
// Convert the path to a mention-friendly format
|
||||
const mentionText = convertToMentionPath(text, cwd)
|
||||
|
||||
const newValue =
|
||||
inputValue.slice(0, cursorPosition) + mentionText + " " + inputValue.slice(cursorPosition)
|
||||
setInputValue(newValue)
|
||||
const newCursorPosition = cursorPosition + text.length
|
||||
const newCursorPosition = cursorPosition + mentionText.length + 1
|
||||
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<string | null>((resolve) => {
|
||||
|
|
|
|||
|
|
@ -880,9 +880,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
|||
const placeholderText = useMemo(() => {
|
||||
const baseText = task ? "Type a message..." : "Type your task here..."
|
||||
const contextText = "(@ to add context, / to switch modes"
|
||||
const imageText = shouldDisableImages ? "" : ", hold shift to drag in images"
|
||||
const helpText = imageText ? `\n${contextText}${imageText})` : `\n${contextText})`
|
||||
return baseText + helpText
|
||||
const imageText = shouldDisableImages ? "hold shift to drag in files" : ", hold shift to drag in files/images"
|
||||
return baseText + `\n${contextText}${imageText})`
|
||||
}, [task, shouldDisableImages])
|
||||
|
||||
const itemContent = useCallback(
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ const TaskActions = ({ item }: { item: HistoryItem | undefined }) => (
|
|||
<Button variant="ghost" size="sm" onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
|
||||
<span className="codicon codicon-cloud-download" />
|
||||
</Button>
|
||||
{item?.size && (
|
||||
{!!item?.size && item.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
|
|||
16
webview-ui/src/components/settings/ApiErrorMessage.tsx
Normal file
16
webview-ui/src/components/settings/ApiErrorMessage.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from "react"
|
||||
|
||||
interface ApiErrorMessageProps {
|
||||
errorMessage: string | undefined
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ApiErrorMessage = ({ errorMessage, children }: ApiErrorMessageProps) => (
|
||||
<div className="flex flex-col gap-2 text-vscode-errorForeground text-sm">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className="codicon codicon-close" />
|
||||
<div>{errorMessage}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import { memo, useCallback, useMemo, useState } from "react"
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useDebounce, useEvent } from "react-use"
|
||||
import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
|
||||
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import * as vscodemodels from "vscode"
|
||||
|
||||
import { Slider } from "@/components/ui"
|
||||
|
||||
import {
|
||||
ApiConfiguration,
|
||||
ModelInfo,
|
||||
|
|
@ -38,45 +36,73 @@ import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
|||
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import VSCodeButtonLink from "../common/VSCodeButtonLink"
|
||||
import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
|
||||
import OpenAiModelPicker from "./OpenAiModelPicker"
|
||||
import { GlamaModelPicker } from "./GlamaModelPicker"
|
||||
import { UnboundModelPicker } from "./UnboundModelPicker"
|
||||
import { ModelInfoView } from "./ModelInfoView"
|
||||
import { DROPDOWN_Z_INDEX } from "./styles"
|
||||
import { RequestyModelPicker } from "./RequestyModelPicker"
|
||||
import { ModelPicker } from "./ModelPicker"
|
||||
import { TemperatureControl } from "./TemperatureControl"
|
||||
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
|
||||
import { ApiErrorMessage } from "./ApiErrorMessage"
|
||||
import { ThinkingBudget } from "./ThinkingBudget"
|
||||
|
||||
const modelsByProvider: Record<string, Record<string, ModelInfo>> = {
|
||||
anthropic: anthropicModels,
|
||||
bedrock: bedrockModels,
|
||||
vertex: vertexModels,
|
||||
gemini: geminiModels,
|
||||
"openai-native": openAiNativeModels,
|
||||
deepseek: deepSeekModels,
|
||||
mistral: mistralModels,
|
||||
}
|
||||
|
||||
interface ApiOptionsProps {
|
||||
uriScheme: string | undefined
|
||||
apiConfiguration: ApiConfiguration | undefined
|
||||
apiConfiguration: ApiConfiguration
|
||||
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
|
||||
apiErrorMessage?: string
|
||||
modelIdErrorMessage?: string
|
||||
fromWelcomeView?: boolean
|
||||
errorMessage: string | undefined
|
||||
setErrorMessage: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
}
|
||||
|
||||
const ApiOptions = ({
|
||||
uriScheme,
|
||||
apiConfiguration,
|
||||
setApiConfigurationField,
|
||||
apiErrorMessage,
|
||||
modelIdErrorMessage,
|
||||
fromWelcomeView,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
}: ApiOptionsProps) => {
|
||||
const [ollamaModels, setOllamaModels] = useState<string[]>([])
|
||||
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
|
||||
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
|
||||
|
||||
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
|
||||
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
|
||||
})
|
||||
|
||||
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
|
||||
[glamaDefaultModelId]: glamaDefaultModelInfo,
|
||||
})
|
||||
|
||||
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
|
||||
[unboundDefaultModelId]: unboundDefaultModelInfo,
|
||||
})
|
||||
|
||||
const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
|
||||
[requestyDefaultModelId]: requestyDefaultModelInfo,
|
||||
})
|
||||
|
||||
const [openAiModels, setOpenAiModels] = useState<Record<string, ModelInfo> | null>(null)
|
||||
|
||||
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
|
||||
const [anthropicThinkingBudget, setAnthropicThinkingBudget] = useState(apiConfiguration?.anthropicThinking)
|
||||
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
|
||||
const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
|
||||
const inputEventTransform = <E,>(event: E) => (event as { target: HTMLInputElement })?.target?.value as any
|
||||
const noTransform = <T,>(value: T) => value
|
||||
const inputEventTransform = <E,>(event: E) => (event as { target: HTMLInputElement })?.target?.value as any
|
||||
const dropdownEventTransform = <T,>(event: DropdownOption | string | undefined) =>
|
||||
(typeof event == "string" ? event : event?.value) as T
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
<K extends keyof ApiConfiguration, E>(
|
||||
field: K,
|
||||
|
|
@ -88,15 +114,32 @@ const ApiOptions = ({
|
|||
[setApiConfigurationField],
|
||||
)
|
||||
|
||||
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(
|
||||
() => normalizeApiConfiguration(apiConfiguration),
|
||||
[apiConfiguration],
|
||||
)
|
||||
|
||||
// Pull ollama/lmstudio models
|
||||
// Debounced model updates, only executed 250ms after the user stops typing
|
||||
// Debounced refresh model updates, only executed 250ms after the user
|
||||
// stops typing.
|
||||
useDebounce(
|
||||
() => {
|
||||
if (selectedProvider === "ollama") {
|
||||
if (selectedProvider === "openrouter") {
|
||||
vscode.postMessage({ type: "refreshOpenRouterModels" })
|
||||
} else if (selectedProvider === "glama") {
|
||||
vscode.postMessage({ type: "refreshGlamaModels" })
|
||||
} else if (selectedProvider === "unbound") {
|
||||
vscode.postMessage({ type: "refreshUnboundModels" })
|
||||
} else if (selectedProvider === "requesty") {
|
||||
vscode.postMessage({
|
||||
type: "refreshRequestyModels",
|
||||
values: { apiKey: apiConfiguration?.requestyApiKey },
|
||||
})
|
||||
} else if (selectedProvider === "openai") {
|
||||
vscode.postMessage({
|
||||
type: "refreshOpenAiModels",
|
||||
values: { baseUrl: apiConfiguration?.openAiBaseUrl, apiKey: apiConfiguration?.openAiApiKey },
|
||||
})
|
||||
} else if (selectedProvider === "ollama") {
|
||||
vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl })
|
||||
} else if (selectedProvider === "lmstudio") {
|
||||
vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl })
|
||||
|
|
@ -105,49 +148,95 @@ const ApiOptions = ({
|
|||
}
|
||||
},
|
||||
250,
|
||||
[selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl],
|
||||
[
|
||||
selectedProvider,
|
||||
apiConfiguration?.requestyApiKey,
|
||||
apiConfiguration?.openAiBaseUrl,
|
||||
apiConfiguration?.openAiApiKey,
|
||||
apiConfiguration?.ollamaBaseUrl,
|
||||
apiConfiguration?.lmStudioBaseUrl,
|
||||
],
|
||||
)
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
|
||||
useEffect(() => {
|
||||
const apiValidationResult =
|
||||
validateApiConfiguration(apiConfiguration) ||
|
||||
validateModelId(apiConfiguration, glamaModels, openRouterModels, unboundModels, requestyModels)
|
||||
|
||||
setErrorMessage(apiValidationResult)
|
||||
}, [apiConfiguration, glamaModels, openRouterModels, setErrorMessage, unboundModels, requestyModels])
|
||||
|
||||
const onMessage = useCallback((event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
if (message.type === "ollamaModels" && Array.isArray(message.ollamaModels)) {
|
||||
const newModels = message.ollamaModels
|
||||
setOllamaModels(newModels)
|
||||
} else if (message.type === "lmStudioModels" && Array.isArray(message.lmStudioModels)) {
|
||||
const newModels = message.lmStudioModels
|
||||
setLmStudioModels(newModels)
|
||||
} else if (message.type === "vsCodeLmModels" && Array.isArray(message.vsCodeLmModels)) {
|
||||
const newModels = message.vsCodeLmModels
|
||||
setVsCodeLmModels(newModels)
|
||||
|
||||
switch (message.type) {
|
||||
case "openRouterModels": {
|
||||
const updatedModels = message.openRouterModels ?? {}
|
||||
setOpenRouterModels({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, ...updatedModels })
|
||||
break
|
||||
}
|
||||
case "glamaModels": {
|
||||
const updatedModels = message.glamaModels ?? {}
|
||||
setGlamaModels({ [glamaDefaultModelId]: glamaDefaultModelInfo, ...updatedModels })
|
||||
break
|
||||
}
|
||||
case "unboundModels": {
|
||||
const updatedModels = message.unboundModels ?? {}
|
||||
setUnboundModels({ [unboundDefaultModelId]: unboundDefaultModelInfo, ...updatedModels })
|
||||
break
|
||||
}
|
||||
case "requestyModels": {
|
||||
const updatedModels = message.requestyModels ?? {}
|
||||
setRequestyModels({ [requestyDefaultModelId]: requestyDefaultModelInfo, ...updatedModels })
|
||||
break
|
||||
}
|
||||
case "openAiModels": {
|
||||
const updatedModels = message.openAiModels ?? []
|
||||
setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults])))
|
||||
break
|
||||
}
|
||||
case "ollamaModels":
|
||||
{
|
||||
const newModels = message.ollamaModels ?? []
|
||||
setOllamaModels(newModels)
|
||||
}
|
||||
break
|
||||
case "lmStudioModels":
|
||||
{
|
||||
const newModels = message.lmStudioModels ?? []
|
||||
setLmStudioModels(newModels)
|
||||
}
|
||||
break
|
||||
case "vsCodeLmModels":
|
||||
{
|
||||
const newModels = message.vsCodeLmModels ?? []
|
||||
setVsCodeLmModels(newModels)
|
||||
}
|
||||
break
|
||||
}
|
||||
}, [])
|
||||
useEvent("message", handleMessage)
|
||||
|
||||
const createDropdown = (models: Record<string, ModelInfo>) => {
|
||||
const options: DropdownOption[] = [
|
||||
{ value: "", label: "Select a model..." },
|
||||
...Object.keys(models).map((modelId) => ({
|
||||
value: modelId,
|
||||
label: modelId,
|
||||
})),
|
||||
]
|
||||
return (
|
||||
<Dropdown
|
||||
id="model-id"
|
||||
value={selectedModelId}
|
||||
onChange={(value) => {
|
||||
setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
options={options}
|
||||
/>
|
||||
)
|
||||
}
|
||||
useEvent("message", onMessage)
|
||||
|
||||
const selectedProviderModelOptions: DropdownOption[] = useMemo(
|
||||
() =>
|
||||
modelsByProvider[selectedProvider]
|
||||
? [
|
||||
{ value: "", label: "Select a model..." },
|
||||
...Object.keys(modelsByProvider[selectedProvider]).map((modelId) => ({
|
||||
value: modelId,
|
||||
label: modelId,
|
||||
})),
|
||||
]
|
||||
: [],
|
||||
[selectedProvider],
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="api-provider">
|
||||
<span style={{ fontWeight: 500 }}>API Provider</span>
|
||||
<label htmlFor="api-provider" className="font-medium">
|
||||
API Provider
|
||||
</label>
|
||||
<Dropdown
|
||||
id="api-provider"
|
||||
|
|
@ -174,6 +263,8 @@ const ApiOptions = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && <ApiErrorMessage errorMessage={errorMessage} />}
|
||||
|
||||
{selectedProvider === "anthropic" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
|
|
@ -182,7 +273,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("apiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
|
||||
<span className="font-medium">Anthropic API Key</span>
|
||||
</VSCodeTextField>
|
||||
|
||||
<Checkbox
|
||||
|
|
@ -233,7 +324,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("glamaApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Glama API Key</span>
|
||||
<span className="font-medium">Glama API Key</span>
|
||||
</VSCodeTextField>
|
||||
{!apiConfiguration?.glamaApiKey && (
|
||||
<VSCodeButtonLink
|
||||
|
|
@ -262,7 +353,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("requestyApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Requesty API Key</span>
|
||||
<span className="font-medium">Requesty API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -283,7 +374,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("openAiNativeApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>OpenAI API Key</span>
|
||||
<span className="font-medium">OpenAI API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -311,7 +402,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("mistralApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Mistral API Key</span>
|
||||
<span className="font-medium">Mistral API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -340,7 +431,7 @@ const ApiOptions = ({
|
|||
type="url"
|
||||
onInput={handleInputChange("mistralCodestralUrl")}
|
||||
placeholder="Default: https://codestral.mistral.ai">
|
||||
<span style={{ fontWeight: 500 }}>Codestral Base URL (Optional)</span>
|
||||
<span className="font-medium">Codestral Base URL (Optional)</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -363,7 +454,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("openRouterApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
|
||||
<span className="font-medium">OpenRouter API Key</span>
|
||||
</VSCodeTextField>
|
||||
{!apiConfiguration?.openRouterApiKey && (
|
||||
<p>
|
||||
|
|
@ -435,7 +526,7 @@ const ApiOptions = ({
|
|||
style={{ width: "100%" }}
|
||||
onInput={handleInputChange("awsProfile")}
|
||||
placeholder="Enter profile name">
|
||||
<span style={{ fontWeight: 500 }}>AWS Profile Name</span>
|
||||
<span className="font-medium">AWS Profile Name</span>
|
||||
</VSCodeTextField>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -446,7 +537,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("awsAccessKey")}
|
||||
placeholder="Enter Access Key...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Access Key</span>
|
||||
<span className="font-medium">AWS Access Key</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsSecretKey || ""}
|
||||
|
|
@ -454,7 +545,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("awsSecretKey")}
|
||||
placeholder="Enter Secret Key...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
|
||||
<span className="font-medium">AWS Secret Key</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsSessionToken || ""}
|
||||
|
|
@ -462,13 +553,13 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("awsSessionToken")}
|
||||
placeholder="Enter Session Token...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Session Token</span>
|
||||
<span className="font-medium">AWS Session Token</span>
|
||||
</VSCodeTextField>
|
||||
</>
|
||||
)}
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="aws-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>AWS Region</span>
|
||||
<span className="font-medium">AWS Region</span>
|
||||
</label>
|
||||
<Dropdown
|
||||
id="aws-region-dropdown"
|
||||
|
|
@ -520,11 +611,11 @@ const ApiOptions = ({
|
|||
style={{ width: "100%" }}
|
||||
onInput={handleInputChange("vertexProjectId")}
|
||||
placeholder="Enter Project ID...">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
|
||||
<span className="font-medium">Google Cloud Project ID</span>
|
||||
</VSCodeTextField>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="vertex-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
|
||||
<span className="font-medium">Google Cloud Region</span>
|
||||
</label>
|
||||
<Dropdown
|
||||
id="vertex-region-dropdown"
|
||||
|
|
@ -572,7 +663,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("geminiApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Gemini API Key</span>
|
||||
<span className="font-medium">Gemini API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -600,7 +691,7 @@ const ApiOptions = ({
|
|||
type="url"
|
||||
onInput={handleInputChange("openAiBaseUrl")}
|
||||
placeholder={"Enter base URL..."}>
|
||||
<span style={{ fontWeight: 500 }}>Base URL</span>
|
||||
<span className="font-medium">Base URL</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openAiApiKey || ""}
|
||||
|
|
@ -608,9 +699,19 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("openAiApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>API Key</span>
|
||||
<span className="font-medium">API Key</span>
|
||||
</VSCodeTextField>
|
||||
<OpenAiModelPicker />
|
||||
<ModelPicker
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
defaultModelId="gpt-4o"
|
||||
defaultModelInfo={openAiModelInfoSaneDefaults}
|
||||
models={openAiModels}
|
||||
modelIdKey="openAiModelId"
|
||||
modelInfoKey="openAiCustomModelInfo"
|
||||
serviceName="OpenAI"
|
||||
serviceUrl="https://platform.openai.com"
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Checkbox
|
||||
checked={apiConfiguration?.openAiStreamingEnabled ?? true}
|
||||
|
|
@ -642,12 +743,7 @@ const ApiOptions = ({
|
|||
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 15,
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4" />
|
||||
<Pane
|
||||
title="Model Configuration"
|
||||
open={false}
|
||||
|
|
@ -698,7 +794,7 @@ const ApiOptions = ({
|
|||
})(),
|
||||
}}
|
||||
title="Maximum number of tokens the model can generate in a single response"
|
||||
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
const value = parseInt((e.target as HTMLInputElement).value)
|
||||
return {
|
||||
...(apiConfiguration?.openAiCustomModelInfo ||
|
||||
|
|
@ -707,7 +803,7 @@ const ApiOptions = ({
|
|||
}
|
||||
})}
|
||||
placeholder="e.g. 4096">
|
||||
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
|
||||
<span className="font-medium">Max Output Tokens</span>
|
||||
</VSCodeTextField>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -745,7 +841,7 @@ const ApiOptions = ({
|
|||
})(),
|
||||
}}
|
||||
title="Total number of tokens (input + output) the model can process in a single request"
|
||||
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
const parsed = parseInt(value)
|
||||
return {
|
||||
|
|
@ -757,7 +853,7 @@ const ApiOptions = ({
|
|||
}
|
||||
})}
|
||||
placeholder="e.g. 128000">
|
||||
<span style={{ fontWeight: 500 }}>Context Window Size</span>
|
||||
<span className="font-medium">Context Window Size</span>
|
||||
</VSCodeTextField>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -795,7 +891,7 @@ const ApiOptions = ({
|
|||
supportsImages: checked,
|
||||
}
|
||||
})}>
|
||||
<span style={{ fontWeight: 500 }}>Image Support</span>
|
||||
<span className="font-medium">Image Support</span>
|
||||
</Checkbox>
|
||||
<i
|
||||
className="codicon codicon-info"
|
||||
|
|
@ -839,7 +935,7 @@ const ApiOptions = ({
|
|||
supportsComputerUse: checked,
|
||||
}
|
||||
})}>
|
||||
<span style={{ fontWeight: 500 }}>Computer Use</span>
|
||||
<span className="font-medium">Computer Use</span>
|
||||
</Checkbox>
|
||||
<i
|
||||
className="codicon codicon-info"
|
||||
|
|
@ -891,9 +987,9 @@ const ApiOptions = ({
|
|||
: "var(--vscode-errorForeground)"
|
||||
})(),
|
||||
}}
|
||||
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
const parsed = parseInt(value)
|
||||
const parsed = parseFloat(value)
|
||||
return {
|
||||
...(apiConfiguration?.openAiCustomModelInfo ??
|
||||
openAiModelInfoSaneDefaults),
|
||||
|
|
@ -904,7 +1000,7 @@ const ApiOptions = ({
|
|||
})}
|
||||
placeholder="e.g. 0.0001">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<span style={{ fontWeight: 500 }}>Input Price</span>
|
||||
<span className="font-medium">Input Price</span>
|
||||
<i
|
||||
className="codicon codicon-info"
|
||||
title="Cost per million tokens in the input/prompt. This affects the cost of sending context and instructions to the model."
|
||||
|
|
@ -936,9 +1032,9 @@ const ApiOptions = ({
|
|||
: "var(--vscode-errorForeground)"
|
||||
})(),
|
||||
}}
|
||||
onChange={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
onInput={handleInputChange("openAiCustomModelInfo", (e) => {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
const parsed = parseInt(value)
|
||||
const parsed = parseFloat(value)
|
||||
return {
|
||||
...(apiConfiguration?.openAiCustomModelInfo ||
|
||||
openAiModelInfoSaneDefaults),
|
||||
|
|
@ -949,7 +1045,7 @@ const ApiOptions = ({
|
|||
})}
|
||||
placeholder="e.g. 0.0002">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<span style={{ fontWeight: 500 }}>Output Price</span>
|
||||
<span className="font-medium">Output Price</span>
|
||||
<i
|
||||
className="codicon codicon-info"
|
||||
title="Cost per million tokens in the model's response. This affects the cost of generated content and completions."
|
||||
|
|
@ -973,18 +1069,6 @@ const ApiOptions = ({
|
|||
/>
|
||||
|
||||
{/* end Model Info Configuration */}
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>
|
||||
(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
|
||||
with Claude models. Less capable models may not work as expected.)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -996,14 +1080,14 @@ const ApiOptions = ({
|
|||
type="url"
|
||||
onInput={handleInputChange("lmStudioBaseUrl")}
|
||||
placeholder={"Default: http://localhost:1234"}>
|
||||
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
|
||||
<span className="font-medium">Base URL (optional)</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.lmStudioModelId || ""}
|
||||
style={{ width: "100%" }}
|
||||
onInput={handleInputChange("lmStudioModelId")}
|
||||
placeholder={"e.g. meta-llama-3.1-8b-instruct"}>
|
||||
<span style={{ fontWeight: 500 }}>Model ID</span>
|
||||
<span className="font-medium">Model ID</span>
|
||||
</VSCodeTextField>
|
||||
{lmStudioModels.length > 0 && (
|
||||
<VSCodeRadioGroup
|
||||
|
|
@ -1042,7 +1126,7 @@ const ApiOptions = ({
|
|||
</VSCodeLink>{" "}
|
||||
feature to use it with this extension.{" "}
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>
|
||||
(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
|
||||
(<span className="font-medium">Note:</span> Roo Code uses complex prompts and works best
|
||||
with Claude models. Less capable models may not work as expected.)
|
||||
</span>
|
||||
</p>
|
||||
|
|
@ -1057,7 +1141,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onInput={handleInputChange("deepSeekApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>DeepSeek API Key</span>
|
||||
<span className="font-medium">DeepSeek API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
|
|
@ -1081,7 +1165,7 @@ const ApiOptions = ({
|
|||
<div>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="vscode-lm-model">
|
||||
<span style={{ fontWeight: 500 }}>Language Model</span>
|
||||
<span className="font-medium">Language Model</span>
|
||||
</label>
|
||||
{vsCodeLmModels.length > 0 ? (
|
||||
<Dropdown
|
||||
|
|
@ -1140,15 +1224,21 @@ const ApiOptions = ({
|
|||
type="url"
|
||||
onInput={handleInputChange("ollamaBaseUrl")}
|
||||
placeholder={"Default: http://localhost:11434"}>
|
||||
<span style={{ fontWeight: 500 }}>Base URL (optional)</span>
|
||||
<span className="font-medium">Base URL (optional)</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.ollamaModelId || ""}
|
||||
style={{ width: "100%" }}
|
||||
onInput={handleInputChange("ollamaModelId")}
|
||||
placeholder={"e.g. llama3.1"}>
|
||||
<span style={{ fontWeight: 500 }}>Model ID</span>
|
||||
<span className="font-medium">Model ID</span>
|
||||
</VSCodeTextField>
|
||||
{errorMessage && (
|
||||
<div className="text-vscode-errorForeground text-sm">
|
||||
<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{ollamaModels.length > 0 && (
|
||||
<VSCodeRadioGroup
|
||||
value={
|
||||
|
|
@ -1181,7 +1271,7 @@ const ApiOptions = ({
|
|||
quickstart guide.
|
||||
</VSCodeLink>
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>
|
||||
(<span style={{ fontWeight: 500 }}>Note:</span> Roo Code uses complex prompts and works best
|
||||
(<span className="font-medium">Note:</span> Roo Code uses complex prompts and works best
|
||||
with Claude models. Less capable models may not work as expected.)
|
||||
</span>
|
||||
</p>
|
||||
|
|
@ -1196,7 +1286,7 @@ const ApiOptions = ({
|
|||
type="password"
|
||||
onChange={handleInputChange("unboundApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Unbound API Key</span>
|
||||
<span className="font-medium">Unbound API Key</span>
|
||||
</VSCodeTextField>
|
||||
{!apiConfiguration?.unboundApiKey && (
|
||||
<VSCodeButtonLink
|
||||
|
|
@ -1214,89 +1304,93 @@ const ApiOptions = ({
|
|||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
</p>
|
||||
<UnboundModelPicker />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiErrorMessage && (
|
||||
<p
|
||||
style={{
|
||||
margin: "-10px 0 4px 0",
|
||||
fontSize: 12,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
|
||||
{apiErrorMessage}
|
||||
</p>
|
||||
{selectedProvider === "openrouter" && (
|
||||
<ModelPicker
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
defaultModelId={openRouterDefaultModelId}
|
||||
defaultModelInfo={openRouterDefaultModelInfo}
|
||||
models={openRouterModels}
|
||||
modelIdKey="openRouterModelId"
|
||||
modelInfoKey="openRouterModelInfo"
|
||||
serviceName="OpenRouter"
|
||||
serviceUrl="https://openrouter.ai/models"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider === "glama" && <GlamaModelPicker />}
|
||||
{selectedProvider === "openrouter" && <OpenRouterModelPicker />}
|
||||
{selectedProvider === "requesty" && <RequestyModelPicker />}
|
||||
{selectedProvider === "glama" && (
|
||||
<ModelPicker
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
defaultModelId={glamaDefaultModelId}
|
||||
defaultModelInfo={glamaDefaultModelInfo}
|
||||
models={glamaModels}
|
||||
modelInfoKey="glamaModelInfo"
|
||||
modelIdKey="glamaModelId"
|
||||
serviceName="Glama"
|
||||
serviceUrl="https://glama.ai/models"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProvider !== "glama" &&
|
||||
selectedProvider !== "openrouter" &&
|
||||
selectedProvider !== "requesty" &&
|
||||
selectedProvider !== "openai" &&
|
||||
selectedProvider !== "ollama" &&
|
||||
selectedProvider !== "lmstudio" &&
|
||||
selectedProvider !== "unbound" && (
|
||||
<>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="model-id">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
{selectedProvider === "anthropic" && createDropdown(anthropicModels)}
|
||||
{selectedProvider === "bedrock" && createDropdown(bedrockModels)}
|
||||
{selectedProvider === "vertex" && createDropdown(vertexModels)}
|
||||
{selectedProvider === "gemini" && createDropdown(geminiModels)}
|
||||
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
|
||||
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
|
||||
{selectedProvider === "mistral" && createDropdown(mistralModels)}
|
||||
</div>
|
||||
{selectedProvider === "unbound" && (
|
||||
<ModelPicker
|
||||
apiConfiguration={apiConfiguration}
|
||||
defaultModelId={unboundDefaultModelId}
|
||||
defaultModelInfo={unboundDefaultModelInfo}
|
||||
models={unboundModels}
|
||||
modelInfoKey="unboundModelInfo"
|
||||
modelIdKey="unboundModelId"
|
||||
serviceName="Unbound"
|
||||
serviceUrl="https://api.getunbound.ai/models"
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
{selectedProvider === "requesty" && (
|
||||
<ModelPicker
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
defaultModelId={requestyDefaultModelId}
|
||||
defaultModelInfo={requestyDefaultModelInfo}
|
||||
models={requestyModels}
|
||||
modelIdKey="requestyModelId"
|
||||
modelInfoKey="requestyModelInfo"
|
||||
serviceName="Requesty"
|
||||
serviceUrl="https://requesty.ai"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProviderModelOptions.length > 0 && (
|
||||
<>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="model-id" className="font-medium">
|
||||
Model
|
||||
</label>
|
||||
<Dropdown
|
||||
id="model-id"
|
||||
value={selectedModelId}
|
||||
onChange={(value) => {
|
||||
setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
|
||||
}}
|
||||
options={selectedProviderModelOptions}
|
||||
className="w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedProvider === "anthropic" && selectedModelId === "claude-3-7-sonnet-20250219" && (
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<Checkbox
|
||||
checked={!!anthropicThinkingBudget}
|
||||
onChange={(checked) => {
|
||||
const budget = checked ? 16_384 : undefined
|
||||
setAnthropicThinkingBudget(budget)
|
||||
setApiConfigurationField("anthropicThinking", budget)
|
||||
}}>
|
||||
Thinking?
|
||||
</Checkbox>
|
||||
{anthropicThinkingBudget && (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Number of tokens Claude is allowed to use for its internal reasoning process.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={1024}
|
||||
max={anthropicModels["claude-3-7-sonnet-20250219"].maxTokens - 1}
|
||||
step={1024}
|
||||
value={[anthropicThinkingBudget]}
|
||||
onValueChange={(value) => {
|
||||
const budget = value[0]
|
||||
setAnthropicThinkingBudget(budget)
|
||||
setApiConfigurationField("anthropicThinking", budget)
|
||||
}}
|
||||
/>
|
||||
<div className="w-10">{anthropicThinkingBudget}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ThinkingBudget
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
modelInfo={selectedModelInfo}
|
||||
/>
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!fromWelcomeView && (
|
||||
|
|
@ -1308,18 +1402,6 @@ const ApiOptions = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modelIdErrorMessage && (
|
||||
<p
|
||||
style={{
|
||||
margin: "-10px 0 4px 0",
|
||||
fontSize: 12,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
<span style={{ fontSize: "2em" }} className={`codicon codicon-close align-middle mr-1`} />
|
||||
{modelIdErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1341,6 +1423,7 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
const getProviderData = (models: Record<string, ModelInfo>, defaultId: string) => {
|
||||
let selectedModelId: string
|
||||
let selectedModelInfo: ModelInfo
|
||||
|
||||
if (modelId && modelId in models) {
|
||||
selectedModelId = modelId
|
||||
selectedModelInfo = models[modelId]
|
||||
|
|
@ -1348,8 +1431,10 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
selectedModelId = defaultId
|
||||
selectedModelInfo = models[defaultId]
|
||||
}
|
||||
|
||||
return { selectedProvider: provider, selectedModelId, selectedModelInfo }
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
|
|
@ -1363,12 +1448,6 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
return getProviderData(deepSeekModels, deepSeekDefaultModelId)
|
||||
case "openai-native":
|
||||
return getProviderData(openAiNativeModels, openAiNativeDefaultModelId)
|
||||
case "glama":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.glamaModelInfo || glamaDefaultModelInfo,
|
||||
}
|
||||
case "mistral":
|
||||
return getProviderData(mistralModels, mistralDefaultModelId)
|
||||
case "openrouter":
|
||||
|
|
@ -1377,6 +1456,24 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
|
||||
}
|
||||
case "glama":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.glamaModelId || glamaDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.glamaModelInfo || glamaDefaultModelInfo,
|
||||
}
|
||||
case "unbound":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
|
||||
}
|
||||
case "requesty":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
|
||||
}
|
||||
case "openai":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
|
|
@ -1403,21 +1500,9 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
: "",
|
||||
selectedModelInfo: {
|
||||
...openAiModelInfoSaneDefaults,
|
||||
supportsImages: false, // VSCode LM API currently doesn't support images
|
||||
supportsImages: false, // VSCode LM API currently doesn't support images.
|
||||
},
|
||||
}
|
||||
case "unbound":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
|
||||
}
|
||||
case "requesty":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
|
||||
}
|
||||
default:
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { ModelPicker } from "./ModelPicker"
|
||||
import { glamaDefaultModelId } from "../../../../src/shared/api"
|
||||
|
||||
export const GlamaModelPicker = () => (
|
||||
<ModelPicker
|
||||
defaultModelId={glamaDefaultModelId}
|
||||
modelsKey="glamaModels"
|
||||
configKey="glamaModelId"
|
||||
infoKey="glamaModelInfo"
|
||||
refreshMessageType="refreshGlamaModels"
|
||||
serviceName="Glama"
|
||||
serviceUrl="https://glama.ai/models"
|
||||
recommendedModel="anthropic/claude-3-7-sonnet"
|
||||
/>
|
||||
)
|
||||
|
|
@ -1,186 +1,95 @@
|
|||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react"
|
||||
import { useMount } from "react-use"
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Button,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui"
|
||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem } from "@/components/ui/combobox"
|
||||
|
||||
import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
|
||||
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { normalizeApiConfiguration } from "./ApiOptions"
|
||||
import { ThinkingBudget } from "./ThinkingBudget"
|
||||
import { ModelInfoView } from "./ModelInfoView"
|
||||
|
||||
type ModelProvider = "glama" | "openRouter" | "unbound" | "requesty" | "openAi"
|
||||
type ExtractType<T> = NonNullable<
|
||||
{ [K in keyof ApiConfiguration]: Required<ApiConfiguration>[K] extends T ? K : never }[keyof ApiConfiguration]
|
||||
>
|
||||
|
||||
type ModelKeys<T extends ModelProvider> = `${T}Models`
|
||||
type ConfigKeys<T extends ModelProvider> = `${T}ModelId`
|
||||
type InfoKeys<T extends ModelProvider> = `${T}ModelInfo`
|
||||
type RefreshMessageType<T extends ModelProvider> = `refresh${Capitalize<T>}Models`
|
||||
type ModelIdKeys = NonNullable<
|
||||
{ [K in keyof ApiConfiguration]: K extends `${string}ModelId` ? K : never }[keyof ApiConfiguration]
|
||||
>
|
||||
|
||||
interface ModelPickerProps<T extends ModelProvider = ModelProvider> {
|
||||
interface ModelPickerProps {
|
||||
defaultModelId: string
|
||||
modelsKey: ModelKeys<T>
|
||||
configKey: ConfigKeys<T>
|
||||
infoKey: InfoKeys<T>
|
||||
refreshMessageType: RefreshMessageType<T>
|
||||
refreshValues?: Record<string, any>
|
||||
defaultModelInfo?: ModelInfo
|
||||
models: Record<string, ModelInfo> | null
|
||||
modelIdKey: ModelIdKeys
|
||||
modelInfoKey: ExtractType<ModelInfo>
|
||||
serviceName: string
|
||||
serviceUrl: string
|
||||
recommendedModel: string
|
||||
allowCustomModel?: boolean
|
||||
apiConfiguration: ApiConfiguration
|
||||
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
|
||||
}
|
||||
|
||||
export const ModelPicker = ({
|
||||
defaultModelId,
|
||||
modelsKey,
|
||||
configKey,
|
||||
infoKey,
|
||||
refreshMessageType,
|
||||
refreshValues,
|
||||
models,
|
||||
modelIdKey,
|
||||
modelInfoKey,
|
||||
serviceName,
|
||||
serviceUrl,
|
||||
recommendedModel,
|
||||
allowCustomModel = false,
|
||||
apiConfiguration,
|
||||
setApiConfigurationField,
|
||||
defaultModelInfo,
|
||||
}: ModelPickerProps) => {
|
||||
const [customModelId, setCustomModelId] = useState("")
|
||||
const [isCustomModel, setIsCustomModel] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState(defaultModelId)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const prevRefreshValuesRef = useRef<Record<string, any> | undefined>()
|
||||
const isInitialized = useRef(false)
|
||||
|
||||
const { apiConfiguration, [modelsKey]: models, onUpdateApiConfig, setApiConfiguration } = useExtensionState()
|
||||
|
||||
const modelIds = useMemo(
|
||||
() => (Array.isArray(models) ? models : Object.keys(models)).sort((a, b) => a.localeCompare(b)),
|
||||
[models],
|
||||
)
|
||||
const modelIds = useMemo(() => Object.keys(models ?? {}).sort((a, b) => a.localeCompare(b)), [models])
|
||||
|
||||
const { selectedModelId, selectedModelInfo } = useMemo(
|
||||
() => normalizeApiConfiguration(apiConfiguration),
|
||||
[apiConfiguration],
|
||||
)
|
||||
|
||||
const onSelectCustomModel = useCallback(
|
||||
(modelId: string) => {
|
||||
setCustomModelId(modelId)
|
||||
const modelInfo = { id: modelId }
|
||||
const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
|
||||
setApiConfiguration(apiConfig)
|
||||
onUpdateApiConfig(apiConfig)
|
||||
setValue(modelId)
|
||||
setOpen(false)
|
||||
setIsCustomModel(false)
|
||||
},
|
||||
[apiConfiguration, configKey, infoKey, onUpdateApiConfig, setApiConfiguration],
|
||||
)
|
||||
|
||||
const onSelect = useCallback(
|
||||
(modelId: string) => {
|
||||
const modelInfo = Array.isArray(models)
|
||||
? { id: modelId } // For OpenAI models which are just strings
|
||||
: models[modelId] // For other models that have full info objects
|
||||
const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
|
||||
setApiConfiguration(apiConfig)
|
||||
onUpdateApiConfig(apiConfig)
|
||||
setValue(modelId)
|
||||
setOpen(false)
|
||||
const modelInfo = models?.[modelId]
|
||||
setApiConfigurationField(modelIdKey, modelId)
|
||||
setApiConfigurationField(modelInfoKey, modelInfo ?? defaultModelInfo)
|
||||
},
|
||||
[apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration],
|
||||
[modelIdKey, modelInfoKey, models, setApiConfigurationField, defaultModelInfo],
|
||||
)
|
||||
|
||||
const debouncedRefreshModels = useMemo(() => {
|
||||
return debounce(() => {
|
||||
const message = refreshValues
|
||||
? { type: refreshMessageType, values: refreshValues }
|
||||
: { type: refreshMessageType }
|
||||
vscode.postMessage(message)
|
||||
}, 100)
|
||||
}, [refreshMessageType, refreshValues])
|
||||
|
||||
useMount(() => {
|
||||
debouncedRefreshModels()
|
||||
return () => debouncedRefreshModels.clear()
|
||||
})
|
||||
const inputValue = apiConfiguration[modelIdKey]
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshValues) {
|
||||
prevRefreshValuesRef.current = undefined
|
||||
return
|
||||
if (!inputValue && !isInitialized.current) {
|
||||
const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId
|
||||
setApiConfigurationField(modelIdKey, initialValue)
|
||||
}
|
||||
|
||||
// Check if all values in refreshValues are truthy
|
||||
if (Object.values(refreshValues).some((value) => !value)) {
|
||||
prevRefreshValuesRef.current = undefined
|
||||
return
|
||||
}
|
||||
|
||||
// Compare with previous values
|
||||
const prevValues = prevRefreshValuesRef.current
|
||||
if (prevValues && JSON.stringify(prevValues) === JSON.stringify(refreshValues)) {
|
||||
return
|
||||
}
|
||||
|
||||
prevRefreshValuesRef.current = refreshValues
|
||||
debouncedRefreshModels()
|
||||
}, [debouncedRefreshModels, refreshValues])
|
||||
|
||||
useEffect(() => setValue(selectedModelId), [selectedModelId])
|
||||
isInitialized.current = true
|
||||
}, [inputValue, modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold">Model</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="combobox" role="combobox" aria-expanded={open} className="w-full justify-between">
|
||||
{value ?? "Select model..."}
|
||||
<CaretSortIcon className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search model..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modelIds.map((model) => (
|
||||
<CommandItem key={model} value={model} onSelect={onSelect}>
|
||||
{model}
|
||||
<CheckIcon
|
||||
className={cn("ml-auto", value === model ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{allowCustomModel && (
|
||||
<CommandGroup heading="Custom">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setIsCustomModel(true)
|
||||
setOpen(false)
|
||||
}}>
|
||||
+ Add custom model
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedModelId && selectedModelInfo && (
|
||||
<Combobox type="single" inputValue={inputValue} onInputValueChange={onSelect}>
|
||||
<ComboboxInput placeholder="Search model..." data-testid="model-input" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No model found.</ComboboxEmpty>
|
||||
{modelIds.map((model) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
{model}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<ThinkingBudget
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
modelInfo={selectedModelInfo}
|
||||
/>
|
||||
{selectedModelId && selectedModelInfo && selectedModelId === inputValue && (
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
|
|
@ -194,31 +103,9 @@ export const ModelPicker = ({
|
|||
{serviceName}.
|
||||
</VSCodeLink>
|
||||
If you're unsure which model to choose, Roo Code works best with{" "}
|
||||
<VSCodeLink onClick={() => onSelect(recommendedModel)}>{recommendedModel}.</VSCodeLink>
|
||||
<VSCodeLink onClick={() => onSelect(defaultModelId)}>{defaultModelId}.</VSCodeLink>
|
||||
You can also try searching "free" for no-cost options currently available.
|
||||
</p>
|
||||
{allowCustomModel && isCustomModel && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--vscode-editor-background)] p-6 rounded-lg w-96">
|
||||
<h3 className="text-lg font-semibold mb-4">Add Custom Model</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 mb-4 bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] border border-[var(--vscode-input-border)] rounded"
|
||||
placeholder="Enter model ID"
|
||||
value={customModelId}
|
||||
onChange={(e) => setCustomModelId(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setIsCustomModel(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onSelectCustomModel(customModelId)} disabled={!customModelId.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import React from "react"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { ModelPicker } from "./ModelPicker"
|
||||
|
||||
const OpenAiModelPicker: React.FC = () => {
|
||||
const { apiConfiguration } = useExtensionState()
|
||||
|
||||
return (
|
||||
<ModelPicker
|
||||
defaultModelId={apiConfiguration?.openAiModelId || ""}
|
||||
modelsKey="openAiModels"
|
||||
configKey="openAiModelId"
|
||||
infoKey="openAiModelInfo"
|
||||
refreshMessageType="refreshOpenAiModels"
|
||||
refreshValues={{
|
||||
baseUrl: apiConfiguration?.openAiBaseUrl,
|
||||
apiKey: apiConfiguration?.openAiApiKey,
|
||||
}}
|
||||
serviceName="OpenAI"
|
||||
serviceUrl="https://platform.openai.com"
|
||||
recommendedModel="gpt-4-turbo-preview"
|
||||
allowCustomModel={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenAiModelPicker
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { ModelPicker } from "./ModelPicker"
|
||||
import { openRouterDefaultModelId } from "../../../../src/shared/api"
|
||||
|
||||
export const OpenRouterModelPicker = () => (
|
||||
<ModelPicker
|
||||
defaultModelId={openRouterDefaultModelId}
|
||||
modelsKey="openRouterModels"
|
||||
configKey="openRouterModelId"
|
||||
infoKey="openRouterModelInfo"
|
||||
refreshMessageType="refreshOpenRouterModels"
|
||||
serviceName="OpenRouter"
|
||||
serviceUrl="https://openrouter.ai/models"
|
||||
recommendedModel="anthropic/claude-3.7-sonnet"
|
||||
/>
|
||||
)
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { ModelPicker } from "./ModelPicker"
|
||||
import { requestyDefaultModelId } from "../../../../src/shared/api"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
|
||||
export const RequestyModelPicker = () => {
|
||||
const { apiConfiguration } = useExtensionState()
|
||||
return (
|
||||
<ModelPicker
|
||||
defaultModelId={requestyDefaultModelId}
|
||||
modelsKey="requestyModels"
|
||||
configKey="requestyModelId"
|
||||
infoKey="requestyModelInfo"
|
||||
refreshMessageType="refreshRequestyModels"
|
||||
refreshValues={{
|
||||
apiKey: apiConfiguration?.requestyApiKey,
|
||||
}}
|
||||
serviceName="Requesty"
|
||||
serviceUrl="https://requesty.ai"
|
||||
recommendedModel="anthropic/claude-3-7-sonnet-latest"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"
|
||||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Dropdown, type DropdownOption } from "vscrui"
|
||||
import { Button, Dropdown, type DropdownOption } from "vscrui"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -14,7 +14,6 @@ import {
|
|||
} from "@/components/ui"
|
||||
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
|
||||
import { ExtensionStateContextType, useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { EXPERIMENT_IDS, experimentConfigsMap, ExperimentId } from "../../../../src/shared/experiments"
|
||||
import { ApiConfiguration } from "../../../../src/shared/api"
|
||||
|
|
@ -33,19 +32,17 @@ export interface SettingsViewRef {
|
|||
|
||||
const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone }, ref) => {
|
||||
const extensionState = useExtensionState()
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
|
||||
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
|
||||
const [commandInput, setCommandInput] = useState("")
|
||||
const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
|
||||
const [cachedState, setCachedState] = useState(extensionState)
|
||||
const [isChangeDetected, setChangeDetected] = useState(false)
|
||||
const prevApiConfigName = useRef(extensionState.currentApiConfigName)
|
||||
const confirmDialogHandler = useRef<() => void>()
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
|
||||
|
||||
// TODO: Reduce WebviewMessage/ExtensionState complexity
|
||||
const { currentApiConfigName } = extensionState
|
||||
const {
|
||||
apiConfiguration,
|
||||
alwaysAllowReadOnly,
|
||||
allowedCommands,
|
||||
alwaysAllowBrowser,
|
||||
|
|
@ -70,17 +67,19 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
writeDelayMs,
|
||||
} = cachedState
|
||||
|
||||
//Make sure apiConfiguration is initialized and managed by SettingsView
|
||||
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
// Update only when currentApiConfigName is changed
|
||||
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration
|
||||
// Update only when currentApiConfigName is changed.
|
||||
// Expected to be triggered by loadApiConfiguration/upsertApiConfiguration.
|
||||
if (prevApiConfigName.current === currentApiConfigName) {
|
||||
return
|
||||
}
|
||||
setCachedState((prevCachedState) => ({
|
||||
...prevCachedState,
|
||||
...extensionState,
|
||||
}))
|
||||
|
||||
setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
|
||||
prevApiConfigName.current = currentApiConfigName
|
||||
// console.log("useEffect: currentApiConfigName changed, setChangeDetected -> false")
|
||||
setChangeDetected(false)
|
||||
}, [currentApiConfigName, extensionState, isChangeDetected])
|
||||
|
||||
|
|
@ -90,11 +89,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
if (prevState[field] === value) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
// console.log(`setCachedStateField(${field} -> ${value}): setChangeDetected -> true`)
|
||||
setChangeDetected(true)
|
||||
return {
|
||||
...prevState,
|
||||
[field]: value,
|
||||
}
|
||||
return { ...prevState, [field]: value }
|
||||
})
|
||||
},
|
||||
[],
|
||||
|
|
@ -107,15 +105,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
return prevState
|
||||
}
|
||||
|
||||
// console.log(`setApiConfigurationField(${field} -> ${value}): setChangeDetected -> true`)
|
||||
setChangeDetected(true)
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
apiConfiguration: {
|
||||
...prevState.apiConfiguration,
|
||||
[field]: value,
|
||||
},
|
||||
}
|
||||
return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } }
|
||||
})
|
||||
},
|
||||
[],
|
||||
|
|
@ -126,7 +119,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
if (prevState.experiments?.[id] === enabled) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
// console.log("setExperimentEnabled: setChangeDetected -> true")
|
||||
setChangeDetected(true)
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
experiments: { ...prevState.experiments, [id]: enabled },
|
||||
|
|
@ -134,19 +130,10 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
})
|
||||
}, [])
|
||||
|
||||
const isSettingValid = !errorMessage
|
||||
|
||||
const handleSubmit = () => {
|
||||
const apiValidationResult = validateApiConfiguration(apiConfiguration)
|
||||
|
||||
const modelIdValidationResult = validateModelId(
|
||||
apiConfiguration,
|
||||
extensionState.glamaModels,
|
||||
extensionState.openRouterModels,
|
||||
)
|
||||
|
||||
setApiErrorMessage(apiValidationResult)
|
||||
setModelIdErrorMessage(modelIdValidationResult)
|
||||
|
||||
if (!apiValidationResult && !modelIdValidationResult) {
|
||||
if (isSettingValid) {
|
||||
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
|
||||
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
|
||||
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
|
||||
|
|
@ -171,27 +158,11 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
vscode.postMessage({ type: "updateExperimental", values: experiments })
|
||||
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
|
||||
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
|
||||
// console.log("handleSubmit: setChangeDetected -> false")
|
||||
setChangeDetected(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setApiErrorMessage(undefined)
|
||||
setModelIdErrorMessage(undefined)
|
||||
}, [apiConfiguration])
|
||||
|
||||
// Initial validation on mount
|
||||
useEffect(() => {
|
||||
const apiValidationResult = validateApiConfiguration(apiConfiguration)
|
||||
const modelIdValidationResult = validateModelId(
|
||||
apiConfiguration,
|
||||
extensionState.glamaModels,
|
||||
extensionState.openRouterModels,
|
||||
)
|
||||
setApiErrorMessage(apiValidationResult)
|
||||
setModelIdErrorMessage(modelIdValidationResult)
|
||||
}, [apiConfiguration, extensionState.glamaModels, extensionState.openRouterModels])
|
||||
|
||||
const checkUnsaveChanges = useCallback(
|
||||
(then: () => void) => {
|
||||
if (isChangeDetected) {
|
||||
|
|
@ -204,13 +175,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
[isChangeDetected],
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
checkUnsaveChanges,
|
||||
}),
|
||||
[checkUnsaveChanges],
|
||||
)
|
||||
useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges])
|
||||
|
||||
const onConfirmDialogResult = useCallback((confirm: boolean) => {
|
||||
if (confirm) {
|
||||
|
|
@ -228,10 +193,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
const newCommands = [...currentCommands, commandInput]
|
||||
setCachedStateField("allowedCommands", newCommands)
|
||||
setCommandInput("")
|
||||
vscode.postMessage({
|
||||
type: "allowedCommands",
|
||||
commands: newCommands,
|
||||
})
|
||||
vscode.postMessage({ type: "allowedCommands", commands: newCommands })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,13 +247,14 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
justifyContent: "space-between",
|
||||
gap: "6px",
|
||||
}}>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
title={isChangeDetected ? "Save changes" : "Nothing changed"}
|
||||
<Button
|
||||
appearance={isSettingValid ? "primary" : "secondary"}
|
||||
className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
|
||||
title={!isSettingValid ? errorMessage : isChangeDetected ? "Save changes" : "Nothing changed"}
|
||||
onClick={handleSubmit}
|
||||
disabled={!isChangeDetected}>
|
||||
disabled={!isChangeDetected || !isSettingValid}>
|
||||
Save
|
||||
</VSCodeButton>
|
||||
</Button>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
title="Discard unsaved changes and close settings panel"
|
||||
|
|
@ -342,8 +305,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
uriScheme={extensionState.uriScheme}
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
apiErrorMessage={apiErrorMessage}
|
||||
modelIdErrorMessage={modelIdErrorMessage}
|
||||
errorMessage={errorMessage}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
29
webview-ui/src/components/settings/ThinkingBudget.tsx
Normal file
29
webview-ui/src/components/settings/ThinkingBudget.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Slider } from "@/components/ui"
|
||||
|
||||
import { ApiConfiguration, ModelInfo, THINKING_BUDGET } from "../../../../src/shared/api"
|
||||
|
||||
interface ThinkingBudgetProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
|
||||
modelInfo?: ModelInfo
|
||||
}
|
||||
|
||||
export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => {
|
||||
const budget = apiConfiguration?.anthropicThinking ?? THINKING_BUDGET.default
|
||||
|
||||
return modelInfo && modelInfo.thinking ? (
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<div className="font-medium">Thinking Budget</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Slider
|
||||
min={THINKING_BUDGET.min}
|
||||
max={(modelInfo.maxTokens ?? THINKING_BUDGET.default) - 1}
|
||||
step={THINKING_BUDGET.step}
|
||||
value={[budget]}
|
||||
onValueChange={(value) => setApiConfigurationField("anthropicThinking", value[0])}
|
||||
/>
|
||||
<div className="w-12 text-sm text-center">{budget}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { ModelPicker } from "./ModelPicker"
|
||||
import { unboundDefaultModelId } from "../../../../src/shared/api"
|
||||
|
||||
export const UnboundModelPicker = () => (
|
||||
<ModelPicker
|
||||
defaultModelId={unboundDefaultModelId}
|
||||
modelsKey="unboundModels"
|
||||
configKey="unboundModelId"
|
||||
infoKey="unboundModelInfo"
|
||||
refreshMessageType="refreshUnboundModels"
|
||||
serviceName="Unbound"
|
||||
serviceUrl="https://api.getunbound.ai/models"
|
||||
recommendedModel={unboundDefaultModelId}
|
||||
/>
|
||||
)
|
||||
|
|
@ -51,6 +51,8 @@ describe("ApiOptions", () => {
|
|||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<ApiOptions
|
||||
errorMessage={undefined}
|
||||
setErrorMessage={() => {}}
|
||||
uriScheme={undefined}
|
||||
apiConfiguration={{}}
|
||||
setApiConfigurationField={() => {}}
|
||||
|
|
@ -69,4 +71,6 @@ describe("ApiOptions", () => {
|
|||
renderApiOptions({ fromWelcomeView: true })
|
||||
expect(screen.queryByTestId("temperature-control")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
//TODO: More test cases needed
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { screen, fireEvent, render } from "@testing-library/react"
|
||||
import { act } from "react"
|
||||
import { ModelPicker } from "../ModelPicker"
|
||||
import { useExtensionState } from "../../../context/ExtensionStateContext"
|
||||
|
||||
jest.mock("../../../context/ExtensionStateContext", () => ({
|
||||
useExtensionState: jest.fn(),
|
||||
|
|
@ -20,36 +19,40 @@ global.ResizeObserver = MockResizeObserver
|
|||
Element.prototype.scrollIntoView = jest.fn()
|
||||
|
||||
describe("ModelPicker", () => {
|
||||
const mockOnUpdateApiConfig = jest.fn()
|
||||
const mockSetApiConfiguration = jest.fn()
|
||||
|
||||
const mockSetApiConfigurationField = jest.fn()
|
||||
const modelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
}
|
||||
const mockModels = {
|
||||
model1: { name: "Model 1", description: "Test model 1", ...modelInfo },
|
||||
model2: { name: "Model 2", description: "Test model 2", ...modelInfo },
|
||||
}
|
||||
const defaultProps = {
|
||||
apiConfiguration: {},
|
||||
defaultModelId: "model1",
|
||||
modelsKey: "glamaModels" as const,
|
||||
configKey: "glamaModelId" as const,
|
||||
infoKey: "glamaModelInfo" as const,
|
||||
refreshMessageType: "refreshGlamaModels" as const,
|
||||
defaultModelInfo: modelInfo,
|
||||
modelIdKey: "glamaModelId" as const,
|
||||
modelInfoKey: "glamaModelInfo" as const,
|
||||
serviceName: "Test Service",
|
||||
serviceUrl: "https://test.service",
|
||||
recommendedModel: "recommended-model",
|
||||
}
|
||||
|
||||
const mockModels = {
|
||||
model1: { name: "Model 1", description: "Test model 1" },
|
||||
model2: { name: "Model 2", description: "Test model 2" },
|
||||
models: mockModels,
|
||||
setApiConfigurationField: mockSetApiConfigurationField,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(useExtensionState as jest.Mock).mockReturnValue({
|
||||
apiConfiguration: {},
|
||||
setApiConfiguration: mockSetApiConfiguration,
|
||||
glamaModels: mockModels,
|
||||
onUpdateApiConfig: mockOnUpdateApiConfig,
|
||||
})
|
||||
})
|
||||
|
||||
it("calls onUpdateApiConfig when a model is selected", async () => {
|
||||
it("calls setApiConfigurationField when a model is selected", async () => {
|
||||
await act(async () => {
|
||||
render(<ModelPicker {...defaultProps} />)
|
||||
})
|
||||
|
|
@ -67,20 +70,12 @@ describe("ModelPicker", () => {
|
|||
|
||||
await act(async () => {
|
||||
// Find and click the model item by its value.
|
||||
const modelItem = screen.getByRole("option", { name: "model2" })
|
||||
fireEvent.click(modelItem)
|
||||
const modelItem = screen.getByTestId("model-input")
|
||||
fireEvent.input(modelItem, { target: { value: "model2" } })
|
||||
})
|
||||
|
||||
// Verify the API config was updated.
|
||||
expect(mockSetApiConfiguration).toHaveBeenCalledWith({
|
||||
glamaModelId: "model2",
|
||||
glamaModelInfo: mockModels["model2"],
|
||||
})
|
||||
|
||||
// Verify onUpdateApiConfig was called with the new config.
|
||||
expect(mockOnUpdateApiConfig).toHaveBeenCalledWith({
|
||||
glamaModelId: "model2",
|
||||
glamaModelInfo: mockModels["model2"],
|
||||
})
|
||||
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(defaultProps.modelIdKey, "model2")
|
||||
expect(mockSetApiConfigurationField).toHaveBeenCalledWith(defaultProps.modelInfoKey, mockModels.model2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,94 +4,97 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
|||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-left", className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-row justify-end space-x-2", className)} {...props} />
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-base text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "secondary" }), "mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
|
|
|
|||
522
webview-ui/src/components/ui/combobox-primitive.tsx
Normal file
522
webview-ui/src/components/ui/combobox-primitive.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
/* eslint-disable react/jsx-pascal-case */
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { composeEventHandlers } from "@radix-ui/primitive"
|
||||
import { useComposedRefs } from "@radix-ui/react-compose-refs"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import * as RovingFocusGroupPrimitive from "@radix-ui/react-roving-focus"
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
export type ComboboxContextProps = {
|
||||
inputValue: string
|
||||
onInputValueChange: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
|
||||
onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentTabStopId: string | null
|
||||
onCurrentTabStopIdChange: (currentTabStopId: string | null) => void
|
||||
inputRef: React.RefObject<HTMLInputElement>
|
||||
tagGroupRef: React.RefObject<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
} & (
|
||||
| Required<Pick<ComboboxSingleProps, "type" | "value" | "onValueChange">>
|
||||
| Required<Pick<ComboboxMultipleProps, "type" | "value" | "onValueChange">>
|
||||
)
|
||||
|
||||
const ComboboxContext = React.createContext<ComboboxContextProps>({
|
||||
type: "single",
|
||||
value: "",
|
||||
onValueChange: () => {},
|
||||
inputValue: "",
|
||||
onInputValueChange: () => {},
|
||||
onInputBlur: () => {},
|
||||
open: false,
|
||||
onOpenChange: () => {},
|
||||
currentTabStopId: null,
|
||||
onCurrentTabStopIdChange: () => {},
|
||||
inputRef: { current: null },
|
||||
tagGroupRef: { current: null },
|
||||
disabled: false,
|
||||
required: false,
|
||||
})
|
||||
|
||||
export const useComboboxContext = () => React.useContext(ComboboxContext)
|
||||
|
||||
export type ComboboxType = "single" | "multiple"
|
||||
|
||||
export interface ComboboxBaseProps
|
||||
extends React.ComponentProps<typeof PopoverPrimitive.Root>,
|
||||
Omit<React.ComponentProps<typeof CommandPrimitive>, "value" | "defaultValue" | "onValueChange"> {
|
||||
type?: ComboboxType | undefined
|
||||
inputValue?: string
|
||||
defaultInputValue?: string
|
||||
onInputValueChange?: (inputValue: string, reason: "inputChange" | "itemSelect" | "clearClick") => void
|
||||
onInputBlur?: (e: React.FocusEvent<HTMLInputElement, Element>) => void
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export type ComboboxValue<T extends ComboboxType = "single"> = T extends "single"
|
||||
? string
|
||||
: T extends "multiple"
|
||||
? string[]
|
||||
: never
|
||||
|
||||
export interface ComboboxSingleProps {
|
||||
type: "single"
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export interface ComboboxMultipleProps {
|
||||
type: "multiple"
|
||||
value?: string[]
|
||||
defaultValue?: string[]
|
||||
onValueChange?: (value: string[]) => void
|
||||
}
|
||||
|
||||
export type ComboboxProps = ComboboxBaseProps & (ComboboxSingleProps | ComboboxMultipleProps)
|
||||
|
||||
export const Combobox = React.forwardRef(
|
||||
<T extends ComboboxType = "single">(
|
||||
{
|
||||
type = "single" as T,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
defaultOpen,
|
||||
modal,
|
||||
children,
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
inputValue: inputValueProp,
|
||||
defaultInputValue,
|
||||
onInputValueChange,
|
||||
onInputBlur,
|
||||
disabled,
|
||||
required,
|
||||
...props
|
||||
}: ComboboxProps,
|
||||
ref: React.ForwardedRef<React.ElementRef<typeof CommandPrimitive>>,
|
||||
) => {
|
||||
const [value = type === "multiple" ? [] : "", setValue] = useControllableState<ComboboxValue<T>>({
|
||||
prop: valueProp as ComboboxValue<T>,
|
||||
defaultProp: defaultValue as ComboboxValue<T>,
|
||||
onChange: onValueChange as (value: ComboboxValue<T>) => void,
|
||||
})
|
||||
const [inputValue = "", setInputValue] = useControllableState({
|
||||
prop: inputValueProp,
|
||||
defaultProp: defaultInputValue,
|
||||
})
|
||||
const [open = false, setOpen] = useControllableState({
|
||||
prop: openProp,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
})
|
||||
const [currentTabStopId, setCurrentTabStopId] = React.useState<string | null>(null)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const tagGroupRef = React.useRef<React.ElementRef<typeof RovingFocusGroupPrimitive.Root>>(null)
|
||||
|
||||
const handleInputValueChange: ComboboxContextProps["onInputValueChange"] = React.useCallback(
|
||||
(inputValue, reason) => {
|
||||
setInputValue(inputValue)
|
||||
onInputValueChange?.(inputValue, reason)
|
||||
},
|
||||
[setInputValue, onInputValueChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<ComboboxContext.Provider
|
||||
value={
|
||||
{
|
||||
type,
|
||||
value,
|
||||
onValueChange: setValue,
|
||||
inputValue,
|
||||
onInputValueChange: handleInputValueChange,
|
||||
onInputBlur,
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
currentTabStopId,
|
||||
onCurrentTabStopIdChange: setCurrentTabStopId,
|
||||
inputRef,
|
||||
tagGroupRef,
|
||||
disabled,
|
||||
required,
|
||||
} as ComboboxContextProps
|
||||
}>
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal={modal}>
|
||||
<CommandPrimitive ref={ref} {...props}>
|
||||
{children}
|
||||
{!open && <CommandPrimitive.List aria-hidden hidden />}
|
||||
</CommandPrimitive>
|
||||
</PopoverPrimitive.Root>
|
||||
</ComboboxContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
Combobox.displayName = "Combobox"
|
||||
|
||||
export const ComboboxTagGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RovingFocusGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Root>
|
||||
>((props, ref) => {
|
||||
const { currentTabStopId, onCurrentTabStopIdChange, tagGroupRef, type } = useComboboxContext()
|
||||
|
||||
if (type !== "multiple") {
|
||||
throw new Error('<ComboboxTagGroup> should only be used when type is "multiple"')
|
||||
}
|
||||
|
||||
const composedRefs = useComposedRefs(ref, tagGroupRef)
|
||||
|
||||
return (
|
||||
<RovingFocusGroupPrimitive.Root
|
||||
ref={composedRefs}
|
||||
tabIndex={-1}
|
||||
currentTabStopId={currentTabStopId}
|
||||
onCurrentTabStopIdChange={onCurrentTabStopIdChange}
|
||||
onBlur={() => onCurrentTabStopIdChange(null)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ComboboxTagGroup.displayName = "ComboboxTagGroup"
|
||||
|
||||
export interface ComboboxTagGroupItemProps
|
||||
extends React.ComponentPropsWithoutRef<typeof RovingFocusGroupPrimitive.Item> {
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ComboboxTagGroupItemContext = React.createContext<Pick<ComboboxTagGroupItemProps, "value" | "disabled">>({
|
||||
value: "",
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const useComboboxTagGroupItemContext = () => React.useContext(ComboboxTagGroupItemContext)
|
||||
|
||||
export const ComboboxTagGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RovingFocusGroupPrimitive.Item>,
|
||||
ComboboxTagGroupItemProps
|
||||
>(({ onClick, onKeyDown, value: valueProp, disabled, ...props }, ref) => {
|
||||
const { value, onValueChange, inputRef, currentTabStopId, type } = useComboboxContext()
|
||||
|
||||
if (type !== "multiple") {
|
||||
throw new Error('<ComboboxTagGroupItem> should only be used when type is "multiple"')
|
||||
}
|
||||
|
||||
const lastItemValue = value.at(-1)
|
||||
|
||||
return (
|
||||
<ComboboxTagGroupItemContext.Provider value={{ value: valueProp, disabled }}>
|
||||
<RovingFocusGroupPrimitive.Item
|
||||
ref={ref}
|
||||
onKeyDown={composeEventHandlers(onKeyDown, (event) => {
|
||||
if (event.key === "Escape") {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
if (event.key === "ArrowRight" && currentTabStopId === lastItemValue) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
if (event.key === "Backspace" || event.key === "Delete") {
|
||||
onValueChange(value.filter((v) => v !== currentTabStopId))
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
})}
|
||||
onClick={composeEventHandlers(onClick, () => disabled && inputRef.current?.focus())}
|
||||
tabStopId={valueProp}
|
||||
focusable={!disabled}
|
||||
data-disabled={disabled}
|
||||
active={valueProp === lastItemValue}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxTagGroupItemContext.Provider>
|
||||
)
|
||||
})
|
||||
ComboboxTagGroupItem.displayName = "ComboboxTagGroupItem"
|
||||
|
||||
export const ComboboxTagGroupItemRemove = React.forwardRef<
|
||||
React.ElementRef<typeof Primitive.button>,
|
||||
React.ComponentPropsWithoutRef<typeof Primitive.button>
|
||||
>(({ onClick, ...props }, ref) => {
|
||||
const { value, onValueChange, type } = useComboboxContext()
|
||||
|
||||
if (type !== "multiple") {
|
||||
throw new Error('<ComboboxTagGroupItemRemove> should only be used when type is "multiple"')
|
||||
}
|
||||
|
||||
const { value: valueProp, disabled } = useComboboxTagGroupItemContext()
|
||||
|
||||
return (
|
||||
<Primitive.button
|
||||
ref={ref}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
onClick={composeEventHandlers(onClick, () => onValueChange(value.filter((v) => v !== valueProp)))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ComboboxTagGroupItemRemove.displayName = "ComboboxTagGroupItemRemove"
|
||||
|
||||
export const ComboboxInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
Omit<React.ComponentProps<typeof CommandPrimitive.Input>, "value" | "onValueChange">
|
||||
>(({ onKeyDown, onMouseDown, onFocus, onBlur, ...props }, ref) => {
|
||||
const {
|
||||
type,
|
||||
inputValue,
|
||||
onInputValueChange,
|
||||
onInputBlur,
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
onValueChange,
|
||||
inputRef,
|
||||
disabled,
|
||||
required,
|
||||
tagGroupRef,
|
||||
} = useComboboxContext()
|
||||
|
||||
const composedRefs = useComposedRefs(ref, inputRef)
|
||||
|
||||
return (
|
||||
<CommandPrimitive.Input
|
||||
ref={composedRefs}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
value={inputValue}
|
||||
onValueChange={(search) => {
|
||||
if (!open) {
|
||||
onOpenChange(true)
|
||||
}
|
||||
// Schedule input value change to the next tick.
|
||||
setTimeout(() => onInputValueChange(search, "inputChange"))
|
||||
if (!search && type === "single") {
|
||||
onValueChange("")
|
||||
}
|
||||
}}
|
||||
onKeyDown={composeEventHandlers(onKeyDown, (event) => {
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
if (!open) {
|
||||
event.preventDefault()
|
||||
onOpenChange(true)
|
||||
}
|
||||
}
|
||||
if (type !== "multiple") {
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowLeft" && !inputValue && value.length) {
|
||||
tagGroupRef.current?.focus()
|
||||
}
|
||||
if (event.key === "Backspace" && !inputValue) {
|
||||
onValueChange(value.slice(0, -1))
|
||||
}
|
||||
})}
|
||||
onMouseDown={composeEventHandlers(onMouseDown, () => onOpenChange(!!inputValue || !open))}
|
||||
onFocus={composeEventHandlers(onFocus, () => onOpenChange(true))}
|
||||
onBlur={composeEventHandlers(onBlur, (event) => {
|
||||
if (!event.relatedTarget?.hasAttribute("cmdk-list")) {
|
||||
onInputBlur?.(event)
|
||||
}
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ComboboxInput.displayName = "ComboboxInput"
|
||||
|
||||
export const ComboboxClear = React.forwardRef<
|
||||
React.ElementRef<typeof Primitive.button>,
|
||||
React.ComponentPropsWithoutRef<typeof Primitive.button>
|
||||
>(({ onClick, ...props }, ref) => {
|
||||
const { value, onValueChange, inputValue, onInputValueChange, type } = useComboboxContext()
|
||||
|
||||
const isValueEmpty = type === "single" ? !value : !value.length
|
||||
|
||||
return (
|
||||
<Primitive.button
|
||||
ref={ref}
|
||||
disabled={isValueEmpty && !inputValue}
|
||||
onClick={composeEventHandlers(onClick, () => {
|
||||
if (type === "single") {
|
||||
onValueChange("")
|
||||
} else {
|
||||
onValueChange([])
|
||||
}
|
||||
onInputValueChange("", "clearClick")
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ComboboxClear.displayName = "ComboboxClear"
|
||||
|
||||
export const ComboboxTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
export const ComboboxAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
export const ComboboxPortal = PopoverPrimitive.Portal
|
||||
|
||||
export const ComboboxContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ children, onOpenAutoFocus, onInteractOutside, ...props }, ref) => (
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
ref={ref}
|
||||
onOpenAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
|
||||
onCloseAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => event.preventDefault())}
|
||||
onInteractOutside={composeEventHandlers(onInteractOutside, (event) => {
|
||||
if (event.target instanceof Element && event.target.hasAttribute("cmdk-input")) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})}
|
||||
{...props}>
|
||||
<CommandPrimitive.List>{children}</CommandPrimitive.List>
|
||||
</PopoverPrimitive.Content>
|
||||
))
|
||||
ComboboxContent.displayName = "ComboboxContent"
|
||||
|
||||
export const ComboboxEmpty = CommandPrimitive.Empty
|
||||
|
||||
export const ComboboxLoading = CommandPrimitive.Loading
|
||||
|
||||
export interface ComboboxItemProps extends Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>, "value"> {
|
||||
value: string
|
||||
}
|
||||
|
||||
const ComboboxItemContext = React.createContext({ isSelected: false })
|
||||
|
||||
const useComboboxItemContext = () => React.useContext(ComboboxItemContext)
|
||||
|
||||
const findComboboxItemText = (children: React.ReactNode) => {
|
||||
let text = ""
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (React.isValidElement<{ children: React.ReactNode }>(child)) {
|
||||
if (child.type === ComboboxItemText) {
|
||||
text = child.props.children as string
|
||||
} else {
|
||||
text = findComboboxItemText(child.props.children)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
export const ComboboxItem = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, ComboboxItemProps>(
|
||||
({ value: valueProp, children, onMouseDown, ...props }, ref) => {
|
||||
const { type, value, onValueChange, onInputValueChange, onOpenChange } = useComboboxContext()
|
||||
|
||||
const inputValue = React.useMemo(() => findComboboxItemText(children), [children])
|
||||
|
||||
const isSelected = type === "single" ? value === valueProp : value.includes(valueProp)
|
||||
|
||||
return (
|
||||
<ComboboxItemContext.Provider value={{ isSelected }}>
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
onMouseDown={composeEventHandlers(onMouseDown, (event) => event.preventDefault())}
|
||||
onSelect={() => {
|
||||
if (type === "multiple") {
|
||||
onValueChange(
|
||||
value.includes(valueProp)
|
||||
? value.filter((v) => v !== valueProp)
|
||||
: [...value, valueProp],
|
||||
)
|
||||
onInputValueChange("", "itemSelect")
|
||||
} else {
|
||||
onValueChange(valueProp)
|
||||
onInputValueChange(inputValue, "itemSelect")
|
||||
// Schedule open change to the next tick.
|
||||
setTimeout(() => onOpenChange(false))
|
||||
}
|
||||
}}
|
||||
value={inputValue}
|
||||
{...props}>
|
||||
{children}
|
||||
</CommandPrimitive.Item>
|
||||
</ComboboxItemContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
ComboboxItem.displayName = "ComboboxItem"
|
||||
|
||||
export const ComboboxItemIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof Primitive.span>,
|
||||
React.ComponentPropsWithoutRef<typeof Primitive.span>
|
||||
>((props, ref) => {
|
||||
const { isSelected } = useComboboxItemContext()
|
||||
|
||||
if (!isSelected) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <Primitive.span ref={ref} aria-hidden {...props} />
|
||||
})
|
||||
ComboboxItemIndicator.displayName = "ComboboxItemIndicator"
|
||||
|
||||
export interface ComboboxItemTextProps extends React.ComponentPropsWithoutRef<typeof React.Fragment> {
|
||||
children: string
|
||||
}
|
||||
|
||||
export const ComboboxItemText = (props: ComboboxItemTextProps) => <React.Fragment {...props} />
|
||||
ComboboxItemText.displayName = "ComboboxItemText"
|
||||
|
||||
export const ComboboxGroup = CommandPrimitive.Group
|
||||
|
||||
export const ComboboxSeparator = CommandPrimitive.Separator
|
||||
|
||||
const Root = Combobox
|
||||
const TagGroup = ComboboxTagGroup
|
||||
const TagGroupItem = ComboboxTagGroupItem
|
||||
const TagGroupItemRemove = ComboboxTagGroupItemRemove
|
||||
const Input = ComboboxInput
|
||||
const Clear = ComboboxClear
|
||||
const Trigger = ComboboxTrigger
|
||||
const Anchor = ComboboxAnchor
|
||||
const Portal = ComboboxPortal
|
||||
const Content = ComboboxContent
|
||||
const Empty = ComboboxEmpty
|
||||
const Loading = ComboboxLoading
|
||||
const Item = ComboboxItem
|
||||
const ItemIndicator = ComboboxItemIndicator
|
||||
const ItemText = ComboboxItemText
|
||||
const Group = ComboboxGroup
|
||||
const Separator = ComboboxSeparator
|
||||
|
||||
export {
|
||||
Root,
|
||||
TagGroup,
|
||||
TagGroupItem,
|
||||
TagGroupItemRemove,
|
||||
Input,
|
||||
Clear,
|
||||
Trigger,
|
||||
Anchor,
|
||||
Portal,
|
||||
Content,
|
||||
Empty,
|
||||
Loading,
|
||||
Item,
|
||||
ItemIndicator,
|
||||
ItemText,
|
||||
Group,
|
||||
Separator,
|
||||
}
|
||||
177
webview-ui/src/components/ui/combobox.tsx
Normal file
177
webview-ui/src/components/ui/combobox.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slottable } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { Check, ChevronsUpDown, Loader, X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as ComboboxPrimitive from "@/components/ui/combobox-primitive"
|
||||
import { badgeVariants } from "@/components/ui/badge"
|
||||
// import * as ComboboxPrimitive from "@/registry/default/ui/combobox-primitive"
|
||||
import {
|
||||
InputBase,
|
||||
InputBaseAdornmentButton,
|
||||
InputBaseControl,
|
||||
InputBaseFlexWrapper,
|
||||
InputBaseInput,
|
||||
} from "@/components/ui/input-base"
|
||||
|
||||
export const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
const ComboboxInputBase = React.forwardRef<
|
||||
React.ElementRef<typeof InputBase>,
|
||||
React.ComponentPropsWithoutRef<typeof InputBase>
|
||||
>(({ children, ...props }, ref) => (
|
||||
<ComboboxPrimitive.Anchor asChild>
|
||||
<InputBase ref={ref} {...props}>
|
||||
{children}
|
||||
<ComboboxPrimitive.Clear asChild>
|
||||
<InputBaseAdornmentButton>
|
||||
<X />
|
||||
</InputBaseAdornmentButton>
|
||||
</ComboboxPrimitive.Clear>
|
||||
<ComboboxPrimitive.Trigger asChild>
|
||||
<InputBaseAdornmentButton>
|
||||
<ChevronsUpDown />
|
||||
</InputBaseAdornmentButton>
|
||||
</ComboboxPrimitive.Trigger>
|
||||
</InputBase>
|
||||
</ComboboxPrimitive.Anchor>
|
||||
))
|
||||
ComboboxInputBase.displayName = "ComboboxInputBase"
|
||||
|
||||
export const ComboboxInput = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
|
||||
>((props, ref) => (
|
||||
<ComboboxInputBase>
|
||||
<InputBaseControl>
|
||||
<ComboboxPrimitive.Input asChild>
|
||||
<InputBaseInput ref={ref} {...props} />
|
||||
</ComboboxPrimitive.Input>
|
||||
</InputBaseControl>
|
||||
</ComboboxInputBase>
|
||||
))
|
||||
ComboboxInput.displayName = "ComboboxInput"
|
||||
|
||||
export const ComboboxTagsInput = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Input>
|
||||
>(({ children, ...props }, ref) => (
|
||||
<ComboboxInputBase>
|
||||
<ComboboxPrimitive.ComboboxTagGroup asChild>
|
||||
<InputBaseFlexWrapper className="flex items-center gap-2">
|
||||
{children}
|
||||
<InputBaseControl>
|
||||
<ComboboxPrimitive.Input asChild>
|
||||
<InputBaseInput ref={ref} {...props} />
|
||||
</ComboboxPrimitive.Input>
|
||||
</InputBaseControl>
|
||||
</InputBaseFlexWrapper>
|
||||
</ComboboxPrimitive.ComboboxTagGroup>
|
||||
</ComboboxInputBase>
|
||||
))
|
||||
ComboboxTagsInput.displayName = "ComboboxTagsInput"
|
||||
|
||||
export const ComboboxTag = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ComboboxTagGroupItem>
|
||||
>(({ children, className, ...props }, ref) => (
|
||||
<ComboboxPrimitive.ComboboxTagGroupItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
badgeVariants({ variant: "outline" }),
|
||||
"group gap-1 pr-1.5 data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}>
|
||||
<Slottable>{children}</Slottable>
|
||||
<ComboboxPrimitive.ComboboxTagGroupItemRemove className="group-data-[disabled]:pointer-events-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</ComboboxPrimitive.ComboboxTagGroupItemRemove>
|
||||
</ComboboxPrimitive.ComboboxTagGroupItem>
|
||||
))
|
||||
ComboboxTag.displayName = "ComboboxTag"
|
||||
|
||||
export const ComboboxContent = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Content>
|
||||
>(({ className, align = "start", alignOffset = 0, ...props }, ref) => (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
"min-w-72 border-vscode-dropdown-border relative z-50 left-0 max-h-96 w-[--radix-popover-trigger-width] overflow-y-auto overflow-x-hidden rounded-xs border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Portal>
|
||||
))
|
||||
ComboboxContent.displayName = "ComboboxContent"
|
||||
|
||||
export const ComboboxEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Empty>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ComboboxPrimitive.Empty ref={ref} className={cn("py-6 text-center text-sm", className)} {...props} />
|
||||
))
|
||||
ComboboxEmpty.displayName = "ComboboxEmpty"
|
||||
|
||||
export const ComboboxLoading = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Loading>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Loading>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ComboboxPrimitive.Loading
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center px-1.5 py-2", className)}
|
||||
{...props}>
|
||||
<Loader className="size-4 animate-spin [mask:conic-gradient(transparent_45deg,_white)]" />
|
||||
</ComboboxPrimitive.Loading>
|
||||
))
|
||||
ComboboxLoading.displayName = "ComboboxLoading"
|
||||
|
||||
export const ComboboxGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ComboboxPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ComboboxGroup.displayName = "ComboboxGroup"
|
||||
|
||||
const ComboboxSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ComboboxPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
|
||||
))
|
||||
ComboboxSeparator.displayName = "ComboboxSeparator"
|
||||
|
||||
export const comboboxItemStyle = cva(
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-vscode-dropdown-foreground data-[disabled=true]:opacity-50",
|
||||
)
|
||||
|
||||
export const ComboboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ComboboxPrimitive.Item>,
|
||||
Omit<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Item>, "children"> &
|
||||
Pick<React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.ItemText>, "children">
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ComboboxPrimitive.Item ref={ref} className={cn(comboboxItemStyle(), className)} {...props}>
|
||||
<ComboboxPrimitive.ItemText>{children}</ComboboxPrimitive.ItemText>
|
||||
<ComboboxPrimitive.ItemIndicator className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<Check className="size-4" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
))
|
||||
ComboboxItem.displayName = "ComboboxItem"
|
||||
|
|
@ -1,96 +1,108 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-vscode-editor-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="cursor-pointer absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
|
|
|||
157
webview-ui/src/components/ui/input-base.tsx
Normal file
157
webview-ui/src/components/ui/input-base.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/* eslint-disable react/jsx-no-comment-textnodes */
|
||||
/* eslint-disable react/jsx-pascal-case */
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { composeEventHandlers } from "@radix-ui/primitive"
|
||||
import { composeRefs } from "@radix-ui/react-compose-refs"
|
||||
import { Primitive } from "@radix-ui/react-primitive"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "./button"
|
||||
|
||||
export type InputBaseContextProps = Pick<InputBaseProps, "autoFocus" | "disabled"> & {
|
||||
controlRef: React.RefObject<HTMLElement>
|
||||
onFocusedChange: (focused: boolean) => void
|
||||
}
|
||||
|
||||
const InputBaseContext = React.createContext<InputBaseContextProps>({
|
||||
autoFocus: false,
|
||||
controlRef: { current: null },
|
||||
disabled: false,
|
||||
onFocusedChange: () => {},
|
||||
})
|
||||
|
||||
const useInputBaseContext = () => React.useContext(InputBaseContext)
|
||||
|
||||
export interface InputBaseProps extends React.ComponentPropsWithoutRef<typeof Primitive.div> {
|
||||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const InputBase = React.forwardRef<React.ElementRef<typeof Primitive.div>, InputBaseProps>(
|
||||
({ autoFocus, disabled, className, onClick, ...props }, ref) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [focused, setFocused] = React.useState(false)
|
||||
|
||||
const controlRef = React.useRef<HTMLElement>(null)
|
||||
|
||||
return (
|
||||
<InputBaseContext.Provider
|
||||
value={{
|
||||
autoFocus,
|
||||
controlRef,
|
||||
disabled,
|
||||
onFocusedChange: setFocused,
|
||||
}}>
|
||||
<Primitive.div
|
||||
ref={ref}
|
||||
onClick={composeEventHandlers(onClick, (event) => {
|
||||
// Based on MUI's <InputBase /> implementation.
|
||||
// https://github.com/mui/material-ui/blob/master/packages/mui-material/src/InputBase/InputBase.js#L458~L460
|
||||
if (controlRef.current && event.currentTarget === event.target) {
|
||||
controlRef.current.focus()
|
||||
}
|
||||
})}
|
||||
className={cn(
|
||||
"flex w-full text-vscode-input-foreground border border-vscode-dropdown-border bg-vscode-input-background rounded-xs px-3 py-0.5 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-0 focus-visible:outline-none focus-visible:border-vscode-focusBorder disabled:cursor-not-allowed disabled:opacity-50",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</InputBaseContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
InputBase.displayName = "InputBase"
|
||||
|
||||
export const InputBaseFlexWrapper = React.forwardRef<
|
||||
React.ElementRef<typeof Primitive.div>,
|
||||
React.ComponentPropsWithoutRef<typeof Primitive.div>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Primitive.div ref={ref} className={cn("flex flex-1 flex-wrap", className)} {...props} />
|
||||
))
|
||||
InputBaseFlexWrapper.displayName = "InputBaseFlexWrapper"
|
||||
|
||||
export const InputBaseControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ onFocus, onBlur, ...props }, ref) => {
|
||||
const { controlRef, autoFocus, disabled, onFocusedChange } = useInputBaseContext()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={composeRefs(controlRef, ref)}
|
||||
autoFocus={autoFocus}
|
||||
onFocus={composeEventHandlers(onFocus, () => onFocusedChange(true))}
|
||||
onBlur={composeEventHandlers(onBlur, () => onFocusedChange(false))}
|
||||
{...{ disabled }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
InputBaseControl.displayName = "InputBaseControl"
|
||||
|
||||
export interface InputBaseAdornmentProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
asChild?: boolean
|
||||
disablePointerEvents?: boolean
|
||||
}
|
||||
|
||||
export const InputBaseAdornment = React.forwardRef<React.ElementRef<"div">, InputBaseAdornmentProps>(
|
||||
({ className, disablePointerEvents, asChild, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : typeof children === "string" ? "p" : "div"
|
||||
|
||||
const isAction = React.isValidElement(children) && children.type === InputBaseAdornmentButton
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center text-muted-foreground [&_svg]:size-4",
|
||||
(!isAction || disablePointerEvents) && "pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
},
|
||||
)
|
||||
InputBaseAdornment.displayName = "InputBaseAdornment"
|
||||
|
||||
export const InputBaseAdornmentButton = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentPropsWithoutRef<typeof Button>
|
||||
>(({ type = "button", variant = "ghost", size = "icon", disabled: disabledProp, className, ...props }, ref) => {
|
||||
const { disabled } = useInputBaseContext()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled || disabledProp}
|
||||
className={cn("size-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
InputBaseAdornmentButton.displayName = "InputBaseAdornmentButton"
|
||||
|
||||
export const InputBaseInput = React.forwardRef<
|
||||
React.ElementRef<typeof Primitive.input>,
|
||||
React.ComponentPropsWithoutRef<typeof Primitive.input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Primitive.input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full flex-1 bg-transparent file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none disabled:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
InputBaseInput.displayName = "InputBaseInput"
|
||||
|
|
@ -11,8 +11,8 @@ const Slider = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
<SliderPrimitive.Track className="relative w-full h-[8px] grow overflow-hidden bg-vscode-button-secondaryBackground border border-[#767676] dark:border-[#858585] rounded-sm">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-vscode-button-background" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-3 w-3 rounded-full border border-primary/50 bg-primary shadow transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -12,16 +12,14 @@ const WelcomeView = () => {
|
|||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const error = validateApiConfiguration(apiConfiguration)
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage(undefined)
|
||||
vscode.postMessage({
|
||||
type: "upsertApiConfiguration",
|
||||
text: currentApiConfigName,
|
||||
apiConfiguration,
|
||||
})
|
||||
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
|
||||
}, [apiConfiguration, currentApiConfigName])
|
||||
|
||||
return (
|
||||
|
|
@ -42,6 +40,8 @@ const WelcomeView = () => {
|
|||
apiConfiguration={apiConfiguration || {}}
|
||||
uriScheme={uriScheme}
|
||||
setApiConfigurationField={(field, value) => setApiConfiguration({ [field]: value })}
|
||||
errorMessage={errorMessage}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,7 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { ApiConfigMeta, ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
|
||||
import {
|
||||
ApiConfiguration,
|
||||
ModelInfo,
|
||||
glamaDefaultModelId,
|
||||
glamaDefaultModelInfo,
|
||||
openRouterDefaultModelId,
|
||||
openRouterDefaultModelInfo,
|
||||
unboundDefaultModelId,
|
||||
unboundDefaultModelInfo,
|
||||
requestyDefaultModelId,
|
||||
requestyDefaultModelInfo,
|
||||
} from "../../../src/shared/api"
|
||||
import { ApiConfiguration } from "../../../src/shared/api"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import { convertTextMateToHljs } from "../utils/textMateToHljs"
|
||||
import { findLastIndex } from "../../../src/shared/array"
|
||||
|
|
@ -26,11 +15,6 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
didHydrateState: boolean
|
||||
showWelcome: boolean
|
||||
theme: any
|
||||
glamaModels: Record<string, ModelInfo>
|
||||
requestyModels: Record<string, ModelInfo>
|
||||
openRouterModels: Record<string, ModelInfo>
|
||||
unboundModels: Record<string, ModelInfo>
|
||||
openAiModels: string[]
|
||||
mcpServers: McpServer[]
|
||||
currentCheckpoint?: string
|
||||
filePaths: string[]
|
||||
|
|
@ -70,7 +54,6 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setRateLimitSeconds: (value: number) => void
|
||||
setCurrentApiConfigName: (value: string) => void
|
||||
setListApiConfigMeta: (value: ApiConfigMeta[]) => void
|
||||
onUpdateApiConfig: (apiConfig: ApiConfiguration) => void
|
||||
mode: Mode
|
||||
setMode: (value: Mode) => void
|
||||
setCustomModePrompts: (value: CustomModePrompts) => void
|
||||
|
|
@ -118,27 +101,15 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
autoApprovalEnabled: false,
|
||||
customModes: [],
|
||||
maxOpenTabsContext: 20,
|
||||
cwd: "",
|
||||
})
|
||||
|
||||
const [didHydrateState, setDidHydrateState] = useState(false)
|
||||
const [showWelcome, setShowWelcome] = useState(false)
|
||||
const [theme, setTheme] = useState<any>(undefined)
|
||||
const [filePaths, setFilePaths] = useState<string[]>([])
|
||||
const [glamaModels, setGlamaModels] = useState<Record<string, ModelInfo>>({
|
||||
[glamaDefaultModelId]: glamaDefaultModelInfo,
|
||||
})
|
||||
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
|
||||
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
|
||||
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
|
||||
})
|
||||
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
|
||||
[unboundDefaultModelId]: unboundDefaultModelInfo,
|
||||
})
|
||||
const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
|
||||
[requestyDefaultModelId]: requestyDefaultModelInfo,
|
||||
})
|
||||
|
||||
const [openAiModels, setOpenAiModels] = useState<string[]>([])
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
|
||||
|
||||
|
|
@ -146,18 +117,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
|
||||
[],
|
||||
)
|
||||
|
||||
const onUpdateApiConfig = useCallback((apiConfig: ApiConfiguration) => {
|
||||
setState((currentState) => {
|
||||
vscode.postMessage({
|
||||
type: "upsertApiConfiguration",
|
||||
text: currentState.currentApiConfigName,
|
||||
apiConfiguration: { ...currentState.apiConfiguration, ...apiConfig },
|
||||
})
|
||||
return currentState // No state update needed
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
|
|
@ -202,40 +161,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
})
|
||||
break
|
||||
}
|
||||
case "glamaModels": {
|
||||
const updatedModels = message.glamaModels ?? {}
|
||||
setGlamaModels({
|
||||
[glamaDefaultModelId]: glamaDefaultModelInfo, // in case the extension sent a model list without the default model
|
||||
...updatedModels,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "openRouterModels": {
|
||||
const updatedModels = message.openRouterModels ?? {}
|
||||
setOpenRouterModels({
|
||||
[openRouterDefaultModelId]: openRouterDefaultModelInfo, // in case the extension sent a model list without the default model
|
||||
...updatedModels,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "openAiModels": {
|
||||
const updatedModels = message.openAiModels ?? []
|
||||
setOpenAiModels(updatedModels)
|
||||
break
|
||||
}
|
||||
case "unboundModels": {
|
||||
const updatedModels = message.unboundModels ?? {}
|
||||
setUnboundModels(updatedModels)
|
||||
break
|
||||
}
|
||||
case "requestyModels": {
|
||||
const updatedModels = message.requestyModels ?? {}
|
||||
setRequestyModels({
|
||||
[requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model
|
||||
...updatedModels,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "mcpServers": {
|
||||
setMcpServers(message.mcpServers ?? [])
|
||||
break
|
||||
|
|
@ -264,11 +189,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
didHydrateState,
|
||||
showWelcome,
|
||||
theme,
|
||||
glamaModels,
|
||||
requestyModels,
|
||||
openRouterModels,
|
||||
openAiModels,
|
||||
unboundModels,
|
||||
mcpServers,
|
||||
currentCheckpoint,
|
||||
filePaths,
|
||||
|
|
@ -316,7 +236,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setRateLimitSeconds: (value) => setState((prevState) => ({ ...prevState, rateLimitSeconds: value })),
|
||||
setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })),
|
||||
setListApiConfigMeta,
|
||||
onUpdateApiConfig,
|
||||
setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
|
||||
setCustomModePrompts: (value) => setState((prevState) => ({ ...prevState, customModePrompts: value })),
|
||||
setCustomSupportPrompts: (value) => setState((prevState) => ({ ...prevState, customSupportPrompts: value })),
|
||||
|
|
|
|||
45
webview-ui/src/utils/__tests__/path-mentions.test.ts
Normal file
45
webview-ui/src/utils/__tests__/path-mentions.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { convertToMentionPath } from "../path-mentions"
|
||||
|
||||
describe("path-mentions", () => {
|
||||
describe("convertToMentionPath", () => {
|
||||
it("should convert an absolute path to a mention path when it starts with cwd", () => {
|
||||
// Windows-style paths
|
||||
expect(convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project")).toBe(
|
||||
"@/file.txt",
|
||||
)
|
||||
|
||||
// Unix-style paths
|
||||
expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project")).toBe("@/file.txt")
|
||||
})
|
||||
|
||||
it("should handle paths with trailing slashes in cwd", () => {
|
||||
expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project/")).toBe("@/file.txt")
|
||||
})
|
||||
|
||||
it("should be case-insensitive when matching paths", () => {
|
||||
expect(convertToMentionPath("/Users/User/Project/file.txt", "/users/user/project")).toBe("@/file.txt")
|
||||
})
|
||||
|
||||
it("should return the original path when cwd is not provided", () => {
|
||||
expect(convertToMentionPath("/Users/user/project/file.txt")).toBe("/Users/user/project/file.txt")
|
||||
})
|
||||
|
||||
it("should return the original path when it does not start with cwd", () => {
|
||||
expect(convertToMentionPath("/Users/other/project/file.txt", "/Users/user/project")).toBe(
|
||||
"/Users/other/project/file.txt",
|
||||
)
|
||||
})
|
||||
|
||||
it("should normalize backslashes to forward slashes", () => {
|
||||
expect(convertToMentionPath("C:\\Users\\user\\project\\subdir\\file.txt", "C:\\Users\\user\\project")).toBe(
|
||||
"@/subdir/file.txt",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle nested paths correctly", () => {
|
||||
expect(convertToMentionPath("/Users/user/project/nested/deeply/file.txt", "/Users/user/project")).toBe(
|
||||
"@/nested/deeply/file.txt",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
38
webview-ui/src/utils/path-mentions.ts
Normal file
38
webview-ui/src/utils/path-mentions.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Utilities for handling path-related operations in mentions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts an absolute path to a mention-friendly path
|
||||
* If the provided path starts with the current working directory,
|
||||
* it's converted to a relative path prefixed with @
|
||||
*
|
||||
* @param path The path to convert
|
||||
* @param cwd The current working directory
|
||||
* @returns A mention-friendly path
|
||||
*/
|
||||
export function convertToMentionPath(path: string, cwd?: string): string {
|
||||
const normalizedPath = path.replace(/\\/g, "/")
|
||||
let normalizedCwd = cwd ? cwd.replace(/\\/g, "/") : ""
|
||||
|
||||
if (!normalizedCwd) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Remove trailing slash from cwd if it exists
|
||||
if (normalizedCwd.endsWith("/")) {
|
||||
normalizedCwd = normalizedCwd.slice(0, -1)
|
||||
}
|
||||
|
||||
// Always use case-insensitive comparison for path matching
|
||||
const lowerPath = normalizedPath.toLowerCase()
|
||||
const lowerCwd = normalizedCwd.toLowerCase()
|
||||
|
||||
if (lowerPath.startsWith(lowerCwd)) {
|
||||
const relativePath = normalizedPath.substring(normalizedCwd.length)
|
||||
// Ensure there's a slash after the @ symbol when we create the mention path
|
||||
return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
|
@ -1,79 +1,83 @@
|
|||
import {
|
||||
ApiConfiguration,
|
||||
glamaDefaultModelId,
|
||||
openRouterDefaultModelId,
|
||||
unboundDefaultModelId,
|
||||
} from "../../../src/shared/api"
|
||||
import { ModelInfo } from "../../../src/shared/api"
|
||||
import { ApiConfiguration, ModelInfo } from "../../../src/shared/api"
|
||||
|
||||
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
|
||||
if (apiConfiguration) {
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "anthropic":
|
||||
if (!apiConfiguration.apiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "glama":
|
||||
if (!apiConfiguration.glamaApiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "bedrock":
|
||||
if (!apiConfiguration.awsRegion) {
|
||||
return "You must choose a region to use with AWS Bedrock."
|
||||
}
|
||||
break
|
||||
case "openrouter":
|
||||
if (!apiConfiguration.openRouterApiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "vertex":
|
||||
if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
|
||||
return "You must provide a valid Google Cloud Project ID and Region."
|
||||
}
|
||||
break
|
||||
case "gemini":
|
||||
if (!apiConfiguration.geminiApiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "openai-native":
|
||||
if (!apiConfiguration.openAiNativeApiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "mistral":
|
||||
if (!apiConfiguration.mistralApiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "openai":
|
||||
if (
|
||||
!apiConfiguration.openAiBaseUrl ||
|
||||
!apiConfiguration.openAiApiKey ||
|
||||
!apiConfiguration.openAiModelId
|
||||
) {
|
||||
return "You must provide a valid base URL, API key, and model ID."
|
||||
}
|
||||
break
|
||||
case "ollama":
|
||||
if (!apiConfiguration.ollamaModelId) {
|
||||
return "You must provide a valid model ID."
|
||||
}
|
||||
break
|
||||
case "lmstudio":
|
||||
if (!apiConfiguration.lmStudioModelId) {
|
||||
return "You must provide a valid model ID."
|
||||
}
|
||||
break
|
||||
case "vscode-lm":
|
||||
if (!apiConfiguration.vsCodeLmModelSelector) {
|
||||
return "You must provide a valid model selector."
|
||||
}
|
||||
break
|
||||
}
|
||||
if (!apiConfiguration) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "openrouter":
|
||||
if (!apiConfiguration.openRouterApiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "glama":
|
||||
if (!apiConfiguration.glamaApiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "unbound":
|
||||
if (!apiConfiguration.unboundApiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "requesty":
|
||||
if (!apiConfiguration.requestyApiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "anthropic":
|
||||
if (!apiConfiguration.apiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "bedrock":
|
||||
if (!apiConfiguration.awsRegion) {
|
||||
return "You must choose a region to use with AWS Bedrock."
|
||||
}
|
||||
break
|
||||
case "vertex":
|
||||
if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
|
||||
return "You must provide a valid Google Cloud Project ID and Region."
|
||||
}
|
||||
break
|
||||
case "gemini":
|
||||
if (!apiConfiguration.geminiApiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "openai-native":
|
||||
if (!apiConfiguration.openAiNativeApiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "mistral":
|
||||
if (!apiConfiguration.mistralApiKey) {
|
||||
return "You must provide a valid API key."
|
||||
}
|
||||
break
|
||||
case "openai":
|
||||
if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) {
|
||||
return "You must provide a valid base URL, API key, and model ID."
|
||||
}
|
||||
break
|
||||
case "ollama":
|
||||
if (!apiConfiguration.ollamaModelId) {
|
||||
return "You must provide a valid model ID."
|
||||
}
|
||||
break
|
||||
case "lmstudio":
|
||||
if (!apiConfiguration.lmStudioModelId) {
|
||||
return "You must provide a valid model ID."
|
||||
}
|
||||
break
|
||||
case "vscode-lm":
|
||||
if (!apiConfiguration.vsCodeLmModelSelector) {
|
||||
return "You must provide a valid model selector."
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
|
@ -82,40 +86,81 @@ export function validateModelId(
|
|||
glamaModels?: Record<string, ModelInfo>,
|
||||
openRouterModels?: Record<string, ModelInfo>,
|
||||
unboundModels?: Record<string, ModelInfo>,
|
||||
requestyModels?: Record<string, ModelInfo>,
|
||||
): string | undefined {
|
||||
if (apiConfiguration) {
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "glama":
|
||||
const glamaModelId = apiConfiguration.glamaModelId || glamaDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
|
||||
if (!glamaModelId) {
|
||||
return "You must provide a model ID."
|
||||
}
|
||||
if (glamaModels && !Object.keys(glamaModels).includes(glamaModelId)) {
|
||||
// even if the model list endpoint failed, extensionstatecontext will always have the default model info
|
||||
return "The model ID you provided is not available. Please choose a different model."
|
||||
}
|
||||
break
|
||||
case "openrouter":
|
||||
const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
|
||||
if (!modelId) {
|
||||
return "You must provide a model ID."
|
||||
}
|
||||
if (openRouterModels && !Object.keys(openRouterModels).includes(modelId)) {
|
||||
// even if the model list endpoint failed, extensionstatecontext will always have the default model info
|
||||
return "The model ID you provided is not available. Please choose a different model."
|
||||
}
|
||||
break
|
||||
case "unbound":
|
||||
const unboundModelId = apiConfiguration.unboundModelId || unboundDefaultModelId
|
||||
if (!unboundModelId) {
|
||||
return "You must provide a model ID."
|
||||
}
|
||||
if (unboundModels && !Object.keys(unboundModels).includes(unboundModelId)) {
|
||||
// even if the model list endpoint failed, extensionstatecontext will always have the default model info
|
||||
return "The model ID you provided is not available. Please choose a different model."
|
||||
}
|
||||
break
|
||||
}
|
||||
if (!apiConfiguration) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "openrouter":
|
||||
const modelId = apiConfiguration.openRouterModelId
|
||||
|
||||
if (!modelId) {
|
||||
return "You must provide a model ID."
|
||||
}
|
||||
|
||||
if (
|
||||
openRouterModels &&
|
||||
Object.keys(openRouterModels).length > 1 &&
|
||||
!Object.keys(openRouterModels).includes(modelId)
|
||||
) {
|
||||
return `The model ID (${modelId}) you provided is not available. Please choose a different model.`
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case "glama":
|
||||
const glamaModelId = apiConfiguration.glamaModelId
|
||||
|
||||
if (!glamaModelId) {
|
||||
return "You must provide a model ID."
|
||||
}
|
||||
|
||||
if (
|
||||
glamaModels &&
|
||||
Object.keys(glamaModels).length > 1 &&
|
||||
!Object.keys(glamaModels).includes(glamaModelId)
|
||||
) {
|
||||
return `The model ID (${glamaModelId}) you provided is not available. Please choose a different model.`
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case "unbound":
|
||||
const unboundModelId = apiConfiguration.unboundModelId
|
||||
|
||||
if (!unboundModelId) {
|
||||
return "You must provide a model ID."
|
||||
}
|
||||
|
||||
if (
|
||||
unboundModels &&
|
||||
Object.keys(unboundModels).length > 1 &&
|
||||
!Object.keys(unboundModels).includes(unboundModelId)
|
||||
) {
|
||||
return `The model ID (${unboundModelId}) you provided is not available. Please choose a different model.`
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case "requesty":
|
||||
const requestyModelId = apiConfiguration.requestyModelId
|
||||
|
||||
if (!requestyModelId) {
|
||||
return "You must provide a model ID."
|
||||
}
|
||||
|
||||
if (
|
||||
requestyModels &&
|
||||
Object.keys(requestyModels).length > 1 &&
|
||||
!Object.keys(requestyModels).includes(requestyModelId)
|
||||
) {
|
||||
return `The model ID (${requestyModelId}) you provided is not available. Please choose a different model.`
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue