mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat: add provider Disconnect with automatic default reassignment
Each connected provider gets an explicit Disconnect in Settings. When the disconnected provider was the default, another connected provider is silently promoted (defaultSelection's provider preferred) through the existing models:saveConfig path, and the toast names the new default. Disconnecting the last provider clears the top-level config entirely so llm:getDefaultModel degrades to the signed-in gateway default or a clean no-config rejection and the composer shows its connect hint instead of a phantom default. A defaultSelection pointing at the removed flavor is dropped in the same write.
This commit is contained in:
parent
c9ebbd7600
commit
9bf286bfea
1 changed files with 88 additions and 2 deletions
|
|
@ -653,12 +653,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) {
|
||||
|
|
@ -677,6 +729,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",
|
||||
|
|
@ -691,9 +761,15 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
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")
|
||||
}
|
||||
|
|
@ -875,6 +951,16 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue