OpenAiModelPicker.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
  2. import { Fzf } from "fzf"
  3. import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
  4. import { useRemark } from "react-remark"
  5. import styled from "styled-components"
  6. import { useExtensionState } from "../../context/ExtensionStateContext"
  7. import { vscode } from "../../utils/vscode"
  8. import { highlightFzfMatch } from "../../utils/highlight"
  9. const OpenAiModelPicker: React.FC = () => {
  10. const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
  11. const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "")
  12. const [isDropdownVisible, setIsDropdownVisible] = useState(false)
  13. const [selectedIndex, setSelectedIndex] = useState(-1)
  14. const dropdownRef = useRef<HTMLDivElement>(null)
  15. const itemRefs = useRef<(HTMLDivElement | null)[]>([])
  16. const dropdownListRef = useRef<HTMLDivElement>(null)
  17. const handleModelChange = (newModelId: string) => {
  18. // could be setting invalid model id/undefined info but validation will catch it
  19. const apiConfig = {
  20. ...apiConfiguration,
  21. openAiModelId: newModelId,
  22. }
  23. setApiConfiguration(apiConfig)
  24. onUpdateApiConfig(apiConfig)
  25. setSearchTerm(newModelId)
  26. }
  27. useEffect(() => {
  28. if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) {
  29. setSearchTerm(apiConfiguration?.openAiModelId)
  30. }
  31. }, [apiConfiguration, searchTerm])
  32. useEffect(() => {
  33. if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
  34. return
  35. }
  36. vscode.postMessage({
  37. type: "refreshOpenAiModels", values: {
  38. baseUrl: apiConfiguration?.openAiBaseUrl,
  39. apiKey: apiConfiguration?.openAiApiKey
  40. }
  41. })
  42. }, [apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey])
  43. useEffect(() => {
  44. const handleClickOutside = (event: MouseEvent) => {
  45. if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
  46. setIsDropdownVisible(false)
  47. }
  48. }
  49. document.addEventListener("mousedown", handleClickOutside)
  50. return () => {
  51. document.removeEventListener("mousedown", handleClickOutside)
  52. }
  53. }, [])
  54. const modelIds = useMemo(() => {
  55. return openAiModels.sort((a, b) => a.localeCompare(b))
  56. }, [openAiModels])
  57. const searchableItems = useMemo(() => {
  58. return modelIds.map((id) => ({
  59. id,
  60. html: id,
  61. }))
  62. }, [modelIds])
  63. const fzf = useMemo(() => {
  64. return new Fzf(searchableItems, {
  65. selector: item => item.html
  66. })
  67. }, [searchableItems])
  68. const modelSearchResults = useMemo(() => {
  69. if (!searchTerm) return searchableItems
  70. const searchResults = fzf.find(searchTerm)
  71. return searchResults.map(result => ({
  72. ...result.item,
  73. html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight")
  74. }))
  75. }, [searchableItems, searchTerm, fzf])
  76. const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
  77. if (!isDropdownVisible) return
  78. switch (event.key) {
  79. case "ArrowDown":
  80. event.preventDefault()
  81. setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
  82. break
  83. case "ArrowUp":
  84. event.preventDefault()
  85. setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
  86. break
  87. case "Enter":
  88. event.preventDefault()
  89. if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
  90. handleModelChange(modelSearchResults[selectedIndex].id)
  91. setIsDropdownVisible(false)
  92. }
  93. break
  94. case "Escape":
  95. setIsDropdownVisible(false)
  96. setSelectedIndex(-1)
  97. break
  98. }
  99. }
  100. useEffect(() => {
  101. setSelectedIndex(-1)
  102. if (dropdownListRef.current) {
  103. dropdownListRef.current.scrollTop = 0
  104. }
  105. }, [searchTerm])
  106. useEffect(() => {
  107. if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
  108. itemRefs.current[selectedIndex]?.scrollIntoView({
  109. block: "nearest",
  110. behavior: "smooth",
  111. })
  112. }
  113. }, [selectedIndex])
  114. return (
  115. <>
  116. <style>
  117. {`
  118. .model-item-highlight {
  119. background-color: var(--vscode-editor-findMatchHighlightBackground);
  120. color: inherit;
  121. }
  122. `}
  123. </style>
  124. <div>
  125. <DropdownWrapper ref={dropdownRef}>
  126. <VSCodeTextField
  127. id="model-search"
  128. placeholder="Search and select a model..."
  129. value={searchTerm}
  130. onInput={(e) => {
  131. handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
  132. setIsDropdownVisible(true)
  133. }}
  134. onFocus={() => setIsDropdownVisible(true)}
  135. onKeyDown={handleKeyDown}
  136. style={{ width: "100%", zIndex: OPENAI_MODEL_PICKER_Z_INDEX, position: "relative" }}>
  137. {searchTerm && (
  138. <div
  139. className="input-icon-button codicon codicon-close"
  140. aria-label="Clear search"
  141. onClick={() => {
  142. handleModelChange("")
  143. setIsDropdownVisible(true)
  144. }}
  145. slot="end"
  146. style={{
  147. display: "flex",
  148. justifyContent: "center",
  149. alignItems: "center",
  150. height: "100%",
  151. }}
  152. />
  153. )}
  154. </VSCodeTextField>
  155. {isDropdownVisible && (
  156. <DropdownList ref={dropdownListRef}>
  157. {modelSearchResults.map((item, index) => (
  158. <DropdownItem
  159. key={item.id}
  160. ref={(el) => (itemRefs.current[index] = el)}
  161. isSelected={index === selectedIndex}
  162. onMouseEnter={() => setSelectedIndex(index)}
  163. onClick={() => {
  164. handleModelChange(item.id)
  165. setIsDropdownVisible(false)
  166. }}
  167. dangerouslySetInnerHTML={{
  168. __html: item.html,
  169. }}
  170. />
  171. ))}
  172. </DropdownList>
  173. )}
  174. </DropdownWrapper>
  175. </div>
  176. </>
  177. )
  178. }
  179. export default OpenAiModelPicker
  180. // Dropdown
  181. const DropdownWrapper = styled.div`
  182. position: relative;
  183. width: 100%;
  184. `
  185. export const OPENAI_MODEL_PICKER_Z_INDEX = 1_000
  186. const DropdownList = styled.div`
  187. position: absolute;
  188. top: calc(100% - 3px);
  189. left: 0;
  190. width: calc(100% - 2px);
  191. max-height: 200px;
  192. overflow-y: auto;
  193. background-color: var(--vscode-dropdown-background);
  194. border: 1px solid var(--vscode-list-activeSelectionBackground);
  195. z-index: ${OPENAI_MODEL_PICKER_Z_INDEX - 1};
  196. border-bottom-left-radius: 3px;
  197. border-bottom-right-radius: 3px;
  198. `
  199. const DropdownItem = styled.div<{ isSelected: boolean }>`
  200. padding: 5px 10px;
  201. cursor: pointer;
  202. word-break: break-all;
  203. white-space: normal;
  204. background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
  205. &:hover {
  206. background-color: var(--vscode-list-activeSelectionBackground);
  207. }
  208. `
  209. // Markdown
  210. const StyledMarkdown = styled.div`
  211. font-family:
  212. var(--vscode-font-family),
  213. system-ui,
  214. -apple-system,
  215. BlinkMacSystemFont,
  216. "Segoe UI",
  217. Roboto,
  218. Oxygen,
  219. Ubuntu,
  220. Cantarell,
  221. "Open Sans",
  222. "Helvetica Neue",
  223. sans-serif;
  224. font-size: 12px;
  225. color: var(--vscode-descriptionForeground);
  226. p,
  227. li,
  228. ol,
  229. ul {
  230. line-height: 1.25;
  231. margin: 0;
  232. }
  233. ol,
  234. ul {
  235. padding-left: 1.5em;
  236. margin-left: 0;
  237. }
  238. p {
  239. white-space: pre-wrap;
  240. }
  241. a {
  242. text-decoration: none;
  243. }
  244. a {
  245. &:hover {
  246. text-decoration: underline;
  247. }
  248. }
  249. `
  250. export const ModelDescriptionMarkdown = memo(
  251. ({
  252. markdown,
  253. key,
  254. isExpanded,
  255. setIsExpanded,
  256. }: {
  257. markdown?: string
  258. key: string
  259. isExpanded: boolean
  260. setIsExpanded: (isExpanded: boolean) => void
  261. }) => {
  262. const [reactContent, setMarkdown] = useRemark()
  263. // const [isExpanded, setIsExpanded] = useState(false)
  264. const [showSeeMore, setShowSeeMore] = useState(false)
  265. const textContainerRef = useRef<HTMLDivElement>(null)
  266. const textRef = useRef<HTMLDivElement>(null)
  267. useEffect(() => {
  268. setMarkdown(markdown || "")
  269. }, [markdown, setMarkdown])
  270. useEffect(() => {
  271. if (textRef.current && textContainerRef.current) {
  272. const { scrollHeight } = textRef.current
  273. const { clientHeight } = textContainerRef.current
  274. const isOverflowing = scrollHeight > clientHeight
  275. setShowSeeMore(isOverflowing)
  276. // if (!isOverflowing) {
  277. // setIsExpanded(false)
  278. // }
  279. }
  280. }, [reactContent, setIsExpanded])
  281. return (
  282. <StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
  283. <div
  284. ref={textContainerRef}
  285. style={{
  286. overflowY: isExpanded ? "auto" : "hidden",
  287. position: "relative",
  288. wordBreak: "break-word",
  289. overflowWrap: "anywhere",
  290. }}>
  291. <div
  292. ref={textRef}
  293. style={{
  294. display: "-webkit-box",
  295. WebkitLineClamp: isExpanded ? "unset" : 3,
  296. WebkitBoxOrient: "vertical",
  297. overflow: "hidden",
  298. // whiteSpace: "pre-wrap",
  299. // wordBreak: "break-word",
  300. // overflowWrap: "anywhere",
  301. }}>
  302. {reactContent}
  303. </div>
  304. {!isExpanded && showSeeMore && (
  305. <div
  306. style={{
  307. position: "absolute",
  308. right: 0,
  309. bottom: 0,
  310. display: "flex",
  311. alignItems: "center",
  312. }}>
  313. <div
  314. style={{
  315. width: 30,
  316. height: "1.2em",
  317. background:
  318. "linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
  319. }}
  320. />
  321. <VSCodeLink
  322. style={{
  323. // cursor: "pointer",
  324. // color: "var(--vscode-textLink-foreground)",
  325. fontSize: "inherit",
  326. paddingRight: 0,
  327. paddingLeft: 3,
  328. backgroundColor: "var(--vscode-sideBar-background)",
  329. }}
  330. onClick={() => setIsExpanded(true)}>
  331. See more
  332. </VSCodeLink>
  333. </div>
  334. )}
  335. </div>
  336. </StyledMarkdown>
  337. )
  338. },
  339. )