mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
feat(x): one model-selection experience — unified settings, provider lifecycle, split-view picker
Task slots (config + runtime): - taskModels gains backgroundTask and subagent; getBackgroundTaskAgentModel resolves its own override instead of mirroring live-notes (migration materializes the old mirror rule so effective models are unchanged) - spawn-agent precedence: explicit per-spawn args > configured subagent override > parent turn's model - repo.setProvider rejects auth-derived flavors (rowboat/codex) and merges over existing entries so key rotation keeps contextLength/reasoningEffort Models settings, rebuilt: - ModelSelectionSection: ONE Assistant model picker (with an Unavailable badge when the saved model drops off its provider's live list) + all seven tasks defaulting to "Same as Assistant" with resolve subtext and a clear-override link. Replaces both the signed-in and BYOK screens; the "Auto (recommended)" sentinel and both hardcoded preferredDefaults maps are gone — connect-time resolution uses backend recommendations - ProvidersSection replaces the old ModelSettings card grid: status cards (Connected · N models / error + Retry), an add-provider dialog (choose → creds → loading → first-vs-more result states → error with manual-model fallback), and a manage dialog (masked key + Replace, endpoint, Refresh models, Used-by list, disconnect with consequences spelled out). Rowboat and ChatGPT are cards in the same list - connecting a provider seeds the assistant ONLY when none is configured — never replaces a saved choice - models:getConfig IPC serves selections + credential-free provider metadata; the probe machinery (use-provider-models, liveCredentials) is deleted — the add flow validates credentials click-driven Onboarding: - screen 2 is the same ProvidersSection (onboarding variant) for BOTH paths: "Add more providers" after sign-in, "Connect a model provider" otherwise; Continue gates on an assistant model existing. The tile grid, per-provider key forms, and ~350 lines of duplicate state are deleted; one step sequence replaces the two path-dependent indicators Picker: - split-view browse (Popover + cmdk Command): providers left with counts and error dots, selected provider's models right, ←/→ switches provider, ↑/↓ navigates, typing collapses to a flat cross-provider list that matches provider NAMES as well as model ids (searching "rowboat" now works). Scoped/static pickers stay flat; public API unchanged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f39daa9f5d
commit
c695ddf1c1
24 changed files with 1599 additions and 1917 deletions
|
|
@ -1271,6 +1271,30 @@ export function setupIpcHandlers() {
|
|||
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
|
||||
return result;
|
||||
},
|
||||
'models:getConfig': async () => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const cfg = await repo.getConfig().catch(() => null);
|
||||
const tasks = cfg?.taskModels ?? {};
|
||||
return {
|
||||
providers: Object.entries(cfg?.providers ?? {}).map(([id, entry]) => ({
|
||||
id,
|
||||
flavor: entry.flavor,
|
||||
...(entry.baseURL ? { baseURL: entry.baseURL } : {}),
|
||||
hasApiKey: Boolean(entry.apiKey),
|
||||
})),
|
||||
assistantModel: cfg?.assistantModel ?? null,
|
||||
taskModels: {
|
||||
knowledgeGraph: tasks.knowledgeGraph ?? null,
|
||||
meetingNotes: tasks.meetingNotes ?? null,
|
||||
liveNoteAgent: tasks.liveNoteAgent ?? null,
|
||||
autoPermissionDecision: tasks.autoPermissionDecision ?? null,
|
||||
chatTitle: tasks.chatTitle ?? null,
|
||||
backgroundTask: tasks.backgroundTask ?? null,
|
||||
subagent: tasks.subagent ?? null,
|
||||
},
|
||||
deferBackgroundTasks: cfg?.deferBackgroundTasks === true,
|
||||
};
|
||||
},
|
||||
'models:setProvider': async (_event, args) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.setProvider(args.id, args.provider);
|
||||
|
|
|
|||
|
|
@ -549,12 +549,11 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
// 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-fetches the shared store too — that re-read lands on the same
|
||||
// selection (selectedModel is untouched, the live lists come from the
|
||||
// useProviderModels cache), so it's visually a no-op.
|
||||
// Selecting a model here is PER-CHAT: it affects the next run created
|
||||
// from this tab (frozen once a run exists) and nothing else. The config's
|
||||
// assistantModel is the durable default — new tabs and background work
|
||||
// always start from it, and only the settings Assistant picker (or a
|
||||
// provider connect's initial selection) writes it.
|
||||
const handleModelChange = useCallback((model: SelectedModel | null) => {
|
||||
if (lockedModel) return
|
||||
// null = the sentinel row, which the composer never renders (no
|
||||
|
|
@ -562,9 +561,6 @@ function ChatInputInner({
|
|||
if (!model) return
|
||||
setSelectedModel(model)
|
||||
onSelectedModelChange?.(model)
|
||||
void window.ipc.invoke('models:updateConfig', { assistantModel: { provider: model.provider, model: model.model } })
|
||||
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
|
||||
.catch(() => { toast.error('Failed to save default model') })
|
||||
}, [lockedModel, onSelectedModelChange])
|
||||
|
||||
// Effort is per-turn and unpersisted; ModelSelector reports '' when the
|
||||
|
|
|
|||
|
|
@ -34,11 +34,8 @@ function serveTwoProviders(): void {
|
|||
}
|
||||
|
||||
async function openMenu(): Promise<void> {
|
||||
const trigger = screen.getByRole('button')
|
||||
// Radix opens the menu from the trigger's keydown handler in jsdom
|
||||
// (pointerdown would ALSO toggle — one gesture only).
|
||||
fireEvent.keyDown(trigger, { key: 'Enter' })
|
||||
await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => expect(document.querySelector('[cmdk-root]')).not.toBeNull())
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -124,7 +121,7 @@ describe('ModelSelector', () => {
|
|||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), {
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), {
|
||||
target: { value: 'openrouter/meituan/longcat-2.0' },
|
||||
})
|
||||
fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"'))
|
||||
|
|
@ -144,39 +141,68 @@ describe('ModelSelector', () => {
|
|||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
// Wait for the store snapshot (the default provider comes from it).
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-local-model' } })
|
||||
// Wait for the store snapshot (split mode opens on the default
|
||||
// provider's group, so its model is what renders first).
|
||||
await screen.findByText('gpt-5.4')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'my-local-model' } })
|
||||
fireEvent.click(await screen.findByText('Use "my-local-model"'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
|
||||
})
|
||||
|
||||
it('liveCredentials live-fetches a provider that is not saved anywhere', async () => {
|
||||
it('split view: ArrowRight switches provider and Enter selects from the new group', async () => {
|
||||
serveTwoProviders()
|
||||
// openrouter is absent from models.json AND the static catalog — only
|
||||
// the form's typed credentials can produce its list.
|
||||
handlers['models:listForProvider'] = async () => ({
|
||||
success: true,
|
||||
models: ['meituan/longcat-2.0', 'qwen/qwen-3'],
|
||||
})
|
||||
const onChange = vi.fn()
|
||||
render(<ModelSelector variant="field" value={null} onChange={onChange} defaultOption={{ label: 'x' }} />)
|
||||
await openMenu()
|
||||
await screen.findByText('gpt-5.4')
|
||||
|
||||
// Regression: swapping the provider column used to orphan cmdk's
|
||||
// internal highlight, making Enter a no-op until ↑/↓ was pressed.
|
||||
const input = screen.getByPlaceholderText('Search models and providers…')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'claude-opus-4-8' })
|
||||
})
|
||||
|
||||
it('search matches provider names and shows that provider\'s whole group', async () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
providerFilter="openrouter"
|
||||
liveCredentials={{ flavor: 'openrouter', apiKey: 'sk-or-typed', baseURL: '' }}
|
||||
allowCustom
|
||||
defaultOption={{ label: 'Auto (recommended)' }}
|
||||
/>,
|
||||
<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />,
|
||||
)
|
||||
await openMenu()
|
||||
// 600ms debounce in useProviderModels before the fetch fires.
|
||||
const row = await screen.findByText('meituan/longcat-2.0', undefined, { timeout: 3000 })
|
||||
await screen.findByText('gpt-5.4')
|
||||
// "anthropic" matches no model id — but it matches the provider, so its
|
||||
// group stays visible in full while others filter away.
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'anthropic' } })
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
|
||||
expect(screen.queryByText('No models match')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a failed provider group as an error row with Retry (refreshing that provider)', async () => {
|
||||
handlers['models:list'] = async (args) => {
|
||||
const refreshed = (args as { refreshProvider?: string } | null)?.refreshProvider === 'ollama'
|
||||
return {
|
||||
providers: [
|
||||
{ id: 'openai', flavor: 'openai', status: 'ok', models: [{ id: 'gpt-5.4' }] },
|
||||
refreshed
|
||||
? { id: 'ollama', flavor: 'ollama', status: 'ok', models: [{ id: 'llama3' }] }
|
||||
: { id: 'ollama', flavor: 'ollama', status: 'error', error: 'connection refused', models: [] },
|
||||
],
|
||||
defaultModel: { provider: 'openai', model: 'gpt-5.4' },
|
||||
}
|
||||
}
|
||||
render(<ModelSelector variant="field" value={null} onChange={() => {}} defaultOption={{ label: 'x' }} />)
|
||||
await openMenu()
|
||||
// Split mode opens on the default provider (openai); the failed
|
||||
// provider shows an error dot in the column — select it to see the row.
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /Ollama/ }))
|
||||
const retry = await screen.findByText('Retry')
|
||||
expect(screen.getByText('connection refused')).toBeInTheDocument()
|
||||
fireEvent.click(retry)
|
||||
// The retried fetch recovers the group's models.
|
||||
await screen.findByText('llama3')
|
||||
})
|
||||
|
||||
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
|
||||
|
|
@ -198,7 +224,7 @@ describe('ModelSelector', () => {
|
|||
await openMenu()
|
||||
// Only the caller's rows — nothing from the shared catalog store.
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="menuitemradio"] span' })).not.toBeNull()
|
||||
expect(screen.queryByText('claude-opus-4-8', { selector: '[role="option"] span' })).not.toBeNull()
|
||||
// Colliding "Opus" labels are disambiguated by their raw id.
|
||||
expect(screen.getAllByText('Opus')).toHaveLength(2)
|
||||
|
||||
|
|
@ -206,7 +232,7 @@ describe('ModelSelector', () => {
|
|||
expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' })
|
||||
|
||||
await openMenu()
|
||||
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' }))
|
||||
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="option"] span' }))
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
|
|
@ -224,7 +250,7 @@ describe('ModelSelector', () => {
|
|||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), { target: { value: 'my-custom-model' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models and providers…'), { target: { value: 'my-custom-model' } })
|
||||
const custom = await screen.findByText('Use "my-custom-model"')
|
||||
fireEvent.click(custom)
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'anthropic', model: 'my-custom-model' })
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Brain, ChevronDown, LoaderIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Brain, Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
|
||||
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { ModelRef } from '@/hooks/use-models'
|
||||
|
|
@ -21,7 +26,7 @@ export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
|
|||
|
||||
const TOOLTIP_DELAY_MS = 1000
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
export const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
|
|
@ -47,94 +52,34 @@ function getModelDisplayName(model: string) {
|
|||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
// The one remaining renderer-side fetch: probing a provider that is being
|
||||
// configured RIGHT NOW (credentials typed into a form but not yet saved).
|
||||
// Connected providers all come pre-listed through the unified catalog
|
||||
// (useModels); this path exists only because unsaved credentials, by
|
||||
// definition, aren't in that catalog. Pinned models (the app default) render
|
||||
// first so the model that actually runs stays pickable while the fetch is
|
||||
// pending or failed. Probe-fetched ids carry no reasoning metadata, so the
|
||||
// effort control stays hidden for them.
|
||||
//
|
||||
// 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 ProbeProviderGroupItems({ flavor, apiKey, baseURL, label, pinnedModels, filter, onModelRowsChange }: {
|
||||
flavor: ProviderModelsFlavor
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
label: string
|
||||
pinnedModels: string[]
|
||||
filter: string
|
||||
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
|
||||
}) {
|
||||
const { status, models, error, refetch } = useProviderModels({ flavor, apiKey, 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(flavor, hasModelRows)
|
||||
}, [flavor, hasModelRows, onModelRowsChange])
|
||||
if (!hasModelRows && !showStatus) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
{visible.map((m) => {
|
||||
const key = `${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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The standardized model picker (model-selection consolidation), mounted
|
||||
// everywhere models are chosen. One controlled value/onChange contract with
|
||||
// per-surface modes layered on as optional props: the composer's full
|
||||
// catalog picker (provider groups, live fetches, search, reasoning effort),
|
||||
// settings' default sentinel / field trigger / provider scoping / typed-id
|
||||
// escape hatch, per-task "(global default)" inheritance, and caller-supplied
|
||||
// restricted lists (coding-agent options).
|
||||
// everywhere models are chosen — the composer pill, every settings field,
|
||||
// per-task overrides, and the coding-agent restricted lists. One controlled
|
||||
// value/onChange contract with per-surface modes layered on as optional
|
||||
// props.
|
||||
//
|
||||
// The dropdown is a Popover + cmdk Command. With the search empty and more
|
||||
// than one provider connected, it browses as a SPLIT VIEW: providers on the
|
||||
// left (the assistant's provider pre-selected), the chosen provider's models
|
||||
// on the right — ←/→ switches provider, ↑/↓ navigates models. Typing
|
||||
// collapses to one flat list filtered across ALL providers (model ids and
|
||||
// provider names both match). Scoped/static pickers stay flat.
|
||||
export interface ModelSelectorProps {
|
||||
/** Current selection; null follows the app default / the sentinel. */
|
||||
value: ModelRef | null
|
||||
/** null only ever fires when defaultOption is set (sentinel picked). */
|
||||
onChange: (value: ModelRef | null) => void
|
||||
/**
|
||||
* Pinned top entry ("Rowboat default", "Same as assistant") that selects
|
||||
* null. When set, a null value renders this label instead of the app
|
||||
* default model.
|
||||
* Pinned top entry ("Same as Assistant") that selects null. When set, a
|
||||
* null value renders this label instead of the app default model.
|
||||
*/
|
||||
defaultOption?: { label: string }
|
||||
/**
|
||||
* Inheritance flavor of defaultOption for per-task overrides
|
||||
* ("(global default)"): same sentinel row and null semantics, but null
|
||||
* means "inherit the global default at runtime" and the trigger renders
|
||||
* the label muted, so an un-overridden field reads like a placeholder.
|
||||
* Mutually exclusive with defaultOption (defaultOption wins).
|
||||
* Inheritance flavor of defaultOption for per-task overrides: same
|
||||
* sentinel row and null semantics, but null means "inherit at runtime"
|
||||
* and the trigger renders the label muted, so an un-overridden field
|
||||
* reads like a placeholder. Mutually exclusive with defaultOption
|
||||
* (defaultOption wins).
|
||||
*/
|
||||
inheritDefault?: { label: string }
|
||||
/**
|
||||
|
|
@ -143,22 +88,9 @@ export interface ModelSelectorProps {
|
|||
*/
|
||||
variant?: 'pill' | 'field'
|
||||
/**
|
||||
* Restrict the picker to one provider's group (catalog or live). Providers
|
||||
* not configured in models.json fall back to their static models:list
|
||||
* catalog so a provider mid-setup still lists models.
|
||||
* Restrict the picker to one connected provider's group.
|
||||
*/
|
||||
providerFilter?: string
|
||||
/**
|
||||
* Live-fetch credentials for a provider being configured right now (typed
|
||||
* but possibly unsaved). Store groups only exist for providers saved in
|
||||
* models.json and the catalog fallback only covers openai/anthropic/
|
||||
* google — this synthesizes the scoped live group from the form's current
|
||||
* inputs instead (and wins over a saved group, whose stored key may be
|
||||
* stale). Requires providerFilter set to the same flavor; ignored until
|
||||
* some credential is typed. useProviderModels debounces + caches, so
|
||||
* keystrokes don't spray fetches.
|
||||
*/
|
||||
liveCredentials?: { flavor: ProviderModelsFlavor; apiKey: string; baseURL: string }
|
||||
/**
|
||||
* When the search text matches no rows, offer a `Use "<text>"` row that
|
||||
* selects the typed id — arbitrary ids for ollama / openai-compatible.
|
||||
|
|
@ -171,12 +103,11 @@ export interface ModelSelectorProps {
|
|||
/**
|
||||
* Caller-supplied restricted list (e.g. a coding agent's own model
|
||||
* options): the picker renders ONLY these rows plus the defaultOption
|
||||
* sentinel — no catalog groups, no live fetches. Entries are opaque
|
||||
* engine ids, not provider/model pairs, so the selected ref is
|
||||
* {provider: '', model: id} — the id travels in .model and provider is
|
||||
* meaningless. Search filters on label and id; rows whose label differs
|
||||
* from their id show the id as secondary text (labels can collide, e.g.
|
||||
* Claude lists both the 'opus' alias and the concrete id as "Opus").
|
||||
* sentinel — no catalog groups. Entries are opaque engine ids, not
|
||||
* provider/model pairs, so the selected ref is {provider: '', model: id}.
|
||||
* Search filters on label and id; rows whose label differs from their id
|
||||
* show the id as secondary text (labels can collide, e.g. Claude lists
|
||||
* both the 'opus' alias and the concrete id as "Opus").
|
||||
*/
|
||||
staticOptions?: Array<{ id: string; label?: string }>
|
||||
/** Optional title attribute for the trigger button (header tooltips). */
|
||||
|
|
@ -193,8 +124,8 @@ export interface ModelSelectorProps {
|
|||
onEffortChange?: (effort: '' | ReasoningEffortLevel) => void
|
||||
}
|
||||
|
||||
// Radio value for the defaultOption sentinel row. Never a valid model key
|
||||
// (real keys always contain "provider/").
|
||||
// cmdk item value for the defaultOption sentinel row. Never a valid model
|
||||
// key (real keys always contain "provider/").
|
||||
const DEFAULT_OPTION_KEY = '__default__'
|
||||
|
||||
// Un-scoped custom entries can't know their provider, so the rule is:
|
||||
|
|
@ -230,7 +161,6 @@ export function ModelSelector({
|
|||
inheritDefault,
|
||||
variant = 'pill',
|
||||
providerFilter,
|
||||
liveCredentials,
|
||||
allowCustom = false,
|
||||
staticOptions,
|
||||
triggerTitle,
|
||||
|
|
@ -245,15 +175,7 @@ export function ModelSelector({
|
|||
const sentinel = defaultOption ?? inheritDefault
|
||||
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
|
||||
|
||||
// Probe mode: the form's typed credentials trump anything saved — an
|
||||
// unsaved provider isn't in the catalog, so its list is probe-fetched.
|
||||
const liveFlavor = liveCredentials?.flavor
|
||||
const liveApiKey = liveCredentials?.apiKey.trim() ?? ''
|
||||
const liveBaseURL = liveCredentials?.baseURL.trim() ?? ''
|
||||
const probeActive = Boolean(providerFilter && liveFlavor === providerFilter && (liveApiKey || liveBaseURL))
|
||||
|
||||
const groups = useMemo<ModelPickerGroup[]>(() => {
|
||||
if (probeActive) return []
|
||||
if (!providerFilter) return allGroups
|
||||
const scoped = allGroups.filter((g) => g.id === providerFilter)
|
||||
if (scoped.length > 0) return scoped
|
||||
|
|
@ -261,68 +183,97 @@ export function ModelSelector({
|
|||
return catalogModels.length > 0
|
||||
? [{ id: providerFilter, flavor: providerFilter, models: catalogModels, status: 'ok' }]
|
||||
: []
|
||||
}, [allGroups, providerFilter, catalogByProvider, probeActive])
|
||||
}, [allGroups, providerFilter, catalogByProvider])
|
||||
|
||||
// Search filter for the model dropdown. Reset each time the menu opens;
|
||||
// matching is a case-insensitive substring test on the model id. The probe
|
||||
// group filters itself and reports whether it still has rows, so the
|
||||
// parent can render the global "No models match" row.
|
||||
const [modelFilter, setModelFilter] = useState('')
|
||||
const modelFilterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [probeHasRows, setProbeHasRows] = useState(true)
|
||||
const modelFilterValue = modelFilter.trim().toLowerCase()
|
||||
const handleProbeRows = useCallback((_flavor: string, hasRows: boolean) => {
|
||||
setProbeHasRows(hasRows)
|
||||
}, [])
|
||||
const [open, setOpen] = useState(false)
|
||||
// cmdk's highlighted-item value, controlled: when the split view swaps the
|
||||
// provider column, the previous group's items unmount and cmdk's internal
|
||||
// highlight is left pointing at a value with no item — ↵ becomes a no-op.
|
||||
// Driving the value ourselves re-anchors the highlight on the new group.
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
// Search text; case-insensitive substring test on the model id AND the
|
||||
// provider name — typing "rowboat" surfaces the whole Rowboat group.
|
||||
const [query, setQuery] = useState('')
|
||||
const queryValue = query.trim().toLowerCase()
|
||||
const groupMatchesFilter = useCallback((g: ModelPickerGroup) =>
|
||||
(providerDisplayNames[g.flavor] || g.flavor).toLowerCase().includes(queryValue)
|
||||
|| g.id.toLowerCase().includes(queryValue), [queryValue])
|
||||
|
||||
// Split view only where browsing across providers is meaningful.
|
||||
const splitMode = !staticOptions && !providerFilter && !queryValue && groups.length > 1
|
||||
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
|
||||
const activeGroup = splitMode
|
||||
? (groups.find((g) => g.id === activeProviderId) ?? groups[0])
|
||||
: null
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next)
|
||||
if (next) {
|
||||
// Per-opening state: fresh search, provider column on the selection's
|
||||
// (else the assistant's) provider — groups[0] is already the
|
||||
// assistant's group by store ordering. Empty commandValue lets cmdk
|
||||
// highlight the first rendered item itself.
|
||||
setQuery('')
|
||||
setCommandValue('')
|
||||
setActiveProviderId(value?.provider ?? defaultModel?.provider ?? null)
|
||||
}
|
||||
}, [value, defaultModel])
|
||||
|
||||
// Switch the split view's provider column and re-anchor the keyboard
|
||||
// highlight on the new group's first row (see commandValue above).
|
||||
const switchProvider = useCallback((g: ModelPickerGroup) => {
|
||||
setActiveProviderId(g.id)
|
||||
setCommandValue(
|
||||
g.models.length > 0
|
||||
? `${g.id}/${g.models[0]}`
|
||||
: g.status === 'error'
|
||||
? `__retry__:${g.id}`
|
||||
: sentinel ? DEFAULT_OPTION_KEY : '',
|
||||
)
|
||||
}, [sentinel])
|
||||
|
||||
// The effective default always renders even when no group carries it (the
|
||||
// provider's list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. The probe
|
||||
// group pins the default itself, so it opts out here. A provider-scoped
|
||||
// picker only shows it when it belongs to that provider.
|
||||
// picker must never be missing the model that actually runs. A
|
||||
// provider-scoped picker only shows it when it belongs to that provider.
|
||||
const standaloneDefault = useMemo<ModelRef | null>(() => {
|
||||
if (!defaultModel || probeActive) return null
|
||||
if (!defaultModel) return null
|
||||
if (providerFilter && defaultModel.provider !== providerFilter) return null
|
||||
const covered = groups.some((g) =>
|
||||
g.id === defaultModel.provider && g.models.includes(defaultModel.model))
|
||||
return covered ? null : defaultModel
|
||||
}, [groups, defaultModel, providerFilter, probeActive])
|
||||
}, [groups, defaultModel, providerFilter])
|
||||
|
||||
const standaloneVisible = standaloneDefault !== null &&
|
||||
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
|
||||
(!queryValue || standaloneDefault.model.toLowerCase().includes(queryValue))
|
||||
// Static mode replaces all store-driven rows with the caller's list.
|
||||
const staticVisible = useMemo(() => {
|
||||
if (!staticOptions) return null
|
||||
if (!modelFilterValue) return staticOptions
|
||||
if (!queryValue) return staticOptions
|
||||
return staticOptions.filter((o) =>
|
||||
(o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue))
|
||||
}, [staticOptions, modelFilterValue])
|
||||
(o.label ?? o.id).toLowerCase().includes(queryValue) || o.id.toLowerCase().includes(queryValue))
|
||||
}, [staticOptions, queryValue])
|
||||
const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id
|
||||
// Nothing matches anywhere → "No models match". A probe that hasn't
|
||||
// reported yet (first render after opening) counts as having rows so the
|
||||
// empty row never flashes.
|
||||
// Nothing matches anywhere → "No models match".
|
||||
const anyModelRowVisible = staticVisible
|
||||
? staticVisible.length > 0
|
||||
: standaloneVisible
|
||||
|| groups.some((g) => g.models.some((m) => m.toLowerCase().includes(modelFilterValue)))
|
||||
|| (probeActive && probeHasRows)
|
||||
|| groups.some((g) =>
|
||||
groupMatchesFilter(g) ? g.models.length > 0
|
||||
: g.models.some((m) => m.toLowerCase().includes(queryValue)))
|
||||
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
// The cmdk value of the current selection, for check indicators.
|
||||
const selectedKey = value
|
||||
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
|
||||
: sentinel
|
||||
? DEFAULT_OPTION_KEY
|
||||
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
|
||||
const select = useCallback((ref: ModelRef | null) => {
|
||||
if (lockedModel) return
|
||||
if (key === DEFAULT_OPTION_KEY) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
// Static keys are opaque engine ids — no provider/model split (ids may
|
||||
// themselves contain slashes).
|
||||
if (staticOptions) {
|
||||
onChange({ provider: '', model: key })
|
||||
return
|
||||
}
|
||||
const slash = key.indexOf('/')
|
||||
if (slash <= 0 || slash === key.length - 1) return
|
||||
onChange({ provider: key.slice(0, slash), model: key.slice(slash + 1) })
|
||||
}, [lockedModel, onChange, staticOptions])
|
||||
setOpen(false)
|
||||
onChange(ref)
|
||||
}, [lockedModel, onChange])
|
||||
|
||||
// Reasoning effort applies to the model the next message will actually use:
|
||||
// the frozen model when locked, else the picker selection, else the app
|
||||
|
|
@ -345,6 +296,42 @@ export function ModelSelector({
|
|||
}
|
||||
}, [reasoningAvailable, effort, onEffortChange])
|
||||
|
||||
const renderModelItem = (providerId: string, model: string, secondary?: string) => {
|
||||
const key = `${providerId}/${model}`
|
||||
return (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
onSelect={() => select({ provider: providerId, model })}
|
||||
>
|
||||
<Check className={cn('size-3.5 shrink-0', selectedKey === key ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{model}</span>
|
||||
{secondary && <span className="ml-auto shrink-0 text-xs text-muted-foreground">{secondary}</span>}
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSentinelItem = () => sentinel && (
|
||||
<CommandItem value={DEFAULT_OPTION_KEY} onSelect={() => select(null)}>
|
||||
<Check className={cn('size-3.5 shrink-0', selectedKey === DEFAULT_OPTION_KEY ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{sentinel.label}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
const renderErrorItem = (g: ModelPickerGroup) => (
|
||||
<CommandItem
|
||||
key={`__retry__:${g.id}`}
|
||||
value={`__retry__:${g.id}`}
|
||||
// Retry refreshes in place — the popover stays open and the group
|
||||
// re-renders when the store updates.
|
||||
onSelect={() => refresh(g.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="truncate text-destructive">{g.error || 'Failed to load models'}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{reasoningAvailable && onEffortChange && (
|
||||
|
|
@ -390,19 +377,14 @@ export function ModelSelector({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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('')
|
||||
setProbeHasRows(true)
|
||||
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
// modal: the settings Dialog's scroll-lock cancels wheel events over
|
||||
// content portalled outside its subtree — a modal popover brings its
|
||||
// own lock layer that permits scrolling within (Radix's supported
|
||||
// fix for popover-inside-dialog; matches the old DropdownMenu's
|
||||
// modality). Keyboard scrolling was never affected (cmdk uses
|
||||
// programmatic scrollIntoView).
|
||||
<Popover open={open} onOpenChange={handleOpenChange} modal>
|
||||
<PopoverTrigger asChild>
|
||||
{variant === 'field' ? (
|
||||
// Styled after ui/select's SelectTrigger so it sits naturally
|
||||
// in forms next to real Select fields.
|
||||
|
|
@ -439,139 +421,154 @@ export function ModelSelector({
|
|||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align={variant === 'field' ? 'start' : 'end'}
|
||||
className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')}
|
||||
>
|
||||
{!staticOptions && !probeActive && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
|
||||
<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={
|
||||
value
|
||||
? (staticOptions ? value.model : `${value.provider}/${value.model}`)
|
||||
: sentinel
|
||||
? DEFAULT_OPTION_KEY
|
||||
: (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{sentinel && (
|
||||
<DropdownMenuRadioItem value={DEFAULT_OPTION_KEY}>
|
||||
<span className="truncate">{sentinel.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{staticVisible?.map((o) => (
|
||||
<DropdownMenuRadioItem key={o.id} value={o.id}>
|
||||
<span className="truncate">{o.label ?? o.id}</span>
|
||||
{o.label && o.label !== o.id && (
|
||||
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
|
||||
)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{!staticOptions && 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>
|
||||
)}
|
||||
{!staticOptions && groups.map((g) => {
|
||||
const label = providerDisplayNames[g.flavor] || g.flavor
|
||||
const visibleModels = modelFilterValue
|
||||
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: g.models
|
||||
// Error rows are status, not models: they render (with
|
||||
// the header) regardless of the filter and don't count
|
||||
// toward "No models match".
|
||||
const showError = g.status === 'error'
|
||||
if (visibleModels.length === 0 && !showError) return null
|
||||
return (
|
||||
<Fragment key={g.id}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
{visibleModels.map((m) => {
|
||||
const key = `${g.id}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
{showError && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
refresh(g.id)
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="truncate text-destructive">{g.error || 'Failed to load models'}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground">Retry</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{probeActive && liveFlavor && (
|
||||
<ProbeProviderGroupItems
|
||||
flavor={liveFlavor}
|
||||
apiKey={liveApiKey}
|
||||
baseURL={liveBaseURL}
|
||||
label={providerDisplayNames[liveFlavor] || liveFlavor}
|
||||
pinnedModels={defaultModel && defaultModel.provider === liveFlavor ? [defaultModel.model] : []}
|
||||
filter={modelFilterValue}
|
||||
onModelRowsChange={handleProbeRows}
|
||||
/>
|
||||
)}
|
||||
{modelFilterValue && !anyModelRowVisible && (
|
||||
allowCustom ? (
|
||||
// Escape hatch for ids the lists don't carry (local
|
||||
// servers, brand-new models): select exactly what was
|
||||
// typed. Scoped pickers attach it to their provider;
|
||||
// un-scoped ones split "provider/model" on the first
|
||||
// slash, else pair the text with the default provider.
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onChange(parseCustomModel(modelFilter.trim(), providerFilter, defaultModel))}
|
||||
>
|
||||
<span className="truncate">Use "{modelFilter.trim()}"</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
</>
|
||||
className={cn(
|
||||
'p-0 overflow-hidden',
|
||||
splitMode
|
||||
? 'w-[480px]'
|
||||
: variant === 'field'
|
||||
? 'w-[var(--radix-popover-trigger-width)] min-w-[300px]'
|
||||
: 'w-[320px]',
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
>
|
||||
{!staticOptions && groups.length === 0 && !standaloneDefault && !sentinel && !allowCustom ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Connect a provider in Settings</div>
|
||||
) : (
|
||||
<Command
|
||||
// Filtering is ours (provider-name matching, the custom-id
|
||||
// escape hatch, split-mode layout) — cmdk only does keyboard
|
||||
// navigation and selection over what we render, with the
|
||||
// highlighted value controlled (see commandValue).
|
||||
shouldFilter={false}
|
||||
value={commandValue}
|
||||
onValueChange={setCommandValue}
|
||||
onKeyDown={(e) => {
|
||||
// Split mode: ←/→ cycles the provider column (tabs
|
||||
// semantics); ↑/↓ stays on the model list via cmdk.
|
||||
if (!splitMode || groups.length === 0) return
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const index = groups.findIndex((g) => g.id === (activeGroup?.id ?? ''))
|
||||
const next = e.key === 'ArrowRight'
|
||||
? (index + 1) % groups.length
|
||||
: (index - 1 + groups.length) % groups.length
|
||||
switchProvider(groups[next])
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Search models and providers…"
|
||||
/>
|
||||
{splitMode && activeGroup ? (
|
||||
<div className="flex">
|
||||
{/* Provider column — tab-like: click or ←/→. */}
|
||||
<div className="w-40 shrink-0 border-r max-h-80 overflow-y-auto p-1" role="tablist" aria-label="Providers">
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={g.id === activeGroup.id}
|
||||
tabIndex={-1}
|
||||
onClick={() => switchProvider(g)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm',
|
||||
g.id === activeGroup.id ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{providerDisplayNames[g.flavor] || g.flavor}</span>
|
||||
{g.status === 'error' ? (
|
||||
<span className="size-2 shrink-0 rounded-full bg-destructive" />
|
||||
) : (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{g.models.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CommandList className="max-h-80 flex-1">
|
||||
<CommandGroup>
|
||||
{renderSentinelItem()}
|
||||
{standaloneDefault && standaloneDefault.provider === activeGroup.id &&
|
||||
renderModelItem(standaloneDefault.provider, standaloneDefault.model)}
|
||||
{activeGroup.models.map((m) => renderModelItem(activeGroup.id, m))}
|
||||
{activeGroup.status === 'error' && renderErrorItem(activeGroup)}
|
||||
{activeGroup.status === 'ok' && activeGroup.models.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models reported</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
) : (
|
||||
<CommandList className="max-h-80">
|
||||
{sentinel && !queryValue && (
|
||||
<CommandGroup>{renderSentinelItem()}</CommandGroup>
|
||||
)}
|
||||
{staticVisible && staticVisible.length > 0 && (
|
||||
<CommandGroup>
|
||||
{staticVisible.map((o) => (
|
||||
<CommandItem key={o.id} value={o.id} onSelect={() => select({ provider: '', model: o.id })}>
|
||||
<Check className={cn('size-3.5 shrink-0', selectedKey === o.id ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">{o.label ?? o.id}</span>
|
||||
{o.label && o.label !== o.id && (
|
||||
<span className="ml-2 shrink-0 text-xs text-muted-foreground">{o.id}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!staticOptions && standaloneDefault && standaloneVisible && (
|
||||
<CommandGroup>
|
||||
{renderModelItem(
|
||||
standaloneDefault.provider,
|
||||
standaloneDefault.model,
|
||||
providerDisplayNames[standaloneDefault.provider] || standaloneDefault.provider,
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{!staticOptions && groups.map((g) => {
|
||||
// A provider-name match shows the whole group.
|
||||
const visibleModels = queryValue && !groupMatchesFilter(g)
|
||||
? g.models.filter((m) => m.toLowerCase().includes(queryValue))
|
||||
: g.models
|
||||
// Error rows are status, not models: they render (with
|
||||
// the header) regardless of the filter and don't count
|
||||
// toward "No models match".
|
||||
const showError = g.status === 'error'
|
||||
if (visibleModels.length === 0 && !showError) return null
|
||||
return (
|
||||
<CommandGroup key={g.id} heading={providerDisplayNames[g.flavor] || g.flavor}>
|
||||
{visibleModels.map((m) => renderModelItem(g.id, m))}
|
||||
{showError && renderErrorItem(g)}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
{queryValue && !anyModelRowVisible && (
|
||||
allowCustom ? (
|
||||
// Escape hatch for ids the lists don't carry (local
|
||||
// servers, brand-new models): select exactly what was
|
||||
// typed.
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__custom__"
|
||||
onSelect={() => select(parseCustomModel(query.trim(), providerFilter, defaultModel))}
|
||||
>
|
||||
<span className="truncate">Use "{query.trim()}"</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col h-full max-h-[85vh] overflow-y-auto p-8 md:p-10">
|
||||
<StepIndicator
|
||||
currentStep={state.currentStep}
|
||||
path={state.onboardingPath}
|
||||
/>
|
||||
<StepIndicator currentStep={state.currentStep} />
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={state.currentStep}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import * as React from "react"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Step, OnboardingPath } from "./use-onboarding-state"
|
||||
import type { Step } from "./use-onboarding-state"
|
||||
|
||||
const ROWBOAT_STEPS = [
|
||||
// Both paths share one sequence now: screen 1 handles Rowboat sign-in, and
|
||||
// screen 2 ("Providers") serves everyone — first provider for key users,
|
||||
// optional extras for signed-in users.
|
||||
const STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
]
|
||||
|
||||
const BYOK_STEPS = [
|
||||
{ step: 0 as Step, label: "Welcome" },
|
||||
{ step: 1 as Step, label: "Model" },
|
||||
{ step: 1 as Step, label: "Providers" },
|
||||
{ step: 2 as Step, label: "Connect" },
|
||||
{ step: 3 as Step, label: "Code" },
|
||||
{ step: 4 as Step, label: "Done" },
|
||||
|
|
@ -20,11 +16,10 @@ const BYOK_STEPS = [
|
|||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: Step
|
||||
path: OnboardingPath
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep, path }: StepIndicatorProps) {
|
||||
const steps = path === 'byok' ? BYOK_STEPS : ROWBOAT_STEPS
|
||||
export function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
const steps = STEPS
|
||||
const currentIndex = steps.findIndex(s => s.step === currentStep)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,268 +1,37 @@
|
|||
import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
OpenAIIcon,
|
||||
AnthropicIcon,
|
||||
GoogleIcon,
|
||||
OllamaIcon,
|
||||
OpenRouterIcon,
|
||||
VercelIcon,
|
||||
GenericApiIcon,
|
||||
} from "../provider-icons"
|
||||
import type { OnboardingState, LlmProviderFlavor } from "../use-onboarding-state"
|
||||
import { ProvidersSection } from "@/components/settings/providers-section"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
import type { OnboardingState } from "../use-onboarding-state"
|
||||
|
||||
interface LlmSetupStepProps {
|
||||
state: OnboardingState
|
||||
}
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", color: "bg-green-500/10 text-green-600 dark:text-green-400", icon: <OpenAIIcon /> },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", color: "bg-orange-500/10 text-orange-600 dark:text-orange-400", icon: <AnthropicIcon /> },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400", icon: <GoogleIcon /> },
|
||||
{ id: "ollama", name: "Ollama", description: "Local models", color: "bg-purple-500/10 text-purple-600 dark:text-purple-400", icon: <OllamaIcon /> },
|
||||
]
|
||||
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; color: string; icon: React.ReactNode }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", color: "bg-pink-500/10 text-pink-600 dark:text-pink-400", icon: <OpenRouterIcon /> },
|
||||
{ id: "aigateway", name: "AI Gateway", description: "Vercel AI Gateway", color: "bg-sky-500/10 text-sky-600 dark:text-sky-400", icon: <VercelIcon /> },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom endpoint", color: "bg-gray-500/10 text-gray-600 dark:text-gray-400", icon: <GenericApiIcon /> },
|
||||
]
|
||||
|
||||
// Screen 2 of onboarding: the SAME provider surface as Settings (connected
|
||||
// list + add-provider flow), reframed for first-run. Users who signed in on
|
||||
// screen 1 already have a working assistant (initial selection runs at
|
||||
// sign-in) and see "Add more providers"; users who skipped sign-in connect
|
||||
// their first provider here. Continue gates on an assistant model existing —
|
||||
// the one thing chat can't run without.
|
||||
export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
||||
const {
|
||||
llmProvider, setLlmProvider, modelsLoading, modelsError,
|
||||
activeConfig, testState, setTestState, showApiKey,
|
||||
showBaseURL, canTest, showMoreProviders, setShowMoreProviders,
|
||||
updateProviderConfig, handleTestAndSaveLlmConfig, handleTestAndAddAnother,
|
||||
connectedFlavors, handleNext, handleBack,
|
||||
upsellDismissed, setUpsellDismissed, handleSwitchToRowboat,
|
||||
chatgpt,
|
||||
} = state
|
||||
|
||||
const isMoreProvider = moreProviders.some(p => p.id === llmProvider)
|
||||
// "Sign in with ChatGPT" (below the OpenAI card) is an alternative to a key:
|
||||
// signing in counts as a connected provider alongside any saved BYOK one,
|
||||
// which is what unlocks Continue when no API key was entered.
|
||||
const hasConnectedProvider = connectedFlavors.size > 0 || chatgpt.status.signedIn
|
||||
// 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
|
||||
return (
|
||||
<motion.button
|
||||
key={provider.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => {
|
||||
setLlmProvider(provider.id)
|
||||
setTestState({ status: "idle" })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent bg-muted/50 hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("size-10 rounded-lg flex items-center justify-center shrink-0", provider.color)}>
|
||||
{provider.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{provider.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{provider.description}</div>
|
||||
</div>
|
||||
{connectedFlavors.has(provider.id) && (
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-400 ml-auto shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
const { handleNext, handleBack } = state
|
||||
const { defaultModel, isRowboatConnected } = useModels()
|
||||
const hasAssistant = defaultModel !== null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Title */}
|
||||
<h2 className="text-3xl font-bold tracking-tight text-center mb-2">
|
||||
Choose your provider
|
||||
{isRowboatConnected ? "Add more providers" : "Connect a model provider"}
|
||||
</h2>
|
||||
<p className="text-base text-muted-foreground text-center mb-6">
|
||||
Select a provider and configure your API key
|
||||
{isRowboatConnected
|
||||
? "Rowboat is ready to use. Optionally connect your own API keys or local models — their models appear alongside your Rowboat models."
|
||||
: "Connect an API key or a local model to power the Assistant."}
|
||||
</p>
|
||||
|
||||
{/* Inline Rowboat upsell callout */}
|
||||
{!upsellDismissed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="rounded-xl bg-primary/5 border border-primary/20 p-4 mb-6 flex items-start gap-3"
|
||||
>
|
||||
<Lightbulb className="size-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">Tip:</span> Hosted models recommended. Locally run LLMs can struggle with Rowboat's parallel background agents. Bring your own API keys below, or sign in for instant access.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSwitchToRowboat}
|
||||
className="text-sm text-primary font-medium hover:underline mt-1 inline-block"
|
||||
>
|
||||
Sign in instead
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUpsellDismissed(true)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Provider selection */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Provider</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{primaryProviders.map((p, i) => renderProviderCard(p, i))}
|
||||
</div>
|
||||
{(showMoreProviders || isMoreProvider) ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2 mt-2">
|
||||
{moreProviders.map((p, i) => renderProviderCard(p, i + 4))}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowMoreProviders(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
More providers...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-px bg-border my-4" />
|
||||
|
||||
{/* Provider configuration */}
|
||||
<div className="space-y-4">
|
||||
{/* 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">
|
||||
Model
|
||||
</label>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Model ID (leave empty to auto-select)"
|
||||
/>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showApiKey && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
API Key {!state.requiresApiKey && "(optional)"}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
|
||||
placeholder="Paste your API key"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ChatGPT subscription — OAuth sign-in shown under the OpenAI card,
|
||||
mirroring Settings (settings-dialog.tsx, gated on provider ===
|
||||
"openai"). Alternative to an API key: signs in via the shared
|
||||
chatgpt:* path, independent of models.json (the Codex model client
|
||||
consumes the token in core). */}
|
||||
{llmProvider === "openai" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
ChatGPT Subscription
|
||||
</label>
|
||||
{chatgpt.status.signedIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400 min-w-0">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={chatgpt.signOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
) : chatgpt.isSigningIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Waiting for browser…
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Use your ChatGPT Plus/Pro subscription
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { baseURL: e.target.value })}
|
||||
placeholder={
|
||||
llmProvider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: llmProvider === "openai-compatible"
|
||||
? "http://localhost:1234/v1"
|
||||
: "https://ai-gateway.vercel.sh/v1"
|
||||
}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProvidersSection dialogOpen variant="onboarding" />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t">
|
||||
|
|
@ -270,44 +39,9 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
<ArrowLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{testState.status === "success" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected
|
||||
</motion.div>
|
||||
)}
|
||||
{testState.status === "error" && (
|
||||
<span className="text-sm text-destructive max-w-[200px] truncate">
|
||||
{testState.error}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestAndAddAnother}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
>
|
||||
Save & add another
|
||||
</Button>
|
||||
<Button
|
||||
onClick={canTest ? handleTestAndSaveLlmConfig : handleNext}
|
||||
disabled={testState.status === "testing" || (!canTest && !hasConnectedProvider)}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Testing...</>
|
||||
) : (canTest || !hasConnectedProvider) ? (
|
||||
"Test & Continue"
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleNext} disabled={!hasAssistant} className="min-w-[140px]">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect, useCallback } from "react"
|
||||
import { setGoogleCredentials } from "@/lib/google-credentials-store"
|
||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export interface ProviderState {
|
||||
|
|
@ -13,45 +12,10 @@ export type Step = 0 | 1 | 2 | 3 | 4
|
|||
|
||||
export type OnboardingPath = 'rowboat' | 'byok' | null
|
||||
|
||||
export type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
export interface LlmModelOption {
|
||||
id: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
}
|
||||
|
||||
export function useOnboardingState(open: boolean, onComplete: (opts?: { startTour?: boolean }) => void) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(0)
|
||||
const [onboardingPath, setOnboardingPath] = useState<OnboardingPath>(null)
|
||||
|
||||
// LLM setup state
|
||||
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
})
|
||||
const [connectedFlavors, setConnectedFlavors] = useState<Set<LlmProviderFlavor>>(new Set())
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
||||
// "Sign in with ChatGPT" (subscription OAuth) is offered below the OpenAI
|
||||
// card, mirroring Settings. It isn't a LlmProviderFlavor — no API key, no
|
||||
// models.json entry — it signs in via the dedicated chatgpt:* IPC (same
|
||||
// path Settings uses) and the Codex model client consumes the token in
|
||||
// core, leaving the BYOK config machinery untouched.
|
||||
const chatgpt = useChatGPT()
|
||||
|
||||
// OAuth provider states
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
|
|
@ -72,9 +36,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
const [slackDiscovering, setSlackDiscovering] = useState(false)
|
||||
const [slackDiscoverError, setSlackDiscoverError] = useState<string | null>(null)
|
||||
|
||||
// Inline upsell callout dismissed
|
||||
const [upsellDismissed, setUpsellDismissed] = useState(false)
|
||||
|
||||
// Composio Gmail/Calendar sync was removed — flags are seeded false and
|
||||
// never flipped. Kept here so legacy gating expressions still type-check.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
|
|
@ -89,27 +50,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
const [googleCalendarLoading, setGoogleCalendarLoading] = useState(true)
|
||||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const activeConfig = providerConfigs[llmProvider]
|
||||
const showApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway" || llmProvider === "openai-compatible"
|
||||
const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
|
||||
const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
|
||||
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const canTest =
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
// Track connected providers for the completion step
|
||||
const connectedProviders = Object.entries(providerStates)
|
||||
.filter(([, state]) => state.isConnected)
|
||||
|
|
@ -135,56 +75,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
loadProviders()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
setModelsLoading(true)
|
||||
setModelsError(null)
|
||||
const result = await window.ipc.invoke("models:list", null)
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
for (const provider of result.providers || []) {
|
||||
catalog[provider.id] = provider.models || []
|
||||
}
|
||||
setModelsCatalog(catalog)
|
||||
} catch (error) {
|
||||
console.error("Failed to load models catalog:", error)
|
||||
setModelsError("Failed to load models list")
|
||||
setModelsCatalog({})
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
}, [open])
|
||||
|
||||
// Preferred default models for each provider
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
// Initialize default models from catalog
|
||||
useEffect(() => {
|
||||
if (Object.keys(modelsCatalog).length === 0) return
|
||||
setProviderConfigs(prev => {
|
||||
const next = { ...prev }
|
||||
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
for (const provider of cloudProviders) {
|
||||
const models = modelsCatalog[provider]
|
||||
if (models?.length && !next[provider].model) {
|
||||
const preferredModel = preferredDefaults[provider]
|
||||
const hasPreferred = preferredModel && models.some(m => m.id === preferredModel)
|
||||
next[provider] = { ...next[provider], model: hasPreferred ? preferredModel : (models[0]?.id || "") }
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [modelsCatalog])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -389,11 +279,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
// BYOK path: 0 (welcome) → 1 (llm setup) → 2 (connect) → 3 (code mode) → 4 (done)
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 0) {
|
||||
if (onboardingPath === 'byok') {
|
||||
setCurrentStep(1)
|
||||
} else {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
setCurrentStep(1)
|
||||
} else if (currentStep === 1) {
|
||||
setCurrentStep(2)
|
||||
} else if (currentStep === 2) {
|
||||
|
|
@ -408,11 +294,7 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
setCurrentStep(0)
|
||||
setOnboardingPath(null)
|
||||
} else if (currentStep === 2) {
|
||||
if (onboardingPath === 'rowboat') {
|
||||
setCurrentStep(0)
|
||||
} else {
|
||||
setCurrentStep(1)
|
||||
}
|
||||
setCurrentStep(1)
|
||||
} else if (currentStep === 3) {
|
||||
setCurrentStep(2)
|
||||
}
|
||||
|
|
@ -429,103 +311,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
onComplete({ startTour: true })
|
||||
}, [onComplete])
|
||||
|
||||
// Test the active provider's credentials and persist its config. Returns
|
||||
// whether it succeeded so callers can decide whether to advance or stay.
|
||||
const testAndSaveActiveProvider = useCallback(async (): Promise<boolean> => {
|
||||
if (!canTest) return false
|
||||
setTestState({ status: "testing" })
|
||||
try {
|
||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const provider = { flavor: llmProvider, apiKey, baseURL }
|
||||
|
||||
// Fetch the provider's models from the key — this both validates the
|
||||
// credentials and gives us the list to populate the chat picker.
|
||||
const result = await window.ipc.invoke("models:listForProvider", { provider })
|
||||
if (!result.success) {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
return false
|
||||
}
|
||||
|
||||
const catalog: string[] = result.models ?? []
|
||||
const typed = activeConfig.model.trim()
|
||||
// Hosted providers hide the model field (it holds an auto-seeded
|
||||
// default), so only treat it as user intent where the field is shown —
|
||||
// mirrors showModelInput in llm-setup-step.
|
||||
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
const modelInputShown = !hostedProviders.includes(llmProvider)
|
||||
|
||||
if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) {
|
||||
// Ollama's tag list is authoritative: an unlisted model isn't pulled,
|
||||
// so saving it would break chat at runtime with no obvious cause.
|
||||
const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}`
|
||||
setTestState({ status: "error", error })
|
||||
toast.error(error)
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// v2 writes: the provider entry carries credentials only; the resolved
|
||||
// model becomes the assistant model (onboarding's connect doubles as
|
||||
// the initial selection).
|
||||
await window.ipc.invoke("models:setProvider", { id: llmProvider, provider })
|
||||
if (model) {
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
assistantModel: { provider: llmProvider, model },
|
||||
})
|
||||
}
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
setTestState({ status: "success" })
|
||||
setConnectedFlavors(prev => new Set(prev).add(llmProvider))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Connection test failed:", error)
|
||||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
return false
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
|
||||
|
||||
// Save the active provider and advance to the next step.
|
||||
const handleTestAndSaveLlmConfig = useCallback(async () => {
|
||||
const ok = await testAndSaveActiveProvider()
|
||||
if (ok) handleNext()
|
||||
}, [testAndSaveActiveProvider, handleNext])
|
||||
|
||||
// Save the active provider but stay on the step. Switch to the next provider the
|
||||
// user hasn't connected yet so the form is fresh and the buttons re-enable once
|
||||
// they enter that key. (Clearing the current field instead left the buttons
|
||||
// disabled on an empty form with no clear next step.)
|
||||
const handleTestAndAddAnother = useCallback(async () => {
|
||||
const ok = await testAndSaveActiveProvider()
|
||||
if (!ok) return
|
||||
// setConnectedFlavors is async, so include the just-saved provider here.
|
||||
const connectedNow = new Set(connectedFlavors).add(llmProvider)
|
||||
const order: LlmProviderFlavor[] = ["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]
|
||||
const next = order.find(p => !connectedNow.has(p))
|
||||
if (next) setLlmProvider(next)
|
||||
setTestState({ status: "idle" })
|
||||
}, [testAndSaveActiveProvider, connectedFlavors, llmProvider])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
refreshGranolaConfig()
|
||||
|
|
@ -681,12 +466,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
startConnect('google', { clientId, clientSecret })
|
||||
}, [startConnect])
|
||||
|
||||
// Switch to rowboat path from BYOK inline callout
|
||||
const handleSwitchToRowboat = useCallback(() => {
|
||||
setOnboardingPath('rowboat')
|
||||
setCurrentStep(0)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Step state
|
||||
currentStep,
|
||||
|
|
@ -694,32 +473,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
onboardingPath,
|
||||
setOnboardingPath,
|
||||
|
||||
// LLM state
|
||||
llmProvider,
|
||||
setLlmProvider,
|
||||
modelsCatalog,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
providerConfigs,
|
||||
activeConfig,
|
||||
testState,
|
||||
setTestState,
|
||||
showApiKey,
|
||||
requiresApiKey,
|
||||
requiresBaseURL,
|
||||
showBaseURL,
|
||||
isLocalProvider,
|
||||
canTest,
|
||||
connectedFlavors,
|
||||
showMoreProviders,
|
||||
setShowMoreProviders,
|
||||
updateProviderConfig,
|
||||
handleTestAndSaveLlmConfig,
|
||||
handleTestAndAddAnother,
|
||||
|
||||
// ChatGPT subscription sign-in (shown below the OpenAI card)
|
||||
chatgpt,
|
||||
|
||||
// OAuth state
|
||||
providers,
|
||||
providersLoading,
|
||||
|
|
@ -750,10 +503,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
handleSlackSaveWorkspaces,
|
||||
handleSlackDisable,
|
||||
|
||||
// Upsell
|
||||
upsellDismissed,
|
||||
setUpsellDismissed,
|
||||
|
||||
// Composio/Gmail state
|
||||
useComposioForGoogle,
|
||||
gmailConnected,
|
||||
|
|
@ -777,7 +526,6 @@ export function useOnboardingState(open: boolean, onComplete: (opts?: { startTou
|
|||
handleBack,
|
||||
handleComplete,
|
||||
handleCompleteWithTour,
|
||||
handleSwitchToRowboat,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { cn } from "@/lib/utils"
|
|||
import * as analytics from "@/lib/analytics"
|
||||
import { useTheme } from "@/contexts/theme-context"
|
||||
import { toast } from "sonner"
|
||||
import { AnthropicIcon, DiscordIcon, GenericApiIcon, GitHubIcon, GoogleIcon, OllamaIcon, OpenAIIcon, OpenRouterIcon, VercelIcon } from "@/components/onboarding/provider-icons"
|
||||
import { AnthropicIcon, DiscordIcon, GitHubIcon, OpenAIIcon } from "@/components/onboarding/provider-icons"
|
||||
import { AccountSettings } from "@/components/settings/account-settings"
|
||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
|
||||
|
|
@ -32,10 +32,8 @@ import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
|||
import { DEFAULT_TURN_LIMITS_SETTINGS } from "@x/shared/src/turn-limits.js"
|
||||
import type { ipc as ipcShared } from "@x/shared"
|
||||
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
|
||||
import { useProviderModels } from "@/hooks/use-provider-models"
|
||||
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
|
||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import { ModelSelector, type ModelRef } from "@/components/model-selector"
|
||||
import { ModelSelectionSection } from "@/components/settings/model-selection-section"
|
||||
import { ProvidersSection } from "@/components/settings/providers-section"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
|
||||
|
|
@ -484,708 +482,7 @@ function AppearanceSettings() {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Model Settings UI ---
|
||||
|
||||
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "GPT models", icon: OpenAIIcon },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Claude models", icon: AnthropicIcon },
|
||||
{ id: "google", name: "Gemini", description: "Google AI Studio", icon: GoogleIcon },
|
||||
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally", icon: OllamaIcon },
|
||||
]
|
||||
|
||||
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string; icon: React.ElementType }> = [
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key", icon: OpenRouterIcon },
|
||||
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway", icon: VercelIcon },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API", icon: GenericApiIcon },
|
||||
]
|
||||
|
||||
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
|
||||
openai: "gpt-5.4",
|
||||
anthropic: "claude-opus-4-8",
|
||||
}
|
||||
|
||||
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 = {
|
||||
apiKey: string
|
||||
baseURL: string
|
||||
models: string[]
|
||||
knowledgeGraphModel: string
|
||||
meetingNotesModel: string
|
||||
liveNoteAgentModel: string
|
||||
autoPermissionDecisionModel: string
|
||||
}
|
||||
|
||||
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: "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 [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
// "Defer background tasks while a chat is running" — a top-level
|
||||
// models.json flag. deferExplicit tracks whether the user (or the Ollama
|
||||
// auto-enable) has ever set it, so we only auto-enable once.
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [deferExplicit, setDeferExplicit] = useState(false)
|
||||
|
||||
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.
|
||||
// Backend model recommendations (flavor-keyed) — used when a provider
|
||||
// becomes the assistant without an explicit model pick.
|
||||
const modelRecommendations = useRowboatConfig()?.modelRecommendations
|
||||
const providerModels = useProviderModels({
|
||||
flavor: provider,
|
||||
apiKey: activeConfig.apiKey,
|
||||
baseURL: activeConfig.baseURL,
|
||||
})
|
||||
// "Sign in with ChatGPT" subscription state — only rendered on the OpenAI
|
||||
// card; independent of the API-key providerConfigs state above.
|
||||
const chatgpt = useChatGPT()
|
||||
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"
|
||||
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
|
||||
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
|
||||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||
|
||||
const primaryModel = activeConfig.models[0] || ""
|
||||
// What "Auto" resolves to right now: the flavor's preferred default when
|
||||
// the fetched list carries it, else the first fetched id. Empty while the
|
||||
// live list hasn't settled — the sentinel label and handleTestAndSave's
|
||||
// on-demand fetch cover that window.
|
||||
const autoResolvePreview = (() => {
|
||||
if (providerModels.status !== "loaded" || providerModels.models.length === 0) return ""
|
||||
const preferred = preferredDefaults[provider]
|
||||
return preferred && providerModels.models.includes(preferred) ? preferred : providerModels.models[0]
|
||||
})()
|
||||
// The model chats run on. An explicit pick (the Assistant model field,
|
||||
// including its typed-id escape hatch and the offline manual inputs)
|
||||
// lives in models[0] and always wins — connecting must never silently
|
||||
// swap a model the user chose; models:test reports honestly if it's bad.
|
||||
// Empty ("Auto") keeps the silent resolve this card has always done.
|
||||
const resolvedModel = primaryModel.trim() || autoResolvePreview
|
||||
// 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 =
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], ...updates },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// 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(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
|
||||
|
||||
async function loadCurrentConfig() {
|
||||
try {
|
||||
setConfigLoading(true)
|
||||
const result = await window.ipc.invoke("workspace:readFile", {
|
||||
path: "config/models.json",
|
||||
})
|
||||
// models.json v2: providers carry credentials only; the assistant
|
||||
// model and per-task overrides live at the top level. This screen's
|
||||
// per-provider "model" state is a UI concept — hydrated from
|
||||
// assistantModel for its provider, empty for the rest (the picker
|
||||
// lists come from the catalog / live probe, not from config).
|
||||
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))
|
||||
))
|
||||
const assistant = parsed?.assistantModel as { provider?: string; model?: string } | undefined
|
||||
const taskModels = (parsed?.taskModels ?? {}) as Record<string, { provider?: string; model?: string } | undefined>
|
||||
const taskStringFor = (key: string, providerId: string): string => {
|
||||
const ref = taskModels[key]
|
||||
return ref?.provider === providerId ? asString(ref.model) : ""
|
||||
}
|
||||
setProviderConfigs(prev => {
|
||||
const next = { ...prev };
|
||||
for (const [key, entry] of Object.entries(parsed?.providers ?? {})) {
|
||||
if (!(key in next)) continue
|
||||
const e = entry as { apiKey?: string; baseURL?: string };
|
||||
next[key as LlmProviderFlavor] = {
|
||||
apiKey: e.apiKey || "",
|
||||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||
models: assistant?.provider === key && assistant.model ? [assistant.model] : [""],
|
||||
knowledgeGraphModel: taskStringFor("knowledgeGraph", key),
|
||||
meetingNotesModel: taskStringFor("meetingNotes", key),
|
||||
liveNoteAgentModel: taskStringFor("liveNoteAgent", key),
|
||||
autoPermissionDecisionModel: taskStringFor("autoPermissionDecision", key),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
})
|
||||
if (assistant?.provider && knownFlavors.has(assistant.provider)) {
|
||||
setProvider(assistant.provider as LlmProviderFlavor)
|
||||
setDefaultProvider(assistant.provider as LlmProviderFlavor)
|
||||
}
|
||||
} catch {
|
||||
// No existing config or parse error - use defaults
|
||||
} finally {
|
||||
setConfigLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadCurrentConfig()
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleDeferToggle = useCallback(async (value: boolean) => {
|
||||
setDeferBackgroundTasks(value)
|
||||
setDeferExplicit(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save setting")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTestAndSave = useCallback(async () => {
|
||||
if (!canTest) return
|
||||
setTestState({ status: "testing" })
|
||||
try {
|
||||
// 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 the Auto path (this branch can only
|
||||
// run when no explicit model is set — an explicit pick IS resolved).
|
||||
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
|
||||
}
|
||||
const providerEntry = {
|
||||
flavor: provider,
|
||||
apiKey: activeConfig.apiKey.trim() || undefined,
|
||||
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", { provider: providerEntry, model })
|
||||
if (result.success) {
|
||||
// v2 writes: the provider entry carries credentials only; the model
|
||||
// choice becomes the assistant model, and the BYOK per-task strings
|
||||
// become task overrides scoped to this provider ('' clears → inherit).
|
||||
const taskRef = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? { provider, model: trimmed } : null
|
||||
}
|
||||
await window.ipc.invoke("models:setProvider", { id: provider, provider: providerEntry })
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
assistantModel: { provider, model },
|
||||
...(rowboatConnected ? {} : {
|
||||
taskModels: {
|
||||
knowledgeGraph: taskRef(activeConfig.knowledgeGraphModel),
|
||||
meetingNotes: taskRef(activeConfig.meetingNotesModel),
|
||||
liveNoteAgent: taskRef(activeConfig.liveNoteAgentModel),
|
||||
autoPermissionDecision: taskRef(activeConfig.autoPermissionDecisionModel),
|
||||
},
|
||||
}),
|
||||
})
|
||||
setDefaultProvider(provider)
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], models: [model] },
|
||||
}))
|
||||
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:
|
||||
// when the user connects Ollama and has never touched the defer
|
||||
// flag, enable it for them (they can switch it off below).
|
||||
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
|
||||
void handleDeferToggle(true)
|
||||
}
|
||||
// Capability probe caveats (local models): saved, but the user should
|
||||
// know when the model can't do tools or has a too-small context.
|
||||
const warnings: string[] = result.warnings ?? []
|
||||
if (warnings.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
toast.warning(warning, { duration: 12000 })
|
||||
}
|
||||
toast.success("Model configuration saved (with warnings)")
|
||||
} else {
|
||||
toast.success("Model configuration saved")
|
||||
}
|
||||
} else {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
}
|
||||
} catch {
|
||||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [canTest, resolvedModel, provider, activeConfig, rowboatConnected, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
||||
|
||||
// "Set as default" = choose this provider's model as the assistant model.
|
||||
// The UI state only knows a model for the provider that was already the
|
||||
// assistant, so other providers resolve one the same way a first connect
|
||||
// does: recommendation if the provider lists it, else the first listed.
|
||||
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const config = providerConfigs[prov]
|
||||
try {
|
||||
let model = config.models[0]?.trim() || ""
|
||||
if (!model) {
|
||||
const listRes = await window.ipc.invoke("models:listForProvider", {
|
||||
provider: {
|
||||
flavor: prov,
|
||||
apiKey: config.apiKey.trim() || undefined,
|
||||
baseURL: config.baseURL.trim() || undefined,
|
||||
},
|
||||
})
|
||||
const list = listRes.success ? listRes.models ?? [] : []
|
||||
const recommended = modelRecommendations?.[prov]
|
||||
model = (recommended && list.includes(recommended)) ? recommended : (list[0] ?? "")
|
||||
}
|
||||
if (!model) {
|
||||
toast.error("Couldn't determine a model for this provider")
|
||||
return
|
||||
}
|
||||
await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: prov, model } })
|
||||
setDefaultProvider(prov)
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], models: [model] },
|
||||
}))
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
toast.success("Default provider updated")
|
||||
} catch {
|
||||
toast.error("Failed to set default provider")
|
||||
}
|
||||
}, [providerConfigs, modelRecommendations])
|
||||
|
||||
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const isDefaultProv = defaultProvider === prov
|
||||
try {
|
||||
// Removes the entry AND any assistant/task selection referencing it
|
||||
// (repo-side dangling cleanup).
|
||||
await window.ipc.invoke("models:removeProvider", { id: prov })
|
||||
|
||||
// Disconnecting the assistant's provider: promote another connected
|
||||
// provider — the connect-only UI has no usable set-as-default step to
|
||||
// send the user to. Best-effort; with none left the composer shows
|
||||
// its connect hint.
|
||||
let promoted: LlmProviderFlavor | null = null
|
||||
if (isDefaultProv) {
|
||||
const candidate = [...savedProviders].find((p): p is LlmProviderFlavor => p !== prov)
|
||||
if (candidate) {
|
||||
await handleSetDefault(candidate)
|
||||
promoted = candidate
|
||||
}
|
||||
}
|
||||
|
||||
setProviderConfigs(prev => ({
|
||||
...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'))
|
||||
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")
|
||||
}
|
||||
}, [defaultProvider, savedProviders, handleSetDefault])
|
||||
|
||||
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
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setProvider(p.id)
|
||||
setTestState({ status: "idle" })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p.icon className="size-4 shrink-0" />
|
||||
<span className="text-sm font-medium">{p.name}</span>
|
||||
{isDefault && !rowboatConnected && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
|
||||
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 && (
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
{!rowboatConnected && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSetDefault(p.id)
|
||||
}}
|
||||
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
Set as default
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteProvider(p.id)
|
||||
}}
|
||||
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (configLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Provider selection */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
|
||||
<div className="grid gap-2 grid-cols-2">
|
||||
{primaryProviders.map(renderProviderCard)}
|
||||
</div>
|
||||
{(showMoreProviders || isMoreProvider) ? (
|
||||
<div className="grid gap-2 grid-cols-2 mt-2">
|
||||
{moreProviders.map(renderProviderCard)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowMoreProviders(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
More providers...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* ChatGPT subscription — OAuth sign-in, independent of the API key and
|
||||
of models.json (the Codex model client consumes the token via core) */}
|
||||
{provider === "openai" && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
ChatGPT Subscription
|
||||
</span>
|
||||
{chatgpt.status.signedIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600 min-w-0">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={chatgpt.signOut}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
) : chatgpt.isSigningIn ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Waiting for browser…
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Use your ChatGPT Plus/Pro subscription
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* The model chats run on — the anchor the category fields' "Same as
|
||||
assistant" points at. Auto keeps the silent resolve; the picker
|
||||
live-fetches with the card's CURRENT inputs (typed, maybe unsaved),
|
||||
and allowCustom subsumes the old openai-compatible free-text field
|
||||
(its /models often doesn't exist — type the id in the search).
|
||||
Explicit picks land in models[0] via setPrimaryModel (preserving
|
||||
models[1..]); the offline manual inputs above write the same slot. */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{rowboatConnected ? "Model" : "Assistant model"}
|
||||
</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
providerFilter={provider}
|
||||
liveCredentials={{ flavor: provider, apiKey: activeConfig.apiKey, baseURL: activeConfig.baseURL }}
|
||||
allowCustom
|
||||
defaultOption={{ label: autoResolvePreview ? `Auto (currently ${autoResolvePreview})` : "Auto (recommended)" }}
|
||||
value={primaryModel.trim() ? { provider, model: primaryModel.trim() } : null}
|
||||
onChange={(ref) => setPrimaryModel(provider, ref ? ref.model : "")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-function model overrides. Persisted as bare model-id strings
|
||||
inside providers[flavor] ('' = "Same as assistant"), so the
|
||||
ModelRef picker value is adapted at this boundary: string ↔
|
||||
{provider, model} with the active card's flavor. allowCustom keeps
|
||||
arbitrary ids typeable (local servers, unlisted models). */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{!rowboatConnected && (<>
|
||||
{(
|
||||
[
|
||||
{ label: "Knowledge graph model", field: "knowledgeGraphModel" },
|
||||
{ label: "Meeting notes model", field: "meetingNotesModel" },
|
||||
{ label: "Track block model", field: "liveNoteAgentModel" },
|
||||
{ label: "Auto-permission model", field: "autoPermissionDecisionModel" },
|
||||
] as const
|
||||
).map(({ label, field }) => (
|
||||
<div key={field} className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{label}</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
providerFilter={provider}
|
||||
allowCustom
|
||||
defaultOption={{ label: "Same as assistant" }}
|
||||
value={activeConfig[field] ? { provider, model: activeConfig[field] } : null}
|
||||
onChange={(ref) => updateConfig(provider, { [field]: ref ? ref.model : "" })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* Test status */}
|
||||
{testState.status === "error" && (
|
||||
<div className="text-sm text-destructive">
|
||||
{testState.error || "Connection test failed"}
|
||||
</div>
|
||||
)}
|
||||
{testState.status === "success" && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
Connected and saved
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Defer background tasks while chatting */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Defer background tasks while chatting</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
|
||||
</div>
|
||||
|
||||
{/* Test & Save button */}
|
||||
<Button
|
||||
onClick={handleTestAndSave}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
className="w-full"
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Connecting...</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tools Library Settings ---
|
||||
|
||||
interface ToolkitInfo {
|
||||
slug: string
|
||||
|
|
@ -1504,129 +801,6 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
|
|||
)
|
||||
}
|
||||
|
||||
// --- Rowboat Model Settings (when signed in via Rowboat) ---
|
||||
//
|
||||
// Hybrid mode: every picker lists the gateway catalog PLUS any BYOK
|
||||
// providers configured below (ModelSelector renders the shared store's
|
||||
// groups, live-fetching list-less providers inside the open dropdown).
|
||||
// Saved via models:updateConfig as {provider, model} refs, so a signed-in
|
||||
// user can e.g. keep the gateway assistant while running background agents
|
||||
// on a local Ollama model. Selections stay local until Save — unlike the
|
||||
// composer, picking here must not write anything by itself.
|
||||
|
||||
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [selectedDefault, setSelectedDefault] = useState<ModelRef | null>(null)
|
||||
const [selectedKg, setSelectedKg] = useState<ModelRef | null>(null)
|
||||
const [selectedLiveNote, setSelectedLiveNote] = useState<ModelRef | null>(null)
|
||||
const [selectedAutoPermission, setSelectedAutoPermission] = useState<ModelRef | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
|
||||
parsed = JSON.parse(configResult.data)
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
// models.json v2: assistantModel + taskModels refs.
|
||||
const toRef = (value: unknown): ModelRef | null => {
|
||||
const ref = value as { provider?: unknown; model?: unknown } | null | undefined
|
||||
return ref && typeof ref.provider === "string" && typeof ref.model === "string"
|
||||
? { provider: ref.provider, model: ref.model }
|
||||
: null
|
||||
}
|
||||
const taskModels = (parsed.taskModels ?? {}) as Record<string, unknown>
|
||||
setSelectedDefault(toRef(parsed.assistantModel))
|
||||
setSelectedKg(toRef(taskModels.knowledgeGraph))
|
||||
setSelectedLiveNote(toRef(taskModels.liveNoteAgent))
|
||||
setSelectedAutoPermission(toRef(taskModels.autoPermissionDecision))
|
||||
} catch {
|
||||
toast.error("Failed to load models")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
// The "Rowboat default" sentinel no longer maps to a hidden curated
|
||||
// default — a null task ref simply inherits the assistant model. The
|
||||
// assistant itself is only written when explicitly picked (clearing
|
||||
// it would leave the app with no model at all).
|
||||
...(selectedDefault ? { assistantModel: selectedDefault } : {}),
|
||||
taskModels: {
|
||||
knowledgeGraph: selectedKg,
|
||||
liveNoteAgent: selectedLiveNote,
|
||||
autoPermissionDecision: selectedAutoPermission,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Model configuration saved")
|
||||
} catch {
|
||||
toast.error("Failed to save model configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
|
||||
|
||||
const renderModelField = (
|
||||
label: string,
|
||||
value: ModelRef | null,
|
||||
onChange: (v: ModelRef | null) => void,
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: "Rowboat default" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
|
||||
</p>
|
||||
|
||||
{renderModelField("Assistant model", selectedDefault, setSelectedDefault)}
|
||||
{renderModelField("Knowledge graph model", selectedKg, setSelectedKg)}
|
||||
{renderModelField("Background agents model", selectedLiveNote, setSelectedLiveNote)}
|
||||
{renderModelField("Permission checks model", selectedAutoPermission, setSelectedAutoPermission)}
|
||||
|
||||
{/* Save */}
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Note Tagging Settings ---
|
||||
|
||||
interface TagDef {
|
||||
|
|
@ -2719,8 +1893,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<div className="px-6 pb-4 pt-5">
|
||||
<h3 className="text-lg font-semibold tracking-tight">{activeTabConfig.label}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{activeTab === "models" && rowboatConnected
|
||||
? "Select your default models"
|
||||
{activeTab === "models"
|
||||
? "Choose the models Rowboat uses for chat and background work."
|
||||
: activeTabConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -2747,21 +1921,22 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
) : activeTab === "mobile" ? (
|
||||
<MobileChannelsSettings dialogOpen={open} />
|
||||
) : activeTab === "models" ? (
|
||||
rowboatConnected
|
||||
? (
|
||||
<div className="space-y-8">
|
||||
<RowboatModelSettings dialogOpen={open} />
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Your own providers</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
|
||||
</p>
|
||||
<ModelSettings dialogOpen={open} rowboatConnected />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: <ModelSettings dialogOpen={open} />
|
||||
// ONE model-selection surface for signed-in and BYOK alike:
|
||||
// the Assistant model + per-task overrides, then provider
|
||||
// (credential) management below.
|
||||
<div className="space-y-8">
|
||||
<ModelSelectionSection dialogOpen={open} />
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">{rowboatConnected ? "Your own providers" : "Providers"}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{rowboatConnected
|
||||
? "Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in every picker alongside your Rowboat models, and are billed to you directly."
|
||||
: "Connect API keys or local runtimes (Ollama, LM Studio). Every connected provider's models appear in the pickers above."}
|
||||
</p>
|
||||
<ProvidersSection dialogOpen={open} />
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "note-tagging" ? (
|
||||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { __resetModelsForTests } from '@/hooks/use-models'
|
||||
import { ModelSelectionSection } from './model-selection-section'
|
||||
|
||||
// Same preload stub pattern as use-models.test.tsx.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
let updateCalls: unknown[] = []
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
if (channel === 'models:updateConfig') updateCalls.push(args)
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
const EMPTY_TASKS = {
|
||||
knowledgeGraph: null,
|
||||
meetingNotes: null,
|
||||
liveNoteAgent: null,
|
||||
autoPermissionDecision: null,
|
||||
chatTitle: null,
|
||||
backgroundTask: null,
|
||||
subagent: null,
|
||||
}
|
||||
|
||||
function serve(opts: {
|
||||
assistant?: { provider: string; model: string } | null
|
||||
taskModels?: Record<string, { provider: string; model: string } | null>
|
||||
}): void {
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'rowboat', flavor: 'rowboat', status: 'ok', models: [{ id: 'google/gemini-3.5-flash' }] },
|
||||
],
|
||||
defaultModel: opts.assistant ?? null,
|
||||
})
|
||||
handlers['models:getConfig'] = async () => ({
|
||||
assistantModel: opts.assistant ?? null,
|
||||
taskModels: { ...EMPTY_TASKS, ...(opts.taskModels ?? {}) },
|
||||
deferBackgroundTasks: false,
|
||||
})
|
||||
handlers['models:updateConfig'] = async () => ({ success: true })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelsForTests()
|
||||
handlers = {}
|
||||
updateCalls = []
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ModelSelectionSection', () => {
|
||||
it('shows the effective assistant model and "Same as Assistant" for un-overridden tasks', async () => {
|
||||
serve({ assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' } })
|
||||
render(<ModelSelectionSection dialogOpen />)
|
||||
|
||||
// Assistant trigger shows the actual model — no "Auto" anywhere.
|
||||
await waitFor(() => expect(screen.getByTitle('Assistant model')).toHaveTextContent('google/gemini-3.5-flash'))
|
||||
// The old sentinel labels are gone for good.
|
||||
expect(screen.queryByText(/Auto \(/)).toBeNull()
|
||||
expect(screen.queryByText('Rowboat default')).toBeNull()
|
||||
|
||||
// All seven tasks render, inheriting.
|
||||
for (const label of ['Background agents', 'Subagents', 'Knowledge graph', 'Meeting notes', 'Live notes', 'Permission checks', 'Chat titles']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getAllByText('Same as Assistant').length).toBe(7)
|
||||
// Inherit subtext names the resolved assistant.
|
||||
expect(screen.getAllByText('Currently uses Rowboat · google/gemini-3.5-flash').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('an overridden task shows "Use Assistant model" and clicking it clears the override', async () => {
|
||||
serve({
|
||||
assistant: { provider: 'rowboat', model: 'google/gemini-3.5-flash' },
|
||||
taskModels: { knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' } },
|
||||
})
|
||||
render(<ModelSelectionSection dialogOpen />)
|
||||
|
||||
const clear = await screen.findByText('Use Assistant model')
|
||||
fireEvent.click(clear)
|
||||
await waitFor(() => expect(updateCalls).toEqual([
|
||||
{ taskModels: { knowledgeGraph: null } },
|
||||
]))
|
||||
// Back to inheriting.
|
||||
await waitFor(() => expect(screen.queryByText('Use Assistant model')).toBeNull())
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ModelSelector, providerDisplayNames, type ModelRef } from "@/components/model-selector"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
|
||||
// The unified model-selection surface (signed-in and BYOK alike): ONE
|
||||
// required Assistant model plus per-task overrides that default to
|
||||
// "Same as Assistant". No "Auto" rows — every choice is an explicit model;
|
||||
// recommendation logic only ever picks INITIAL models at provider-connect
|
||||
// time, never appears as a dropdown option.
|
||||
|
||||
type TaskKey =
|
||||
| "backgroundTask"
|
||||
| "subagent"
|
||||
| "knowledgeGraph"
|
||||
| "meetingNotes"
|
||||
| "liveNoteAgent"
|
||||
| "autoPermissionDecision"
|
||||
| "chatTitle"
|
||||
|
||||
const TASKS: Array<{ key: TaskKey; label: string; description: string }> = [
|
||||
{ key: "backgroundTask", label: "Background agents", description: "Scheduled and event-driven agents that run without a chat" },
|
||||
{ key: "subagent", label: "Subagents", description: "Workers the assistant spawns during a chat" },
|
||||
{ key: "knowledgeGraph", label: "Knowledge graph", description: "Note creation, email classification, knowledge sync" },
|
||||
{ key: "meetingNotes", label: "Meeting notes", description: "Meeting summaries and prep briefs" },
|
||||
{ key: "liveNoteAgent", label: "Live notes", description: "Self-updating notes and their routing" },
|
||||
{ key: "autoPermissionDecision", label: "Permission checks", description: "Auto-approval of safe tool calls" },
|
||||
{ key: "chatTitle", label: "Chat titles", description: "Naming chats from the first message" },
|
||||
]
|
||||
|
||||
function refLabel(ref: ModelRef): string {
|
||||
return `${providerDisplayNames[ref.provider] || ref.provider} · ${ref.model}`
|
||||
}
|
||||
|
||||
export function ModelSelectionSection({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
// The effective assistant model — the same value every picker shows.
|
||||
const { defaultModel, groups } = useModels()
|
||||
const [taskModels, setTaskModels] = useState<Partial<Record<TaskKey, ModelRef | null>>>({})
|
||||
|
||||
// Retired-model detection: the saved assistant no longer appears in its
|
||||
// provider's live list. Only trusted lists count — a failed fetch or an
|
||||
// openai-compatible endpoint (whose /models is often unreliable) must not
|
||||
// flag a working model.
|
||||
const assistantUnavailable = (() => {
|
||||
if (!defaultModel) return false
|
||||
const group = groups.find((g) => g.id === defaultModel.provider)
|
||||
if (!group || group.status !== "ok" || group.models.length === 0) return false
|
||||
if (group.flavor === "openai-compatible") return false
|
||||
return !group.models.includes(defaultModel.model)
|
||||
})()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await window.ipc.invoke("models:getConfig", null)
|
||||
setTaskModels(cfg.taskModels)
|
||||
} catch {
|
||||
// Fresh install — everything inherits.
|
||||
setTaskModels({})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) void load()
|
||||
}, [dialogOpen, load])
|
||||
|
||||
const setAssistant = useCallback(async (ref: ModelRef | null) => {
|
||||
// No sentinel row on the assistant picker, so ref is never null — the
|
||||
// assistant is the one required selection.
|
||||
if (!ref) return
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { assistantModel: ref })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save the Assistant model")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setTask = useCallback(async (key: TaskKey, ref: ModelRef | null) => {
|
||||
const previous = taskModels
|
||||
setTaskModels((prev) => ({ ...prev, [key]: ref }))
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { taskModels: { [key]: ref } })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save the model")
|
||||
setTaskModels(previous)
|
||||
}
|
||||
}, [taskModels])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Assistant model — the one required primary selection. */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
Assistant model
|
||||
{assistantUnavailable && (
|
||||
<span className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-destructive">
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Used for chat and for any task without its own model selection.
|
||||
</p>
|
||||
</div>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={defaultModel}
|
||||
onChange={setAssistant}
|
||||
triggerTitle="Assistant model"
|
||||
/>
|
||||
{assistantUnavailable && defaultModel && (
|
||||
<p className="text-xs text-destructive">
|
||||
This model is no longer listed by {providerDisplayNames[defaultModel.provider] || defaultModel.provider}. Choose another model to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-task overrides — inherit the assistant unless picked. */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Models for other tasks</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
These tasks use the Assistant model unless you choose a different one.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
|
||||
{TASKS.map(({ key, label, description }) => {
|
||||
const override = taskModels[key] ?? null
|
||||
const inheritText = key === "subagent"
|
||||
? "Uses the spawning chat's model"
|
||||
: defaultModel
|
||||
? `Currently uses ${refLabel(defaultModel)}`
|
||||
: "Uses the Assistant model"
|
||||
return (
|
||||
<div key={key} className="space-y-1.5 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
{override && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-muted-foreground underline underline-offset-2 hover:text-foreground shrink-0"
|
||||
onClick={() => void setTask(key, null)}
|
||||
>
|
||||
Use Assistant model
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
allowCustom
|
||||
inheritDefault={{ label: "Same as Assistant" }}
|
||||
value={override}
|
||||
onChange={(ref) => void setTask(key, ref)}
|
||||
triggerTitle={label}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground truncate" title={override ? refLabel(override) : inheritText}>
|
||||
{override ? "Uses a different model from the Assistant" : inheritText}
|
||||
</p>
|
||||
<p className="sr-only">{description}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,747 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { ArrowLeft, CheckCircle2, Loader2, Plus, RefreshCw } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { providerDisplayNames, type ModelRef } from "@/components/model-selector"
|
||||
import { useModels } from "@/hooks/use-models"
|
||||
import { useRowboatConfig } from "@/hooks/use-rowboat-config"
|
||||
import { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import {
|
||||
AnthropicIcon,
|
||||
GenericApiIcon,
|
||||
GoogleIcon,
|
||||
OllamaIcon,
|
||||
OpenAIIcon,
|
||||
OpenRouterIcon,
|
||||
VercelIcon,
|
||||
} from "@/components/onboarding/provider-icons"
|
||||
|
||||
// Provider lifecycle: connected-provider cards (status + model counts), the
|
||||
// add-provider flow, per-provider manage (replace key / endpoint / refresh
|
||||
// models / used-by), and disconnect with its consequences spelled out.
|
||||
// Providers manage CREDENTIALS only — model choices live in
|
||||
// ModelSelectionSection above this section.
|
||||
|
||||
type ByokFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
interface ProviderMeta {
|
||||
id: string
|
||||
flavor: string
|
||||
baseURL?: string
|
||||
hasApiKey: boolean
|
||||
}
|
||||
|
||||
interface Selections {
|
||||
assistantModel: ModelRef | null
|
||||
taskModels: Record<string, ModelRef | null>
|
||||
}
|
||||
|
||||
const TASK_LABELS: Record<string, string> = {
|
||||
backgroundTask: "Background agents",
|
||||
subagent: "Subagents",
|
||||
knowledgeGraph: "Knowledge graph",
|
||||
meetingNotes: "Meeting notes",
|
||||
liveNoteAgent: "Live notes",
|
||||
autoPermissionDecision: "Permission checks",
|
||||
chatTitle: "Chat titles",
|
||||
}
|
||||
|
||||
const BYOK_CATALOG: Array<{ flavor: ByokFlavor; name: string; tagline: string; icon: React.ElementType; needsKey: boolean; needsEndpoint: boolean; optionalKey?: boolean; manualModel?: boolean }> = [
|
||||
{ flavor: "openai", name: "OpenAI", tagline: "GPT models", icon: OpenAIIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "anthropic", name: "Anthropic", tagline: "Claude models", icon: AnthropicIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "google", name: "Gemini", tagline: "Google AI Studio", icon: GoogleIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "ollama", name: "Ollama", tagline: "Run models locally", icon: OllamaIcon, needsKey: false, needsEndpoint: true },
|
||||
{ flavor: "openrouter", name: "OpenRouter", tagline: "One key, many models", icon: OpenRouterIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "aigateway", name: "AI Gateway (Vercel)", tagline: "Vercel's AI Gateway", icon: VercelIcon, needsKey: true, needsEndpoint: false },
|
||||
{ flavor: "openai-compatible", name: "OpenAI-Compatible", tagline: "Custom OpenAI-compatible endpoint", icon: GenericApiIcon, needsKey: true, optionalKey: true, needsEndpoint: true, manualModel: true },
|
||||
]
|
||||
|
||||
const DEFAULT_BASE_URLS: Partial<Record<ByokFlavor, string>> = {
|
||||
ollama: "http://localhost:11434",
|
||||
"openai-compatible": "http://localhost:1234/v1",
|
||||
aigateway: "https://ai-gateway.vercel.sh/v1",
|
||||
}
|
||||
|
||||
function flavorMeta(flavor: string) {
|
||||
return BYOK_CATALOG.find((c) => c.flavor === flavor)
|
||||
}
|
||||
|
||||
export function ProvidersSection({ dialogOpen, variant = "settings" }: {
|
||||
dialogOpen: boolean
|
||||
/**
|
||||
* "onboarding" renders the same connected-provider list + add flow but
|
||||
* without the settings-only chrome (Manage buttons, defer toggle) — the
|
||||
* onboarding step supplies its own framing and navigation.
|
||||
*/
|
||||
variant?: "settings" | "onboarding"
|
||||
}) {
|
||||
const { groups, isRowboatConnected, refresh } = useModels()
|
||||
const chatgpt = useChatGPT()
|
||||
const modelRecommendations = useRowboatConfig()?.modelRecommendations
|
||||
|
||||
const [providersMeta, setProvidersMeta] = useState<ProviderMeta[]>([])
|
||||
const [selections, setSelections] = useState<Selections>({ assistantModel: null, taskModels: {} })
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [manageId, setManageId] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await window.ipc.invoke("models:getConfig", null)
|
||||
setProvidersMeta(cfg.providers)
|
||||
setSelections({ assistantModel: cfg.assistantModel, taskModels: cfg.taskModels })
|
||||
setDeferBackgroundTasks(cfg.deferBackgroundTasks)
|
||||
} catch { /* fresh install */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) void load()
|
||||
}, [dialogOpen, load])
|
||||
useEffect(() => {
|
||||
const handler = () => void load()
|
||||
window.addEventListener("models-config-changed", handler)
|
||||
// Main-side config writes (Rowboat sign-in seeding the assistant,
|
||||
// sign-out clearing selections, ChatGPT state) announce themselves on
|
||||
// the auth broadcasts, not the window event — reload on those too.
|
||||
const cleanups = [
|
||||
window.ipc.on("oauth:didConnect", handler),
|
||||
window.ipc.on("chatgpt:statusChanged", handler),
|
||||
]
|
||||
return () => {
|
||||
window.removeEventListener("models-config-changed", handler)
|
||||
for (const cleanup of cleanups) cleanup()
|
||||
}
|
||||
}, [load])
|
||||
|
||||
// One card per connected provider, in catalog order (assistant's first).
|
||||
const cards = useMemo(() => {
|
||||
return groups.map((g) => {
|
||||
const meta = providersMeta.find((p) => p.id === g.id)
|
||||
return {
|
||||
id: g.id,
|
||||
flavor: g.flavor,
|
||||
name: providerDisplayNames[g.flavor] || g.flavor,
|
||||
status: g.status,
|
||||
error: g.error,
|
||||
modelCount: g.models.length,
|
||||
meta,
|
||||
}
|
||||
})
|
||||
}, [groups, providersMeta])
|
||||
|
||||
const usedBy = useCallback((providerId: string): Array<{ label: string; model: string }> => {
|
||||
const rows: Array<{ label: string; model: string }> = []
|
||||
if (selections.assistantModel?.provider === providerId) {
|
||||
rows.push({ label: "Assistant model", model: selections.assistantModel.model })
|
||||
}
|
||||
for (const [key, ref] of Object.entries(selections.taskModels)) {
|
||||
if (ref?.provider === providerId) {
|
||||
rows.push({ label: TASK_LABELS[key] ?? key, model: ref.model })
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}, [selections])
|
||||
|
||||
const handleDeferToggle = useCallback(async (value: boolean) => {
|
||||
setDeferBackgroundTasks(value)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save setting")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const manageCard = manageId ? cards.find((c) => c.id === manageId) ?? null : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{cards.length === 0 && (
|
||||
<div className="rounded-md border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Connect Rowboat, use your own API key, or choose a local provider to start using the Assistant.
|
||||
</div>
|
||||
)}
|
||||
{cards.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-3 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{c.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
c.status === "ok" ? "bg-green-500" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
{c.status === "ok"
|
||||
? `Connected · ${c.modelCount} model${c.modelCount === 1 ? "" : "s"} available`
|
||||
: (c.error || "Could not load models")}
|
||||
</div>
|
||||
</div>
|
||||
{c.status === "error" && (
|
||||
<Button variant="ghost" size="sm" onClick={() => refresh(c.id)}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{variant === "settings" && (
|
||||
<Button variant="outline" size="sm" onClick={() => setManageId(c.id)}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-4 mr-1" />
|
||||
Add provider
|
||||
</Button>
|
||||
|
||||
{/* Defer background tasks while chatting */}
|
||||
{variant === "settings" && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Defer background tasks while chatting</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Background agents wait until no chat is running. Recommended for local models.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddProviderDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
connectedIds={cards.map((c) => c.id)}
|
||||
isRowboatConnected={isRowboatConnected}
|
||||
chatgptSignedIn={chatgpt.status.signedIn}
|
||||
onChatGPTSignIn={chatgpt.signIn}
|
||||
hadAssistant={selections.assistantModel !== null}
|
||||
modelRecommendations={modelRecommendations}
|
||||
/>
|
||||
|
||||
{manageCard && (
|
||||
<ManageProviderDialog
|
||||
card={manageCard}
|
||||
usedBy={usedBy(manageCard.id)}
|
||||
onClose={() => setManageId(null)}
|
||||
onRefreshModels={async () => { await refresh(manageCard.id) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Add provider ----------
|
||||
|
||||
type AddStep =
|
||||
| { kind: "choose" }
|
||||
| { kind: "creds"; flavor: ByokFlavor }
|
||||
| { kind: "authwait"; which: "rowboat" | "chatgpt" }
|
||||
| { kind: "loading"; flavor: ByokFlavor }
|
||||
| { kind: "result"; name: string; first: boolean; pickedModel: string | null; modelCount: number | null }
|
||||
| { kind: "error"; flavor: ByokFlavor; message: string }
|
||||
|
||||
function AddProviderDialog({ open, onOpenChange, connectedIds, isRowboatConnected, chatgptSignedIn, onChatGPTSignIn, hadAssistant, modelRecommendations }: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
connectedIds: string[]
|
||||
isRowboatConnected: boolean
|
||||
chatgptSignedIn: boolean
|
||||
onChatGPTSignIn: () => Promise<unknown> | void
|
||||
hadAssistant: boolean
|
||||
modelRecommendations: Record<string, string> | undefined
|
||||
}) {
|
||||
const [step, setStep] = useState<AddStep>({ kind: "choose" })
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [baseURL, setBaseURL] = useState("")
|
||||
const [manualModel, setManualModel] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep({ kind: "choose" })
|
||||
setApiKey("")
|
||||
setBaseURL("")
|
||||
setManualModel("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Rowboat / ChatGPT sign-in completes out-of-band (browser); the shared
|
||||
// store refreshes on the auth broadcasts, so the provider appearing in
|
||||
// connectedIds is the completion signal.
|
||||
useEffect(() => {
|
||||
if (step.kind !== "authwait") return
|
||||
const id = step.which === "rowboat" ? "rowboat" : "codex"
|
||||
if (connectedIds.includes(id)) {
|
||||
setStep({
|
||||
kind: "result",
|
||||
name: providerDisplayNames[id] || id,
|
||||
// Both sign-ins seed the assistant when none was set (main-side
|
||||
// initial selection) — first-provider copy applies to either.
|
||||
first: !hadAssistant,
|
||||
pickedModel: null,
|
||||
modelCount: null,
|
||||
})
|
||||
}
|
||||
}, [step, connectedIds, hadAssistant])
|
||||
|
||||
const chooseEntries = useMemo(() => {
|
||||
const entries: Array<{ id: string; name: string; tagline: string; icon: React.ElementType | null; onChoose: () => void }> = []
|
||||
if (!isRowboatConnected) {
|
||||
entries.push({
|
||||
id: "rowboat",
|
||||
name: "Rowboat",
|
||||
tagline: "Included with your plan",
|
||||
icon: null,
|
||||
onChoose: () => {
|
||||
setStep({ kind: "authwait", which: "rowboat" })
|
||||
void window.ipc.invoke("oauth:connect", { provider: "rowboat" }).catch(() => {
|
||||
setStep({ kind: "choose" })
|
||||
toast.error("Sign-in failed to start")
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!chatgptSignedIn) {
|
||||
entries.push({
|
||||
id: "codex",
|
||||
name: "ChatGPT",
|
||||
tagline: "Use your Plus/Pro subscription",
|
||||
icon: OpenAIIcon,
|
||||
onChoose: () => {
|
||||
setStep({ kind: "authwait", which: "chatgpt" })
|
||||
void Promise.resolve(onChatGPTSignIn()).catch(() => {
|
||||
setStep({ kind: "choose" })
|
||||
toast.error("Sign-in failed to start")
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const c of BYOK_CATALOG) {
|
||||
if (connectedIds.includes(c.flavor)) continue
|
||||
entries.push({
|
||||
id: c.flavor,
|
||||
name: c.name,
|
||||
tagline: c.tagline,
|
||||
icon: c.icon,
|
||||
onChoose: () => {
|
||||
setApiKey("")
|
||||
setBaseURL(DEFAULT_BASE_URLS[c.flavor] ?? "")
|
||||
setManualModel("")
|
||||
setStep({ kind: "creds", flavor: c.flavor })
|
||||
},
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}, [isRowboatConnected, chatgptSignedIn, connectedIds, onChatGPTSignIn])
|
||||
|
||||
const connect = useCallback(async (flavor: ByokFlavor) => {
|
||||
const meta = flavorMeta(flavor)
|
||||
if (!meta) return
|
||||
const key = apiKey.trim()
|
||||
const url = baseURL.trim()
|
||||
if (meta.needsKey && !meta.optionalKey && !key) {
|
||||
toast.error("Enter an API key")
|
||||
return
|
||||
}
|
||||
if (meta.needsEndpoint && !url) {
|
||||
toast.error("Enter the endpoint URL")
|
||||
return
|
||||
}
|
||||
setStep({ kind: "loading", flavor })
|
||||
const providerEntry = {
|
||||
flavor,
|
||||
apiKey: key || undefined,
|
||||
baseURL: url || undefined,
|
||||
}
|
||||
try {
|
||||
const listRes = await window.ipc.invoke("models:listForProvider", { provider: providerEntry })
|
||||
const list = listRes.success ? listRes.models ?? [] : []
|
||||
const typed = manualModel.trim()
|
||||
let model = typed
|
||||
if (!model) {
|
||||
const recommended = modelRecommendations?.[flavor]
|
||||
model = recommended && list.includes(recommended) ? recommended : (list[0] ?? "")
|
||||
}
|
||||
if (!listRes.success && !model) {
|
||||
setStep({ kind: "error", flavor, message: listRes.error || "Could not load the provider's model list." })
|
||||
return
|
||||
}
|
||||
if (!model) {
|
||||
setStep({ kind: "error", flavor, message: "The provider reported no models. Enter a model id manually and retry." })
|
||||
return
|
||||
}
|
||||
const testRes = await window.ipc.invoke("models:test", { provider: providerEntry, model })
|
||||
if (!testRes.success) {
|
||||
setStep({ kind: "error", flavor, message: testRes.error || "Connection test failed" })
|
||||
return
|
||||
}
|
||||
await window.ipc.invoke("models:setProvider", { id: flavor, provider: providerEntry })
|
||||
// Initial selection only — a saved assistant is never replaced. The
|
||||
// prop can be stale (a Rowboat sign-in moments ago seeds the
|
||||
// assistant MAIN-side), so re-read the authoritative config at the
|
||||
// moment of decision instead of trusting render-time state.
|
||||
const cfgNow = await window.ipc.invoke("models:getConfig", null).catch(() => null)
|
||||
const hasAssistantNow = cfgNow ? cfgNow.assistantModel !== null : hadAssistant
|
||||
if (!hasAssistantNow) {
|
||||
await window.ipc.invoke("models:updateConfig", { assistantModel: { provider: flavor, model } })
|
||||
}
|
||||
for (const warning of testRes.warnings ?? []) {
|
||||
toast.warning(warning, { duration: 12000 })
|
||||
}
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
setStep({
|
||||
kind: "result",
|
||||
name: meta.name,
|
||||
first: !hasAssistantNow,
|
||||
pickedModel: !hasAssistantNow ? model : null,
|
||||
modelCount: list.length > 0 ? list.length : null,
|
||||
})
|
||||
} catch {
|
||||
setStep({ kind: "error", flavor, message: "Connection failed" })
|
||||
}
|
||||
}, [apiKey, baseURL, manualModel, modelRecommendations, hadAssistant])
|
||||
|
||||
const credsMeta = step.kind === "creds" || step.kind === "error" ? flavorMeta(step.flavor) : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step.kind === "creds" && credsMeta ? `Connect ${credsMeta.name}` : "Add provider"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{step.kind === "choose" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect Rowboat, add your own API key, or run models locally. Each provider's models appear alongside the others in every picker.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{chooseEntries.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
onClick={e.onChoose}
|
||||
className="rounded-md border px-3 py-2.5 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{e.icon && <e.icon className="size-4 shrink-0" />}
|
||||
<span className="text-sm font-medium">{e.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{e.tagline}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "creds" && credsMeta && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep({ kind: "choose" })}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
All providers
|
||||
</button>
|
||||
{credsMeta.needsKey && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
API key{credsMeta.optionalKey ? " (optional)" : ""}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Paste your API key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{credsMeta.needsEndpoint && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
|
||||
<Input
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
placeholder={DEFAULT_BASE_URLS[credsMeta.flavor] ?? "https://api.example.com/v1"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{credsMeta.manualModel && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id (optional)</span>
|
||||
<Input
|
||||
value={manualModel}
|
||||
onChange={(e) => setManualModel(e.target.value)}
|
||||
placeholder="Leave empty to auto-select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={() => void connect(credsMeta.flavor)}>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "authwait" && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-medium">Complete sign-in in your browser…</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setStep({ kind: "choose" })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "loading" && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<div className="text-sm font-medium">Loading available models…</div>
|
||||
<div className="text-xs text-muted-foreground">Validating the connection and fetching the model list.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "result" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
{step.name} connected
|
||||
</div>
|
||||
{step.first && step.pickedModel && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We selected <span className="font-medium text-foreground">{step.pickedModel}</span> as your Assistant model to get you started. You can change it any time above.
|
||||
</p>
|
||||
)}
|
||||
{step.first && !step.pickedModel && (
|
||||
<p className="text-xs text-muted-foreground">Your Assistant model has been set. You can change it any time above.</p>
|
||||
)}
|
||||
{!step.first && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step.modelCount ? `${step.modelCount} models are now available. ` : ""}Your Assistant model is unchanged — pick a model from {step.name} for any task whenever you like.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === "error" && credsMeta && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-destructive">Couldn't connect</div>
|
||||
<p className="text-xs text-muted-foreground break-words">{step.message}</p>
|
||||
{credsMeta.manualModel && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model id</span>
|
||||
<Input
|
||||
value={manualModel}
|
||||
onChange={(e) => setManualModel(e.target.value)}
|
||||
placeholder="Enter a model id to connect anyway"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setStep({ kind: "creds", flavor: step.flavor })}>
|
||||
Review connection
|
||||
</Button>
|
||||
<Button onClick={() => void connect(step.flavor)}>Retry</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Manage provider ----------
|
||||
|
||||
function ManageProviderDialog({ card, usedBy, onClose, onRefreshModels }: {
|
||||
card: { id: string; flavor: string; name: string; status: "ok" | "error"; error?: string; modelCount: number; meta?: ProviderMeta }
|
||||
usedBy: Array<{ label: string; model: string }>
|
||||
onClose: () => void
|
||||
onRefreshModels: () => Promise<void>
|
||||
}) {
|
||||
const isAuthDerived = card.flavor === "rowboat" || card.flavor === "codex"
|
||||
const meta = flavorMeta(card.flavor)
|
||||
const chatgpt = useChatGPT()
|
||||
const [replacingKey, setReplacingKey] = useState(false)
|
||||
const [newKey, setNewKey] = useState("")
|
||||
const [endpoint, setEndpoint] = useState(card.meta?.baseURL ?? "")
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const handleRefreshModels = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await onRefreshModels()
|
||||
toast.success("Models refreshed")
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [onRefreshModels])
|
||||
|
||||
const saveCredentials = useCallback(async (updates: { apiKey?: string; baseURL?: string }) => {
|
||||
try {
|
||||
await window.ipc.invoke("models:setProvider", {
|
||||
id: card.id,
|
||||
provider: {
|
||||
flavor: card.flavor as ByokFlavor,
|
||||
...(updates.apiKey !== undefined ? { apiKey: updates.apiKey } : {}),
|
||||
...(updates.baseURL !== undefined ? { baseURL: updates.baseURL } : {}),
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Provider updated")
|
||||
setReplacingKey(false)
|
||||
setNewKey("")
|
||||
} catch {
|
||||
toast.error("Failed to update provider")
|
||||
}
|
||||
}, [card.id, card.flavor])
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
if (card.flavor === "rowboat") {
|
||||
await window.ipc.invoke("oauth:disconnect", { provider: "rowboat" })
|
||||
} else if (card.flavor === "codex") {
|
||||
await chatgpt.signOut()
|
||||
} else {
|
||||
await window.ipc.invoke("models:removeProvider", { id: card.id })
|
||||
}
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success(`${card.name} disconnected`)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error("Failed to disconnect")
|
||||
}
|
||||
}, [card, chatgpt, onClose])
|
||||
|
||||
const assistantAffected = usedBy.some((u) => u.label === "Assistant model")
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{card.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className={cn("size-2 rounded-full shrink-0", card.status === "ok" ? "bg-green-500" : "bg-destructive")} />
|
||||
{card.status === "ok"
|
||||
? `Connected · ${card.modelCount} model${card.modelCount === 1 ? "" : "s"} available`
|
||||
: (card.error || "Could not load models")}
|
||||
</div>
|
||||
|
||||
{!isAuthDerived && meta?.needsKey && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API key</span>
|
||||
{replacingKey ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="Paste the new API key"
|
||||
/>
|
||||
<Button size="sm" disabled={!newKey.trim()} onClick={() => void saveCredentials({ apiKey: newKey.trim() })}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input readOnly value={card.meta?.hasApiKey ? "••••••••••••••••" : "No key saved"} className="font-mono text-xs" />
|
||||
<Button variant="outline" size="sm" onClick={() => setReplacingKey(true)}>
|
||||
Replace
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthDerived && meta?.needsEndpoint && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Endpoint URL</span>
|
||||
<div className="flex gap-2">
|
||||
<Input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!endpoint.trim() || endpoint.trim() === (card.meta?.baseURL ?? "")}
|
||||
onClick={() => void saveCredentials({ baseURL: endpoint.trim() })}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2.5">
|
||||
<div className="text-xs text-muted-foreground">Refresh to pull the latest models from {card.name}.</div>
|
||||
<Button variant="outline" size="sm" disabled={refreshing} onClick={() => void handleRefreshModels()}>
|
||||
<RefreshCw className={cn("size-3.5 mr-1", refreshing && "animate-spin")} />
|
||||
{refreshing ? "Refreshing…" : "Refresh models"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Used by</span>
|
||||
{usedBy.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No model selections currently use this provider.</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{usedBy.map((u) => (
|
||||
<div key={u.label} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{u.label}</span>
|
||||
<span className="text-muted-foreground truncate ml-3">{u.model}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
{!confirmDisconnect ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{card.flavor === "rowboat"
|
||||
? "Sign out of your Rowboat account."
|
||||
: `Remove ${card.name} and its models from Rowboat.`}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" className="text-destructive" onClick={() => setConfirmDisconnect(true)}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Disconnect {card.name}?</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{usedBy.length > 0
|
||||
? `${usedBy.length} model selection${usedBy.length === 1 ? "" : "s"} use${usedBy.length === 1 ? "s" : ""} this provider. Task overrides will reset to the Assistant model${assistantAffected ? ", and you'll need to pick a new Assistant model" : ""}.`
|
||||
: "Its models will no longer be available in Rowboat."}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDisconnect(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => void disconnect()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -190,8 +190,12 @@ describe('useModels', () => {
|
|||
const { result } = renderHook(() => useModels())
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(1))
|
||||
|
||||
act(() => result.current.refresh('ollama'))
|
||||
await waitFor(() => expect(invokeCounts['models:list']).toBe(2))
|
||||
// Promise-returning so callers (Manage's "Refresh models") can render
|
||||
// progress and confirm completion.
|
||||
await act(async () => {
|
||||
await result.current.refresh('ollama')
|
||||
})
|
||||
expect(invokeCounts['models:list']).toBe(2)
|
||||
expect(invokeArgs['models:list'][1]).toEqual({ refreshProvider: 'ollama' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -40,9 +40,10 @@ export interface UseModelsResult extends ModelsSnapshot {
|
|||
/**
|
||||
* Force a refetch now (e.g. a composer tab becoming active). With a
|
||||
* provider id, that provider's cached list is dropped and refetched
|
||||
* (the error-row Retry).
|
||||
* (the error-row Retry, Manage's "Refresh models"). Resolves once the
|
||||
* snapshot has been updated, so callers can render progress.
|
||||
*/
|
||||
refresh: (providerId?: string) => void
|
||||
refresh: (providerId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: ModelsSnapshot = {
|
||||
|
|
@ -116,13 +117,13 @@ async function buildSnapshot(refreshProvider?: string): Promise<ModelsSnapshot>
|
|||
}
|
||||
}
|
||||
|
||||
function startFetch(refreshProvider?: string): void {
|
||||
function startFetch(refreshProvider?: string): Promise<void> {
|
||||
// Concurrent fetches race (an event can fire while one is in flight) —
|
||||
// only the newest run may write the snapshot, else a slow stale run can
|
||||
// clobber the fresh list.
|
||||
const seq = ++fetchSeq
|
||||
fetching = true
|
||||
void buildSnapshot(refreshProvider)
|
||||
return buildSnapshot(refreshProvider)
|
||||
.then((next) => {
|
||||
if (seq !== fetchSeq) return
|
||||
snapshot = next
|
||||
|
|
@ -138,19 +139,19 @@ function startFetch(refreshProvider?: string): void {
|
|||
})
|
||||
}
|
||||
|
||||
function refreshModels(providerId?: string): void {
|
||||
startFetch(typeof providerId === 'string' ? providerId : undefined)
|
||||
function refreshModels(providerId?: string): Promise<void> {
|
||||
return startFetch(typeof providerId === 'string' ? providerId : undefined)
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (!loaded && !fetching) startFetch()
|
||||
if (!loaded && !fetching) void startFetch()
|
||||
}
|
||||
|
||||
function wireGlobalEvents(): void {
|
||||
if (wired) return
|
||||
wired = true
|
||||
// Event payloads must not leak into startFetch's refreshProvider arg.
|
||||
const refetch = () => startFetch()
|
||||
const refetch = () => void startFetch()
|
||||
// Config edits anywhere in the app (settings dialog, composer pick,
|
||||
// onboarding) announce themselves on this window event.
|
||||
window.addEventListener('models-config-changed', refetch)
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export interface CatalogModelEntry {
|
|||
|
||||
export interface CatalogProviderEntry {
|
||||
/**
|
||||
* Provider INSTANCE identifier — what ModelRef.provider, defaultSelection,
|
||||
* Provider INSTANCE identifier — what ModelRef.provider, assistantModel,
|
||||
* task overrides, and refreshProvider all reference. Today one instance
|
||||
* exists per flavor, so id always equals the flavor key ("openai",
|
||||
* "ollama", "rowboat", …); a future multi-key setup ("openai-work" /
|
||||
|
|
|
|||
|
|
@ -107,12 +107,18 @@ export async function getChatTitleModel(): Promise<ModelSelection> {
|
|||
return getCategoryModel("chatTitle");
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the background-task agent + routing classifier. Currently
|
||||
* mirrors `getLiveNoteAgentModel()` — both surfaces want a fast, reliable
|
||||
* agent model. Split into its own getter so a future per-feature override
|
||||
* doesn't require touching all call sites.
|
||||
*/
|
||||
/** Model used by the background-task agent + routing classifier. */
|
||||
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
|
||||
return getLiveNoteAgentModel();
|
||||
return getCategoryModel("backgroundTask");
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit subagent model override, or null to inherit the PARENT turn's
|
||||
* model (spawn-agent's default — which is the assistant for a top-level
|
||||
* chat). Not getCategoryModel: the no-override fallback is the parent, not
|
||||
* the assistant, and the caller owns that resolution.
|
||||
*/
|
||||
export async function getSubagentModelOverride(): Promise<ModelSelection | null> {
|
||||
const cfg = await readConfig();
|
||||
return cfg?.taskModels?.subagent ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ describe('migrateModelsConfig', () => {
|
|||
taskModels: {
|
||||
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// v1 background tasks mirrored the live-note model.
|
||||
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// chat titles used flash-lite because the assistant routes
|
||||
// through the gateway; meeting notes used the curated default
|
||||
|
|
@ -59,6 +61,7 @@ describe('migrateModelsConfig', () => {
|
|||
expect(v2?.taskModels).toEqual({
|
||||
knowledgeGraph: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
liveNoteAgent: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
backgroundTask: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
autoPermissionDecision: { provider: 'rowboat', model: 'google/gemini-3.1-flash-lite' },
|
||||
// Meeting notes used the curated gateway default, which now
|
||||
// differs from the (BYOK) assistant — preserved explicitly.
|
||||
|
|
@ -83,6 +86,18 @@ describe('migrateModelsConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('a v1 live-note override propagates to backgroundTask (v1 bg tasks mirrored live-note)', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
model: 'gpt-5.4',
|
||||
liveNoteAgentModel: { provider: 'ollama', model: 'qwen3' },
|
||||
};
|
||||
expect(migrateModelsConfig(v1, false)?.taskModels).toEqual({
|
||||
liveNoteAgent: { provider: 'ollama', model: 'qwen3' },
|
||||
backgroundTask: { provider: 'ollama', model: 'qwen3' },
|
||||
});
|
||||
});
|
||||
|
||||
it('an override equal to the assistant is dropped (inherit produces the same model)', () => {
|
||||
const v1 = {
|
||||
provider: { flavor: 'openai', apiKey: 'sk-a' },
|
||||
|
|
|
|||
|
|
@ -131,11 +131,15 @@ export function migrateModelsConfig(rawInput: unknown, signedIn: boolean): V2 |
|
|||
const assistantModel = v1EffectiveAssistant(raw, signedIn);
|
||||
|
||||
// Old effective model per task, via the deleted v1 rules.
|
||||
const liveNoteEffective = v1Override(raw, "liveNoteAgentModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel);
|
||||
const oldTaskModels: Record<string, Ref | undefined> = {
|
||||
knowledgeGraph: v1Override(raw, "knowledgeGraphModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
liveNoteAgent: v1Override(raw, "liveNoteAgentModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
liveNoteAgent: liveNoteEffective,
|
||||
// v1 had no backgroundTask key — getBackgroundTaskAgentModel mirrored
|
||||
// the live-note model (override included). v2 gives it its own slot.
|
||||
backgroundTask: liveNoteEffective,
|
||||
autoPermissionDecision: v1Override(raw, "autoPermissionDecisionModel", signedIn)
|
||||
?? (signedIn ? { provider: "rowboat", model: V1_CURATED_LITE } : assistantModel),
|
||||
meetingNotes: v1Override(raw, "meetingNotesModel", signedIn)
|
||||
|
|
|
|||
|
|
@ -90,8 +90,20 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
}
|
||||
|
||||
async setProvider(id: string, provider: z.infer<typeof LlmProvider>): Promise<void> {
|
||||
// The credential-less flavors are never stored: their connection IS
|
||||
// their auth token store (oauth.json / chatgpt-auth.json), and the
|
||||
// catalog derives their presence from auth state — a providers-map
|
||||
// entry would double-list them.
|
||||
if (provider.flavor === "rowboat" || provider.flavor === "codex") {
|
||||
throw new Error(`Provider flavor '${provider.flavor}' is auth-derived and cannot be stored in models.json`);
|
||||
}
|
||||
const config = await this.read();
|
||||
config.providers[id] = LlmProvider.parse(provider);
|
||||
// Merge over an existing entry: replacing a key must not wipe
|
||||
// hand-tuned connection prefs (contextLength, reasoningEffort).
|
||||
config.providers[id] = LlmProvider.parse({
|
||||
...config.providers[id],
|
||||
...provider,
|
||||
});
|
||||
await this.write(config);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
|
||||
import type { ITurnRuntime } from "../turns/api.js";
|
||||
|
|
@ -10,6 +10,16 @@ import type {
|
|||
} from "./headless.js";
|
||||
import { runSpawnedAgent } from "./spawn-agent.js";
|
||||
|
||||
// spawn-agent reads the configured subagent override lazily from
|
||||
// models/defaults.js; stub it so tests control the whole precedence chain
|
||||
// (explicit args > configured override > parent model).
|
||||
const subagentOverride = vi.hoisted(() => ({
|
||||
value: null as { provider: string; model: string } | null,
|
||||
}));
|
||||
vi.mock("../../models/defaults.js", () => ({
|
||||
getSubagentModelOverride: async () => subagentOverride.value,
|
||||
}));
|
||||
|
||||
const TS = "2026-07-07T10:00:00Z";
|
||||
|
||||
function parentCreated(
|
||||
|
|
@ -95,6 +105,34 @@ function fakeServices(opts: {
|
|||
const signal = new AbortController().signal;
|
||||
|
||||
describe("runSpawnedAgent", () => {
|
||||
beforeEach(() => {
|
||||
subagentOverride.value = null;
|
||||
});
|
||||
|
||||
it("uses the configured subagent override when no explicit model is passed", async () => {
|
||||
subagentOverride.value = { provider: "ollama", model: "qwen3" };
|
||||
const { services, started } = fakeServices({});
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].agent).toMatchObject({
|
||||
inline: { model: { provider: "ollama", model: "qwen3" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("explicit per-spawn model args beat the configured override", async () => {
|
||||
subagentOverride.value = { provider: "ollama", model: "qwen3" };
|
||||
const { services, started } = fakeServices({});
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x", model: "gpt-5.4", provider: "openai" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].agent).toMatchObject({
|
||||
inline: { model: { provider: "openai", model: "gpt-5.4" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs an inline child on the parent's model and returns the result envelope", async () => {
|
||||
const { services, started } = fakeServices({});
|
||||
const progress: unknown[] = [];
|
||||
|
|
|
|||
|
|
@ -135,12 +135,15 @@ export async function runSpawnedAgent(
|
|||
parentModel = undefined;
|
||||
}
|
||||
|
||||
// Model precedence: explicit per-spawn args > the user's configured
|
||||
// subagent override (taskModels.subagent) > the parent turn's model.
|
||||
const subagentOverride = input.model ? null : await readSubagentOverride();
|
||||
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
|
||||
? {
|
||||
provider: input.provider ?? parentModel?.provider ?? "",
|
||||
model: input.model,
|
||||
}
|
||||
: parentModel;
|
||||
: subagentOverride ?? parentModel;
|
||||
if (model && !model.provider) {
|
||||
return spawnError(
|
||||
"`model` was set but no provider could be determined; pass `provider` too",
|
||||
|
|
@ -232,6 +235,21 @@ export async function runSpawnedAgent(
|
|||
};
|
||||
}
|
||||
|
||||
// Lazy for the same import-cycle reason as resolveServices; best-effort —
|
||||
// an unreadable config means "no override", never a failed spawn.
|
||||
async function readSubagentOverride(): Promise<
|
||||
z.infer<typeof ModelDescriptor> | null
|
||||
> {
|
||||
try {
|
||||
const { getSubagentModelOverride } = await import(
|
||||
"../../models/defaults.js"
|
||||
);
|
||||
return await getSubagentModelOverride();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServices(): Promise<
|
||||
NonNullable<SpawnedAgentCallbacks["services"]>
|
||||
> {
|
||||
|
|
|
|||
|
|
@ -656,7 +656,7 @@ const ipcSchemas = {
|
|||
}).nullable(),
|
||||
res: z.object({
|
||||
providers: z.array(z.object({
|
||||
// Provider INSTANCE id — what ModelRef.provider / defaultSelection /
|
||||
// Provider INSTANCE id — what ModelRef.provider / assistantModel /
|
||||
// refreshProvider reference. One instance per flavor today, so it
|
||||
// always equals the flavor key; kept distinct so a future
|
||||
// multi-instance setup ("openai-work") is additive.
|
||||
|
|
@ -748,6 +748,33 @@ const ipcSchemas = {
|
|||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Current model selections plus credential-FREE provider metadata (the
|
||||
// renderer never needs keys to render pickers or provider cards). Null
|
||||
// assistantModel = not configured yet.
|
||||
'models:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
// Configured BYOK provider entries, secrets stripped: enough to
|
||||
// render manage/edit surfaces (masked key indicator, endpoint).
|
||||
providers: z.array(z.object({
|
||||
id: z.string(),
|
||||
flavor: z.string(),
|
||||
baseURL: z.string().optional(),
|
||||
hasApiKey: z.boolean(),
|
||||
})),
|
||||
assistantModel: ModelRef.nullable(),
|
||||
taskModels: z.object({
|
||||
knowledgeGraph: ModelRef.nullable(),
|
||||
meetingNotes: ModelRef.nullable(),
|
||||
liveNoteAgent: ModelRef.nullable(),
|
||||
autoPermissionDecision: ModelRef.nullable(),
|
||||
chatTitle: ModelRef.nullable(),
|
||||
backgroundTask: ModelRef.nullable(),
|
||||
subagent: ModelRef.nullable(),
|
||||
}),
|
||||
deferBackgroundTasks: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Partial merge of model selections into models.json. Omitted keys are
|
||||
// untouched; null clears a key (a cleared task override inherits the
|
||||
// assistant model again). taskModels merges per-key.
|
||||
|
|
@ -760,6 +787,8 @@ const ipcSchemas = {
|
|||
liveNoteAgent: ModelRef.nullable().optional(),
|
||||
autoPermissionDecision: ModelRef.nullable().optional(),
|
||||
chatTitle: ModelRef.nullable().optional(),
|
||||
backgroundTask: ModelRef.nullable().optional(),
|
||||
subagent: ModelRef.nullable().optional(),
|
||||
}).optional(),
|
||||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -41,13 +41,17 @@ export const ModelRef = z.object({
|
|||
model: z.string(),
|
||||
});
|
||||
|
||||
// The per-task model override slots. Absence = inherit the assistant model.
|
||||
// The per-task model override slots. Absence = inherit the assistant model
|
||||
// (except `subagent`, whose default is the PARENT turn's model — which is
|
||||
// the assistant for a top-level chat).
|
||||
export const TaskModels = z.object({
|
||||
knowledgeGraph: ModelRef.optional(),
|
||||
meetingNotes: ModelRef.optional(),
|
||||
liveNoteAgent: ModelRef.optional(),
|
||||
autoPermissionDecision: ModelRef.optional(),
|
||||
chatTitle: ModelRef.optional(),
|
||||
backgroundTask: ModelRef.optional(),
|
||||
subagent: ModelRef.optional(),
|
||||
});
|
||||
export type TaskModelKey = keyof z.infer<typeof TaskModels>;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue