mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
Merge pull request #738 from rowboatlabs/feat/settings-models-picker
feat: connect-only model settings with dynamic composer model picker
This commit is contained in:
commit
1e8e36ebfb
6 changed files with 763 additions and 193 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ArrowUp,
|
||||
|
|
@ -38,6 +38,7 @@ import {
|
|||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
|
|
@ -45,6 +46,7 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
|
||||
import {
|
||||
type AttachmentIconKind,
|
||||
getAttachmentDisplayName,
|
||||
|
|
@ -101,6 +103,84 @@ interface ConfiguredModel {
|
|||
model: string
|
||||
}
|
||||
|
||||
// One picker group per connected provider. Catalog groups carry a resolved
|
||||
// model list (models:list / saved config); live groups carry credentials and
|
||||
// fetch their list from the provider inside the dropdown via
|
||||
// useProviderModels (models:listForProvider).
|
||||
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
|
||||
// Catalog-preferred flavors that degrade to a live fetch when models:list has
|
||||
// no catalog for them (signed-in mode returns only the rowboat provider, or
|
||||
// the models.dev cache is empty).
|
||||
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
|
||||
|
||||
type ModelPickerGroup =
|
||||
| { kind: 'catalog'; flavor: string; models: string[] }
|
||||
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
|
||||
|
||||
// Rendered inside the dropdown's radio group: each live provider fetches its
|
||||
// own list, so groups load and fail independently. Pinned models (the saved
|
||||
// default / app default) render first — the model that actually runs is
|
||||
// always pickable even while the fetch is pending or failed. Live-fetched
|
||||
// ids carry no reasoning metadata, so the effort control stays hidden for
|
||||
// them (reasoningByKey lookup misses default to off).
|
||||
//
|
||||
// The group owns its header so it can hide itself when the search filter
|
||||
// matches none of its rows. Loading/error rows are status, not models — they
|
||||
// render (with the header) regardless of the filter, and don't count toward
|
||||
// the parent's "No models match" check (which is what gets reported up).
|
||||
function LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
|
||||
group: Extract<ModelPickerGroup, { kind: 'live' }>
|
||||
label: string
|
||||
pinnedModels: string[]
|
||||
filter: string
|
||||
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
|
||||
}) {
|
||||
const { status, models, error, refetch } = useProviderModels({
|
||||
flavor: group.flavor,
|
||||
apiKey: group.apiKey,
|
||||
baseURL: group.baseURL,
|
||||
})
|
||||
const items = [...pinnedModels, ...models.filter((m) => !pinnedModels.includes(m))]
|
||||
const visible = filter ? items.filter((m) => m.toLowerCase().includes(filter)) : items
|
||||
const showStatus = status === 'loading' || status === 'error'
|
||||
const hasModelRows = visible.length > 0
|
||||
useEffect(() => {
|
||||
onModelRowsChange(group.flavor, hasModelRows)
|
||||
}, [group.flavor, hasModelRows, onModelRowsChange])
|
||||
if (!hasModelRows && !showStatus) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
{visible.map((m) => {
|
||||
const key = `${group.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
{status === 'loading' && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<LoaderIcon className="h-3 w-3 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
refetch()
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="truncate text-destructive">{error || 'Failed to load models'}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type RecentWorkDir = {
|
||||
path: string
|
||||
lastUsedAt: number
|
||||
|
|
@ -318,7 +398,7 @@ function ChatInputInner({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
|
||||
|
||||
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||
const [modelGroups, setModelGroups] = useState<ModelPickerGroup[]>([])
|
||||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
// The effective runtime default (what a run actually uses when the user
|
||||
// hasn't picked a model) — shown in the picker instead of guessing from
|
||||
|
|
@ -438,19 +518,9 @@ function ChatInputInner({
|
|||
if (loadModelConfigEpoch.current === epoch) setDefaultModel(null)
|
||||
}
|
||||
try {
|
||||
const models: ConfiguredModel[] = []
|
||||
const seen = new Set<string>()
|
||||
const push = (provider: string, model: string) => {
|
||||
if (!model) return
|
||||
const key = `${provider}/${model}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
models.push({ provider: provider as ProviderName, model })
|
||||
}
|
||||
const groups: ModelPickerGroup[] = []
|
||||
|
||||
// Full catalog per provider (gateway + cloud). Providers with no
|
||||
// catalog (Ollama, OpenAI-compatible) fall back to the models saved in
|
||||
// config below.
|
||||
// Full catalog per provider (gateway + models.dev cloud providers).
|
||||
const catalog: Record<string, string[]> = {}
|
||||
const reasoningFlags: Record<string, boolean> = {}
|
||||
try {
|
||||
|
|
@ -463,55 +533,78 @@ function ChatInputInner({
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch { /* offline / no catalog — fall back to saved config below */ }
|
||||
} catch { /* offline / no catalog — groups fall back to saved config below */ }
|
||||
if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags)
|
||||
|
||||
if (isRowboatConnected) {
|
||||
for (const m of catalog['rowboat'] || []) push('rowboat', m)
|
||||
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// List the default provider first so its default model leads the
|
||||
// BYOK section of the picker.
|
||||
// List the default provider's group first.
|
||||
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
|
||||
const flavors = Object.keys(parsed?.providers || {})
|
||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
|
||||
const hasKey = typeof e.apiKey === 'string' && (e.apiKey as string).trim().length > 0
|
||||
const hasBaseURL = typeof e.baseURL === 'string' && (e.baseURL as string).trim().length > 0
|
||||
if (!hasKey && !hasBaseURL) continue // provider not configured
|
||||
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
|
||||
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
|
||||
if (!apiKey && !baseURL) continue // provider not configured
|
||||
const savedModel = typeof e.model === 'string' ? e.model : ''
|
||||
|
||||
// The provider's saved default model leads, then the rest of its catalog.
|
||||
push(flavor, typeof e.model === 'string' ? e.model : '')
|
||||
// Live flavors fetch their list from the provider inside the
|
||||
// dropdown, with the credentials saved in config. Catalog flavors
|
||||
// degrade to the same live fetch when models:list carried no
|
||||
// catalog for them (signed in, or empty models.dev cache).
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(flavor, m)
|
||||
} else {
|
||||
// No catalog (local provider) — fall back to whatever is saved.
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(flavor, m)
|
||||
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
|
||||
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
|
||||
continue
|
||||
}
|
||||
|
||||
// Catalog group: the saved default model leads, then the catalog.
|
||||
// Saved models[] survives as the fallback for unknown flavors the
|
||||
// live fetch doesn't support.
|
||||
const models: string[] = []
|
||||
const push = (model: string) => {
|
||||
if (model && !models.includes(model)) models.push(model)
|
||||
}
|
||||
push(savedModel)
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(m)
|
||||
} else {
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(m)
|
||||
}
|
||||
groups.push({ kind: 'catalog', flavor, models })
|
||||
}
|
||||
|
||||
// The user's explicit default selection leads the whole picker.
|
||||
// The user's explicit default selection leads the picker: its group
|
||||
// first and, within a catalog group, the model itself first. (Live
|
||||
// groups pin the default at the top themselves.)
|
||||
const sel = parsed?.defaultSelection
|
||||
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
|
||||
const selKey = `${sel.provider}/${sel.model}`
|
||||
const index = models.findIndex((m) => `${m.provider}/${m.model}` === selKey)
|
||||
if (index > 0) {
|
||||
const [entry] = models.splice(index, 1)
|
||||
models.unshift(entry)
|
||||
const index = groups.findIndex((g) => g.flavor === sel.provider)
|
||||
if (index >= 0) {
|
||||
const [group] = groups.splice(index, 1)
|
||||
groups.unshift(group)
|
||||
if (group.kind === 'catalog') {
|
||||
const mi = group.models.indexOf(sel.model)
|
||||
if (mi > 0) {
|
||||
group.models.splice(mi, 1)
|
||||
group.models.unshift(sel.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setConfiguredModels(models)
|
||||
setModelGroups(groups)
|
||||
} catch (err) {
|
||||
// No config yet — but surface unexpected failures for diagnosis.
|
||||
console.error('[chat-input] failed to load model list', err)
|
||||
|
|
@ -701,25 +794,58 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
// The dropdown's items: always include the effective default so the picker
|
||||
// is never empty (and never missing the model that actually runs) even
|
||||
// while the full list is still loading.
|
||||
const pickerModels = useMemo<ConfiguredModel[]>(() => {
|
||||
if (!defaultModel) return configuredModels
|
||||
const defaultKey = `${defaultModel.provider}/${defaultModel.model}`
|
||||
if (configuredModels.some((m) => `${m.provider}/${m.model}` === defaultKey)) return configuredModels
|
||||
return [defaultModel, ...configuredModels]
|
||||
}, [configuredModels, defaultModel])
|
||||
// Search filter for the model dropdown. Reset each time the menu opens;
|
||||
// matching is a case-insensitive substring test on the model id. Live
|
||||
// groups filter themselves and report whether they still have rows, so the
|
||||
// parent can render the global "No models match" row.
|
||||
const [modelFilter, setModelFilter] = useState('')
|
||||
const modelFilterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
|
||||
const modelFilterValue = modelFilter.trim().toLowerCase()
|
||||
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
|
||||
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
|
||||
}, [])
|
||||
|
||||
// Selecting a model affects only the *next* run created from this tab.
|
||||
// Once a run exists, model is frozen on the run and the dropdown is read-only.
|
||||
// The effective default always renders even when no group carries it (the
|
||||
// gateway list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. Live groups
|
||||
// pin the default themselves, so a flavor match is enough there.
|
||||
const standaloneDefault = useMemo<ConfiguredModel | null>(() => {
|
||||
if (!defaultModel) return null
|
||||
const covered = modelGroups.some((g) =>
|
||||
g.flavor === defaultModel.provider &&
|
||||
(g.kind === 'live' || g.models.includes(defaultModel.model)))
|
||||
return covered ? null : defaultModel
|
||||
}, [modelGroups, defaultModel])
|
||||
|
||||
const standaloneVisible = standaloneDefault !== null &&
|
||||
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
|
||||
// Nothing matches anywhere → "No models match". Live groups that haven't
|
||||
// reported yet (first render after opening) count as having rows so the
|
||||
// empty row never flashes.
|
||||
const anyModelRowVisible = standaloneVisible || modelGroups.some((g) =>
|
||||
g.kind === 'catalog'
|
||||
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: liveGroupHasRows[g.flavor] !== false)
|
||||
|
||||
// Selecting a model affects the *next* run created from this tab (frozen
|
||||
// once a run exists) AND persists as the app default so background agents
|
||||
// and new tabs follow the last pick. The models-config-changed dispatch
|
||||
// re-runs loadModelConfig here too — that re-read lands on the same
|
||||
// selection (activeModelKey is untouched, the live lists come from the
|
||||
// hook's cache), so it's visually a no-op.
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
if (lockedModel) return
|
||||
const entry = pickerModels.find((m) => `${m.provider}/${m.model}` === key)
|
||||
if (!entry) return
|
||||
const slash = key.indexOf('/')
|
||||
if (slash <= 0 || slash === key.length - 1) return
|
||||
const provider = key.slice(0, slash)
|
||||
const model = key.slice(slash + 1)
|
||||
setActiveModelKey(key)
|
||||
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
|
||||
}, [pickerModels, lockedModel, onSelectedModelChange])
|
||||
onSelectedModelChange?.({ provider, model })
|
||||
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } })
|
||||
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
|
||||
.catch(() => { toast.error('Failed to save default model') })
|
||||
}, [lockedModel, onSelectedModelChange])
|
||||
|
||||
// Reasoning effort applies to the model the next message will actually use:
|
||||
// the run's frozen model once one exists, else the picker selection, else
|
||||
|
|
@ -1378,8 +1504,19 @@ function ChatInputInner({
|
|||
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : pickerModels.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
// The filter is per-opening, never sticky. Focus the search
|
||||
// input once the content has mounted and Radix has run its own
|
||||
// open-focus (DropdownMenu.Content has no onOpenAutoFocus).
|
||||
if (open) {
|
||||
setModelFilter('')
|
||||
setLiveGroupHasRows({})
|
||||
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1387,33 +1524,103 @@ function ChatInputInner({
|
|||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{getSelectedModelDisplayName(
|
||||
pickerModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model
|
||||
(activeModelKey ? activeModelKey.slice(activeModelKey.indexOf('/') + 1) : '')
|
||||
|| defaultModel?.model
|
||||
|| pickerModels[0]?.model
|
||||
|| 'Model'
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{pickerModels.map((m) => {
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuContent align="end" className="p-0 overflow-hidden">
|
||||
{modelGroups.length === 0 && !standaloneDefault ? (
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem disabled>Connect a provider in Settings</DropdownMenuItem>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fixed search header — lives OUTSIDE the scroll area (the
|
||||
inner div below scrolls), so it's flush at the very top
|
||||
and always visible without any scroll. */}
|
||||
<div className="bg-popover p-1">
|
||||
<input
|
||||
ref={modelFilterInputRef}
|
||||
value={modelFilter}
|
||||
onChange={(e) => setModelFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Printable keys belong to the input, not the menu's
|
||||
// typeahead; arrows and Escape stay with the menu.
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Escape') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
placeholder="Search models…"
|
||||
className="h-7 w-full rounded-sm border border-input bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto p-1 pt-0">
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{standaloneDefault && standaloneVisible && (
|
||||
<DropdownMenuRadioItem value={`${standaloneDefault.provider}/${standaloneDefault.model}`}>
|
||||
<span className="truncate">{standaloneDefault.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{modelGroups.map((g) => {
|
||||
const label = providerDisplayNames[g.flavor] || g.flavor
|
||||
if (g.kind === 'live') {
|
||||
// The app default leads its live group; the group's
|
||||
// own saved model follows (both stay pickable through
|
||||
// fetch loading/failure).
|
||||
const pinned: string[] = []
|
||||
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
|
||||
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
|
||||
return (
|
||||
<LiveProviderGroupItems
|
||||
key={g.flavor}
|
||||
group={g}
|
||||
label={label}
|
||||
pinnedModels={pinned}
|
||||
filter={modelFilterValue}
|
||||
onModelRowsChange={handleLiveGroupRows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const visibleModels = modelFilterValue
|
||||
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: g.models
|
||||
if (visibleModels.length === 0) return null
|
||||
return (
|
||||
<Fragment key={g.flavor}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
{visibleModels.map((m) => {
|
||||
const key = `${g.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{modelFilterValue && !anyModelRowVisible && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
)}
|
||||
{onStartCall && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
|
|
|
|||
|
|
@ -42,14 +42,11 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
} = state
|
||||
|
||||
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
|
||||
// Hosted providers (openai/anthropic/google) get a default model, so we only
|
||||
// ask for a model on providers that truly need one (local/custom/gateway),
|
||||
// or as a fallback if no model is set yet.
|
||||
// Hosted providers (openai/anthropic/google) fetch their models from the API
|
||||
// key on test, so they never need a manual model field. Only local/custom/
|
||||
// gateway providers, where the user must specify a model, show the input.
|
||||
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
const showModelInput = !hostedProviders.includes(llmProvider)
|
||||
// Connect-only, mirroring Settings: entering a key (or base URL) is enough
|
||||
// and the model is resolved silently at save. openai-compatible is the sole
|
||||
// exception with a visible Model field — its /models endpoint often doesn't
|
||||
// exist, so a typed value must be able to win.
|
||||
const showModelInput = llmProvider === "openai-compatible"
|
||||
|
||||
const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => {
|
||||
const isSelected = llmProvider === provider.id
|
||||
|
|
@ -150,9 +147,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
|
||||
{/* Provider configuration */}
|
||||
<div className="space-y-4">
|
||||
{/* Cloud providers get a default model auto-selected; only local/custom
|
||||
providers (no catalog) need a model here. Users can pick any of the
|
||||
provider's models later in the chat view. */}
|
||||
{/* Every provider resolves its model silently at save. openai-compatible
|
||||
alone keeps this field, since its /models is unreliable and a typed
|
||||
value must win; leaving it blank auto-selects from the fetched list. */}
|
||||
{showModelInput && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
|
|
@ -167,7 +164,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
placeholder="Model ID (leave empty to auto-select)"
|
||||
/>
|
||||
)}
|
||||
{modelsError && (
|
||||
|
|
|
|||
|
|
@ -457,13 +457,24 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
return false
|
||||
}
|
||||
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
// A model the user explicitly entered always wins — this used to prefer
|
||||
// catalog[0], which silently replaced the user's Ollama model with
|
||||
// whatever model the local server happened to list first.
|
||||
const model = modelInputShown
|
||||
? (typed || catalog[0] || "")
|
||||
: ((preferred && catalog.includes(preferred) && preferred) || catalog[0] || typed || "")
|
||||
// Resolve the model silently — same precedence as the settings dialog's
|
||||
// resolvedModel, so onboarding no longer asks users to type one.
|
||||
// openai-compatible keeps a visible Model field (its /models is
|
||||
// unreliable), so a typed value there wins; otherwise the typed/saved
|
||||
// model if the fetched list still has it, else the flavor's preferred
|
||||
// default, else the first fetched id. An empty list falls back to
|
||||
// whatever was typed/seeded.
|
||||
let model: string
|
||||
if (llmProvider === "openai-compatible" && typed) {
|
||||
model = typed
|
||||
} else if (catalog.length > 0) {
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
model = (typed && catalog.includes(typed))
|
||||
? typed
|
||||
: ((preferred && catalog.includes(preferred)) ? preferred : catalog[0])
|
||||
} else {
|
||||
model = typed
|
||||
}
|
||||
|
||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { ConnectedAccountsSettings } from "@/components/settings/connected-accou
|
|||
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
import { useProviderModels } from "@/hooks/use-provider-models"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
||||
|
||||
|
|
@ -336,6 +337,7 @@ const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
|||
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
ollama: "http://localhost:11434",
|
||||
"openai-compatible": "http://localhost:1234/v1",
|
||||
aigateway: "https://ai-gateway.vercel.sh/v1",
|
||||
}
|
||||
|
||||
type ProviderModelConfig = {
|
||||
|
|
@ -351,18 +353,20 @@ type ProviderModelConfig = {
|
|||
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||
// Flavors present in the saved providers map — drives each card's
|
||||
// "Connected" indicator, independent of which card is active.
|
||||
const [savedProviders, setSavedProviders] = useState<Set<LlmProviderFlavor>>(new Set())
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "https://ai-gateway.vercel.sh/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
})
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
|
@ -371,8 +375,18 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
// auto-enable) has ever set it, so we only auto-enable once.
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [deferExplicit, setDeferExplicit] = useState(false)
|
||||
// openai-compatible only: free-text model that takes precedence over the
|
||||
// fetched list (many such servers don't implement /models at all).
|
||||
const [customModel, setCustomModel] = useState("")
|
||||
|
||||
const activeConfig = providerConfigs[provider]
|
||||
// Live per-key model list for the active provider — drives the primary
|
||||
// model area. The per-function fields below still use the static catalog.
|
||||
const providerModels = useProviderModels({
|
||||
flavor: provider,
|
||||
apiKey: activeConfig.apiKey,
|
||||
baseURL: activeConfig.baseURL,
|
||||
})
|
||||
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
|
||||
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
|
||||
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
|
||||
|
|
@ -383,8 +397,28 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||
|
||||
const primaryModel = activeConfig.models[0] || ""
|
||||
// Settings no longer exposes model selection — the model is resolved
|
||||
// silently when the user connects (the config schema still requires one;
|
||||
// background agents/channels read it). Precedence: a typed escape-hatch
|
||||
// value (openai-compatible Model field, offline manual input) > the saved
|
||||
// model when the fetched list still has it > the flavor's preferred
|
||||
// default > the first fetched id.
|
||||
const resolvedModel = (() => {
|
||||
if (provider === "openai-compatible" && customModel.trim()) return customModel.trim()
|
||||
const saved = primaryModel.trim()
|
||||
if (providerModels.status === "loaded" && providerModels.models.length > 0) {
|
||||
if (saved && providerModels.models.includes(saved)) return saved
|
||||
const preferred = preferredDefaults[provider]
|
||||
if (preferred && providerModels.models.includes(preferred)) return preferred
|
||||
return providerModels.models[0]
|
||||
}
|
||||
return saved
|
||||
})()
|
||||
// Gate Connect on credentials only, NOT on resolvedModel — that derives
|
||||
// from the live fetch, which hasn't settled the instant a key is pasted, so
|
||||
// gating on it made the first click a no-op (the model is resolved, fetching
|
||||
// on demand if needed, inside handleTestAndSave).
|
||||
const canTest =
|
||||
primaryModel.trim().length > 0 &&
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
|
|
@ -399,6 +433,19 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
[]
|
||||
)
|
||||
|
||||
// All primary-model writes go through here: the old `models: [value]` form
|
||||
// silently dropped every saved model after the first.
|
||||
const setPrimaryModel = useCallback((prov: LlmProviderFlavor, value: string) => {
|
||||
setProviderConfigs(prev => {
|
||||
const existing = prev[prov].models
|
||||
return {
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], models: [value, ...existing.slice(1).filter(m => m && m !== value)] },
|
||||
}
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
}, [])
|
||||
|
||||
|
||||
// Load current config from file
|
||||
useEffect(() => {
|
||||
|
|
@ -415,6 +462,10 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
const parsed = JSON.parse(result.data)
|
||||
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
||||
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
||||
const knownFlavors = new Set<string>([...primaryProviders, ...moreProviders].map(p => p.id))
|
||||
setSavedProviders(new Set(
|
||||
Object.keys(parsed?.providers ?? {}).filter((k): k is LlmProviderFlavor => knownFlavors.has(k))
|
||||
))
|
||||
if (parsed?.provider?.flavor && parsed?.model) {
|
||||
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
||||
setProvider(flavor)
|
||||
|
|
@ -489,7 +540,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
async function loadModels() {
|
||||
try {
|
||||
setModelsLoading(true)
|
||||
setModelsError(null)
|
||||
const result = await window.ipc.invoke("models:list", null)
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
for (const p of result.providers || []) {
|
||||
|
|
@ -497,7 +547,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
}
|
||||
setModelsCatalog(catalog)
|
||||
} catch {
|
||||
setModelsError("Failed to load models list")
|
||||
setModelsCatalog({})
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
|
|
@ -507,30 +556,49 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
loadModels()
|
||||
}, [dialogOpen])
|
||||
|
||||
// Set default models from catalog when catalog loads
|
||||
// A saved openai-compatible model that the server's list doesn't confirm
|
||||
// (not listed, or /models unreachable) belongs in the visible Model field,
|
||||
// where it stays editable and wins over the silent pick.
|
||||
useEffect(() => {
|
||||
if (Object.keys(modelsCatalog).length === 0) return
|
||||
setProviderConfigs(prev => {
|
||||
const next = { ...prev }
|
||||
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
for (const prov of cloudProviders) {
|
||||
const catalog = modelsCatalog[prov]
|
||||
if (catalog?.length && !next[prov].models[0]) {
|
||||
const preferred = preferredDefaults[prov]
|
||||
const hasPreferred = preferred && catalog.some(m => m.id === preferred)
|
||||
const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "")
|
||||
next[prov] = { ...next[prov], models: [defaultModel] }
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [modelsCatalog])
|
||||
if (provider !== "openai-compatible" || customModel || !primaryModel) return
|
||||
if (providerModels.status === "error" || (providerModels.status === "loaded" && !providerModels.models.includes(primaryModel))) {
|
||||
setCustomModel(primaryModel)
|
||||
}
|
||||
}, [provider, providerModels.status, providerModels.models, primaryModel, customModel])
|
||||
|
||||
const handleTestAndSave = useCallback(async () => {
|
||||
if (!canTest) return
|
||||
setTestState({ status: "testing" })
|
||||
try {
|
||||
const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean)
|
||||
// Normally providerModels has loaded by click time and resolvedModel is
|
||||
// set. But a Connect click right after pasting a key can beat the
|
||||
// debounced key-change fetch — so when nothing is resolved yet, fetch
|
||||
// the list on demand (one fetch, then save) instead of forcing a second
|
||||
// click. Same silent precedence as resolvedModel, minus the customModel
|
||||
// branch (that path only runs when resolvedModel is already empty).
|
||||
let model = resolvedModel
|
||||
if (!model) {
|
||||
const listRes = await window.ipc.invoke("models:listForProvider", {
|
||||
provider: {
|
||||
flavor: provider,
|
||||
apiKey: activeConfig.apiKey.trim() || undefined,
|
||||
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||
},
|
||||
})
|
||||
if (listRes.success && listRes.models && listRes.models.length > 0) {
|
||||
const preferred = preferredDefaults[provider]
|
||||
model = preferred && listRes.models.includes(preferred) ? preferred : listRes.models[0]
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
setTestState({ status: "error", error: "Enter a model to connect" })
|
||||
toast.error("Enter a model to connect")
|
||||
return
|
||||
}
|
||||
// The silently resolved model takes the primary slot; the rest of the
|
||||
// saved list is preserved (same semantics setPrimaryModel had).
|
||||
const existing = activeConfig.models.map(m => m.trim())
|
||||
const allModels = [model, ...existing.slice(1).filter(m => m && m !== model)]
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: provider,
|
||||
|
|
@ -550,6 +618,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
if (result.success) {
|
||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||
setDefaultProvider(provider)
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], models: allModels },
|
||||
}))
|
||||
setSavedProviders(prev => new Set(prev).add(provider))
|
||||
setTestState({ status: "success" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
// Local models compete with background agents for the same hardware:
|
||||
|
|
@ -577,7 +650,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [canTest, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
||||
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
||||
|
||||
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const config = providerConfigs[prov]
|
||||
|
|
@ -608,12 +681,64 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
}, [providerConfigs, rowboatConnected])
|
||||
|
||||
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const isDefaultProv = defaultProvider === prov
|
||||
try {
|
||||
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
const parsed = JSON.parse(result.data)
|
||||
let parsed = JSON.parse(result.data)
|
||||
|
||||
// Disconnecting the default provider: silently promote another
|
||||
// connected provider first — the connect-only UI has no usable
|
||||
// set-as-default step to send the user to. Prefer the provider the
|
||||
// user's explicit defaultSelection points at; promotion goes through
|
||||
// the same models:saveConfig path Set-as-default uses, so the repo
|
||||
// writes a valid top-level provider/model.
|
||||
let promoted: LlmProviderFlavor | null = null
|
||||
if (isDefaultProv) {
|
||||
const selProvider = parsed?.defaultSelection?.provider
|
||||
const candidates = Object.keys(parsed?.providers ?? {})
|
||||
.filter((k): k is LlmProviderFlavor => k !== prov && k in providerConfigs)
|
||||
.sort((a, b) => (a === selProvider ? -1 : b === selProvider ? 1 : 0))
|
||||
for (const candidate of candidates) {
|
||||
const config = providerConfigs[candidate]
|
||||
const allModels = config.models.map(m => m.trim()).filter(Boolean)
|
||||
// Same silent precedence as connect, minus the live list we don't
|
||||
// have here: the provider's saved model, else its preferred default.
|
||||
const model = allModels[0] || preferredDefaults[candidate] || ""
|
||||
if (!model) continue
|
||||
await window.ipc.invoke("models:saveConfig", {
|
||||
provider: {
|
||||
flavor: candidate,
|
||||
apiKey: config.apiKey.trim() || undefined,
|
||||
baseURL: config.baseURL.trim() || undefined,
|
||||
},
|
||||
model,
|
||||
models: allModels.length > 0 ? allModels : [model],
|
||||
...(rowboatConnected ? {} : {
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
promoted = candidate
|
||||
break
|
||||
}
|
||||
if (promoted) {
|
||||
// saveConfig rewrote top-level and the providers map — re-read so
|
||||
// the deletion write below doesn't clobber the promotion.
|
||||
const fresh = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
parsed = JSON.parse(fresh.data)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed?.providers?.[prov]) {
|
||||
delete parsed.providers[prov]
|
||||
}
|
||||
// A defaultSelection pointing at the removed provider is dangling —
|
||||
// drop it so llm:getDefaultModel falls back cleanly.
|
||||
if (parsed?.defaultSelection?.provider === prov) {
|
||||
delete parsed.defaultSelection
|
||||
}
|
||||
// If the deleted provider is the current top-level active one,
|
||||
// switch top-level config to the current default provider
|
||||
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
|
||||
|
|
@ -632,6 +757,24 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||
}
|
||||
} else if (parsed?.provider?.flavor === prov) {
|
||||
// Removing the last connected provider: drop the top-level pair
|
||||
// entirely. The schema requires it, so core's readConfig() treats
|
||||
// the file as "no config" — signed-in falls back to the curated
|
||||
// gateway default and signed-out llm:getDefaultModel rejects, which
|
||||
// the composer already handles (it shows the connect hint).
|
||||
delete parsed.provider
|
||||
delete parsed.model
|
||||
delete parsed.models
|
||||
delete parsed.knowledgeGraphModel
|
||||
delete parsed.meetingNotesModel
|
||||
delete parsed.liveNoteAgentModel
|
||||
delete parsed.autoPermissionDecisionModel
|
||||
// With no BYOK providers left, any non-gateway selection is dangling.
|
||||
if (parsed?.defaultSelection && parsed.defaultSelection.provider !== "rowboat"
|
||||
&& Object.keys(parsed?.providers ?? {}).length === 0) {
|
||||
delete parsed.defaultSelection
|
||||
}
|
||||
}
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/models.json",
|
||||
|
|
@ -641,9 +784,20 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
...prev,
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||
}))
|
||||
setSavedProviders(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(prov)
|
||||
return next
|
||||
})
|
||||
if (isDefaultProv) setDefaultProvider(promoted)
|
||||
setTestState({ status: "idle" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
toast.success("Provider configuration removed")
|
||||
if (promoted) {
|
||||
const promotedName = [...primaryProviders, ...moreProviders].find(p => p.id === promoted)?.name || promoted
|
||||
toast.success(`Disconnected · ${promotedName} is now the default`)
|
||||
} else {
|
||||
toast.success("Provider configuration removed")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to remove provider")
|
||||
}
|
||||
|
|
@ -652,6 +806,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
|
||||
const isDefault = defaultProvider === p.id
|
||||
const isSelected = provider === p.id
|
||||
const isConnected = savedProviders.has(p.id)
|
||||
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
|
||||
return (
|
||||
<button
|
||||
|
|
@ -675,6 +830,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
Default
|
||||
</span>
|
||||
)}
|
||||
{isConnected && (
|
||||
<span className="rounded-full bg-green-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
||||
{!isDefault && hasModel && isSelected && (
|
||||
|
|
@ -738,48 +898,117 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Model selection - side by side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Assistant models (left column) */}
|
||||
{/* API Key — key-first: the model list is fetched from it */}
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{rowboatConnected ? "Model" : "Assistant model"}</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => updateConfig(provider, { models: [e.target.value] })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={primaryModel}
|
||||
onValueChange={(value) => updateConfig(provider, { models: [value] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
|
||||
onBlur={() => providerModels.refetch()}
|
||||
placeholder="Paste your API key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
|
||||
onBlur={() => providerModels.refetch()}
|
||||
placeholder={
|
||||
provider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: provider === "openai-compatible"
|
||||
? "http://localhost:1234/v1"
|
||||
: "https://ai-gateway.vercel.sh/v1"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status — the model itself is resolved silently on save */}
|
||||
<div className="space-y-2">
|
||||
{providerModels.status === "idle" ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalProvider
|
||||
? "Enter your base URL to connect"
|
||||
: "Enter your API key to connect"}
|
||||
</div>
|
||||
) : providerModels.status === "loading" ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking connection…
|
||||
</div>
|
||||
) : providerModels.status === "error" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-destructive break-words">
|
||||
{providerModels.error || "Connection check failed"}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
{provider !== "openai-compatible" && (
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
||||
placeholder="Enter a model to connect anyway"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : providerModels.models.length === 0 && provider !== "openai-compatible" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Connected, but the provider reported no models — enter one manually
|
||||
</div>
|
||||
<Input
|
||||
value={primaryModel}
|
||||
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
|
||||
</div>
|
||||
)}
|
||||
{savedProviders.has(provider) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => handleDeleteProvider(provider)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* openai-compatible escape hatch: its /models often doesn't exist, and
|
||||
a typed model always wins over the silent pick */}
|
||||
{provider === "openai-compatible" && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
||||
<Input
|
||||
value={customModel}
|
||||
onChange={(e) => {
|
||||
setCustomModel(e.target.value)
|
||||
setPrimaryModel(provider, e.target.value)
|
||||
}}
|
||||
placeholder="Model ID (leave empty to auto-select)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-function model overrides */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{!rowboatConnected && (<>
|
||||
{/* Knowledge graph model (right column) */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -919,39 +1148,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
</>)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
|
||||
placeholder="Paste your API key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL */}
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
|
||||
placeholder={
|
||||
provider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: provider === "openai-compatible"
|
||||
? "http://localhost:1234/v1"
|
||||
: "https://ai-gateway.vercel.sh/v1"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test status */}
|
||||
{testState.status === "error" && (
|
||||
<div className="text-sm text-destructive">
|
||||
|
|
@ -983,9 +1179,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
className="w-full"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Testing connection...</>
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
|
||||
) : (
|
||||
"Test & Save"
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
147
apps/x/apps/renderer/src/hooks/use-provider-models.ts
Normal file
147
apps/x/apps/renderer/src/hooks/use-provider-models.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
// Flavors the live model-list fetch (models:listForProvider) supports.
|
||||
// "rowboat" (the signed-in gateway) is deliberately absent — its catalog
|
||||
// comes from models:list, and core throws on the flavor.
|
||||
export type ProviderModelsFlavor =
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
| "google"
|
||||
| "openrouter"
|
||||
| "aigateway"
|
||||
| "ollama"
|
||||
| "openai-compatible"
|
||||
|
||||
export type ProviderModelsStatus = "idle" | "loading" | "loaded" | "error"
|
||||
|
||||
export interface UseProviderModelsResult {
|
||||
/** idle = credentials are insufficient to attempt a fetch. */
|
||||
status: ProviderModelsStatus
|
||||
models: string[]
|
||||
error: string | null
|
||||
/** Bypass the cache and fetch now (key-field blur / Retry). No-op while idle. */
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
const AIGATEWAY_DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1"
|
||||
// The automatic fetch fires only once the credential inputs stop changing —
|
||||
// never per keystroke, which would spray partial API keys at the provider.
|
||||
const FETCH_DEBOUNCE_MS = 600
|
||||
|
||||
// Module-level so provider switches and dialog reopens don't refetch.
|
||||
// Successful results only, keyed on `${flavor}|${apiKey}|${baseURL}`.
|
||||
const listCache = new Map<string, string[]>()
|
||||
// De-dupes concurrent requests for the same key (debounce firing + field blur).
|
||||
const inFlight = new Map<string, Promise<string[]>>()
|
||||
|
||||
function credentialsSufficient(flavor: ProviderModelsFlavor, apiKey: string, baseURL: string): boolean {
|
||||
if (flavor === "ollama" || flavor === "openai-compatible") return baseURL.length > 0
|
||||
return apiKey.length > 0
|
||||
}
|
||||
|
||||
function fetchProviderModels(
|
||||
cacheKey: string,
|
||||
provider: { flavor: ProviderModelsFlavor; apiKey?: string; baseURL?: string },
|
||||
): Promise<string[]> {
|
||||
const pending = inFlight.get(cacheKey)
|
||||
if (pending) return pending
|
||||
const request = window.ipc
|
||||
.invoke("models:listForProvider", { provider })
|
||||
.then((result) => {
|
||||
if (!result.success) throw new Error(result.error || "Failed to list models")
|
||||
const models = result.models ?? []
|
||||
listCache.set(cacheKey, models)
|
||||
return models
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(cacheKey)
|
||||
})
|
||||
inFlight.set(cacheKey, request)
|
||||
return request
|
||||
}
|
||||
|
||||
export function useProviderModels(input: {
|
||||
flavor: ProviderModelsFlavor
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
}): UseProviderModelsResult {
|
||||
const { flavor } = input
|
||||
const apiKey = input.apiKey.trim()
|
||||
const baseURL = input.baseURL.trim() || (flavor === "aigateway" ? AIGATEWAY_DEFAULT_BASE_URL : "")
|
||||
const cacheKey = `${flavor}|${apiKey}|${baseURL}`
|
||||
const sufficient = credentialsSufficient(flavor, apiKey, baseURL)
|
||||
|
||||
const [state, setState] = useState<{
|
||||
key: string
|
||||
status: ProviderModelsStatus
|
||||
models: string[]
|
||||
error: string | null
|
||||
}>({ key: "", status: "idle", models: [], error: null })
|
||||
// Bumped whenever the inputs change (and on unmount) so completions of
|
||||
// superseded fetches never write state.
|
||||
const epochRef = useRef(0)
|
||||
|
||||
const startFetch = useCallback(() => {
|
||||
const epoch = ++epochRef.current
|
||||
setState({ key: cacheKey, status: "loading", models: [], error: null })
|
||||
fetchProviderModels(cacheKey, {
|
||||
flavor,
|
||||
apiKey: apiKey || undefined,
|
||||
baseURL: baseURL || undefined,
|
||||
})
|
||||
.then((models) => {
|
||||
if (epochRef.current !== epoch) return
|
||||
setState({ key: cacheKey, status: "loaded", models, error: null })
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (epochRef.current !== epoch) return
|
||||
const message = err instanceof Error ? err.message : "Failed to list models"
|
||||
setState({ key: cacheKey, status: "error", models: [], error: message })
|
||||
})
|
||||
}, [cacheKey, flavor, apiKey, baseURL])
|
||||
|
||||
useEffect(() => {
|
||||
epochRef.current++
|
||||
if (!sufficient) {
|
||||
setState({ key: cacheKey, status: "idle", models: [], error: null })
|
||||
return
|
||||
}
|
||||
const cached = listCache.get(cacheKey)
|
||||
if (cached) {
|
||||
setState({ key: cacheKey, status: "loaded", models: cached, error: null })
|
||||
return
|
||||
}
|
||||
setState({ key: cacheKey, status: "loading", models: [], error: null })
|
||||
const timer = setTimeout(() => {
|
||||
// A blur-triggered refetch may have already filled the cache while the
|
||||
// debounce was pending — don't fetch the same key twice.
|
||||
const nowCached = listCache.get(cacheKey)
|
||||
if (nowCached) {
|
||||
setState({ key: cacheKey, status: "loaded", models: nowCached, error: null })
|
||||
return
|
||||
}
|
||||
startFetch()
|
||||
}, FETCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [cacheKey, sufficient, startFetch])
|
||||
|
||||
useEffect(() => () => {
|
||||
epochRef.current++
|
||||
}, [])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
if (!sufficient) return
|
||||
listCache.delete(cacheKey)
|
||||
startFetch()
|
||||
}, [sufficient, cacheKey, startFetch])
|
||||
|
||||
// State lags the inputs by one render (the effect above reconciles), so
|
||||
// derive the answer for the *current* inputs — a provider switch must never
|
||||
// flash the previous provider's list.
|
||||
if (state.key !== cacheKey) {
|
||||
const cached = sufficient ? listCache.get(cacheKey) : undefined
|
||||
if (cached) return { status: "loaded", models: cached, error: null, refetch }
|
||||
return { status: sufficient ? "loading" : "idle", models: [], error: null, refetch }
|
||||
}
|
||||
return { status: state.status, models: state.models, error: state.error, refetch }
|
||||
}
|
||||
|
|
@ -259,8 +259,20 @@ export async function listModelsForProvider(
|
|||
url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey ?? ""}`;
|
||||
break;
|
||||
case "openrouter":
|
||||
url = "https://openrouter.ai/api/v1/models";
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
// /api/v1/models is a public catalog — it returns the full
|
||||
// list even with an invalid/absent key, so listing it can't
|
||||
// tell a bad key from a good one (a false "Connected"). When
|
||||
// a key is given, hit the account-scoped /models/user behind
|
||||
// Bearer auth instead: a bad key 401s here and the shared
|
||||
// throw below surfaces it. Same OpenAI-shaped { data:[{id}] }
|
||||
// response, so the parse path is unchanged. No key → keep the
|
||||
// public catalog so an unconfigured provider can still preview.
|
||||
if (apiKey) {
|
||||
url = "https://openrouter.ai/api/v1/models/user";
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
} else {
|
||||
url = "https://openrouter.ai/api/v1/models";
|
||||
}
|
||||
break;
|
||||
case "ollama":
|
||||
url = `${(baseURL ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue