mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Fetches all models from gateway during init
This commit is contained in:
parent
29dfe11303
commit
ea3d384e48
11 changed files with 11436 additions and 10881 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler, SingleCompletionHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, UnboundModelId, unboundDefaultModelId, unboundModels } from "../../shared/api"
|
||||
import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
|
||||
|
|
@ -130,15 +130,15 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
|
|||
}
|
||||
}
|
||||
|
||||
getModel(): { id: UnboundModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in unboundModels) {
|
||||
const id = modelId as UnboundModelId
|
||||
return { id, info: unboundModels[id] }
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.unboundModelId
|
||||
const modelInfo = this.options.unboundModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
return {
|
||||
id: unboundDefaultModelId,
|
||||
info: unboundModels[unboundDefaultModelId],
|
||||
info: unboundDefaultModelInfo,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ type GlobalStateKey =
|
|||
| "autoApprovalEnabled"
|
||||
| "customModes" // Array of custom modes
|
||||
| "unboundModelId"
|
||||
| "unboundModelInfo"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
|
|
@ -131,6 +132,7 @@ export const GlobalFileNames = {
|
|||
glamaModels: "glama_models.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
unboundModels: "unbound_models.json",
|
||||
}
|
||||
|
||||
export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
|
|
@ -529,6 +531,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
})
|
||||
|
||||
this.readUnboundModels().then((unboundModels) => {
|
||||
if (unboundModels) {
|
||||
this.postMessageToWebview({ type: "unboundModels", unboundModels })
|
||||
}
|
||||
})
|
||||
this.refreshUnboundModels().then(async (unboundModels) => {
|
||||
if (unboundModels) {
|
||||
const { apiConfiguration } = await this.getState()
|
||||
if (apiConfiguration?.unboundModelId) {
|
||||
await this.updateGlobalState(
|
||||
"unboundModelInfo",
|
||||
unboundModels[apiConfiguration.unboundModelId],
|
||||
)
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.configManager
|
||||
.listConfig()
|
||||
.then(async (listApiConfig) => {
|
||||
|
|
@ -548,7 +568,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
}
|
||||
}
|
||||
|
||||
let currentConfigName = (await this.getGlobalState("currentApiConfigName")) as string
|
||||
const currentConfigName = (await this.getGlobalState("currentApiConfigName")) as string
|
||||
|
||||
if (currentConfigName) {
|
||||
if (!(await this.configManager.hasConfig(currentConfigName))) {
|
||||
|
|
@ -687,6 +707,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.postMessageToWebview({ type: "openAiModels", openAiModels })
|
||||
}
|
||||
break
|
||||
case "refreshUnboundModels":
|
||||
await this.refreshUnboundModels()
|
||||
break
|
||||
case "openImage":
|
||||
openImage(message.text!)
|
||||
break
|
||||
|
|
@ -1124,7 +1147,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
if (message.text && message.apiConfiguration) {
|
||||
try {
|
||||
await this.configManager.saveConfig(message.text, message.apiConfiguration)
|
||||
let listApiConfig = await this.configManager.listConfig()
|
||||
const listApiConfig = await this.configManager.listConfig()
|
||||
|
||||
await Promise.all([
|
||||
this.updateGlobalState("listApiConfigMeta", listApiConfig),
|
||||
|
|
@ -1149,7 +1172,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await this.configManager.saveConfig(newName, message.apiConfiguration)
|
||||
await this.configManager.deleteConfig(oldName)
|
||||
|
||||
let listApiConfig = await this.configManager.listConfig()
|
||||
const listApiConfig = await this.configManager.listConfig()
|
||||
const config = listApiConfig?.find((c) => c.name === newName)
|
||||
|
||||
// Update listApiConfigMeta first to ensure UI has latest data
|
||||
|
|
@ -1207,7 +1230,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
|
||||
|
||||
// If this was the current config, switch to first available
|
||||
let currentApiConfigName = await this.getGlobalState("currentApiConfigName")
|
||||
const currentApiConfigName = await this.getGlobalState("currentApiConfigName")
|
||||
if (message.text === currentApiConfigName && listApiConfig?.[0]?.name) {
|
||||
const apiConfig = await this.configManager.loadConfig(listApiConfig[0].name)
|
||||
await Promise.all([
|
||||
|
|
@ -1227,7 +1250,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
break
|
||||
case "getListApiConfiguration":
|
||||
try {
|
||||
let listApiConfig = await this.configManager.listConfig()
|
||||
const listApiConfig = await this.configManager.listConfig()
|
||||
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
|
||||
this.postMessageToWebview({ type: "listApiConfig", listApiConfig })
|
||||
} catch (error) {
|
||||
|
|
@ -1267,7 +1290,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.outputChannel.appendLine(
|
||||
`Failed to update timeout for ${message.serverName}: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
|
||||
)
|
||||
vscode.window.showErrorMessage(`Failed to update server timeout`)
|
||||
vscode.window.showErrorMessage("Failed to update server timeout")
|
||||
}
|
||||
}
|
||||
break
|
||||
|
|
@ -1395,6 +1418,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
mistralApiKey,
|
||||
unboundApiKey,
|
||||
unboundModelId,
|
||||
unboundModelInfo,
|
||||
} = apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
|
|
@ -1435,6 +1459,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await this.storeSecret("mistralApiKey", mistralApiKey)
|
||||
await this.storeSecret("unboundApiKey", unboundApiKey)
|
||||
await this.updateGlobalState("unboundModelId", unboundModelId)
|
||||
await this.updateGlobalState("unboundModelInfo", unboundModelInfo)
|
||||
if (this.cline) {
|
||||
this.cline.api = buildApiHandler(apiConfiguration)
|
||||
}
|
||||
|
|
@ -1620,7 +1645,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
async refreshGlamaModels() {
|
||||
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
|
||||
|
||||
let models: Record<string, ModelInfo> = {}
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const response = await axios.get("https://glama.ai/api/gateway/v1/models")
|
||||
/*
|
||||
|
|
@ -1710,7 +1735,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
GlobalFileNames.openRouterModels,
|
||||
)
|
||||
|
||||
let models: Record<string, ModelInfo> = {}
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const response = await axios.get("https://openrouter.ai/api/v1/models")
|
||||
/*
|
||||
|
|
@ -1816,6 +1841,52 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
return models
|
||||
}
|
||||
|
||||
async readUnboundModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
|
||||
const fileExists = await fileExistsAtPath(unboundModelsFilePath)
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(unboundModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async refreshUnboundModels() {
|
||||
const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
|
||||
|
||||
const models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const response = await axios.get("http://localhost:8787/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))
|
||||
this.outputChannel.appendLine(`Unbound models fetched and saved: ${JSON.stringify(models, null, 2)}`)
|
||||
} 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<{
|
||||
|
|
@ -2104,6 +2175,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
experiments,
|
||||
unboundApiKey,
|
||||
unboundModelId,
|
||||
unboundModelInfo,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("apiModelId") as Promise<string | undefined>,
|
||||
|
|
@ -2176,6 +2248,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>,
|
||||
this.getSecret("unboundApiKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("unboundModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("unboundModelInfo") as Promise<ModelInfo | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
|
|
@ -2233,6 +2306,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
vsCodeLmModelSelector,
|
||||
unboundApiKey,
|
||||
unboundModelId,
|
||||
unboundModelInfo,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ export interface ExtensionMessage {
|
|||
| "autoApprovalEnabled"
|
||||
| "updateCustomMode"
|
||||
| "deleteCustomMode"
|
||||
| "unboundModels"
|
||||
| "refreshUnboundModels"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
|
|
@ -61,6 +63,7 @@ export interface ExtensionMessage {
|
|||
glamaModels?: Record<string, ModelInfo>
|
||||
openRouterModels?: Record<string, ModelInfo>
|
||||
openAiModels?: string[]
|
||||
unboundModels?: Record<string, ModelInfo>
|
||||
mcpServers?: McpServer[]
|
||||
commits?: GitCommit[]
|
||||
listApiConfig?: ApiConfigMeta[]
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface WebviewMessage {
|
|||
| "refreshGlamaModels"
|
||||
| "refreshOpenRouterModels"
|
||||
| "refreshOpenAiModels"
|
||||
| "refreshUnboundModels"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysAllowMcp"
|
||||
| "alwaysAllowModeSwitch"
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export interface ApiHandlerOptions {
|
|||
includeMaxTokens?: boolean
|
||||
unboundApiKey?: string
|
||||
unboundModelId?: string
|
||||
unboundModelInfo?: ModelInfo
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
|
|
@ -564,7 +565,7 @@ export const deepSeekModels = {
|
|||
supportsPromptCache: false,
|
||||
inputPrice: 0.014, // $0.014 per million tokens
|
||||
outputPrice: 0.28, // $0.28 per million tokens
|
||||
description: `DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally.`,
|
||||
description: "DeepSeek-V3 achieves a significant breakthrough in inference speed over previous models. It tops the leaderboard among open-source models and rivals the most advanced closed-source models globally.",
|
||||
},
|
||||
"deepseek-reasoner": {
|
||||
maxTokens: 8192,
|
||||
|
|
@ -573,7 +574,7 @@ export const deepSeekModels = {
|
|||
supportsPromptCache: false,
|
||||
inputPrice: 0.55, // $0.55 per million tokens
|
||||
outputPrice: 2.19, // $2.19 per million tokens
|
||||
description: `DeepSeek-R1 achieves performance comparable to OpenAI-o1 across math, code, and reasoning tasks.`,
|
||||
description: "DeepSeek-R1 achieves performance comparable to OpenAI-o1 across math, code, and reasoning tasks.",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
|
|
@ -598,12 +599,20 @@ export const mistralModels = {
|
|||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Unbound Security
|
||||
export type UnboundModelId = keyof typeof unboundModels
|
||||
// export type UnboundModelId = keyof typeof unboundModels
|
||||
export const unboundDefaultModelId = "openai/gpt-4o"
|
||||
export const unboundModels = {
|
||||
"anthropic/claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
|
||||
"openai/gpt-4o": openAiNativeModels["gpt-4o"],
|
||||
"deepseek/deepseek-chat": deepSeekModels["deepseek-chat"],
|
||||
"deepseek/deepseek-reasoner": deepSeekModels["deepseek-reasoner"],
|
||||
"mistral/codestral-latest": mistralModels["codestral-latest"],
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
// export const unboundModels = {
|
||||
// "anthropic/claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
|
||||
// "openai/gpt-4o": openAiNativeModels["gpt-4o"],
|
||||
// "deepseek/deepseek-chat": deepSeekModels["deepseek-chat"],
|
||||
// "deepseek/deepseek-reasoner": deepSeekModels["deepseek-reasoner"],
|
||||
// "mistral/codestral-latest": mistralModels["codestral-latest"],
|
||||
// } as const satisfies Record<string, ModelInfo>
|
||||
export const unboundDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
}
|
||||
|
|
|
|||
21702
webview-ui/package-lock.json
generated
21702
webview-ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,7 @@ describe("AutoApproveMenu", () => {
|
|||
glamaModels: {},
|
||||
openRouterModels: {},
|
||||
openAiModels: [],
|
||||
unboundModels: {},
|
||||
mcpServers: [],
|
||||
filePaths: [],
|
||||
experiments: experimentDefault,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
unboundDefaultModelId,
|
||||
unboundModels,
|
||||
unboundDefaultModelInfo,
|
||||
} from "../../../../src/shared/api"
|
||||
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
|
|
@ -40,6 +40,7 @@ import OpenRouterModelPicker, {
|
|||
} from "./OpenRouterModelPicker"
|
||||
import OpenAiModelPicker from "./OpenAiModelPicker"
|
||||
import GlamaModelPicker from "./GlamaModelPicker"
|
||||
import UnboundModelPicker from "./UnboundModelPicker"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
apiErrorMessage?: string
|
||||
|
|
@ -55,6 +56,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
|||
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
|
||||
const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({})
|
||||
|
||||
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
|
|
@ -89,6 +91,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
|||
setLmStudioModels(message.lmStudioModels)
|
||||
} else if (message.type === "vsCodeLmModels" && message.vsCodeLmModels) {
|
||||
setVsCodeLmModels(message.vsCodeLmModels)
|
||||
} else if (message.type === "unboundModels" && message.unboundModels) {
|
||||
setUnboundModels(message.unboundModels)
|
||||
}
|
||||
}, [])
|
||||
useEvent("message", handleMessage)
|
||||
|
|
@ -1312,6 +1316,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
|||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
</p>
|
||||
<UnboundModelPicker />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1334,7 +1339,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
|||
selectedProvider !== "openrouter" &&
|
||||
selectedProvider !== "openai" &&
|
||||
selectedProvider !== "ollama" &&
|
||||
selectedProvider !== "lmstudio" && (
|
||||
selectedProvider !== "lmstudio" &&
|
||||
selectedProvider !== "unbound" && (
|
||||
<>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="model-id">
|
||||
|
|
@ -1347,7 +1353,6 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
|||
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
|
||||
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
|
||||
{selectedProvider === "mistral" && createDropdown(mistralModels)}
|
||||
{selectedProvider === "unbound" && createDropdown(unboundModels)}
|
||||
</div>
|
||||
|
||||
<ModelInfoView
|
||||
|
|
@ -1586,7 +1591,11 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
},
|
||||
}
|
||||
case "unbound":
|
||||
return getProviderData(unboundModels, unboundDefaultModelId)
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
|
||||
selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
|
||||
}
|
||||
default:
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
}
|
||||
|
|
|
|||
430
webview-ui/src/components/settings/UnboundModelPicker.tsx
Normal file
430
webview-ui/src/components/settings/UnboundModelPicker.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { Fzf } from "fzf"
|
||||
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
import { useMount } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { unboundDefaultModelId } from "../../../../src/shared/api"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { highlightFzfMatch } from "../../utils/highlight"
|
||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
|
||||
const UnboundModelPicker: React.FC = () => {
|
||||
const { apiConfiguration, setApiConfiguration, unboundModels, onUpdateApiConfig } = useExtensionState()
|
||||
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.apiModelId || unboundDefaultModelId)
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleModelChange = (newModelId: string) => {
|
||||
const apiConfig = {
|
||||
...apiConfiguration,
|
||||
unboundModelId: newModelId,
|
||||
unboundModelInfo: unboundModels[newModelId],
|
||||
}
|
||||
|
||||
setApiConfiguration(apiConfig)
|
||||
onUpdateApiConfig(apiConfig)
|
||||
setSearchTerm(newModelId)
|
||||
}
|
||||
|
||||
const { selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfiguration?.unboundModelId && apiConfiguration?.unboundModelId !== searchTerm) {
|
||||
setSearchTerm(apiConfiguration?.unboundModelId)
|
||||
}
|
||||
}, [apiConfiguration, searchTerm])
|
||||
|
||||
const debouncedRefreshModels = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
vscode.postMessage({ type: "refreshUnboundModels" })
|
||||
}, 50),
|
||||
[],
|
||||
)
|
||||
|
||||
useMount(() => {
|
||||
debouncedRefreshModels()
|
||||
return () => {
|
||||
debouncedRefreshModels.clear()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfiguration?.unboundApiKey) {
|
||||
debouncedRefreshModels()
|
||||
}
|
||||
}, [apiConfiguration?.unboundApiKey])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const modelIds = useMemo(() => {
|
||||
return Object.keys(unboundModels).sort((a, b) => a.localeCompare(b))
|
||||
}, [unboundModels])
|
||||
|
||||
const searchableItems = useMemo(() => {
|
||||
return modelIds.map((id) => ({
|
||||
id,
|
||||
html: id,
|
||||
}))
|
||||
}, [modelIds])
|
||||
|
||||
const fzf = useMemo(() => {
|
||||
return new Fzf(searchableItems, {
|
||||
selector: (item) => item.html,
|
||||
})
|
||||
}, [searchableItems])
|
||||
|
||||
const modelSearchResults = useMemo(() => {
|
||||
if (!searchTerm) return searchableItems
|
||||
|
||||
const searchResults = fzf.find(searchTerm)
|
||||
return searchResults.map((result) => ({
|
||||
...result.item,
|
||||
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
|
||||
}))
|
||||
}, [searchableItems, searchTerm, fzf])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isDropdownVisible) return
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
|
||||
break
|
||||
case "Enter":
|
||||
event.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
|
||||
handleModelChange(modelSearchResults[selectedIndex].id)
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
setIsDropdownVisible(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hasInfo = useMemo(() => {
|
||||
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
|
||||
}, [modelIds, searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
if (dropdownListRef.current) {
|
||||
dropdownListRef.current.scrollTop = 0
|
||||
}
|
||||
}, [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.model-item-highlight {
|
||||
background-color: var(--vscode-editor-findMatchHighlightBackground);
|
||||
color: inherit;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div>
|
||||
<label htmlFor="model-search">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
<DropdownWrapper ref={dropdownRef}>
|
||||
<VSCodeTextField
|
||||
id="model-search"
|
||||
placeholder="Search and select a model..."
|
||||
value={searchTerm}
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement)?.value
|
||||
setSearchTerm(value)
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onFocus={() => setIsDropdownVisible(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: "100%", zIndex: UNBOUND_MODEL_PICKER_Z_INDEX, position: "relative" }}>
|
||||
{searchTerm && (
|
||||
<div
|
||||
className="input-icon-button codicon codicon-close"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
handleModelChange("")
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
slot="end"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
{isDropdownVisible && (
|
||||
<DropdownList ref={dropdownListRef}>
|
||||
{modelSearchResults.map((item, index) => (
|
||||
<DropdownItem
|
||||
key={item.id}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
isSelected={index === selectedIndex}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onClick={() => {
|
||||
handleModelChange(item.id)
|
||||
setIsDropdownVisible(false)
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.html,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownList>
|
||||
)}
|
||||
</DropdownWrapper>
|
||||
</div>
|
||||
|
||||
{hasInfo ? (
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 0,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
The extension automatically fetches the latest list of models available from your Unbound instance.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnboundModelPicker
|
||||
|
||||
// Dropdown
|
||||
|
||||
const DropdownWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const UNBOUND_MODEL_PICKER_Z_INDEX = 1_000
|
||||
|
||||
const DropdownList = styled.div`
|
||||
position: absolute;
|
||||
top: calc(100% - 3px);
|
||||
left: 0;
|
||||
width: calc(100% - 2px);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-list-activeSelectionBackground);
|
||||
z-index: ${UNBOUND_MODEL_PICKER_Z_INDEX - 1};
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
`
|
||||
|
||||
const DropdownItem = styled.div<{ isSelected?: boolean }>`
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
background-color: ${(props) => (props.isSelected ? "var(--vscode-list-activeSelectionBackground)" : "transparent")};
|
||||
color: ${(props) =>
|
||||
props.isSelected ? "var(--vscode-list-activeSelectionForeground)" : "var(--vscode-foreground)"};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
`
|
||||
|
||||
// Markdown
|
||||
|
||||
const StyledMarkdown = styled.div`
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
p,
|
||||
li,
|
||||
ol,
|
||||
ul {
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 1.5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ModelDescriptionMarkdown = memo(
|
||||
({
|
||||
markdown,
|
||||
key,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
}: {
|
||||
markdown?: string
|
||||
key: string
|
||||
isExpanded: boolean
|
||||
setIsExpanded: (isExpanded: boolean) => void
|
||||
}) => {
|
||||
const [reactContent, setMarkdown] = useRemark()
|
||||
// const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(markdown || "")
|
||||
}, [markdown, setMarkdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
const { scrollHeight } = textRef.current
|
||||
const { clientHeight } = textContainerRef.current
|
||||
const isOverflowing = scrollHeight > clientHeight
|
||||
setShowSeeMore(isOverflowing)
|
||||
// if (!isOverflowing) {
|
||||
// setIsExpanded(false)
|
||||
// }
|
||||
}
|
||||
}, [reactContent, setIsExpanded])
|
||||
|
||||
return (
|
||||
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
|
||||
<div
|
||||
ref={textContainerRef}
|
||||
style={{
|
||||
overflowY: isExpanded ? "auto" : "hidden",
|
||||
position: "relative",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: isExpanded ? "unset" : 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
// whiteSpace: "pre-wrap",
|
||||
// wordBreak: "break-word",
|
||||
// overflowWrap: "anywhere",
|
||||
}}>
|
||||
{reactContent}
|
||||
</div>
|
||||
{!isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: "1.2em",
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
|
||||
}}
|
||||
/>
|
||||
<VSCodeLink
|
||||
style={{
|
||||
// cursor: "pointer",
|
||||
// color: "var(--vscode-textLink-foreground)",
|
||||
fontSize: "inherit",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-sideBar-background)",
|
||||
}}
|
||||
onClick={() => setIsExpanded(true)}>
|
||||
See more
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* {isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
marginLeft: "auto",
|
||||
textAlign: "right",
|
||||
paddingRight: 2,
|
||||
}}
|
||||
onClick={() => setIsExpanded(false)}>
|
||||
See less
|
||||
</div>
|
||||
)} */}
|
||||
</StyledMarkdown>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -8,6 +8,8 @@ import {
|
|||
glamaDefaultModelInfo,
|
||||
openRouterDefaultModelId,
|
||||
openRouterDefaultModelInfo,
|
||||
unboundDefaultModelId,
|
||||
unboundDefaultModelInfo,
|
||||
} from "../../../src/shared/api"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import { convertTextMateToHljs } from "../utils/textMateToHljs"
|
||||
|
|
@ -24,6 +26,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
theme: any
|
||||
glamaModels: Record<string, ModelInfo>
|
||||
openRouterModels: Record<string, ModelInfo>
|
||||
unboundModels: Record<string, ModelInfo>
|
||||
openAiModels: string[]
|
||||
mcpServers: McpServer[]
|
||||
filePaths: string[]
|
||||
|
|
@ -113,6 +116,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
|
||||
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
|
||||
})
|
||||
const [unboundModels, setUnboundModels] = useState<Record<string, ModelInfo>>({
|
||||
[unboundDefaultModelId]: unboundDefaultModelInfo,
|
||||
})
|
||||
|
||||
const [openAiModels, setOpenAiModels] = useState<string[]>([])
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
|
|
@ -208,6 +214,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setOpenAiModels(updatedModels)
|
||||
break
|
||||
}
|
||||
case "unboundModels": {
|
||||
const updatedModels = message.unboundModels ?? {}
|
||||
setUnboundModels(updatedModels)
|
||||
break
|
||||
}
|
||||
case "mcpServers": {
|
||||
setMcpServers(message.mcpServers ?? [])
|
||||
break
|
||||
|
|
@ -235,6 +246,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
glamaModels,
|
||||
openRouterModels,
|
||||
openAiModels,
|
||||
unboundModels,
|
||||
mcpServers,
|
||||
filePaths,
|
||||
soundVolume: state.soundVolume,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { ApiConfiguration, glamaDefaultModelId, openRouterDefaultModelId } from "../../../src/shared/api"
|
||||
import {
|
||||
ApiConfiguration,
|
||||
glamaDefaultModelId,
|
||||
openRouterDefaultModelId,
|
||||
unboundDefaultModelId,
|
||||
} from "../../../src/shared/api"
|
||||
import { ModelInfo } from "../../../src/shared/api"
|
||||
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
|
||||
if (apiConfiguration) {
|
||||
|
|
@ -76,6 +81,7 @@ export function validateModelId(
|
|||
apiConfiguration?: ApiConfiguration,
|
||||
glamaModels?: Record<string, ModelInfo>,
|
||||
openRouterModels?: Record<string, ModelInfo>,
|
||||
unboundModels?: Record<string, ModelInfo>,
|
||||
): string | undefined {
|
||||
if (apiConfiguration) {
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
|
|
@ -99,6 +105,16 @@ export function validateModelId(
|
|||
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
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue