diff --git a/apps/vscode/webview-ui/src/components/settings/providers/LMStudioProvider.tsx b/apps/vscode/webview-ui/src/components/settings/providers/LMStudioProvider.tsx index 0b206937e7..bcaddb2ccb 100644 --- a/apps/vscode/webview-ui/src/components/settings/providers/LMStudioProvider.tsx +++ b/apps/vscode/webview-ui/src/components/settings/providers/LMStudioProvider.tsx @@ -1,14 +1,16 @@ +import { type ModelInfo, openAiModelInfoSafeDefaults } from "@shared/api" import type { Mode } from "@shared/storage/types" import { VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { useCallback, useEffect, useMemo, useState } from "react" import { useInterval } from "react-use" import UseCustomPromptCheckbox from "@/components/settings/UseCustomPromptCheckbox" import { useExtensionState } from "@/context/ExtensionStateContext" +import { useProviderConfig } from "@/hooks/useProviderConfig" +import { useProviderModelSelection } from "@/hooks/useProviderModelSelection" import { ModelsServiceClient } from "@/services/grpc-client" import { BaseUrlField } from "../common/BaseUrlField" import { DebouncedTextField } from "../common/DebouncedTextField" import { DropdownContainer } from "../common/ModelSelector" -import { getModeSpecificFields } from "../utils/providerUtils" import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers" /** @@ -38,19 +40,68 @@ interface LMStudioApiModel { */ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => { const { apiConfiguration } = useExtensionState() - const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers() - - const { lmStudioModelId } = getModeSpecificFields(apiConfiguration, currentMode) + const { handleFieldChange } = useApiConfigurationHandlers() + const { config, write, commitSelection } = useProviderConfig("lmstudio") const [lmStudioModels, setLmStudioModels] = useState([]) + const [pendingSelectedModelId, setPendingSelectedModelId] = useState(undefined) + const toLmStudioModelInfo = useCallback((model: LMStudioApiModel | undefined, modelId: string): ModelInfo => { + const contextWindow = model?.loaded_context_length ?? model?.max_context_length + return { + ...openAiModelInfoSafeDefaults, + name: modelId, + ...(contextWindow !== undefined && contextWindow > 0 ? { contextWindow } : {}), + ...(model?.max_context_length !== undefined && model.max_context_length > 0 + ? { maxTokens: model.max_context_length } + : {}), + } + }, []) + const lmStudioModelInfoById = useMemo( + () => Object.fromEntries(lmStudioModels.map((model) => [model.id, toLmStudioModelInfo(model, model.id)])), + [lmStudioModels, toLmStudioModelInfo], + ) + const { selectedModel, commitModelSelection } = useProviderModelSelection("lmstudio", currentMode, { + models: lmStudioModelInfoById, + config, + commitSelection, + fallbackModelInfo: openAiModelInfoSafeDefaults, + customModelInfo: (modelId) => toLmStudioModelInfo(undefined, modelId), + }) + const displayedSelectedModelId = pendingSelectedModelId ?? selectedModel.modelId const currentLMStudioModel = useMemo( - () => lmStudioModels.find((model) => model.id === lmStudioModelId), - [lmStudioModels, lmStudioModelId], + () => lmStudioModels.find((model) => model.id === displayedSelectedModelId), + [displayedSelectedModelId, lmStudioModels], ) const endpoint = useMemo( - () => apiConfiguration?.lmStudioBaseUrl || "http://localhost:1234", - [apiConfiguration?.lmStudioBaseUrl], + () => config?.baseUrl ?? apiConfiguration?.lmStudioBaseUrl ?? "http://localhost:1234", + [apiConfiguration?.lmStudioBaseUrl, config?.baseUrl], + ) + + const handleBaseUrlChange = useCallback( + (value: string) => { + void write({ baseUrl: value }).catch((error) => console.error("Failed to update LM Studio base URL:", error)) + }, + [write], + ) + + const handleModelChange = useCallback( + (modelId: string) => { + const trimmedModelId = modelId.trim() + if (!trimmedModelId) { + return + } + setPendingSelectedModelId(trimmedModelId) + const model = lmStudioModels.find((candidate) => candidate.id === trimmedModelId) + void commitModelSelection({ + modelId: trimmedModelId, + modelInfo: toLmStudioModelInfo(model, trimmedModelId), + }).catch((error) => { + console.error("Failed to update LM Studio model selection:", error) + setPendingSelectedModelId(undefined) + }) + }, + [commitModelSelection, lmStudioModels, toLmStudioModelInfo], ) // Poll LM Studio models @@ -71,11 +122,17 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => { useEffect(() => { requestLmStudioModels() - }, []) + }, [requestLmStudioModels]) const lmStudioMaxTokens = currentLMStudioModel?.max_context_length?.toString() const currentLoadedContext = currentLMStudioModel?.loaded_context_length?.toString() + useEffect(() => { + if (pendingSelectedModelId && selectedModel.modelId === pendingSelectedModelId) { + setPendingSelectedModelId(undefined) + } + }, [pendingSelectedModelId, selectedModel.modelId]) + useEffect(() => { const curr = currentLMStudioModel?.loaded_context_length?.toString() const max = currentLMStudioModel?.max_context_length?.toString() @@ -95,9 +152,9 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => { return (
handleFieldChange("lmStudioBaseUrl", value)} + onChange={handleBaseUrlChange} placeholder="Default: http://localhost:1234" /> @@ -108,16 +165,11 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => { className="w-full mb-3" onChange={(e: any) => { const value = e?.target?.value - handleModeFieldChange( - { - plan: "planModeLmStudioModelId", - act: "actModeLmStudioModelId", - }, - value, - currentMode, - ) + if (typeof value === "string") { + handleModelChange(value) + } }} - value={lmStudioModelId}> + value={displayedSelectedModelId}> {lmStudioModels.map((model) => ( {model.id} @@ -127,17 +179,8 @@ export const LMStudioProvider = ({ currentMode }: LMStudioProviderProps) => { ) : ( - handleModeFieldChange( - { - plan: "planModeLmStudioModelId", - act: "actModeLmStudioModelId", - }, - value, - currentMode, - ) - } + initialValue={displayedSelectedModelId || ""} + onChange={handleModelChange} placeholder={"e.g. meta-llama-3.1-8b-instruct"} style={{ width: "100%" }} /> diff --git a/apps/vscode/webview-ui/src/components/settings/providers/OllamaProvider.tsx b/apps/vscode/webview-ui/src/components/settings/providers/OllamaProvider.tsx index 5f1d3ce768..d8a4ba099d 100644 --- a/apps/vscode/webview-ui/src/components/settings/providers/OllamaProvider.tsx +++ b/apps/vscode/webview-ui/src/components/settings/providers/OllamaProvider.tsx @@ -1,17 +1,20 @@ +import { openAiModelInfoSafeDefaults } from "@shared/api" import { StringRequest } from "@shared/proto/cline/common" import { Mode } from "@shared/storage/types" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useInterval } from "react-use" import UseCustomPromptCheckbox from "@/components/settings/UseCustomPromptCheckbox" import { useExtensionState } from "@/context/ExtensionStateContext" +import { useProviderConfig } from "@/hooks/useProviderConfig" +import { useProviderModelSelection } from "@/hooks/useProviderModelSelection" import { ModelsServiceClient } from "@/services/grpc-client" import { ApiKeyField } from "../common/ApiKeyField" import { BaseUrlField } from "../common/BaseUrlField" import { DebouncedTextField } from "../common/DebouncedTextField" import OllamaModelPicker from "../OllamaModelPicker" -import { getModeSpecificFields } from "../utils/providerUtils" import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers" +import { useProviderApiKeyField } from "../utils/useProviderApiKeyField" /** * Props for the OllamaProvider component @@ -27,18 +30,49 @@ interface OllamaProviderProps { */ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: OllamaProviderProps) => { const { apiConfiguration } = useExtensionState() - const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers() - - const { ollamaModelId } = getModeSpecificFields(apiConfiguration, currentMode) + const { handleFieldChange } = useApiConfigurationHandlers() + const { config, write, commitSelection } = useProviderConfig("ollama") const [ollamaModels, setOllamaModels] = useState([]) + const ollamaBaseUrl = config?.baseUrl ?? apiConfiguration?.ollamaBaseUrl + const ollamaModelInfo = useMemo(() => { + const contextWindow = Number.parseInt(apiConfiguration?.ollamaApiOptionsCtxNum || "", 10) + return { + ...openAiModelInfoSafeDefaults, + ...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow } : {}), + } + }, [apiConfiguration?.ollamaApiOptionsCtxNum]) + const ollamaModelInfoById = useMemo( + () => Object.fromEntries(ollamaModels.map((modelId) => [modelId, { ...ollamaModelInfo, name: modelId }])), + [ollamaModelInfo, ollamaModels], + ) + const { selectedModel, commitModelSelection } = useProviderModelSelection("ollama", currentMode, { + models: ollamaModelInfoById, + config, + commitSelection, + fallbackModelInfo: ollamaModelInfo, + customModelInfo: (modelId) => ({ ...ollamaModelInfo, name: modelId }), + }) + const { savedApiKeyMask, handleApiKeyChange } = useProviderApiKeyField({ + apiKeyLength: config?.apiKeyLength, + providerName: "Ollama", + write, + }) + + const handleBaseUrlChange = useCallback( + (value: string) => { + void write({ baseUrl: value }).catch((error) => console.error("Failed to update Ollama base URL:", error)) + }, + [write], + ) + // Poll ollama models const requestOllamaModels = useCallback(async () => { try { const response = await ModelsServiceClient.getOllamaModels( StringRequest.create({ - value: apiConfiguration?.ollamaBaseUrl || "", + value: ollamaBaseUrl || "", }), ) if (response && response.values) { @@ -48,7 +82,7 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam console.error("Failed to fetch Ollama models:", error) setOllamaModels([]) } - }, [apiConfiguration?.ollamaBaseUrl]) + }, [ollamaBaseUrl]) useEffect(() => { requestOllamaModels() @@ -59,17 +93,17 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam return (
handleFieldChange("ollamaBaseUrl", value)} + onChange={handleBaseUrlChange} placeholder="Default: http://localhost:11434" /> - {apiConfiguration?.ollamaBaseUrl && ( + {ollamaBaseUrl && ( handleFieldChange("ollamaApiKey", value)} + initialValue={savedApiKeyMask} + onChange={handleApiKeyChange} placeholder="Enter API Key (optional)..." providerName="Ollama" /> @@ -82,10 +116,17 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam { - handleModeFieldChange({ plan: "planModeOllamaModelId", act: "actModeOllamaModelId" }, modelId, currentMode) + const trimmedModelId = modelId.trim() + if (!trimmedModelId) { + return + } + void commitModelSelection({ + modelId: trimmedModelId, + modelInfo: { ...ollamaModelInfo, name: trimmedModelId }, + }).catch((error) => console.error("Failed to update Ollama model selection:", error)) }} placeholder={ollamaModels.length > 0 ? "Search and select a model..." : "e.g. llama3.1"} - selectedModelId={ollamaModelId || ""} + selectedModelId={selectedModel.modelId || ""} /> {/* Show status message based on model availability */} @@ -98,7 +139,21 @@ export const OllamaProvider = ({ showModelOptions, isPopup, currentMode }: Ollam handleFieldChange("ollamaApiOptionsCtxNum", v || undefined)} + onChange={(v) => { + handleFieldChange("ollamaApiOptionsCtxNum", v || undefined) + + const contextWindow = Number.parseInt(v, 10) + if (selectedModel.modelId) { + void commitModelSelection({ + modelId: selectedModel.modelId, + modelInfo: { + ...openAiModelInfoSafeDefaults, + name: selectedModel.modelId, + ...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow } : {}), + }, + }).catch((error) => console.error("Failed to update Ollama context window:", error)) + } + }} placeholder={"e.g. 32768"} style={{ width: "100%" }}> Model Context Window