ModelPicker.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { useMemo, useState, useCallback, useEffect, useRef } from "react"
  2. import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
  3. import { Trans } from "react-i18next"
  4. import { ChevronsUpDown, Check, X } from "lucide-react"
  5. import { useAppTranslation } from "@/i18n/TranslationContext"
  6. import { cn } from "@/lib/utils"
  7. import {
  8. Command,
  9. CommandEmpty,
  10. CommandGroup,
  11. CommandInput,
  12. CommandItem,
  13. CommandList,
  14. Popover,
  15. PopoverContent,
  16. PopoverTrigger,
  17. Button,
  18. } from "@/components/ui"
  19. import { ApiConfiguration, ModelInfo } from "../../../../src/shared/api"
  20. import { normalizeApiConfiguration } from "./ApiOptions"
  21. import { ThinkingBudget } from "./ThinkingBudget"
  22. import { ModelInfoView } from "./ModelInfoView"
  23. type ExtractType<T> = NonNullable<
  24. { [K in keyof ApiConfiguration]: Required<ApiConfiguration>[K] extends T ? K : never }[keyof ApiConfiguration]
  25. >
  26. type ModelIdKeys = NonNullable<
  27. { [K in keyof ApiConfiguration]: K extends `${string}ModelId` ? K : never }[keyof ApiConfiguration]
  28. >
  29. interface ModelPickerProps {
  30. defaultModelId: string
  31. defaultModelInfo?: ModelInfo
  32. models: Record<string, ModelInfo> | null
  33. modelIdKey: ModelIdKeys
  34. modelInfoKey: ExtractType<ModelInfo>
  35. serviceName: string
  36. serviceUrl: string
  37. apiConfiguration: ApiConfiguration
  38. setApiConfigurationField: <K extends keyof ApiConfiguration>(field: K, value: ApiConfiguration[K]) => void
  39. }
  40. export const ModelPicker = ({
  41. defaultModelId,
  42. models,
  43. modelIdKey,
  44. modelInfoKey,
  45. serviceName,
  46. serviceUrl,
  47. apiConfiguration,
  48. setApiConfigurationField,
  49. defaultModelInfo,
  50. }: ModelPickerProps) => {
  51. const { t } = useAppTranslation()
  52. const [open, setOpen] = useState(false)
  53. const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
  54. const isInitialized = useRef(false)
  55. const searchInputRef = useRef<HTMLInputElement>(null)
  56. const modelIds = useMemo(() => Object.keys(models ?? {}).sort((a, b) => a.localeCompare(b)), [models])
  57. const { selectedModelId, selectedModelInfo } = useMemo(
  58. () => normalizeApiConfiguration(apiConfiguration),
  59. [apiConfiguration],
  60. )
  61. const [searchValue, setSearchValue] = useState(selectedModelId || "")
  62. const onSelect = useCallback(
  63. (modelId: string) => {
  64. if (!modelId) return
  65. setOpen(false)
  66. const modelInfo = models?.[modelId]
  67. setApiConfigurationField(modelIdKey, modelId)
  68. setApiConfigurationField(modelInfoKey, modelInfo ?? defaultModelInfo)
  69. // Delay to ensure the popover is closed before setting the search value.
  70. setTimeout(() => setSearchValue(modelId), 100)
  71. },
  72. [modelIdKey, modelInfoKey, models, setApiConfigurationField, defaultModelInfo],
  73. )
  74. const onOpenChange = useCallback(
  75. (open: boolean) => {
  76. setOpen(open)
  77. // Abandon the current search if the popover is closed.
  78. if (!open) {
  79. // Delay to ensure the popover is closed before setting the search value.
  80. setTimeout(() => setSearchValue(selectedModelId), 100)
  81. }
  82. },
  83. [selectedModelId],
  84. )
  85. const onClearSearch = useCallback(() => {
  86. setSearchValue("")
  87. searchInputRef.current?.focus()
  88. }, [])
  89. useEffect(() => {
  90. if (!selectedModelId && !isInitialized.current) {
  91. const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId
  92. setApiConfigurationField(modelIdKey, initialValue)
  93. }
  94. isInitialized.current = true
  95. }, [modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId])
  96. return (
  97. <>
  98. <div>
  99. <label className="block font-medium mb-1">{t("settings:modelPicker.label")}</label>
  100. <Popover open={open} onOpenChange={onOpenChange}>
  101. <PopoverTrigger asChild>
  102. <Button
  103. variant="combobox"
  104. role="combobox"
  105. aria-expanded={open}
  106. className="w-full justify-between">
  107. <div>{selectedModelId ?? t("settings:common.select")}</div>
  108. <ChevronsUpDown className="opacity-50" />
  109. </Button>
  110. </PopoverTrigger>
  111. <PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
  112. <Command>
  113. <div className="relative">
  114. <CommandInput
  115. ref={searchInputRef}
  116. value={searchValue}
  117. onValueChange={setSearchValue}
  118. placeholder={t("settings:modelPicker.searchPlaceholder")}
  119. className="h-9 mr-4"
  120. data-testid="model-input"
  121. />
  122. {searchValue.length > 0 && (
  123. <div className="absolute right-2 top-0 bottom-0 flex items-center justify-center">
  124. <X
  125. className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
  126. onClick={onClearSearch}
  127. />
  128. </div>
  129. )}
  130. </div>
  131. <CommandList>
  132. <CommandEmpty>
  133. {searchValue && (
  134. <div className="py-2 px-1 text-sm">
  135. {t("settings:modelPicker.noMatchFound")}
  136. </div>
  137. )}
  138. </CommandEmpty>
  139. <CommandGroup>
  140. {modelIds.map((model) => (
  141. <CommandItem key={model} value={model} onSelect={onSelect}>
  142. {model}
  143. <Check
  144. className={cn(
  145. "size-4 p-0.5 ml-auto",
  146. model === selectedModelId ? "opacity-100" : "opacity-0",
  147. )}
  148. />
  149. </CommandItem>
  150. ))}
  151. </CommandGroup>
  152. </CommandList>
  153. {searchValue && !modelIds.includes(searchValue) && (
  154. <div className="p-1 border-t border-vscode-input-border">
  155. <CommandItem data-testid="use-custom-model" value={searchValue} onSelect={onSelect}>
  156. {t("settings:modelPicker.useCustomModel", { modelId: searchValue })}
  157. </CommandItem>
  158. </div>
  159. )}
  160. </Command>
  161. </PopoverContent>
  162. </Popover>
  163. </div>
  164. {selectedModelId && selectedModelInfo && (
  165. <ModelInfoView
  166. selectedModelId={selectedModelId}
  167. modelInfo={selectedModelInfo}
  168. isDescriptionExpanded={isDescriptionExpanded}
  169. setIsDescriptionExpanded={setIsDescriptionExpanded}
  170. />
  171. )}
  172. <ThinkingBudget
  173. apiConfiguration={apiConfiguration}
  174. setApiConfigurationField={setApiConfigurationField}
  175. modelInfo={selectedModelInfo}
  176. />
  177. <div className="text-sm text-vscode-descriptionForeground">
  178. <Trans
  179. i18nKey="settings:modelPicker.automaticFetch"
  180. components={{
  181. serviceLink: <VSCodeLink href={serviceUrl} className="text-sm" />,
  182. defaultModelLink: <VSCodeLink onClick={() => onSelect(defaultModelId)} className="text-sm" />,
  183. }}
  184. values={{
  185. serviceName,
  186. defaultModelId,
  187. }}
  188. />
  189. </div>
  190. </>
  191. )
  192. }