diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index db306ac7ce..0b609466bb 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -25,10 +25,10 @@ import { ModelInfoView } from "./ModelInfoView" interface ModelPickerProps { defaultModelId: string - modelsKey: "glamaModels" | "openRouterModels" - configKey: "glamaModelId" | "openRouterModelId" - infoKey: "glamaModelInfo" | "openRouterModelInfo" - refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" + modelsKey: "glamaModels" | "openRouterModels" | "unboundModels" + configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" + infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" + refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels" serviceName: string serviceUrl: string recommendedModel: string diff --git a/webview-ui/src/components/settings/UnboundModelPicker.tsx b/webview-ui/src/components/settings/UnboundModelPicker.tsx index ee12f239f9..3be2312388 100644 --- a/webview-ui/src/components/settings/UnboundModelPicker.tsx +++ b/webview-ui/src/components/settings/UnboundModelPicker.tsx @@ -1,430 +1,15 @@ -import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import debounce from "debounce" -import { Fzf } from "fzf" -import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react" -import { useRemark } from "react-remark" -import { useMount } from "react-use" -import styled from "styled-components" +import { ModelPicker } from "./ModelPicker" import { unboundDefaultModelId } from "../../../../src/shared/api" -import { useExtensionState } from "../../context/ExtensionStateContext" -import { vscode } from "../../utils/vscode" -import { highlightFzfMatch } from "../../utils/highlight" -import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions" -const UnboundModelPicker: React.FC = () => { - const { apiConfiguration, setApiConfiguration, unboundModels, onUpdateApiConfig } = useExtensionState() - const [searchTerm, setSearchTerm] = useState(apiConfiguration?.unboundModelId || unboundDefaultModelId) - const [isDropdownVisible, setIsDropdownVisible] = useState(false) - const [selectedIndex, setSelectedIndex] = useState(-1) - const dropdownRef = useRef(null) - const itemRefs = useRef<(HTMLDivElement | null)[]>([]) - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) - const dropdownListRef = useRef(null) - - const handleModelChange = (newModelId: string) => { - const apiConfig = { - ...apiConfiguration, - unboundModelId: newModelId, - unboundModelInfo: unboundModels[newModelId], - } - - setApiConfiguration(apiConfig) - onUpdateApiConfig(apiConfig) - setSearchTerm(newModelId) - } - - const { selectedModelId, selectedModelInfo } = useMemo(() => { - return normalizeApiConfiguration(apiConfiguration) - }, [apiConfiguration]) - - useEffect(() => { - if (apiConfiguration?.unboundModelId && apiConfiguration?.unboundModelId !== searchTerm) { - setSearchTerm(apiConfiguration?.unboundModelId) - } - }, [apiConfiguration, searchTerm]) - - const debouncedRefreshModels = useMemo( - () => - debounce(() => { - vscode.postMessage({ type: "refreshUnboundModels" }) - }, 50), - [], - ) - - useMount(() => { - debouncedRefreshModels() - return () => { - debouncedRefreshModels.clear() - } - }) - - useEffect(() => { - if (apiConfiguration?.unboundApiKey) { - debouncedRefreshModels() - } - }, [apiConfiguration?.unboundApiKey, debouncedRefreshModels]) - - 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(unboundModels).sort((a, b) => a.localeCompare(b)) - }, [unboundModels]) - - const searchableItems = useMemo(() => { - return modelIds.map((id) => ({ - id, - html: id, - })) - }, [modelIds]) - - const fzf = useMemo(() => { - return new Fzf(searchableItems, { - selector: (item) => item.html, - }) - }, [searchableItems]) - - const modelSearchResults = useMemo(() => { - if (!searchTerm) return searchableItems - - const searchResults = fzf.find(searchTerm) - return searchResults.map((result) => ({ - ...result.item, - html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"), - })) - }, [searchableItems, searchTerm, fzf]) - - 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 - } - } - - const hasInfo = useMemo(() => { - return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase()) - }, [modelIds, searchTerm]) - - 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 ( - <> - -
- - - { - const value = (e.target as HTMLInputElement)?.value - setSearchTerm(value) - setIsDropdownVisible(true) - }} - onFocus={() => setIsDropdownVisible(true)} - onKeyDown={handleKeyDown} - style={{ width: "100%", zIndex: UNBOUND_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, - }} - /> - ))} - - )} - -
- - {hasInfo ? ( - - ) : ( -

- The extension automatically fetches the latest list of models available from your Unbound instance. -

- )} - - ) -} - -export default UnboundModelPicker - -// Dropdown - -const DropdownWrapper = styled.div` - position: relative; - width: 100%; -` - -export const UNBOUND_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: ${UNBOUND_MODEL_PICKER_Z_INDEX - 1}; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -` - -const DropdownItem = styled.div<{ isSelected?: boolean }>` - padding: 4px 8px; - cursor: pointer; - background-color: ${(props) => (props.isSelected ? "var(--vscode-list-activeSelectionBackground)" : "transparent")}; - color: ${(props) => - props.isSelected ? "var(--vscode-list-activeSelectionForeground)" : "var(--vscode-foreground)"}; - - &:hover { - background-color: var(--vscode-list-hoverBackground); - } -` - -// 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 - -
- )} -
- {/* {isExpanded && showSeeMore && ( -
setIsExpanded(false)}> - See less -
- )} */} - - ) - }, +export const UnboundModelPicker = () => ( + )