Просмотр исходного кода

Replace the LiteLLM model selector with autocomplete (#9075)

* Replace the LiteLLM model selector with autocomplete

* Add changeset

* refactor
Tomás Barreiro 2 месяцев назад
Родитель
Сommit
a1f2601

+ 5 - 0
.changeset/bright-rooms-wonder.md

@@ -0,0 +1,5 @@
+---
+"cline": patch
+---
+
+Replace the LiteLLM model list with a selector

+ 271 - 0
webview-ui/src/components/settings/common/ModelAutocomplete.tsx

@@ -0,0 +1,271 @@
+import { ModelInfo } from "@shared/api"
+import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import Fuse from "fuse.js"
+import { KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react"
+import styled from "styled-components"
+import { highlight } from "../../history/HistoryView"
+
+interface ModelAutocompleteProps {
+	models: Record<string, ModelInfo>
+	selectedModelId: string | undefined
+	onChange: (modelId: string, modelInfo: ModelInfo | undefined) => void
+	zIndex?: number
+	label?: string
+	placeholder?: string
+}
+
+const AUTOCOMPLETE_Z_INDEX = 1_000
+
+export const ModelAutocomplete = ({
+	models,
+	selectedModelId,
+	onChange,
+	zIndex = AUTOCOMPLETE_Z_INDEX,
+	label = "Model",
+	placeholder = "Search and select a model...",
+}: ModelAutocompleteProps) => {
+	const [searchTerm, setSearchTerm] = useState(selectedModelId || "")
+	const [isDropdownVisible, setIsDropdownVisible] = useState(false)
+	const [selectedIndex, setSelectedIndex] = useState(-1)
+	const dropdownRef = useRef<HTMLDivElement>(null)
+	const itemRefs = useRef<(HTMLDivElement | null)[]>([])
+	const dropdownListRef = useRef<HTMLDivElement>(null)
+	const isSelectingRef = useRef(false) // Track if user is clicking a dropdown item
+
+	// Generate unique IDs for accessibility
+	const uniqueId = useId()
+	const inputId = `model-autocomplete-${uniqueId}`
+	const listboxId = `model-listbox-${uniqueId}`
+
+	useEffect(() => {
+		setSearchTerm(selectedModelId || "")
+	}, [selectedModelId])
+
+	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 Object.keys(models).sort((a, b) => a.localeCompare(b))
+	}, [models])
+
+	const searchableItems = useMemo(() => {
+		return modelIds.map((id) => ({
+			id,
+			html: id,
+		}))
+	}, [modelIds])
+
+	const fuse = useMemo(() => {
+		return new Fuse(searchableItems, {
+			keys: ["html"],
+			threshold: 0.6,
+			shouldSort: true,
+			isCaseSensitive: false,
+			ignoreLocation: false,
+			includeMatches: true,
+			minMatchCharLength: 1,
+		})
+	}, [searchableItems])
+
+	const modelSearchResults = useMemo(() => {
+		if (!searchTerm) {
+			return searchableItems
+		}
+		return highlight(fuse.search(searchTerm), "model-item-highlight")
+	}, [searchableItems, searchTerm, fuse])
+
+	const handleModelChange = (newModelId: string) => {
+		setSearchTerm(newModelId)
+		const modelInfo = models[newModelId]
+		onChange(newModelId, modelInfo)
+	}
+
+	const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
+		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)
+				} else {
+					// User typed a custom model ID
+					handleModelChange(searchTerm)
+					setIsDropdownVisible(false)
+				}
+				break
+			case "Escape":
+				setIsDropdownVisible(false)
+				setSelectedIndex(-1)
+				break
+		}
+	}
+
+	// Reset selection when search term changes
+	useEffect(() => {
+		setSelectedIndex(-1)
+		if (dropdownListRef.current) {
+			dropdownListRef.current.scrollTop = 0
+		}
+	}, [searchTerm])
+
+	// Scroll selected item into view
+	useEffect(() => {
+		if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
+			itemRefs.current[selectedIndex]?.scrollIntoView({
+				block: "nearest",
+				behavior: "smooth",
+			})
+		}
+	}, [selectedIndex])
+
+	const activeDescendantId = selectedIndex >= 0 ? `${listboxId}-option-${selectedIndex}` : undefined
+
+	return (
+		<div style={{ width: "100%", paddingBottom: 2 }}>
+			<style>
+				{`
+				.model-item-highlight {
+					background-color: var(--vscode-editor-findMatchHighlightBackground);
+					color: inherit;
+				}
+				`}
+			</style>
+			<div style={{ display: "flex", flexDirection: "column" }}>
+				<label htmlFor={inputId}>
+					<span style={{ fontWeight: 500 }}>{label}</span>
+				</label>
+
+				<DropdownWrapper ref={dropdownRef}>
+					<VSCodeTextField
+						aria-activedescendant={activeDescendantId}
+						aria-autocomplete="list"
+						aria-controls={isDropdownVisible ? listboxId : undefined}
+						aria-expanded={isDropdownVisible}
+						id={inputId}
+						onBlur={() => {
+							// Delay to allow click events on dropdown items to fire first
+							setTimeout(() => {
+								if (!isSelectingRef.current && searchTerm !== selectedModelId) {
+									handleModelChange(searchTerm)
+								}
+								isSelectingRef.current = false
+							}, 150)
+						}}
+						onFocus={() => setIsDropdownVisible(true)}
+						onInput={(e) => {
+							setSearchTerm((e.target as HTMLInputElement)?.value || "")
+							setIsDropdownVisible(true)
+						}}
+						onKeyDown={handleKeyDown}
+						placeholder={placeholder}
+						style={{
+							width: "100%",
+							zIndex: zIndex,
+							position: "relative",
+						}}
+						value={searchTerm}>
+						{searchTerm && (
+							<div
+								aria-label="Clear search"
+								className="input-icon-button codicon codicon-close"
+								onClick={() => {
+									setSearchTerm("")
+									setIsDropdownVisible(true)
+								}}
+								slot="end"
+								style={{
+									display: "flex",
+									justifyContent: "center",
+									alignItems: "center",
+									height: "100%",
+								}}
+							/>
+						)}
+					</VSCodeTextField>
+					{isDropdownVisible && (
+						<DropdownList
+							aria-label="Model suggestions"
+							id={listboxId}
+							ref={dropdownListRef}
+							role="listbox"
+							style={{ zIndex: zIndex - 1 }}>
+							{modelSearchResults.map((item, index) => (
+								<DropdownItem
+									aria-selected={index === selectedIndex}
+									id={`${listboxId}-option-${index}`}
+									isSelected={index === selectedIndex}
+									key={item.id}
+									onClick={() => {
+										handleModelChange(item.id)
+										setIsDropdownVisible(false)
+										isSelectingRef.current = false
+									}}
+									onMouseDown={() => {
+										isSelectingRef.current = true
+									}}
+									onMouseEnter={() => setSelectedIndex(index)}
+									ref={(el) => (itemRefs.current[index] = el)}
+									role="option">
+									<span dangerouslySetInnerHTML={{ __html: item.html }} />
+								</DropdownItem>
+							))}
+						</DropdownList>
+					)}
+				</DropdownWrapper>
+			</div>
+		</div>
+	)
+}
+
+const DropdownWrapper = styled.div`
+	position: relative;
+	width: 100%;
+`
+
+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);
+	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);
+	}
+`

+ 5 - 7
webview-ui/src/components/settings/providers/LiteLlmProvider.tsx

@@ -1,3 +1,4 @@
+import { ModelInfo } from "@shared/api"
 import { UpdateApiConfigurationRequestNew } from "@shared/proto/index.cline"
 import { Mode } from "@shared/storage/types"
 import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
@@ -6,8 +7,8 @@ import { useState } from "react"
 import { useExtensionState } from "@/context/ExtensionStateContext"
 import { ModelsServiceClient } from "@/services/grpc-client"
 import { DebouncedTextField } from "../common/DebouncedTextField"
+import { ModelAutocomplete } from "../common/ModelAutocomplete"
 import { ModelInfoView } from "../common/ModelInfoView"
-import { ModelSelector } from "../common/ModelSelector"
 import { LockIcon, RemotelyConfiguredInputWrapper } from "../common/RemotelyConfiguredInputWrapper"
 import ThinkingBudgetSlider from "../ThinkingBudgetSlider"
 import { normalizeApiConfiguration } from "../utils/providerUtils"
@@ -31,11 +32,7 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
 	// Get the normalized configuration with model info
 	const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, currentMode)
 
-	// Handle model change
-	const handleModelChange = (e: any) => {
-		const newModelId = e.target.value
-		const modelInfo = liteLlmModels[newModelId]
-
+	const handleModelChange = (newModelId: string, modelInfo: ModelInfo | undefined) => {
 		handleModeFieldsChange(
 			{
 				liteLlmModelId: { plan: "planModeLiteLlmModelId", act: "actModeLiteLlmModelId" },
@@ -112,10 +109,11 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
 			</RemotelyConfiguredInputWrapper>
 			{showModelOptions && (
 				<>
-					<ModelSelector
+					<ModelAutocomplete
 						label="Model"
 						models={liteLlmModels}
 						onChange={handleModelChange}
+						placeholder="Search or enter a custom model ID..."
 						selectedModelId={selectedModelId}
 					/>
 					<VSCodeButton