diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4e56881..029d77a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -520,6 +520,11 @@ 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 @@ -704,6 +709,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 01de0af..f17724d 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -17,6 +17,7 @@ export interface ExtensionMessage { | "invoke" | "partialMessage" | "openRouterModels" + | "openAiModels" | "mcpServers" text?: string action?: @@ -33,6 +34,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 507bc79..113aae2 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -28,6 +28,7 @@ export interface WebviewMessage { | "openMention" | "cancelTask" | "refreshOpenRouterModels" + | "refreshOpenAiModels" | "alwaysAllowBrowser" | "alwaysAllowMcp" | "playSound" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 1e21999..ae4a37c 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -35,6 +35,7 @@ import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX, } from "./OpenRouterModelPicker" +import OpenAiModelPicker from "./OpenAiModelPicker" interface ApiOptionsProps { showModelOptions: boolean @@ -438,13 +439,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: placeholder="Enter API Key..."> API Key - - Model ID - +
{ + 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 634f437..c9a8d56 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -17,6 +17,7 @@ export interface ExtensionStateContextType extends ExtensionState { showWelcome: boolean theme: any openRouterModels: Record + openAiModels: string[], mcpServers: McpServer[] filePaths: string[] setApiConfiguration: (config: ApiConfiguration) => void @@ -61,6 +62,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openRouterModels, setOpenRouterModels] = useState>({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, }) + + const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) const handleMessage = useCallback((event: MessageEvent) => { @@ -118,6 +121,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }) break } + case "openAiModels": { + const updatedModels = message.openAiModels ?? [] + setOpenAiModels(updatedModels) + break + } case "mcpServers": { setMcpServers(message.mcpServers ?? []) break @@ -137,6 +145,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode showWelcome, theme, openRouterModels, + openAiModels, mcpServers, filePaths, soundVolume: state.soundVolume,