Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1884,7 +1884,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Invalid response from Requesty API")
}
await fs.writeFile(requestyModelsFilePath, JSON.stringify(models))
this.outputChannel.appendLine(`Requesty models fetched and saved: ${JSON.stringify(models, null, 2)}`)
} catch (error) {
this.outputChannel.appendLine(
`Error fetching Requesty models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
Expand Down
35 changes: 27 additions & 8 deletions webview-ui/src/components/settings/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@ import { ModelInfoView } from "./ModelInfoView"

interface ModelPickerProps {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to ignore, but Claude suggested doing this:

type ModelProvider = "glama" | "openRouter" | "unbound" | "requesty" | "openAi"

type ModelKeys<T extends ModelProvider> = `${T}Models`
type ConfigKeys<T extends ModelProvider> = `${T}ModelId`
type InfoKeys<T extends ModelProvider> = `${T}ModelInfo`
type RefreshMessageType<T extends ModelProvider> = `refresh${Capitalize<T>}Models`

interface ModelPickerProps<T extends ModelProvider = ModelProvider> {
	defaultModelId: string
	modelsKey: ModelKeys<T>
	configKey: ConfigKeys<T>
	infoKey: InfoKeys<T>
	refreshMessageType: RefreshMessageType<T>
	refreshValues?: Record<string, any>
	serviceName: string
	serviceUrl: string
	recommendedModel: string
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cte updated ! :D

defaultModelId: string
modelsKey: "glamaModels" | "openRouterModels" | "unboundModels" | "requestyModels"
configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" | "requestyModelId"
infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" | "requestyModelInfo"
refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels" | "refreshRequestyModels"
modelsKey: "glamaModels" | "openRouterModels" | "unboundModels" | "requestyModels" | "openAiModels"
configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId" | "requestyModelId" | "openAiModelId"
infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo" | "requestyModelInfo" | "openAiModelInfo"
refreshMessageType:
| "refreshGlamaModels"
| "refreshOpenRouterModels"
| "refreshUnboundModels"
| "refreshRequestyModels"
| "refreshOpenAiModels"
refreshValues?: Record<string, any>
serviceName: string
serviceUrl: string
recommendedModel: string
Expand All @@ -40,6 +46,7 @@ export const ModelPicker = ({
configKey,
infoKey,
refreshMessageType,
refreshValues,
serviceName,
serviceUrl,
recommendedModel,
Expand All @@ -49,7 +56,10 @@ export const ModelPicker = ({
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)

const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState()
const modelIds = useMemo(() => Object.keys(models).sort((a, b) => a.localeCompare(b)), [models])
const modelIds = useMemo(
() => (Array.isArray(models) ? models : Object.keys(models)).sort((a, b) => a.localeCompare(b)),
[models],
)

const { selectedModelId, selectedModelInfo } = useMemo(
() => normalizeApiConfiguration(apiConfiguration),
Expand All @@ -58,7 +68,10 @@ export const ModelPicker = ({

const onSelect = useCallback(
(modelId: string) => {
const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: models[modelId] }
const modelInfo = Array.isArray(models)
? { id: modelId } // For OpenAI models which are just strings
: models[modelId] // For other models that have full info objects
const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: modelInfo }
setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setValue(modelId)
Expand All @@ -68,8 +81,14 @@ export const ModelPicker = ({
)

const debouncedRefreshModels = useMemo(
() => debounce(() => vscode.postMessage({ type: refreshMessageType }), 50),
[refreshMessageType],
() =>
debounce(() => {
const message = refreshValues
? { type: refreshMessageType, values: refreshValues }
: { type: refreshMessageType }
vscode.postMessage(message)
}, 50),
[refreshMessageType, refreshValues],
)

useMount(() => {
Expand Down
225 changes: 17 additions & 208 deletions webview-ui/src/components/settings/OpenAiModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,217 +1,26 @@
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { Fzf } from "fzf"
import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"

import React from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlightFzfMatch } from "../../utils/highlight"
import { DropdownWrapper, DropdownList, DropdownItem } from "./styles"
import { ModelPicker } from "./ModelPicker"

const OpenAiModelPicker: React.FC = () => {
const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openAiModelId || "")
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 handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it
const apiConfig = {
...apiConfiguration,
openAiModelId: newModelId,
}

setApiConfiguration(apiConfig)
onUpdateApiConfig(apiConfig)
setSearchTerm(newModelId)
}

useEffect(() => {
if (apiConfiguration?.openAiModelId && apiConfiguration?.openAiModelId !== searchTerm) {
setSearchTerm(apiConfiguration?.openAiModelId)
}
}, [apiConfiguration, searchTerm])

const debouncedRefreshModels = useMemo(
() =>
debounce((baseUrl: string, apiKey: string) => {
vscode.postMessage({
type: "refreshOpenAiModels",
values: {
baseUrl,
apiKey,
},
})
}, 50),
[],
)

useEffect(() => {
if (!apiConfiguration?.openAiBaseUrl || !apiConfiguration?.openAiApiKey) {
return
}

debouncedRefreshModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey)

// Cleanup debounced function
return () => {
debouncedRefreshModels.clear()
}
}, [apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiApiKey, 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 openAiModels.sort((a, b) => a.localeCompare(b))
}, [openAiModels])

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<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)
}
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])
const { apiConfiguration } = useExtensionState()

return (
<>
<style>
{`
.model-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
`}
</style>
<div>
<DropdownWrapper ref={dropdownRef}>
<VSCodeTextField
id="model-search"
placeholder="Search and select a model..."
value={searchTerm}
onInput={(e) => {
handleModelChange((e.target as HTMLInputElement)?.value)
setIsDropdownVisible(true)
}}
onFocus={() => setIsDropdownVisible(true)}
onKeyDown={handleKeyDown}
style={{ width: "100%", zIndex: OPENAI_MODEL_PICKER_Z_INDEX, position: "relative" }}>
{searchTerm && (
<div
className="input-icon-button codicon codicon-close"
aria-label="Clear search"
onClick={() => {
handleModelChange("")
setIsDropdownVisible(true)
}}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef} $zIndex={OPENAI_MODEL_PICKER_Z_INDEX - 1}>
{modelSearchResults.map((item, index) => (
<DropdownItem
$selected={index === selectedIndex}
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
handleModelChange(item.id)
setIsDropdownVisible(false)
}}
dangerouslySetInnerHTML={{
__html: item.html,
}}
/>
))}
</DropdownList>
)}
</DropdownWrapper>
</div>
</>
<ModelPicker
defaultModelId={apiConfiguration?.openAiModelId || ""}
modelsKey="openAiModels"
configKey="openAiModelId"
infoKey="openAiModelInfo"
refreshMessageType="refreshOpenAiModels"
refreshValues={{
baseUrl: apiConfiguration?.openAiBaseUrl,
apiKey: apiConfiguration?.openAiApiKey,
}}
serviceName="OpenAI"
serviceUrl="https://platform.openai.com"
recommendedModel="gpt-4-turbo-preview"
/>
)
}

export default OpenAiModelPicker

// Dropdown

export const OPENAI_MODEL_PICKER_Z_INDEX = 1_000
31 changes: 19 additions & 12 deletions webview-ui/src/components/settings/RequestyModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { ModelPicker } from "./ModelPicker"
import { requestyDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "@/context/ExtensionStateContext"

export const RequestyModelPicker = () => (
<ModelPicker
defaultModelId={requestyDefaultModelId}
modelsKey="requestyModels"
configKey="requestyModelId"
infoKey="requestyModelInfo"
refreshMessageType="refreshRequestyModels"
serviceName="Requesty"
serviceUrl="https://requesty.ai"
recommendedModel="anthropic/claude-3-5-sonnet-latest"
/>
)
export const RequestyModelPicker = () => {
const { apiConfiguration } = useExtensionState()
return (
<ModelPicker
defaultModelId={requestyDefaultModelId}
modelsKey="requestyModels"
configKey="requestyModelId"
infoKey="requestyModelInfo"
refreshMessageType="refreshRequestyModels"
refreshValues={{
apiKey: apiConfiguration?.requestyApiKey,
}}
serviceName="Requesty"
serviceUrl="https://requesty.ai"
recommendedModel="anthropic/claude-3-5-sonnet-latest"
/>
)
}
Loading