diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9b1082664cd..c88dcc2051b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1834,6 +1834,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { 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}` } } @@ -1884,7 +1890,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.outputChannel.appendLine("Invalid response from Requesty API") } await fs.writeFile(requestyModelsFilePath, JSON.stringify(models)) - this.outputChannel.appendLine(`Requesty models fetched and saved: ${JSON.stringify(models, null, 2)}`) } catch (error) { this.outputChannel.appendLine( `Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 47b4e10e212..64df27496e2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -251,7 +251,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = value={apiConfiguration?.requestyApiKey || ""} style={{ width: "100%" }} type="password" - onInput={handleInputChange("requestyApiKey")} + onBlur={handleInputChange("requestyApiKey")} placeholder="Enter API Key..."> Requesty API Key diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index f45f857d9d0..b21b37ef0f4 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -1,6 +1,6 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import debounce from "debounce" -import { useMemo, useState, useCallback, useEffect } from "react" +import { useMemo, useState, useCallback, useEffect, useRef } from "react" import { useMount } from "react-use" import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons" @@ -23,15 +23,24 @@ import { vscode } from "../../utils/vscode" import { normalizeApiConfiguration } from "./ApiOptions" import { ModelInfoView } from "./ModelInfoView" -interface ModelPickerProps { +type ModelProvider = "glama" | "openRouter" | "unbound" | "requesty" | "openAi" + +type ModelKeys = `${T}Models` +type ConfigKeys = `${T}ModelId` +type InfoKeys = `${T}ModelInfo` +type RefreshMessageType = `refresh${Capitalize}Models` + +interface ModelPickerProps { defaultModelId: string - modelsKey: "glamaModels" | "openRouterModels" | "unboundModels" | "requestyModels" - configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" | "requestyModelId" - infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" | "requestyModelInfo" - refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels" | "refreshRequestyModels" + modelsKey: ModelKeys + configKey: ConfigKeys + infoKey: InfoKeys + refreshMessageType: RefreshMessageType + refreshValues?: Record serviceName: string serviceUrl: string recommendedModel: string + allowCustomModel?: boolean } export const ModelPicker = ({ @@ -40,25 +49,51 @@ export const ModelPicker = ({ configKey, infoKey, refreshMessageType, + refreshValues, serviceName, serviceUrl, recommendedModel, + allowCustomModel = false, }: 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 | undefined>() - const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState() - const modelIds = useMemo(() => Object.keys(models).sort((a, b) => a.localeCompare(b)), [models]) + 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 { 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 apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: models[modelId] } + 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) @@ -67,16 +102,42 @@ export const ModelPicker = ({ [apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration], ) - const debouncedRefreshModels = useMemo( - () => debounce(() => vscode.postMessage({ type: refreshMessageType }), 50), - [refreshMessageType], - ) + 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() }) + useEffect(() => { + if (!refreshValues) { + prevRefreshValuesRef.current = undefined + return + } + + // 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]) return ( @@ -104,6 +165,17 @@ export const ModelPicker = ({ ))} + {allowCustomModel && ( + + { + setIsCustomModel(true) + setOpen(false) + }}> + + Add custom model + + + )} @@ -125,6 +197,28 @@ export const ModelPicker = ({ onSelect(recommendedModel)}>{recommendedModel}. You can also try searching "free" for no-cost options currently available.

+ {allowCustomModel && isCustomModel && ( +
+
+

Add Custom Model

+ setCustomModelId(e.target.value)} + /> +
+ + +
+
+
+ )} ) } diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index a8243547c67..040da1d4210 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -1,217 +1,27 @@ -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import debounce from "debounce" -import { Fzf } from "fzf" -import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react" - +import React from "react" import { useExtensionState } from "../../context/ExtensionStateContext" -import { vscode } from "../../utils/vscode" -import { highlightFzfMatch } from "../../utils/highlight" -import { DropdownWrapper, DropdownList, DropdownItem } from "./styles" +import { ModelPicker } from "./ModelPicker" const OpenAiModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState() - const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "") - const [isDropdownVisible, setIsDropdownVisible] = useState(false) - const [selectedIndex, setSelectedIndex] = useState(-1) - const dropdownRef = useRef(null) - const itemRefs = useRef<(HTMLDivElement | null)[]>([]) - const dropdownListRef = useRef(null) - - const handleModelChange = (newModelId: string) => { - // could be setting invalid model id/undefined info but validation will catch it - const apiConfig = { - ...apiConfiguration, - openAiModelId: newModelId, - } - - setApiConfiguration(apiConfig) - onUpdateApiConfig(apiConfig) - setSearchTerm(newModelId) - } - - useEffect(() => { - if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) { - setSearchTerm(apiConfiguration?.openAiModelId) - } - }, [apiConfiguration, searchTerm]) - - const debouncedRefreshModels = useMemo( - () => - debounce((baseUrl: string, apiKey: string) => { - vscode.postMessage({ - type: "refreshOpenAiModels", - values: { - baseUrl, - apiKey, - }, - }) - }, 50), - [], - ) - - useEffect(() => { - if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) { - return - } - - debouncedRefreshModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey) - - // Cleanup debounced function - return () => { - debouncedRefreshModels.clear() - } - }, [apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey, debouncedRefreshModels]) - - 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 openAiModels.sort((a, b) => a.localeCompare(b)) - }, [openAiModels]) - - 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) => { - 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 - } - } - - 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]) + const { apiConfiguration } = useExtensionState() return ( - <> - -
- - { - handleModelChange((e.target as HTMLInputElement)?.value) - setIsDropdownVisible(true) - }} - onFocus={() => setIsDropdownVisible(true)} - onKeyDown={handleKeyDown} - style={{ width: "100%", zIndex: OPENAI_MODEL_PICKER_Z_INDEX, position: "relative" }}> - {searchTerm && ( -
{ - handleModelChange("") - setIsDropdownVisible(true) - }} - slot="end" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - height: "100%", - }} - /> - )} - - {isDropdownVisible && ( - - {modelSearchResults.map((item, index) => ( - (itemRefs.current[index] = el)} - onMouseEnter={() => setSelectedIndex(index)} - onClick={() => { - handleModelChange(item.id) - setIsDropdownVisible(false) - }} - dangerouslySetInnerHTML={{ - __html: item.html, - }} - /> - ))} - - )} - -
- + ) } export default OpenAiModelPicker - -// Dropdown - -export const OPENAI_MODEL_PICKER_Z_INDEX = 1_000 diff --git a/webview-ui/src/components/settings/RequestyModelPicker.tsx b/webview-ui/src/components/settings/RequestyModelPicker.tsx index bdc2db07448..e0759a43ba1 100644 --- a/webview-ui/src/components/settings/RequestyModelPicker.tsx +++ b/webview-ui/src/components/settings/RequestyModelPicker.tsx @@ -1,15 +1,22 @@ import { ModelPicker } from "./ModelPicker" import { requestyDefaultModelId } from "../../../../src/shared/api" +import { useExtensionState } from "@/context/ExtensionStateContext" -export const RequestyModelPicker = () => ( - -) +export const RequestyModelPicker = () => { + const { apiConfiguration } = useExtensionState() + return ( + + ) +} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 6f4a196d2a8..3fec42f932a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -151,7 +151,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode vscode.postMessage({ type: "upsertApiConfiguration", text: currentState.currentApiConfigName, - apiConfiguration: apiConfig, + apiConfiguration: { ...currentState.apiConfiguration, ...apiConfig }, }) return currentState // No state update needed })