From 26a315b9464c616a9ccffb7bed0846135fc9ac77 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 11 Feb 2025 00:36:31 +0700 Subject: [PATCH 1/6] refactor: unify model picker components - Extend ModelPicker to support OpenAI models and refreshValues - Refactor OpenAiModelPicker to use unified ModelPicker component - Add refreshValues support for Requesty API key --- .../src/components/settings/ModelPicker.tsx | 35 ++- .../components/settings/OpenAiModelPicker.tsx | 225 ++---------------- .../settings/RequestyModelPicker.tsx | 31 ++- 3 files changed, 63 insertions(+), 228 deletions(-) diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index f45f857d9d0..82606c5aa28 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -25,10 +25,16 @@ import { ModelInfoView } from "./ModelInfoView" 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: "glamaModels" | "openRouterModels" | "unboundModels" | "requestyModels" | "openAiModels" + configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" | "requestyModelId" | "openAiModelId" + infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" | "requestyModelInfo" | "openAiModelInfo" + refreshMessageType: + | "refreshGlamaModels" + | "refreshOpenRouterModels" + | "refreshUnboundModels" + | "refreshRequestyModels" + | "refreshOpenAiModels" + refreshValues?: Record serviceName: string serviceUrl: string recommendedModel: string @@ -40,6 +46,7 @@ export const ModelPicker = ({ configKey, infoKey, refreshMessageType, + refreshValues, serviceName, serviceUrl, recommendedModel, @@ -49,7 +56,10 @@ export const ModelPicker = ({ const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState() - const modelIds = useMemo(() => Object.keys(models).sort((a, b) => a.localeCompare(b)), [models]) + const modelIds = useMemo( + () => (Array.isArray(models) ? models : Object.keys(models)).sort((a, b) => a.localeCompare(b)), + [models], + ) const { selectedModelId, selectedModelInfo } = useMemo( () => normalizeApiConfiguration(apiConfiguration), @@ -58,7 +68,10 @@ export const ModelPicker = ({ 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) @@ -68,8 +81,14 @@ export const ModelPicker = ({ ) const debouncedRefreshModels = useMemo( - () => debounce(() => vscode.postMessage({ type: refreshMessageType }), 50), - [refreshMessageType], + () => + debounce(() => { + const message = refreshValues + ? { type: refreshMessageType, values: refreshValues } + : { type: refreshMessageType } + vscode.postMessage(message) + }, 50), + [refreshMessageType, refreshValues], ) useMount(() => { diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx index a8243547c67..4b987c7cf98 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -1,217 +1,26 @@ -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 ( + + ) +} From 793b13ea284d029db1659600419589a681d9d7fc Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 11 Feb 2025 00:43:45 +0700 Subject: [PATCH 2/6] remove verbose --- src/core/webview/ClineProvider.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9b1082664cd..c765fa4707b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1884,7 +1884,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)}`, From e9d9e03b72d0add75340635c8ae4f3901418fbe7 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 11 Feb 2025 01:48:55 +0700 Subject: [PATCH 3/6] fix: improve model picker refresh handling and API config updates - Add proper change detection for refreshValues using deep comparison - Increase debounce timing from 50ms to 100ms for better performance - Add proper error handling for missing Requesty API key - Fix apiConfiguration update to properly merge with existing state - Remove debug console logs --- src/core/webview/ClineProvider.ts | 6 +++ .../src/components/settings/ModelPicker.tsx | 46 ++++++++++++++----- .../src/context/ExtensionStateContext.tsx | 2 +- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c765fa4707b..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}` } } diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index 82606c5aa28..f6b6197d82a 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" @@ -54,8 +54,10 @@ export const ModelPicker = ({ const [open, setOpen] = useState(false) const [value, setValue] = useState(defaultModelId) const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) + const prevRefreshValuesRef = useRef | undefined>() + + const { apiConfiguration, [modelsKey]: models, onUpdateApiConfig, setApiConfiguration } = useExtensionState() - const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState() const modelIds = useMemo( () => (Array.isArray(models) ? models : Object.keys(models)).sort((a, b) => a.localeCompare(b)), [models], @@ -80,22 +82,42 @@ export const ModelPicker = ({ [apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration], ) - const debouncedRefreshModels = useMemo( - () => - debounce(() => { - const message = refreshValues - ? { type: refreshMessageType, values: refreshValues } - : { type: refreshMessageType } - vscode.postMessage(message) - }, 50), - [refreshMessageType, refreshValues], - ) + 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 ( 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 }) From fabaf07a3fa7d6e3f2d7afc0c16fc94e42985a64 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 11 Feb 2025 01:51:50 +0700 Subject: [PATCH 4/6] pr comment --- .../src/components/settings/ModelPicker.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index f6b6197d82a..deb1062fdc4 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -23,17 +23,19 @@ 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" | "openAiModels" - configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" | "requestyModelId" | "openAiModelId" - infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" | "requestyModelInfo" | "openAiModelInfo" - refreshMessageType: - | "refreshGlamaModels" - | "refreshOpenRouterModels" - | "refreshUnboundModels" - | "refreshRequestyModels" - | "refreshOpenAiModels" + modelsKey: ModelKeys + configKey: ConfigKeys + infoKey: InfoKeys + refreshMessageType: RefreshMessageType refreshValues?: Record serviceName: string serviceUrl: string From bbc955503641acc507f15b653382cebca35a62d5 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 11 Feb 2025 02:08:49 +0700 Subject: [PATCH 5/6] feat: add custom model input support to ModelPicker - Add allowCustomModel prop to control custom model feature - Implement custom model input dialog with proper VSCode theming - Enable custom model support in OpenAiModelPicker - Fix z-index issues with modal dialog --- .../src/components/settings/ModelPicker.tsx | 51 +++++++++++++++++++ .../components/settings/OpenAiModelPicker.tsx | 1 + 2 files changed, 52 insertions(+) diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index deb1062fdc4..b21b37ef0f4 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -40,6 +40,7 @@ interface ModelPickerProps { serviceName: string serviceUrl: string recommendedModel: string + allowCustomModel?: boolean } export const ModelPicker = ({ @@ -52,7 +53,10 @@ export const ModelPicker = ({ 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) @@ -70,6 +74,20 @@ export const ModelPicker = ({ [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) @@ -147,6 +165,17 @@ export const ModelPicker = ({ ))} + {allowCustomModel && ( + + { + setIsCustomModel(true) + setOpen(false) + }}> + + Add custom model + + + )} @@ -168,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 4b987c7cf98..040da1d4210 100644 --- a/webview-ui/src/components/settings/OpenAiModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -19,6 +19,7 @@ const OpenAiModelPicker: React.FC = () => { serviceName="OpenAI" serviceUrl="https://platform.openai.com" recommendedModel="gpt-4-turbo-preview" + allowCustomModel={true} /> ) } From 1653263adf4be8ba6d6bf79a854f2a96af4fbec4 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 11 Feb 2025 02:09:07 +0700 Subject: [PATCH 6/6] pr comment --- webview-ui/src/components/settings/ApiOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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