mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix(byok): decouple chat model picker from config.models (#642)
* fix(onboarding): BYOK saved entire model catalog as assistant models testAndSaveActiveProvider persisted the provider's full fetched catalog into config.models. That field is the user's curated assistant-model list (rendered as one row per entry in Settings > Models), so every available model showed up as a separate assistant-model dropdown. Seed it with just the selected model; users add more from Settings. * feat(byok): decouple chat model picker from config.models BYOK config.models was doing double duty: it both seeded the in-chat model picker AND was rendered as a per-entry dropdown stack in Settings. That coupling forced a tradeoff between a clean Settings UI and full model choice in chat, and baked a stale catalog snapshot into config. Decouple them so BYOK matches the signed-in experience: - Chat picker (signed-out) now lists the full live provider catalog from models:list, filtered to providers the user has a key/baseURL for, with the saved default model leading. No longer limited to config.models, so newly released models appear without re-saving. - Settings assistant model collapses to a single default-model dropdown (removed the .map() stack + add/remove). config.models is now just the one default; save/load/delete logic is unchanged.
This commit is contained in:
parent
caa80f7c07
commit
8b76c54295
3 changed files with 74 additions and 89 deletions
|
|
@ -401,18 +401,50 @@ function ChatInputInner({
|
|||
} else {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// Offer every model the configured key supports — the same full catalog
|
||||
// Settings uses for its dropdowns — so BYOK chat matches the signed-in
|
||||
// gateway picker. The picker is no longer limited to a hand-curated
|
||||
// config.models list. Providers with no catalog (Ollama, OpenAI-compatible)
|
||||
// fall back to the model saved in config.
|
||||
const catalog: Record<string, string[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
|
||||
}
|
||||
} catch { /* offline / no catalog — fall back to saved config below */ }
|
||||
|
||||
const models: ConfiguredModel[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) {
|
||||
models.push({ provider: flavor as ProviderName, model })
|
||||
}
|
||||
}
|
||||
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 })
|
||||
}
|
||||
|
||||
// List the default provider first so its default model leads the picker.
|
||||
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
|
||||
|
||||
// The provider's saved default model leads, then the rest of its catalog.
|
||||
push(flavor, typeof e.model === 'string' ? e.model : '')
|
||||
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)
|
||||
}
|
||||
}
|
||||
setConfiguredModels(models)
|
||||
|
|
|
|||
|
|
@ -429,13 +429,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
return false
|
||||
}
|
||||
|
||||
const models: string[] = result.models ?? []
|
||||
const catalog: string[] = result.models ?? []
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
const model =
|
||||
(preferred && models.includes(preferred) && preferred) ||
|
||||
models[0] || activeConfig.model.trim() || ""
|
||||
(preferred && catalog.includes(preferred) && preferred) ||
|
||||
catalog[0] || activeConfig.model.trim() || ""
|
||||
|
||||
await window.ipc.invoke("models:saveConfig", { provider, model, models })
|
||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
||||
// model; users add more from Settings. Persisting the whole catalog here
|
||||
// rendered every model as a separate assistant-model row.
|
||||
await window.ipc.invoke("models:saveConfig", { provider, model, models: model ? [model] : [] })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
setTestState({ status: "success" })
|
||||
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
|
||||
|
|
|
|||
|
|
@ -384,38 +384,6 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
[]
|
||||
)
|
||||
|
||||
const updateModelAt = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number, value: string) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = [...prev[prov].models]
|
||||
models[index] = value
|
||||
return { ...prev, [prov]: { ...prev[prov], models } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const addModel = useCallback(
|
||||
(prov: LlmProviderFlavor) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const removeModel = useCallback(
|
||||
(prov: LlmProviderFlavor, index: number) => {
|
||||
setProviderConfigs(prev => {
|
||||
const models = prev[prov].models.filter((_, i) => i !== index)
|
||||
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
|
||||
})
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Load current config from file
|
||||
useEffect(() => {
|
||||
|
|
@ -727,48 +695,29 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeConfig.models.map((model, index) => (
|
||||
<div key={index} className="group/model relative">
|
||||
{showModelInput ? (
|
||||
<Input
|
||||
value={model}
|
||||
onChange={(e) => updateModelAt(provider, index, e.target.value)}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={model}
|
||||
onValueChange={(value) => updateModelAt(provider, index, 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>
|
||||
)}
|
||||
{activeConfig.models.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeModel(provider, index)}
|
||||
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => addModel(provider)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add assistant model
|
||||
</button>
|
||||
{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 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue