mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat: make Settings > Models connect-only
Settings no longer exposes model selection. Each provider connects with just its API key (or base URL for local providers); the live fetch now drives a connection status line (Connected - N models) and the model the config schema still requires is resolved silently at save (saved > preferred > first fetched). Escape hatches: openai-compatible keeps a visible Model field, and fetch failures offer manual entry so offline saves still work. Provider cards show a Connected badge per configured flavor. Save payload, models:test gate, and the four per-function fields unchanged. Model selection moves to the chat composer next.
This commit is contained in:
parent
75805e2d67
commit
5377d7837a
1 changed files with 116 additions and 96 deletions
|
|
@ -353,6 +353,9 @@ type ProviderModelConfig = {
|
||||||
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
|
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
|
||||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
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>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
|
|
@ -394,8 +397,25 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||||
|
|
||||||
const primaryModel = activeConfig.models[0] || ""
|
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
|
||||||
|
})()
|
||||||
const canTest =
|
const canTest =
|
||||||
primaryModel.trim().length > 0 &&
|
resolvedModel.length > 0 &&
|
||||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||||
|
|
||||||
|
|
@ -439,6 +459,10 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
const parsed = JSON.parse(result.data)
|
const parsed = JSON.parse(result.data)
|
||||||
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
||||||
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
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) {
|
if (parsed?.provider?.flavor && parsed?.model) {
|
||||||
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
||||||
setProvider(flavor)
|
setProvider(flavor)
|
||||||
|
|
@ -529,23 +553,12 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
loadModels()
|
loadModels()
|
||||||
}, [dialogOpen])
|
}, [dialogOpen])
|
||||||
|
|
||||||
// Preselect a model once the live per-key list arrives and nothing is
|
// A saved openai-compatible model that the server's list doesn't confirm
|
||||||
// chosen yet. Only the active provider is touched — unkeyed providers no
|
// (not listed, or /models unreachable) belongs in the visible Model field,
|
||||||
// longer get a model auto-seeded, so they can't look configured.
|
// where it stays editable and wins over the silent pick.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (providerModels.status !== "loaded" || providerModels.models.length === 0) return
|
if (provider !== "openai-compatible" || customModel || !primaryModel) return
|
||||||
if (primaryModel) return
|
if (providerModels.status === "error" || (providerModels.status === "loaded" && !providerModels.models.includes(primaryModel))) {
|
||||||
const preferred = preferredDefaults[provider]
|
|
||||||
const choice = preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0]
|
|
||||||
setPrimaryModel(provider, choice)
|
|
||||||
}, [providerModels.status, providerModels.models, provider, primaryModel, setPrimaryModel])
|
|
||||||
|
|
||||||
// A saved openai-compatible model that isn't in the server's list belongs
|
|
||||||
// in the custom-model field, where it stays visible and editable.
|
|
||||||
useEffect(() => {
|
|
||||||
if (provider !== "openai-compatible" || providerModels.status !== "loaded") return
|
|
||||||
if (customModel) return
|
|
||||||
if (primaryModel && !providerModels.models.includes(primaryModel)) {
|
|
||||||
setCustomModel(primaryModel)
|
setCustomModel(primaryModel)
|
||||||
}
|
}
|
||||||
}, [provider, providerModels.status, providerModels.models, primaryModel, customModel])
|
}, [provider, providerModels.status, providerModels.models, primaryModel, customModel])
|
||||||
|
|
@ -554,7 +567,10 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
if (!canTest) return
|
if (!canTest) return
|
||||||
setTestState({ status: "testing" })
|
setTestState({ status: "testing" })
|
||||||
try {
|
try {
|
||||||
const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean)
|
// 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 = [resolvedModel, ...existing.slice(1).filter(m => m && m !== resolvedModel)]
|
||||||
const providerConfig = {
|
const providerConfig = {
|
||||||
provider: {
|
provider: {
|
||||||
flavor: provider,
|
flavor: provider,
|
||||||
|
|
@ -574,6 +590,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await window.ipc.invoke("models:saveConfig", providerConfig)
|
await window.ipc.invoke("models:saveConfig", providerConfig)
|
||||||
setDefaultProvider(provider)
|
setDefaultProvider(provider)
|
||||||
|
setProviderConfigs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[provider]: { ...prev[provider], models: allModels },
|
||||||
|
}))
|
||||||
|
setSavedProviders(prev => new Set(prev).add(provider))
|
||||||
setTestState({ status: "success" })
|
setTestState({ status: "success" })
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
// Local models compete with background agents for the same hardware:
|
// Local models compete with background agents for the same hardware:
|
||||||
|
|
@ -601,7 +622,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
setTestState({ status: "error", error: "Connection test failed" })
|
setTestState({ status: "error", error: "Connection test failed" })
|
||||||
toast.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 handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||||
const config = providerConfigs[prov]
|
const config = providerConfigs[prov]
|
||||||
|
|
@ -665,6 +686,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
}))
|
}))
|
||||||
|
setSavedProviders(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(prov)
|
||||||
|
return next
|
||||||
|
})
|
||||||
setTestState({ status: "idle" })
|
setTestState({ status: "idle" })
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
toast.success("Provider configuration removed")
|
toast.success("Provider configuration removed")
|
||||||
|
|
@ -676,6 +702,7 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
|
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }) => {
|
||||||
const isDefault = defaultProvider === p.id
|
const isDefault = defaultProvider === p.id
|
||||||
const isSelected = provider === p.id
|
const isSelected = provider === p.id
|
||||||
|
const isConnected = savedProviders.has(p.id)
|
||||||
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
|
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -699,6 +726,11 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
Default
|
Default
|
||||||
</span>
|
</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>
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
|
||||||
{!isDefault && hasModel && isSelected && (
|
{!isDefault && hasModel && isSelected && (
|
||||||
|
|
@ -797,84 +829,72 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Model selection - side by side */}
|
{/* Connection status — the model itself is resolved silently on save */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
{/* Assistant model (left column) — live list fetched from the key */}
|
{providerModels.status === "idle" ? (
|
||||||
<div className="space-y-2">
|
<div className="text-sm text-muted-foreground">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{rowboatConnected ? "Model" : "Assistant model"}</span>
|
{isLocalProvider
|
||||||
{providerModels.status === "idle" ? (
|
? "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">
|
<div className="text-sm text-muted-foreground">
|
||||||
{isLocalProvider
|
Connected, but the provider reported no models — enter one manually
|
||||||
? "Enter your base URL to load available models"
|
|
||||||
: "Enter your API key to load available models"}
|
|
||||||
</div>
|
</div>
|
||||||
) : providerModels.status === "loading" ? (
|
<Input
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
value={primaryModel}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
||||||
Loading models...
|
placeholder="Enter model"
|
||||||
</div>
|
/>
|
||||||
) : providerModels.status === "error" ? (
|
</div>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
<div className="text-xs text-destructive break-words">
|
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||||
{providerModels.error || "Failed to load models"}
|
<CheckCircle2 className="size-4" />
|
||||||
</div>
|
Connected · {providerModels.models.length} model{providerModels.models.length === 1 ? "" : "s"} available
|
||||||
<Button variant="outline" size="sm" onClick={() => providerModels.refetch()}>
|
</div>
|
||||||
Retry
|
)}
|
||||||
</Button>
|
</div>
|
||||||
<Input
|
|
||||||
value={primaryModel}
|
|
||||||
onChange={(e) => setPrimaryModel(provider, e.target.value)}
|
|
||||||
placeholder="Enter model manually"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : providerModels.models.length === 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
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="space-y-2">
|
|
||||||
<Select
|
|
||||||
value={provider === "openai-compatible" && !providerModels.models.includes(primaryModel) ? "" : primaryModel}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setPrimaryModel(provider, value)
|
|
||||||
if (provider === "openai-compatible") setCustomModel("")
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{provider !== "openai-compatible" && primaryModel && !providerModels.models.includes(primaryModel) && (
|
|
||||||
<SelectItem value={primaryModel}>{primaryModel} (saved)</SelectItem>
|
|
||||||
)}
|
|
||||||
{providerModels.models.map((id) => (
|
|
||||||
<SelectItem key={id} value={id}>
|
|
||||||
{id}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{provider === "openai-compatible" && (
|
|
||||||
<Input
|
|
||||||
value={customModel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomModel(e.target.value)
|
|
||||||
setPrimaryModel(provider, e.target.value)
|
|
||||||
}}
|
|
||||||
placeholder="Custom model (overrides selection)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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 && (<>
|
{!rowboatConnected && (<>
|
||||||
{/* Knowledge graph model (right column) */}
|
{/* Knowledge graph model (right column) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -1045,9 +1065,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{testState.status === "testing" ? (
|
{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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue