mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +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 {
|
} else {
|
||||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||||
const parsed = JSON.parse(result.data)
|
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[] = []
|
const models: ConfiguredModel[] = []
|
||||||
if (parsed?.providers) {
|
const seen = new Set<string>()
|
||||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
const push = (provider: string, model: string) => {
|
||||||
const e = entry as Record<string, unknown>
|
if (!model) return
|
||||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
const key = `${provider}/${model}`
|
||||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
if (seen.has(key)) return
|
||||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
seen.add(key)
|
||||||
for (const model of allModels) {
|
models.push({ provider: provider as ProviderName, model })
|
||||||
if (model) {
|
}
|
||||||
models.push({ provider: flavor 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)
|
setConfiguredModels(models)
|
||||||
|
|
|
||||||
|
|
@ -429,13 +429,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const models: string[] = result.models ?? []
|
const catalog: string[] = result.models ?? []
|
||||||
const preferred = preferredDefaults[llmProvider]
|
const preferred = preferredDefaults[llmProvider]
|
||||||
const model =
|
const model =
|
||||||
(preferred && models.includes(preferred) && preferred) ||
|
(preferred && catalog.includes(preferred) && preferred) ||
|
||||||
models[0] || activeConfig.model.trim() || ""
|
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'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
setTestState({ status: "success" })
|
setTestState({ status: "success" })
|
||||||
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
|
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
|
// Load current config from file
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -727,48 +695,29 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeConfig.models.map((model, index) => (
|
{showModelInput ? (
|
||||||
<div key={index} className="group/model relative">
|
<Input
|
||||||
{showModelInput ? (
|
value={primaryModel}
|
||||||
<Input
|
onChange={(e) => updateConfig(provider, { models: [e.target.value] })}
|
||||||
value={model}
|
placeholder="Enter model"
|
||||||
onChange={(e) => updateModelAt(provider, index, e.target.value)}
|
/>
|
||||||
placeholder="Enter model"
|
) : (
|
||||||
/>
|
<Select
|
||||||
) : (
|
value={primaryModel}
|
||||||
<Select
|
onValueChange={(value) => updateConfig(provider, { models: [value] })}
|
||||||
value={model}
|
>
|
||||||
onValueChange={(value) => updateModelAt(provider, index, value)}
|
<SelectTrigger>
|
||||||
>
|
<SelectValue placeholder="Select a model" />
|
||||||
<SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectValue placeholder="Select a model" />
|
<SelectContent>
|
||||||
</SelectTrigger>
|
{modelsForProvider.map((m) => (
|
||||||
<SelectContent>
|
<SelectItem key={m.id} value={m.id}>
|
||||||
{modelsForProvider.map((m) => (
|
{m.name || m.id}
|
||||||
<SelectItem key={m.id} value={m.id}>
|
</SelectItem>
|
||||||
{m.name || m.id}
|
))}
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
))}
|
</Select>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{modelsError && (
|
{modelsError && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue