|
|
@@ -1,8 +1,7 @@
|
|
|
-import { useState, useEffect } from "react"
|
|
|
+import { useState, useEffect, useCallback } from "react"
|
|
|
import { sdk } from "../lib/api/sdkClient"
|
|
|
import type { Provider } from "@opencode-ai/sdk/client"
|
|
|
import { useDropdown } from "../hooks/useDropdown"
|
|
|
-import { formatDate } from "../utils/formatting"
|
|
|
|
|
|
interface ModelSelectorProps {
|
|
|
selectedProviderId?: string
|
|
|
@@ -11,112 +10,179 @@ interface ModelSelectorProps {
|
|
|
disabled?: boolean
|
|
|
}
|
|
|
|
|
|
+interface ModelEntry {
|
|
|
+ providerID: string
|
|
|
+ modelID: string
|
|
|
+}
|
|
|
+
|
|
|
+const MAX_RECENT = 10
|
|
|
+
|
|
|
+function StarIcon({ filled, onClick }: { filled: boolean; onClick: (e: React.MouseEvent) => void }) {
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ onClick={onClick}
|
|
|
+ className="flex-shrink-0 p-0.5 hover:scale-110 transition-transform"
|
|
|
+ title={filled ? "Remove from favorites" : "Add to favorites"}
|
|
|
+ >
|
|
|
+ <svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill={filled ? "#eab308" : "none"} stroke={filled ? "#eab308" : "currentColor"} strokeWidth={1.5}>
|
|
|
+ <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, disabled }: ModelSelectorProps) {
|
|
|
const { isOpen, searchTerm, setSearchTerm, dropdownRef, close, toggle } = useDropdown()
|
|
|
const [providers, setProviders] = useState<Provider[]>([])
|
|
|
const [defaultIds, setDefaultIds] = useState<{ [key: string]: string }>({})
|
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
|
- const [recent, setRecent] = useState<Array<{ provider_id: string; model_id: string; last_used: string }>>([])
|
|
|
+ const [recent, setRecent] = useState<ModelEntry[]>([])
|
|
|
+ const [favorite, setFavorite] = useState<ModelEntry[]>([])
|
|
|
+
|
|
|
+ const isFavorite = useCallback(
|
|
|
+ (providerID: string, modelID: string) => favorite.some((f) => f.providerID === providerID && f.modelID === modelID),
|
|
|
+ [favorite],
|
|
|
+ )
|
|
|
|
|
|
- // Load providers on mount
|
|
|
useEffect(() => {
|
|
|
let active = true
|
|
|
|
|
|
- async function loadProviders() {
|
|
|
+ async function load() {
|
|
|
setIsLoading(true)
|
|
|
try {
|
|
|
- const response = await sdk.config.providers()
|
|
|
+ const [provRes, modelRes] = await Promise.all([sdk.config.providers(), sdk.model.get()])
|
|
|
|
|
|
if (!active) return
|
|
|
|
|
|
- if (response.error) {
|
|
|
- console.error("[ModelSelector] Failed to load providers:", response.error)
|
|
|
+ if (provRes.error) {
|
|
|
+ console.error("[ModelSelector] Failed to load providers:", provRes.error)
|
|
|
setIsLoading(false)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- if (response.data) {
|
|
|
- setProviders(response.data.providers)
|
|
|
- setDefaultIds(response.data.default)
|
|
|
+ if (provRes.data) {
|
|
|
+ setProviders(provRes.data.providers)
|
|
|
+ setDefaultIds(provRes.data.default)
|
|
|
}
|
|
|
|
|
|
- // Load recent models from state for the "Recent" group
|
|
|
- const stateRes = await sdk.state.get()
|
|
|
- if (stateRes.data?.recently_used_models) {
|
|
|
- const list = [...stateRes.data.recently_used_models]
|
|
|
- .sort((a, b) => new Date(b.last_used).getTime() - new Date(a.last_used).getTime())
|
|
|
- .slice(0, 2)
|
|
|
- setRecent(list)
|
|
|
- } else {
|
|
|
- setRecent([])
|
|
|
+ if (modelRes.data) {
|
|
|
+ setRecent(modelRes.data.recent.slice(0, MAX_RECENT))
|
|
|
+ setFavorite(modelRes.data.favorite)
|
|
|
}
|
|
|
} catch (err) {
|
|
|
- if (active) {
|
|
|
- console.error("[ModelSelector] Failed to load providers:", err)
|
|
|
- }
|
|
|
+ if (active) console.error("[ModelSelector] Failed to load:", err)
|
|
|
} finally {
|
|
|
- if (active) {
|
|
|
- setIsLoading(false)
|
|
|
- }
|
|
|
+ if (active) setIsLoading(false)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- loadProviders()
|
|
|
- return () => {
|
|
|
- active = false
|
|
|
- }
|
|
|
+ load()
|
|
|
+ return () => { active = false }
|
|
|
}, [])
|
|
|
|
|
|
- // Get current selection display
|
|
|
const getCurrentDisplay = () => {
|
|
|
- const effectiveProviderId = selectedProviderId || defaultIds.provider
|
|
|
- const effectiveModelId = selectedModelId || defaultIds.model
|
|
|
-
|
|
|
- if (!effectiveProviderId || !effectiveModelId) {
|
|
|
- return "Select Model"
|
|
|
- }
|
|
|
-
|
|
|
- const provider = providers.find((p) => p.id === effectiveProviderId)
|
|
|
+ const pid = selectedProviderId || defaultIds.provider
|
|
|
+ const mid = selectedModelId || defaultIds.model
|
|
|
+ if (!pid || !mid) return "Select Model"
|
|
|
+ const provider = providers.find((p) => p.id === pid)
|
|
|
if (!provider) return "Select Model"
|
|
|
-
|
|
|
- const model = provider.models[effectiveModelId]
|
|
|
- return model?.name || "Select Model"
|
|
|
+ return provider.models[mid]?.name || "Select Model"
|
|
|
}
|
|
|
|
|
|
- const handleSelect = async (providerId: string, modelId: string) => {
|
|
|
- await onSelect(providerId, modelId)
|
|
|
+ const handleSelect = async (providerID: string, modelID: string) => {
|
|
|
+ await onSelect(providerID, modelID)
|
|
|
|
|
|
- try {
|
|
|
- const stateRes = await sdk.state.get()
|
|
|
- if (stateRes.data?.recently_used_models) {
|
|
|
- const list = [...stateRes.data.recently_used_models]
|
|
|
- .sort((a, b) => new Date(b.last_used).getTime() - new Date(a.last_used).getTime())
|
|
|
- .slice(0, 2)
|
|
|
- setRecent(list)
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- console.error("[ModelSelector] Failed to refresh recent models:", err)
|
|
|
- }
|
|
|
+ const entry = { providerID, modelID }
|
|
|
+ const deduped = [entry, ...recent.filter((r) => r.providerID !== providerID || r.modelID !== modelID)]
|
|
|
+ if (deduped.length > MAX_RECENT) deduped.length = MAX_RECENT
|
|
|
+ setRecent(deduped)
|
|
|
+
|
|
|
+ sdk.model.update({ body: { recent: deduped } }).catch((err) =>
|
|
|
+ console.error("[ModelSelector] Failed to update recent:", err),
|
|
|
+ )
|
|
|
|
|
|
close()
|
|
|
}
|
|
|
|
|
|
- // Filter models based on search term
|
|
|
+ const toggleFavorite = async (providerID: string, modelID: string, e: React.MouseEvent) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ const exists = isFavorite(providerID, modelID)
|
|
|
+ const next = exists
|
|
|
+ ? favorite.filter((f) => f.providerID !== providerID || f.modelID !== modelID)
|
|
|
+ : [{ providerID, modelID }, ...favorite]
|
|
|
+ setFavorite(next)
|
|
|
+
|
|
|
+ sdk.model.update({ body: { favorite: next } }).catch((err) =>
|
|
|
+ console.error("[ModelSelector] Failed to update favorites:", err),
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
const filterModels = (provider: Provider) => {
|
|
|
if (!searchTerm) return Object.entries(provider.models)
|
|
|
-
|
|
|
- const lowerSearch = searchTerm.toLowerCase()
|
|
|
+ const q = searchTerm.toLowerCase()
|
|
|
return Object.entries(provider.models).filter(
|
|
|
- ([, model]) =>
|
|
|
- model.name.toLowerCase().includes(lowerSearch) || provider.name.toLowerCase().includes(lowerSearch),
|
|
|
+ ([, model]) => model.name.toLowerCase().includes(q) || provider.name.toLowerCase().includes(q),
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- // Filter recent items based on search term
|
|
|
- const filterRecent = () => {
|
|
|
- if (!searchTerm) return recent
|
|
|
+ const filteredFavorites = () => {
|
|
|
+ if (!searchTerm) return favorite
|
|
|
+ const q = searchTerm.toLowerCase()
|
|
|
+ return favorite.filter((f) => {
|
|
|
+ const provider = providers.find((p) => p.id === f.providerID)
|
|
|
+ const name = provider?.models[f.modelID]?.name || f.modelID
|
|
|
+ return name.toLowerCase().includes(q) || f.providerID.toLowerCase().includes(q)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const filteredRecent = () => {
|
|
|
+ const list = recent.filter((r) => !isFavorite(r.providerID, r.modelID))
|
|
|
+ if (!searchTerm) return list
|
|
|
const q = searchTerm.toLowerCase()
|
|
|
- return recent.filter((r) => r.model_id.toLowerCase().includes(q) || r.provider_id.toLowerCase().includes(q))
|
|
|
+ return list.filter((r) => {
|
|
|
+ const provider = providers.find((p) => p.id === r.providerID)
|
|
|
+ const name = provider?.models[r.modelID]?.name || r.modelID
|
|
|
+ return name.toLowerCase().includes(q) || r.providerID.toLowerCase().includes(q)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const renderModelRow = (providerID: string, modelID: string, extraLabel?: React.ReactNode) => {
|
|
|
+ const isSelected = selectedProviderId === providerID && selectedModelId === modelID
|
|
|
+ const provider = providers.find((p) => p.id === providerID)
|
|
|
+ const model = provider?.models[modelID]
|
|
|
+ const name = model?.name || modelID
|
|
|
+
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ key={`${providerID}:${modelID}`}
|
|
|
+ onClick={() => handleSelect(providerID, modelID)}
|
|
|
+ className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between ${
|
|
|
+ isSelected
|
|
|
+ ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
|
|
+ : "text-gray-900 dark:text-gray-100"
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <div className="flex items-center gap-2 flex-1 min-w-0">
|
|
|
+ <span className="font-medium truncate">{name}</span>
|
|
|
+ {extraLabel}
|
|
|
+ <span className="text-[10px] text-gray-400 dark:text-gray-500 truncate max-w-[6rem]">
|
|
|
+ {provider?.name || providerID}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-1 flex-shrink-0">
|
|
|
+ <StarIcon filled={isFavorite(providerID, modelID)} onClick={(e) => toggleFavorite(providerID, modelID, e)} />
|
|
|
+ {isSelected && (
|
|
|
+ <svg className="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20">
|
|
|
+ <path
|
|
|
+ fillRule="evenodd"
|
|
|
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
|
+ clipRule="evenodd"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
@@ -136,7 +202,6 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
|
|
|
|
|
|
{isOpen && (
|
|
|
<div className="absolute bottom-full left-0 mb-1 min-w-[300px] w-max max-w-[500px] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 max-h-96 overflow-hidden flex flex-col">
|
|
|
- {/* Search input */}
|
|
|
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
|
|
|
<input
|
|
|
type="text"
|
|
|
@@ -148,7 +213,6 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
- {/* Models list */}
|
|
|
<div className="overflow-y-auto flex-1">
|
|
|
{isLoading ? (
|
|
|
<div className="p-4 text-xs text-gray-500 dark:text-gray-400 text-center">Loading models...</div>
|
|
|
@@ -156,103 +220,54 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
|
|
|
<div className="p-4 text-xs text-gray-500 dark:text-gray-400 text-center">No providers configured</div>
|
|
|
) : (
|
|
|
<>
|
|
|
- {/* Recent group */}
|
|
|
- {filterRecent().length > 0 && (
|
|
|
+ {/* Favorites group */}
|
|
|
+ {filteredFavorites().length > 0 && (
|
|
|
+ <div className="border-b border-gray-100 dark:border-gray-800">
|
|
|
+ <div className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
|
|
|
+ Favorites
|
|
|
+ </div>
|
|
|
+ {filteredFavorites().map((item) => renderModelRow(item.providerID, item.modelID))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Recent group (excluding favorites) */}
|
|
|
+ {filteredRecent().length > 0 && (
|
|
|
<div className="border-b border-gray-100 dark:border-gray-800">
|
|
|
<div className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
|
|
|
Recent
|
|
|
</div>
|
|
|
- {filterRecent().map((item) => {
|
|
|
- const isSelected = selectedProviderId === item.provider_id && selectedModelId === item.model_id
|
|
|
-
|
|
|
- // Find model name from providers list
|
|
|
- const provider = providers.find((p) => p.id === item.provider_id)
|
|
|
- const modelName = provider?.models[item.model_id]?.name || item.model_id
|
|
|
-
|
|
|
- return (
|
|
|
- <button
|
|
|
- key={`${item.provider_id}:${item.model_id}:${item.last_used}`}
|
|
|
- onClick={() => handleSelect(item.provider_id, item.model_id)}
|
|
|
- className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between ${
|
|
|
- isSelected
|
|
|
- ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
|
|
- : "text-gray-900 dark:text-gray-100"
|
|
|
- }`}
|
|
|
- >
|
|
|
- <div className="flex items-center gap-2 flex-1 min-w-0">
|
|
|
- <span className="font-medium truncate">{modelName}</span>
|
|
|
- <span className="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
|
|
|
- {formatDate(item.last_used)}
|
|
|
- </span>
|
|
|
- <span className="text-[10px] text-gray-400 dark:text-gray-500 truncate max-w-[6rem]">
|
|
|
- {item.provider_id}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- {isSelected && (
|
|
|
- <svg className="w-4 h-4 ml-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
|
- <path
|
|
|
- fillRule="evenodd"
|
|
|
- d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
|
- clipRule="evenodd"
|
|
|
- />
|
|
|
- </svg>
|
|
|
- )}
|
|
|
- </button>
|
|
|
- )
|
|
|
- })}
|
|
|
+ {filteredRecent().map((item) => renderModelRow(item.providerID, item.modelID))}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{/* Provider groups */}
|
|
|
{providers.map((provider) => {
|
|
|
- const filteredModels = filterModels(provider)
|
|
|
- if (filteredModels.length === 0) return null
|
|
|
+ const filtered = filterModels(provider)
|
|
|
+ if (filtered.length === 0) return null
|
|
|
|
|
|
return (
|
|
|
<div key={provider.id} className="border-b border-gray-100 dark:border-gray-800 last:border-0">
|
|
|
<div className="px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
|
|
|
{provider.name}
|
|
|
</div>
|
|
|
- {filteredModels.map(([modelId, model]) => {
|
|
|
- const isSelected = selectedProviderId === provider.id && selectedModelId === modelId
|
|
|
+ {filtered.map(([modelId, model]) => {
|
|
|
const isDefault = defaultIds.provider === provider.id && defaultIds.model === modelId
|
|
|
|
|
|
- return (
|
|
|
- <button
|
|
|
- key={modelId}
|
|
|
- onClick={() => handleSelect(provider.id, modelId)}
|
|
|
- className={`w-full px-3 py-2 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center justify-between ${
|
|
|
- isSelected
|
|
|
- ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
|
|
- : "text-gray-900 dark:text-gray-100"
|
|
|
- }`}
|
|
|
- >
|
|
|
- <div className="flex items-center gap-2 flex-1 min-w-0">
|
|
|
- <span className="font-medium truncate">{model.name}</span>
|
|
|
- <div className="flex items-center gap-1.5 text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
|
|
|
- {/*<span>{formatDate(model.release_date)}</span>*/}
|
|
|
- {model.capabilities.reasoning && (
|
|
|
- <span className="px-1 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-[9px] leading-none">
|
|
|
- reasoning
|
|
|
- </span>
|
|
|
- )}
|
|
|
- {isDefault && (
|
|
|
- <span className="px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-[9px] leading-none">
|
|
|
- default
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- {isSelected && (
|
|
|
- <svg className="w-4 h-4 ml-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
|
- <path
|
|
|
- fillRule="evenodd"
|
|
|
- d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
|
- clipRule="evenodd"
|
|
|
- />
|
|
|
- </svg>
|
|
|
+ return renderModelRow(
|
|
|
+ provider.id,
|
|
|
+ modelId,
|
|
|
+ <div className="flex items-center gap-1.5 text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0">
|
|
|
+ {model.capabilities.reasoning && (
|
|
|
+ <span className="px-1 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded text-[9px] leading-none">
|
|
|
+ reasoning
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ {isDefault && (
|
|
|
+ <span className="px-1 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded text-[9px] leading-none">
|
|
|
+ default
|
|
|
+ </span>
|
|
|
)}
|
|
|
- </button>
|
|
|
+ </div>,
|
|
|
)
|
|
|
})}
|
|
|
</div>
|