diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 997823b28e..896088a03e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -580,6 +580,14 @@ export class ClineProvider implements vscode.WebviewViewProvider { case "refreshOpenRouterModels": await this.refreshOpenRouterModels() break + case "refreshOpenAiModels": + const { apiConfiguration } = await this.getState() + const openAiModels = await this.getOpenAiModels( + apiConfiguration.openAiBaseUrl, + apiConfiguration.openAiApiKey, + ) + this.postMessageToWebview({ type: "openAiModels", openAiModels }) + break case "openImage": openImage(message.text!) break @@ -839,6 +847,32 @@ export class ClineProvider implements vscode.WebviewViewProvider { } } + // OpenAi + + async getOpenAiModels(baseUrl?: string, apiKey?: string) { + try { + if (!baseUrl) { + return [] + } + + if (!URL.canParse(baseUrl)) { + return [] + } + + const config: Record = {} + 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(modelsArray)] + return models + } catch (error) { + return [] + } + } + // OpenRouter async handleOpenRouterCallback(code: string) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index af1b25d03a..164b7c101b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -20,6 +20,7 @@ export interface ExtensionMessage { | "invoke" | "partialMessage" | "openRouterModels" + | "openAiModels" | "mcpServers" | "relinquishControl" | "vsCodeLmModels" @@ -42,6 +43,7 @@ export interface ExtensionMessage { filePaths?: string[] partialMessage?: ClineMessage openRouterModels?: Record + openAiModels?: string[] mcpServers?: McpServer[] } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index f2d41d31e1..f5fb6eb9be 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -25,6 +25,7 @@ export interface WebviewMessage { | "openMention" | "cancelTask" | "refreshOpenRouterModels" + | "refreshOpenAiModels" | "openMcpSettings" | "restartMcpServer" | "autoApprovalSettings" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 0638c55bf5..d19443cf93 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -38,9 +38,10 @@ import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" import { vscode } from "../../utils/vscode" import VSCodeButtonLink from "../common/VSCodeButtonLink" -import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterModelPicker" import styled from "styled-components" import * as vscodemodels from "vscode" +import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker" +import OpenAiModelPicker from "./OpenAiModelPicker" interface ApiOptionsProps { showModelOptions: boolean @@ -84,10 +85,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => { - setApiConfiguration({ - ...apiConfiguration, - [field]: event.target.value, - }) + setApiConfiguration({ ...apiConfiguration, [field]: event.target.value }) } const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => { @@ -97,10 +95,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is // Poll ollama/lmstudio models const requestLocalModels = useCallback(() => { if (selectedProvider === "ollama") { - vscode.postMessage({ - type: "requestOllamaModels", - text: apiConfiguration?.ollamaBaseUrl, - }) + vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl }) } else if (selectedProvider === "lmstudio") { vscode.postMessage({ type: "requestLmStudioModels", @@ -174,10 +169,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is id="api-provider" value={selectedProvider} onChange={handleInputChange("apiProvider")} - style={{ - minWidth: 130, - position: "relative", - }}> + style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> OpenRouter Anthropic Google Gemini @@ -210,10 +202,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is const isChecked = e.target.checked === true setAnthropicBaseUrlSelected(isChecked) if (!isChecked) { - setApiConfiguration({ - ...apiConfiguration, - anthropicBaseUrl: "", - }) + setApiConfiguration({ ...apiConfiguration, anthropicBaseUrl: "" }) } }}> {t("useCustomBaseUrl")} @@ -373,12 +362,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is )} {selectedProvider === "bedrock" && ( -
+
{ const isChecked = e.target.checked === true - setApiConfiguration({ - ...apiConfiguration, - awsUseCrossRegionInference: isChecked, - }) + setApiConfiguration({ ...apiConfiguration, awsUseCrossRegionInference: isChecked }) }}> {t("useCrossRegionInference")} @@ -463,12 +444,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is )} {apiConfiguration?.apiProvider === "vertex" && ( -
+
{t("apiKey")} - - {t("modelId")} - + {t("model")} + { const isChecked = e.target.checked === true setAzureApiVersionSelected(isChecked) if (!isChecked) { - setApiConfiguration({ - ...apiConfiguration, - azureApiVersion: "", - }) + setApiConfiguration({ ...apiConfiguration, azureApiVersion: "" }) } }}> - {t("useAzureApiVersion")} + {t("setAzureApiVersion")} {azureApiVersionSelected && ( +

{infoItems.map((item, index) => ( {item} @@ -997,11 +960,7 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration): selectedModelId = defaultId selectedModelInfo = models[defaultId] } - return { - selectedProvider: provider, - selectedModelId, - selectedModelInfo, - } + return { selectedProvider: provider, selectedModelId, selectedModelInfo } } switch (provider) { case "anthropic": diff --git a/webview-ui/src/components/settings/OpenAiModelPicker.tsx b/webview-ui/src/components/settings/OpenAiModelPicker.tsx new file mode 100644 index 0000000000..82bac0b7b4 --- /dev/null +++ b/webview-ui/src/components/settings/OpenAiModelPicker.tsx @@ -0,0 +1,360 @@ +import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import Fuse from "fuse.js" +import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" +import { useRemark } from "react-remark" +import styled from "styled-components" +import { useExtensionState } from "../../context/ExtensionStateContext" +import { vscode } from "../../utils/vscode" +import { highlight } from "../history/HistoryView" + +const OpenAiModelPicker: React.FC = () => { + const { apiConfiguration, setApiConfiguration, openAiModels } = 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 + setApiConfiguration({ + ...apiConfiguration, + openAiModelId: newModelId, + }) + setSearchTerm(newModelId) + } + + useEffect(() => { + vscode.postMessage({ type: "refreshOpenAiModels" }) + }, [apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey]) + + 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 fuse = useMemo(() => { + return new Fuse(searchableItems, { + keys: ["html"], // highlight function will update this + threshold: 0.6, + shouldSort: true, + isCaseSensitive: false, + ignoreLocation: false, + includeMatches: true, + minMatchCharLength: 1, + }) + }, [searchableItems]) + + const modelSearchResults = useMemo(() => { + let results: { id: string; html: string }[] = searchTerm + ? highlight(fuse.search(searchTerm), "model-item-highlight") + : searchableItems + // results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched + return results + }, [searchableItems, searchTerm, fuse]) + + 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]) + + return ( + <> + +

+ + { + handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase()) + 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)} + isSelected={index === selectedIndex} + onMouseEnter={() => setSelectedIndex(index)} + onClick={() => { + handleModelChange(item.id) + setIsDropdownVisible(false) + }} + dangerouslySetInnerHTML={{ + __html: item.html, + }} + /> + ))} + + )} + +
+ + ) +} + +export default OpenAiModelPicker + +// Dropdown + +const DropdownWrapper = styled.div` + position: relative; + width: 100%; +` + +export const OPENAI_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: ${OPENAI_MODEL_PICKER_Z_INDEX - 1}; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +` + +const DropdownItem = styled.div<{ isSelected: boolean }>` + padding: 5px 10px; + cursor: pointer; + word-break: break-all; + white-space: normal; + + background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")}; + + &:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } +` + +// 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(null) + const textRef = useRef(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 ( + +
+
+ {reactContent} +
+ {!isExpanded && showSeeMore && ( +
+
+ setIsExpanded(true)}> + See more + +
+ )} +
+ + ) + }, +) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index c2c973ebc9..4bb141e7b7 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -15,6 +15,7 @@ interface ExtensionStateContextType extends ExtensionState { showWelcome: boolean theme: any openRouterModels: Record + openAiModels: string[] mcpServers: McpServer[] filePaths: string[] setApiConfiguration: (config: ApiConfiguration) => void @@ -45,6 +46,8 @@ export const ExtensionStateContextProvider: React.FC<{ const [openRouterModels, setOpenRouterModels] = useState>({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, }) + + const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) const handleMessage = useCallback((event: MessageEvent) => { @@ -105,6 +108,11 @@ export const ExtensionStateContextProvider: React.FC<{ }) break } + case "openAiModels": { + const updatedModels = message.openAiModels ?? [] + setOpenAiModels(updatedModels) + break + } case "mcpServers": { setMcpServers(message.mcpServers ?? []) break @@ -124,6 +132,7 @@ export const ExtensionStateContextProvider: React.FC<{ showWelcome, theme, openRouterModels, + openAiModels, mcpServers, filePaths, setApiConfiguration: (value) => diff --git a/webview-ui/src/locales/de/translation.json b/webview-ui/src/locales/de/translation.json index bda2862b81..38bd488e24 100644 --- a/webview-ui/src/locales/de/translation.json +++ b/webview-ui/src/locales/de/translation.json @@ -24,27 +24,32 @@ "apiKey": "API-Schlüssel", "enterBaseUrl": "Basis-URL eingeben...", "baseUrl": "Basis-URL", + "optionalBaseUrl": "Basis-URL (optional)", "enterModelId": "Modell-ID eingeben...", "modelId": "Modell-ID", "useCustomBaseUrl": "Benutzerdefinierte Basis-URL verwenden", "apiKeyInfo": "Dieser Schlüssel wird lokal gespeichert und nur verwendet, um API-Anfragen von dieser Erweiterung zu stellen.", - "getApiKeyMessage": "Sie können einen {{vendor}}-API-Schlüssel erhalten, indem Sie sich hier anmelden.", - "getApiVendorKey": "{{vendor}}-API-Schlüssel", + "getDefault": "Standard: {{defaultValue}}", + "getApiKeyMessage": "Sie können einen {{vendor}} API-Schlüssel erhalten, indem Sie sich hier anmelden.", + "getApiVendorKey": "{{vendor}} API-Schlüssel", "getCompatibleVendor": "{{vendor}} kompatibel", + "lmStudioInfo": "LM Studio ermöglicht es Ihnen, Modelle lokal auf Ihrem Computer auszuführen. Anweisungen zum Einstieg finden Sie in ihrem Schnellstart-Handbuch. Sie müssen auch die lokale Server-Funktion von LM Studio starten, um sie mit dieser Erweiterung zu verwenden. (Hinweis: Cline verwendet komplexe Prompts und funktioniert am besten mit Claude-Modellen. Weniger leistungsfähige Modelle funktionieren möglicherweise nicht wie erwartet.)", + "ollamaInfo": "Ollama ermöglicht es Ihnen, Modelle lokal auf Ihrem Computer auszuführen. Anweisungen zum Einstieg finden Sie in ihrem Schnellstart-Handbuch. (Hinweis: Cline verwendet komplexe Prompts und funktioniert am besten mit Claude-Modellen. Weniger leistungsfähige Modelle funktionieren möglicherweise nicht wie erwartet.)", + "azureInfo": "(Hinweis: Cline verwendet komplexe Prompts und funktioniert am besten mit Claude-Modellen. Weniger leistungsfähige Modelle funktionieren möglicherweise nicht wie erwartet.)", + "setAzureApiVersion": "Azure API-Version festlegen", "enterGcpProjectId": "Projekt-ID eingeben...", "gcpProjectId": "Google Cloud Projekt-ID", "gcpLinks": "Um Google Cloud Vertex AI zu verwenden, müssen Sie 1) ein Google Cloud-Konto erstellen › die Vertex AI API aktivieren › die gewünschten Claude-Modelle aktivieren,
2) die Google Cloud CLI installieren › Anwendungsstandardanmeldeinformationen konfigurieren. ", - "enterAwsAccessKey": "Zugriffsschlüssel eingeben...", - "awsAccessKey": "AWS-Zugriffsschlüssel", + "enterAwsAccessKey": "Zugangsschlüssel eingeben...", + "awsAccessKey": "AWS Zugangsschlüssel", "enterAwsSecretKey": "Geheimschlüssel eingeben...", - "awsSecretKey": "AWS-Geheimschlüssel", + "awsSecretKey": "AWS Geheimschlüssel", "enterAwsSessionToken": "Sitzungstoken eingeben...", - "awsSessionToken": "AWS-Sitzungstoken", - "awsRegion": "AWS-Region", - "getRegion": "{{vendor}}-Region", + "awsSessionToken": "AWS Sitzungstoken", + "getRegion": "{{vendor}} Region", "selectRegion": "Region auswählen...", "useCrossRegionInference": "Regionsübergreifende Inferenz verwenden", - "awsInfo": "Authentifizieren Sie sich entweder durch Eingabe der oben genannten Schlüssel oder verwenden Sie die Standard-AWS-Anmeldeinformationen, d.h. ~/.aws/credentials oder Umgebungsvariablen. Diese Anmeldeinformationen werden nur lokal verwendet, um API-Anfragen von dieser Erweiterung zu stellen.", + "awsInfo": "Authentifizieren Sie sich entweder durch die Angabe der oben genannten Schlüssel oder verwenden Sie die Standard-AWS-Anmeldeinformationen, d.h. ~/.aws/credentials oder Umgebungsvariablen. Diese Anmeldeinformationen werden nur lokal verwendet, um API-Anfragen von dieser Erweiterung zu stellen.", "vscodeLanguageModelsInfo": "Die VS Code Language Model API ermöglicht es Ihnen, Modelle zu verwenden, die von anderen VS Code-Erweiterungen bereitgestellt werden (einschließlich, aber nicht beschränkt auf GitHub Copilot). Der einfachste Weg, um loszulegen, ist die Installation der Copilot-Erweiterung aus dem VS Marketplace und die Aktivierung von Claude 3.5 Sonnet.", "experimentalFeature": "Hinweis: Dies ist eine sehr experimentelle Integration und funktioniert möglicherweise nicht wie erwartet.", "supportsImages": "Unterstützt Bilder", @@ -54,13 +59,13 @@ "supportsPromptCache": "Unterstützt Prompt-Caching", "doesNotSupportPromptCache": "Unterstützt kein Prompt-Caching", "maxOutput": "Maximale Ausgabe", - "tokens": "Token", + "tokens": "Tokens", "inputPrice": "Eingabepreis", - "millionTokens": "Millionen Token", - "cacheWritesPrice": "Preis für Cache-Schreibvorgänge", - "cacheReadsPrice": "Preis für Cache-Lesevorgänge", + "millionTokens": "Millionen Tokens", + "cacheWritesPrice": "Cache-Schreibpreis", + "cacheReadsPrice": "Cache-Lesepreis", "outputPrice": "Ausgabepreis", - "geminiInfo": "* Kostenlos bis zu {{selectedModelId}} Anfragen pro Minute. Danach hängt die Abrechnung von der Promptgröße ab.", + "geminiInfo": "* Kostenlos bis zu {{selectedModelId}} Anfragen pro Minute. Danach hängt die Abrechnung von der Prompt-Größe ab.", "pricingDetails": "Weitere Informationen finden Sie in den Preisdaten.", "languageModel": "Sprachmodell" }