mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
Feat/model selector (#770)
* feat(x): add useModels hook — shared model-catalog store
One module-level store behind useSyncExternalStore: N mounted consumers
share one snapshot and one in-flight fetch (models:list + models.json +
oauth state + llm:getDefaultModel). Re-fetches on models-config-changed,
oauth:didConnect and chatgpt:statusChanged; newest-wins seq guard ports
the composer's epoch race protection. Extracted verbatim from the chat
composer's loadModelConfig for the ModelSelector consolidation (Phase 1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): add ModelSelector extracted from the chat composer
Controlled picker ({provider, model} value + onChange, persistence stays
with the caller) rendering the composer's exact UI: provider-grouped
catalog + live groups via useProviderModels, search filter, standalone
default row, locked-model display, and the reasoning-effort control
(shown only for catalog-flagged reasoning models, '' reported up when
the model loses reasoning support). Catalog comes from useModels().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): migrate chat composer to useModels + ModelSelector
Deletes the composer's inline copies of the catalog loader (oauth state,
llm:getDefaultModel, models:list, models.json parsing, event listeners)
and the picker/effort dropdown JSX in favor of the shared hook and
component. Behavior preserved: picks persist globally via
models:updateConfig({defaultSelection}) + models-config-changed dispatch,
locked-model display, per-turn unpersisted reasoning effort, and the
tab-activation re-fetch. SelectedModel/ReasoningEffortLevel exports stay
for existing consumers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): ModelSelector settings modes — sentinel, field trigger, provider scoping, custom ids
defaultOption pins a top entry that selects null ('Rowboat default' /
'Same as assistant'); variant='field' renders a SelectTrigger-style
full-width box; providerFilter scopes to one provider's group, falling
back to the raw models:list catalog (newly exposed on the useModels
snapshot as catalogByProvider) for providers mid-setup; allowCustom
offers a Use-"<text>" row when the search matches nothing. onChange
widens to ModelRef | null — the composer guards the (unreachable
without defaultOption) null and is otherwise untouched. Still zero
persistence inside the component.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): signed-in Settings Models section on ModelSelector
The four hybrid dropdowns (Assistant / Knowledge graph / Background
agents / Permission checks) become field-variant ModelSelectors with a
'Rowboat default' sentinel, sourcing the shared store's groups — BYOK
providers without a static catalog (e.g. OpenRouter) now live-fetch
their full list inside the open dropdown instead of showing only the
saved model (user-reported bug). State is ModelRef | null; the
provider::model hybridKey round-trip, options collector, and local
providerDisplayNames map are gone. Persistence unchanged: nothing
writes until Save's single models:updateConfig + models-config-changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): BYOK per-provider category fields on ModelSelector
Knowledge graph / Meeting notes / Track block / Auto-permission become
provider-scoped field-variant ModelSelectors (providerFilter=active
card, allowCustom, 'Same as assistant' sentinel), replacing the static
catalog Select + free-text Input pair. Values still persist as bare
model-id strings inside providers[flavor] through the existing save
path — the ModelRef ↔ string adapter lives at the call site. Deletes
the section's own models:list loader (modelsCatalog/modelsLoading/
showModelInput/LlmModelOption); scoped pickers fall back to the store's
catalogByProvider for providers mid-setup, and configured providers now
live-fetch full lists (OpenRouter bug fix on the BYOK side too).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(x): assert defaultModel refreshes on models-config-changed
Covers the settings-Save → event → store refetch leg that updates a
fresh composer tab's trigger label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): ModelSelector inheritance mode + un-scoped custom ids
inheritDefault?: {label} is defaultOption with placeholder styling —
one sentinel code path, the trigger renders the label muted when
nothing overrides. allowCustom no longer requires providerFilter: an
un-scoped custom entry splits 'provider/model' on the first slash
(OpenRouter-style ids must be typed provider-qualified) and pairs
slash-less text with the global default's provider, matching how the
runtime resolves a provider-less override. Adds modelOverrideToRef /
refToModelOverride adapters for the two-optional-strings persistence
shape shared by BackgroundTask and LiveNote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): bg-task model/provider override on ModelSelector
The Advanced section's two free-text Inputs become one inheritance-mode
picker ('(global default)' sentinel, allowCustom for arbitrary ids).
Same draft state and bg-task:patch save path; a task with model set but
no provider round-trips untouched via the shared override adapters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): live-note model/provider override on ModelSelector
Same inheritance-mode picker as bg-tasks (the section was a copy-paste
of it), same shared adapters, unchanged live-note:set save path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): EnableAgentsDialog model picker on ModelSelector
Replaces the flat models:list-flattening <select> with an
inheritance-mode picker pre-seeded from the pinned agent's bg-task
model/provider. '(default)' (null) still enables with an active-only
patch so agents keep their pinned model; a picked model still patches
each bundled agent once. Drops the dialog's private catalog fetch —
the shared store already has the data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(x): ModelSelector staticOptions mode for caller-supplied lists
When staticOptions is set the picker renders ONLY those rows (+ the
defaultOption sentinel) — no catalog groups, no live fetches. Entries
are opaque engine ids carried in ref.model with provider '', so
call-site adapters stay one-liners ('default' ↔ null, id ↔ string).
Rows whose label differs from their id show the id as secondary text,
disambiguating Claude's colliding 'Opus' labels (alias + concrete id).
Adds toSelectorOptions() in code-agent-options to split an engine list
into sentinel label + rows, and a triggerTitle prop for header tooltips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): new-session dialog on ModelSelector
Deletes loadModelOptions() — the admitted composer-mirror duplicate —
in favor of the shared store: the Rowboat-mode picker (still rendered
only for rowboat mode, gated on configured providers) is a field
ModelSelector whose null sentinel keeps 'default → omit model/provider'
create-payload semantics. The agent-model select becomes a
staticOptions picker fed by toSelectorOptions(fetchCodeAgentOptions),
preserving per-agent reload/reset and the 'default → omit agentModel'
payload rule. The effort select stays a plain Select on purpose — a
caller-supplied axis, not model selection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(x): code session header agent-model picker on ModelSelector
The header's model DropdownMenu becomes a pill ModelSelector with
staticOptions (search + Opus id disambiguation for free); the effort
dropdown stays untouched. codeSession:update payload semantics
unchanged — the sentinel still sends the literal 'default'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(x): delete dead onboarding-modal.tsx
Nothing imports it — App.tsx renders OnboardingModal from
components/onboarding/index.tsx (the steps-based flow). Its rich
four-category model picker was superseded by the silent auto-resolve
in use-onboarding-state; the only other mention was a stale comment in
useConnectors, updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
711aff58f2
commit
2274369383
14 changed files with 1301 additions and 2398 deletions
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
import { themeForIndex, patternFor } from '@/components/apps/card-theme'
|
||||
import { ModelSelector } from '@/components/model-selector'
|
||||
|
||||
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
||||
// disclosure, install from a direct bundle URL.
|
||||
|
|
@ -89,26 +90,9 @@ function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSk
|
|||
onEnable: (model: ModelChoice | null) => void
|
||||
onSkip: () => void
|
||||
}) {
|
||||
const [options, setOptions] = useState<Array<ModelChoice & { label: string }>>([])
|
||||
const [selected, setSelected] = useState<string>(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '')
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('models:list', null)
|
||||
setOptions(r.providers.flatMap((p) => p.models.map((m) => ({
|
||||
provider: p.id,
|
||||
model: m.id,
|
||||
label: `${m.name ?? m.id} (${p.name})`,
|
||||
}))))
|
||||
} catch { /* no picker — enable keeps the pinned model */ }
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const choice = (): ModelChoice | null => {
|
||||
const [provider, model] = selected.split('::')
|
||||
return provider && model ? { provider, model } : null
|
||||
}
|
||||
// Pre-seeded with the host-pinned model; "(default)" (null) enables
|
||||
// without a model patch, keeping whatever each agent has pinned.
|
||||
const [selected, setSelected] = useState<ModelChoice | null>(defaultModel ?? null)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
|
|
@ -123,24 +107,21 @@ function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSk
|
|||
<ul className="mb-3 list-inside list-disc text-sm text-muted-foreground">
|
||||
{names.map((n) => <li key={n}>{n}</li>)}
|
||||
</ul>
|
||||
{options.length > 0 && (
|
||||
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Run with
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)}
|
||||
className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm">
|
||||
{defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && (
|
||||
<option value={`${defaultModel.provider}::${defaultModel.model}`}>{defaultModel.model} (default)</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={`${o.provider}::${o.model}`} value={`${o.provider}::${o.model}`}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Run with
|
||||
<div className="min-w-0 flex-1">
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
inheritDefault={{ label: '(default)' }}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onSkip} disabled={busy}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Not now</button>
|
||||
<button type="button" onClick={() => onEnable(choice())} disabled={busy}
|
||||
<button type="button" onClick={() => onEnable(selected)} disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
{busy ? 'Turning on…' : 'Turn on & run now'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation'
|
|||
import { fetchAgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { useAgentRunTranscript } from '@/hooks/use-agent-run-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
||||
|
|
@ -924,19 +925,13 @@ function SetupTab({
|
|||
{showAdvanced && (
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-[74px_1fr] gap-x-3 gap-y-2.5 text-xs">
|
||||
<span className="pt-1.5 text-muted-foreground">Model</span>
|
||||
<Input
|
||||
value={draft.model ?? ''}
|
||||
onChange={e => setDraft({ ...draft, model: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
<span className="pt-1.5 text-muted-foreground">Provider</span>
|
||||
<Input
|
||||
value={draft.provider ?? ''}
|
||||
onChange={e => setDraft({ ...draft, provider: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
<span className="pt-2 text-muted-foreground">Model</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
value={modelOverrideToRef(draft.model, draft.provider)}
|
||||
onChange={(ref) => setDraft({ ...draft, ...refToModelOverride(ref) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ArrowUp,
|
||||
|
|
@ -17,7 +17,6 @@ import {
|
|||
Globe,
|
||||
ImagePlus,
|
||||
LoaderIcon,
|
||||
Brain,
|
||||
Lock,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
|
|
@ -38,15 +37,13 @@ import {
|
|||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useProviderModels, type ProviderModelsFlavor } from '@/hooks/use-provider-models'
|
||||
import { ModelSelector, type ModelRef, type ReasoningEffortLevel } from '@/components/model-selector'
|
||||
import { useModels } from '@/hooks/use-models'
|
||||
import {
|
||||
type AttachmentIconKind,
|
||||
getAttachmentDisplayName,
|
||||
|
|
@ -85,131 +82,18 @@ const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
|
|||
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
|
||||
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
// Matches what other subscription clients call this provider; the auth
|
||||
// itself is "Sign in with ChatGPT" (Plus/Pro subscription).
|
||||
codex: 'OpenAI Codex',
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" | "codex"
|
||||
|
||||
interface ConfiguredModel {
|
||||
provider: ProviderName
|
||||
model: string
|
||||
}
|
||||
|
||||
// One picker group per connected provider. Catalog groups carry a resolved
|
||||
// model list (models:list / saved config); live groups carry credentials and
|
||||
// fetch their list from the provider inside the dropdown via
|
||||
// useProviderModels (models:listForProvider).
|
||||
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
|
||||
// Catalog-preferred flavors that degrade to a live fetch when models:list has
|
||||
// no catalog for them (signed-in mode returns only the rowboat provider, or
|
||||
// the models.dev cache is empty).
|
||||
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
|
||||
|
||||
type ModelPickerGroup =
|
||||
| { kind: 'catalog'; flavor: string; models: string[] }
|
||||
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
|
||||
|
||||
// Rendered inside the dropdown's radio group: each live provider fetches its
|
||||
// own list, so groups load and fail independently. Pinned models (the saved
|
||||
// default / app default) render first — the model that actually runs is
|
||||
// always pickable even while the fetch is pending or failed. Live-fetched
|
||||
// ids carry no reasoning metadata, so the effort control stays hidden for
|
||||
// them (reasoningByKey lookup misses default to off).
|
||||
//
|
||||
// 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 LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
|
||||
group: Extract<ModelPickerGroup, { kind: 'live' }>
|
||||
label: string
|
||||
pinnedModels: string[]
|
||||
filter: string
|
||||
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
|
||||
}) {
|
||||
const { status, models, error, refetch } = useProviderModels({
|
||||
flavor: group.flavor,
|
||||
apiKey: group.apiKey,
|
||||
baseURL: group.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(group.flavor, hasModelRows)
|
||||
}, [group.flavor, hasModelRows, onModelRowsChange])
|
||||
if (!hasModelRows && !showStatus) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
{visible.map((m) => {
|
||||
const key = `${group.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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type RecentWorkDir = {
|
||||
path: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export interface SelectedModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
|
||||
|
||||
// '' = auto (provider default). Ordered as shown in the picker.
|
||||
const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [
|
||||
{ value: '', label: 'Auto', hint: 'Provider default' },
|
||||
{ value: 'low', label: 'Fast', hint: 'Minimal thinking' },
|
||||
{ value: 'medium', label: 'Balanced', hint: 'Moderate thinking' },
|
||||
{ value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' },
|
||||
]
|
||||
// The picker itself lives in ModelSelector; these aliases keep the composer's
|
||||
// public prop surface stable for existing consumers (chat-sidebar, App).
|
||||
export type SelectedModel = ModelRef
|
||||
export type { ReasoningEffortLevel } from '@/components/model-selector'
|
||||
|
||||
export type PermissionMode = 'manual' | 'auto'
|
||||
|
||||
function getSelectedModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
switch (kind) {
|
||||
case 'audio':
|
||||
|
|
@ -401,21 +285,15 @@ function ChatInputInner({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
|
||||
|
||||
const [modelGroups, setModelGroups] = useState<ModelPickerGroup[]>([])
|
||||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
// The effective runtime default (what a run actually uses when the user
|
||||
// hasn't picked a model) — shown in the picker instead of guessing from
|
||||
// list order, which can disagree with the real default.
|
||||
const [defaultModel, setDefaultModel] = useState<ConfiguredModel | null>(null)
|
||||
const loadModelConfigEpoch = useRef(0)
|
||||
// Shared model-catalog store (one fetch app-wide); sign-in state also
|
||||
// gates search availability below.
|
||||
const { isRowboatConnected, refresh: refreshModels } = useModels()
|
||||
const [selectedModel, setSelectedModel] = useState<SelectedModel | null>(null)
|
||||
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
|
||||
// '' = auto. Per-model reasoning capability ("provider/model" → flag) from
|
||||
// models:list; the effort control renders only for known-reasoning models.
|
||||
// '' = auto. Effort is per-turn config: reported up, never persisted.
|
||||
const [reasoningEffort, setReasoningEffort] = useState<'' | ReasoningEffortLevel>('')
|
||||
const [reasoningByKey, setReasoningByKey] = useState<Record<string, boolean>>({})
|
||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||
|
|
@ -455,7 +333,7 @@ function ChatInputInner({
|
|||
// no-dep effect below still re-collapses if any toggle happens to widen the row.
|
||||
useLayoutEffect(() => {
|
||||
setCollapseLevel(0)
|
||||
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, activeModelKey])
|
||||
}, [workDir, searchAvailable, codeModeFeatureEnabled, lockedModel, selectedModel])
|
||||
|
||||
// After each render, if the left group still overflows, collapse one more step.
|
||||
// Runs before paint, so the intermediate (overflowing) state is never visible.
|
||||
|
|
@ -487,155 +365,17 @@ function ChatInputInner({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Check Rowboat sign-in state
|
||||
// The store loads on mount and re-fetches on config/sign-in events by
|
||||
// itself; re-fetch on tab activation too, preserving the old per-mount
|
||||
// reload that picked up external edits to config/models.json.
|
||||
const didLoadModelsRef = useRef(false)
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
|
||||
}).catch(() => setIsRowboatConnected(false))
|
||||
}, [isActive])
|
||||
|
||||
// Update sign-in state when OAuth events fire
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('oauth:didConnect', () => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
setIsRowboatConnected(result.config?.rowboat?.connected ?? false)
|
||||
}).catch(() => setIsRowboatConnected(false))
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Load the list of models the user can choose from. Hybrid mode: signed-in
|
||||
// users get the gateway list AND every BYOK provider configured in
|
||||
// models.json (selecting a BYOK model routes that message through the
|
||||
// user's own key / local server). Signed-out users get BYOK only.
|
||||
const loadModelConfig = useCallback(async () => {
|
||||
// Concurrent runs race (mount fires one before the sign-in state resolves,
|
||||
// which fires another) — only the newest run may write state, else a slow
|
||||
// stale run can clobber the fresh list with an empty one.
|
||||
const epoch = ++loadModelConfigEpoch.current
|
||||
try {
|
||||
const def = await window.ipc.invoke('llm:getDefaultModel', null)
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setDefaultModel({ provider: def.provider as ProviderName, model: def.model })
|
||||
} catch {
|
||||
if (loadModelConfigEpoch.current === epoch) setDefaultModel(null)
|
||||
if (!didLoadModelsRef.current) {
|
||||
didLoadModelsRef.current = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const groups: ModelPickerGroup[] = []
|
||||
|
||||
// Full catalog per provider (gateway + models.dev cloud providers).
|
||||
const catalog: Record<string, string[]> = {}
|
||||
const reasoningFlags: Record<string, boolean> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
|
||||
for (const m of p.models || []) {
|
||||
if (typeof m.reasoning === 'boolean') {
|
||||
reasoningFlags[`${p.id}/${m.id}`] = m.reasoning
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* offline / no catalog — groups fall back to saved config below */ }
|
||||
if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags)
|
||||
|
||||
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
|
||||
}
|
||||
|
||||
// ChatGPT subscription (codex): models:list only carries this catalog
|
||||
// while signed in with ChatGPT, so presence is the gate.
|
||||
if ((catalog['codex'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// List the default provider's group first.
|
||||
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
|
||||
const flavors = Object.keys(parsed?.providers || {})
|
||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
|
||||
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
|
||||
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
|
||||
if (!apiKey && !baseURL) continue // provider not configured
|
||||
const savedModel = typeof e.model === 'string' ? e.model : ''
|
||||
|
||||
// Live flavors fetch their list from the provider inside the
|
||||
// dropdown, with the credentials saved in config. Catalog flavors
|
||||
// degrade to the same live fetch when models:list carried no
|
||||
// catalog for them (signed in, or empty models.dev cache).
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
|
||||
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
|
||||
continue
|
||||
}
|
||||
|
||||
// Catalog group: the saved default model leads, then the catalog.
|
||||
// Saved models[] survives as the fallback for unknown flavors the
|
||||
// live fetch doesn't support.
|
||||
const models: string[] = []
|
||||
const push = (model: string) => {
|
||||
if (model && !models.includes(model)) models.push(model)
|
||||
}
|
||||
push(savedModel)
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(m)
|
||||
} else {
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(m)
|
||||
}
|
||||
groups.push({ kind: 'catalog', flavor, models })
|
||||
}
|
||||
|
||||
// The user's explicit default selection leads the picker: its group
|
||||
// first and, within a catalog group, the model itself first. (Live
|
||||
// groups pin the default at the top themselves.)
|
||||
const sel = parsed?.defaultSelection
|
||||
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
|
||||
const index = groups.findIndex((g) => g.flavor === sel.provider)
|
||||
if (index >= 0) {
|
||||
const [group] = groups.splice(index, 1)
|
||||
groups.unshift(group)
|
||||
if (group.kind === 'catalog') {
|
||||
const mi = group.models.indexOf(sel.model)
|
||||
if (mi > 0) {
|
||||
group.models.splice(mi, 1)
|
||||
group.models.unshift(sel.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
if (loadModelConfigEpoch.current !== epoch) return
|
||||
setModelGroups(groups)
|
||||
} catch (err) {
|
||||
// No config yet — but surface unexpected failures for diagnosis.
|
||||
console.error('[chat-input] failed to load model list', err)
|
||||
}
|
||||
}, [isRowboatConnected])
|
||||
|
||||
useEffect(() => {
|
||||
loadModelConfig()
|
||||
}, [isActive, loadModelConfig])
|
||||
|
||||
// ChatGPT subscription models appear/disappear with the ChatGPT session.
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('chatgpt:statusChanged', () => { loadModelConfig() })
|
||||
return cleanup
|
||||
}, [loadModelConfig])
|
||||
|
||||
// Reload when model config changes (e.g. from settings dialog)
|
||||
useEffect(() => {
|
||||
const handler = () => { loadModelConfig() }
|
||||
window.addEventListener('models-config-changed', handler)
|
||||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
refreshModels()
|
||||
}, [isActive, refreshModels])
|
||||
|
||||
// Load the global code-mode feature flag (from settings) and stay in sync.
|
||||
useEffect(() => {
|
||||
|
|
@ -809,83 +549,31 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
// Search filter for the model dropdown. Reset each time the menu opens;
|
||||
// matching is a case-insensitive substring test on the model id. Live
|
||||
// groups filter themselves and report whether they still have rows, so the
|
||||
// parent can render the global "No models match" row.
|
||||
const [modelFilter, setModelFilter] = useState('')
|
||||
const modelFilterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
|
||||
const modelFilterValue = modelFilter.trim().toLowerCase()
|
||||
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
|
||||
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
|
||||
}, [])
|
||||
|
||||
// The effective default always renders even when no group carries it (the
|
||||
// gateway list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. Live groups
|
||||
// pin the default themselves, so a flavor match is enough there.
|
||||
const standaloneDefault = useMemo<ConfiguredModel | null>(() => {
|
||||
if (!defaultModel) return null
|
||||
const covered = modelGroups.some((g) =>
|
||||
g.flavor === defaultModel.provider &&
|
||||
(g.kind === 'live' || g.models.includes(defaultModel.model)))
|
||||
return covered ? null : defaultModel
|
||||
}, [modelGroups, defaultModel])
|
||||
|
||||
const standaloneVisible = standaloneDefault !== null &&
|
||||
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
|
||||
// Nothing matches anywhere → "No models match". Live groups that haven't
|
||||
// reported yet (first render after opening) count as having rows so the
|
||||
// empty row never flashes.
|
||||
const anyModelRowVisible = standaloneVisible || modelGroups.some((g) =>
|
||||
g.kind === 'catalog'
|
||||
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: liveGroupHasRows[g.flavor] !== false)
|
||||
|
||||
// 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-runs loadModelConfig here too — that re-read lands on the same
|
||||
// selection (activeModelKey is untouched, the live lists come from the
|
||||
// hook's cache), so it's visually a no-op.
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
// 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.
|
||||
const handleModelChange = useCallback((model: SelectedModel | null) => {
|
||||
if (lockedModel) return
|
||||
const slash = key.indexOf('/')
|
||||
if (slash <= 0 || slash === key.length - 1) return
|
||||
const provider = key.slice(0, slash)
|
||||
const model = key.slice(slash + 1)
|
||||
setActiveModelKey(key)
|
||||
onSelectedModelChange?.({ provider, model })
|
||||
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider, model } })
|
||||
// null = the sentinel row, which the composer never renders (no
|
||||
// defaultOption) — guard for the widened onChange contract only.
|
||||
if (!model) return
|
||||
setSelectedModel(model)
|
||||
onSelectedModelChange?.(model)
|
||||
void window.ipc.invoke('models:updateConfig', { defaultSelection: { provider: model.provider, model: model.model } })
|
||||
.then(() => { window.dispatchEvent(new Event('models-config-changed')) })
|
||||
.catch(() => { toast.error('Failed to save default model') })
|
||||
}, [lockedModel, onSelectedModelChange])
|
||||
|
||||
// Reasoning effort applies to the model the next message will actually use:
|
||||
// the run's frozen model once one exists, else the picker selection, else
|
||||
// the app default. Only known-reasoning models show the control.
|
||||
const effectiveModelKey = lockedModel
|
||||
? `${lockedModel.provider}/${lockedModel.model}`
|
||||
: activeModelKey
|
||||
|| (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
const reasoningAvailable = reasoningByKey[effectiveModelKey] === true
|
||||
|
||||
const handleReasoningEffortChange = useCallback((value: string) => {
|
||||
const effort = value === 'low' || value === 'medium' || value === 'high' ? value : ''
|
||||
// Effort is per-turn and unpersisted; ModelSelector reports '' when the
|
||||
// effective model loses reasoning support so a stale effort never sticks.
|
||||
const handleReasoningEffortChange = useCallback((effort: '' | ReasoningEffortLevel) => {
|
||||
setReasoningEffort(effort)
|
||||
onReasoningEffortChange?.(effort === '' ? null : effort)
|
||||
}, [onReasoningEffortChange])
|
||||
|
||||
// Switching to a model without reasoning support drops a stale selection —
|
||||
// otherwise the next message would carry an effort the model rejects.
|
||||
useEffect(() => {
|
||||
if (!reasoningAvailable && reasoningEffort !== '') {
|
||||
setReasoningEffort('')
|
||||
onReasoningEffortChange?.(null)
|
||||
}
|
||||
}, [reasoningAvailable, reasoningEffort, onReasoningEffortChange])
|
||||
|
||||
// Restore the tab draft when this input mounts.
|
||||
useEffect(() => {
|
||||
if (initialDraft) {
|
||||
|
|
@ -1477,165 +1165,13 @@ function ChatInputInner({
|
|||
</DropdownMenu>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{reasoningAvailable && (
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Brain className="h-3 w-3 shrink-0" />
|
||||
{reasoningEffort !== '' && (
|
||||
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === reasoningEffort)?.label}</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Reasoning effort — applies to your next message</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={reasoningEffort} onValueChange={handleReasoningEffortChange}>
|
||||
{REASONING_EFFORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
|
||||
<span>{option.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{lockedModel ? (
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat
|
||||
</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('')
|
||||
setLiveGroupHasRows({})
|
||||
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{getSelectedModelDisplayName(
|
||||
(activeModelKey ? activeModelKey.slice(activeModelKey.indexOf('/') + 1) : '')
|
||||
|| defaultModel?.model
|
||||
|| 'Model'
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0 overflow-hidden">
|
||||
{modelGroups.length === 0 && !standaloneDefault ? (
|
||||
<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={activeModelKey || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')}
|
||||
onValueChange={handleModelChange}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{modelGroups.map((g) => {
|
||||
const label = providerDisplayNames[g.flavor] || g.flavor
|
||||
if (g.kind === 'live') {
|
||||
// The app default leads its live group; the group's
|
||||
// own saved model follows (both stay pickable through
|
||||
// fetch loading/failure).
|
||||
const pinned: string[] = []
|
||||
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
|
||||
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
|
||||
return (
|
||||
<LiveProviderGroupItems
|
||||
key={g.flavor}
|
||||
group={g}
|
||||
label={label}
|
||||
pinnedModels={pinned}
|
||||
filter={modelFilterValue}
|
||||
onModelRowsChange={handleLiveGroupRows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const visibleModels = modelFilterValue
|
||||
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: g.models
|
||||
if (visibleModels.length === 0) return null
|
||||
return (
|
||||
<Fragment key={g.flavor}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
{visibleModels.map((m) => {
|
||||
const key = `${g.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{modelFilterValue && !anyModelRowVisible && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">No models match</div>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<ModelSelector
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
lockedModel={lockedModel}
|
||||
effort={reasoningEffort}
|
||||
onEffortChange={handleReasoningEffortChange}
|
||||
/>
|
||||
{onStartCall && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,20 @@ export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
|
|||
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
|
||||
}
|
||||
|
||||
// Adapt an engine option list for ModelSelector: the 'default' entry becomes
|
||||
// the sentinel (keeping the engine's own label, e.g. Claude's "Default
|
||||
// (recommended)"), the rest become staticOptions rows.
|
||||
export function toSelectorOptions(options: CodeAgentOption[]): {
|
||||
defaultLabel: string
|
||||
options: Array<{ id: string; label?: string }>
|
||||
} {
|
||||
const all = withDefault(options)
|
||||
return {
|
||||
defaultLabel: all.find((o) => o.value === 'default')?.label ?? 'Default',
|
||||
options: all.filter((o) => o.value !== 'default').map((o) => ({ id: o.value, label: o.label })),
|
||||
}
|
||||
}
|
||||
|
||||
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
|
||||
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
|
||||
import { fetchCodeAgentOptions, toSelectorOptions, withDefault, optionLabel } from './code-agent-options'
|
||||
import { ModelSelector } from '@/components/model-selector'
|
||||
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -228,27 +229,15 @@ export function CodeView({
|
|||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Coding agent model"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
|
||||
{m.label}
|
||||
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ModelSelector
|
||||
triggerTitle="Coding agent model"
|
||||
defaultOption={{ label: toSelectorOptions(modelOpts.models).defaultLabel }}
|
||||
staticOptions={toSelectorOptions(modelOpts.models).options}
|
||||
value={selectedSession.agentModel && selectedSession.agentModel !== 'default'
|
||||
? { provider: '', model: selectedSession.agentModel }
|
||||
: null}
|
||||
onChange={(ref) => void handleUpdateSession({ agentModel: ref?.model ?? 'default' })}
|
||||
/>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
|
||||
import { fetchCodeAgentOptions, toSelectorOptions, withDefault } from './code-agent-options'
|
||||
import { ModelSelector, type ModelRef } from '@/components/model-selector'
|
||||
import { useModels } from '@/hooks/use-models'
|
||||
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -25,7 +27,6 @@ import {
|
|||
import type { ProjectRow } from './use-code-sessions'
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type ModelOption = { provider: string; model: string }
|
||||
|
||||
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask every time',
|
||||
|
|
@ -33,38 +34,6 @@ const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
|||
yolo: 'Auto-approve everything (YOLO)',
|
||||
}
|
||||
|
||||
// Models the user can pick for Rowboat-mode turns — mirrors the chat
|
||||
// composer's loading: gateway list when signed in, models.json otherwise.
|
||||
async function loadModelOptions(): Promise<ModelOption[]> {
|
||||
try {
|
||||
const oauth = await window.ipc.invoke('oauth:getState', null)
|
||||
const connected = oauth.config?.rowboat?.connected ?? false
|
||||
if (connected) {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined)
|
||||
?.find((p) => p.id === 'rowboat')
|
||||
return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id }))
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ModelOption[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) models.push({ provider: flavor, model })
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function NewSessionDialog({
|
||||
projectRow,
|
||||
open,
|
||||
|
|
@ -84,9 +53,11 @@ export function NewSessionDialog({
|
|||
const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo')
|
||||
const [title, setTitle] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
|
||||
// 'default' = let the backend use the configured default model.
|
||||
const [modelKey, setModelKey] = useState('default')
|
||||
// null = let the backend use the configured default model.
|
||||
const [sessionModel, setSessionModel] = useState<ModelRef | null>(null)
|
||||
// Gates the Rowboat-mode picker the way the old options list did: no
|
||||
// configured providers → no picker.
|
||||
const { groups: modelGroups } = useModels()
|
||||
// The coding agent's own model + reasoning effort. 'default' leaves the
|
||||
// engine default. Choices are discovered live per agent (see effect below).
|
||||
const [agentModel, setAgentModel] = useState('default')
|
||||
|
|
@ -102,10 +73,9 @@ export function NewSessionDialog({
|
|||
setCreating(false)
|
||||
setIsolation('in-repo')
|
||||
setMode('direct')
|
||||
setModelKey('default')
|
||||
setSessionModel(null)
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
void loadModelOptions().then(setModelOptions)
|
||||
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
|
||||
setAgentStatus(status)
|
||||
// Default to whichever agent is actually ready.
|
||||
|
|
@ -138,9 +108,6 @@ export function NewSessionDialog({
|
|||
if (!projectRow) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const picked = modelKey !== 'default'
|
||||
? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey)
|
||||
: undefined
|
||||
const res = await window.ipc.invoke('codeSession:create', {
|
||||
projectId: projectRow.project.id,
|
||||
title: title.trim() || undefined,
|
||||
|
|
@ -148,7 +115,7 @@ export function NewSessionDialog({
|
|||
mode,
|
||||
policy,
|
||||
isolation,
|
||||
...(picked ? { model: picked.model, provider: picked.provider } : {}),
|
||||
...(sessionModel ? { model: sessionModel.model, provider: sessionModel.provider } : {}),
|
||||
...(agentModel !== 'default' ? { agentModel } : {}),
|
||||
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
|
||||
})
|
||||
|
|
@ -303,20 +270,19 @@ export function NewSessionDialog({
|
|||
{/* The coding agent's own model + reasoning effort, discovered live
|
||||
from the engine and applied to the ACP session each turn (so they
|
||||
stay editable from the session header later). Effort is a separate
|
||||
axis only for Claude; Codex folds it into the model id. */}
|
||||
axis only for Claude; Codex folds it into the model id — it stays
|
||||
a plain Select deliberately: it's not model selection, so it's
|
||||
out of ModelSelector's scope. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={agentModel} onValueChange={setAgentModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
defaultOption={{ label: toSelectorOptions(modelOpts.models).defaultLabel }}
|
||||
staticOptions={toSelectorOptions(modelOpts.models).options}
|
||||
value={agentModel !== 'default' ? { provider: '', model: agentModel } : null}
|
||||
onChange={(ref) => setAgentModel(ref?.model ?? 'default')}
|
||||
/>
|
||||
</div>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
@ -337,21 +303,15 @@ export function NewSessionDialog({
|
|||
|
||||
{/* The model only powers Rowboat's own turns; the coding agent uses its
|
||||
own configured model, so hide this entirely for direct sessions. */}
|
||||
{mode === 'rowboat' && modelOptions.length > 0 && (
|
||||
{mode === 'rowboat' && modelGroups.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={modelKey} onValueChange={setModelKey}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default model</SelectItem>
|
||||
{modelOptions.map((m) => {
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return <SelectItem key={key} value={key}>{m.model}</SelectItem>
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
defaultOption={{ label: 'Default model' }}
|
||||
value={sessionModel}
|
||||
onChange={setSessionModel}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Used when Rowboat drives. Fixed once the session is created, like any chat.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ModelSelector, modelOverrideToRef, refToModelOverride } from '@/components/model-selector'
|
||||
import {
|
||||
Play, Square, Loader2, Sparkles,
|
||||
AlertCircle, Plus, X, Check, Pencil, Radio, Repeat, Clock, Zap,
|
||||
|
|
@ -605,19 +606,13 @@ function DetailsTab({
|
|||
{showAdvanced && (
|
||||
<div className="mt-3">
|
||||
<div className="grid grid-cols-[74px_1fr] gap-x-3 gap-y-2.5 text-xs">
|
||||
<span className="pt-1.5 text-muted-foreground">Model</span>
|
||||
<Input
|
||||
value={draft.model ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, model: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
<span className="pt-1.5 text-muted-foreground">Provider</span>
|
||||
<Input
|
||||
value={draft.provider ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, provider: e.target.value || undefined })}
|
||||
placeholder="(global default)"
|
||||
className="h-7 font-mono text-xs"
|
||||
<span className="pt-2 text-muted-foreground">Model</span>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
value={modelOverrideToRef(draft.model, draft.provider)}
|
||||
onChange={(ref) => setDraft({ ...draft, ...refToModelOverride(ref) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
|
|
|
|||
215
apps/x/apps/renderer/src/components/model-selector.test.tsx
Normal file
215
apps/x/apps/renderer/src/components/model-selector.test.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { __resetModelsForTests } from '@/hooks/use-models'
|
||||
import { ModelSelector } from './model-selector'
|
||||
|
||||
// Radix popper content needs these in jsdom.
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
|
||||
// Same preload stub pattern as use-models.test.tsx: invoke routes by channel.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
function serveTwoProviders(): void {
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4' }] },
|
||||
{ id: 'anthropic', name: 'Anthropic', models: [{ id: 'claude-opus-4-8' }] },
|
||||
],
|
||||
})
|
||||
handlers['workspace:readFile'] = async () => ({
|
||||
data: JSON.stringify({
|
||||
provider: { flavor: 'openai' },
|
||||
model: 'gpt-5.4',
|
||||
providers: {
|
||||
openai: { apiKey: 'sk-a', model: 'gpt-5.4' },
|
||||
anthropic: { apiKey: 'sk-b', model: 'claude-opus-4-8' },
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelsForTests()
|
||||
handlers = {}
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ModelSelector', () => {
|
||||
it('renders the defaultOption label when value is null and round-trips null through onChange', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={{ provider: 'openai', model: 'gpt-5.4' }}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: 'Rowboat default' }}
|
||||
/>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('button')).toHaveTextContent('gpt-5.4'))
|
||||
|
||||
await openMenu()
|
||||
const sentinel = await screen.findByText('Rowboat default')
|
||||
fireEvent.click(sentinel)
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('shows the sentinel as the trigger label when value is null', async () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
defaultOption={{ label: 'Same as assistant' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Same as assistant')
|
||||
})
|
||||
|
||||
it('providerFilter restricts the list to that provider', async () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
providerFilter="anthropic"
|
||||
defaultOption={{ label: 'Same as assistant' }}
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
await screen.findByText('claude-opus-4-8')
|
||||
expect(screen.queryByText('gpt-5.4')).toBeNull()
|
||||
})
|
||||
|
||||
it('inheritDefault renders its label muted in the trigger when value is null', () => {
|
||||
serveTwoProviders()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
/>,
|
||||
)
|
||||
const label = screen.getByText('(global default)')
|
||||
expect(label.className).toContain('text-muted-foreground')
|
||||
})
|
||||
|
||||
it('un-scoped allowCustom splits "provider/model" on the first slash', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), {
|
||||
target: { value: 'openrouter/meituan/longcat-2.0' },
|
||||
})
|
||||
fireEvent.click(await screen.findByText('Use "openrouter/meituan/longcat-2.0"'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openrouter', model: 'meituan/longcat-2.0' })
|
||||
})
|
||||
|
||||
it('un-scoped allowCustom pairs slash-less text with the default provider', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
inheritDefault={{ label: '(global default)' }}
|
||||
allowCustom
|
||||
/>,
|
||||
)
|
||||
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' } })
|
||||
fireEvent.click(await screen.findByText('Use "my-local-model"'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: 'openai', model: 'my-local-model' })
|
||||
})
|
||||
|
||||
it('staticOptions renders only the supplied rows and round-trips ids and null', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: 'Default (recommended)' }}
|
||||
staticOptions={[
|
||||
{ id: 'opus', label: 'Opus' },
|
||||
{ id: 'claude-opus-4-8', label: 'Opus' },
|
||||
{ id: 'sonnet', label: 'Sonnet' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
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()
|
||||
// Colliding "Opus" labels are disambiguated by their raw id.
|
||||
expect(screen.getAllByText('Opus')).toHaveLength(2)
|
||||
|
||||
fireEvent.click(screen.getByText('Sonnet'))
|
||||
expect(onChange).toHaveBeenCalledWith({ provider: '', model: 'sonnet' })
|
||||
|
||||
await openMenu()
|
||||
fireEvent.click(screen.getByText('Default (recommended)', { selector: '[role="menuitemradio"] span' }))
|
||||
expect(onChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('allowCustom offers the typed id when nothing matches', async () => {
|
||||
serveTwoProviders()
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
providerFilter="anthropic"
|
||||
allowCustom
|
||||
defaultOption={{ label: 'Same as assistant' }}
|
||||
/>,
|
||||
)
|
||||
await openMenu()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search models…'), { 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' })
|
||||
})
|
||||
})
|
||||
549
apps/x/apps/renderer/src/components/model-selector.tsx
Normal file
549
apps/x/apps/renderer/src/components/model-selector.tsx
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Brain, ChevronDown, LoaderIcon } 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 { useModels, type ModelPickerGroup, type ModelRef } from '@/hooks/use-models'
|
||||
import { useProviderModels } from '@/hooks/use-provider-models'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type { ModelRef } from '@/hooks/use-models'
|
||||
|
||||
export type ReasoningEffortLevel = 'low' | 'medium' | 'high'
|
||||
|
||||
const TOOLTIP_DELAY_MS = 1000
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
// Matches what other subscription clients call this provider; the auth
|
||||
// itself is "Sign in with ChatGPT" (Plus/Pro subscription).
|
||||
codex: 'OpenAI Codex',
|
||||
}
|
||||
|
||||
// '' = auto (provider default). Ordered as shown in the picker.
|
||||
const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [
|
||||
{ value: '', label: 'Auto', hint: 'Provider default' },
|
||||
{ value: 'low', label: 'Fast', hint: 'Minimal thinking' },
|
||||
{ value: 'medium', label: 'Balanced', hint: 'Moderate thinking' },
|
||||
{ value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' },
|
||||
]
|
||||
|
||||
function getModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
// Rendered inside the dropdown's radio group: each live provider fetches its
|
||||
// own list, so groups load and fail independently. Pinned models (the saved
|
||||
// default / app default) render first — the model that actually runs is
|
||||
// always pickable even while the fetch is pending or failed. Live-fetched
|
||||
// ids carry no reasoning metadata, so the effort control stays hidden for
|
||||
// them (reasoningByKey lookup misses default to off).
|
||||
//
|
||||
// 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 LiveProviderGroupItems({ group, label, pinnedModels, filter, onModelRowsChange }: {
|
||||
group: Extract<ModelPickerGroup, { kind: 'live' }>
|
||||
label: string
|
||||
pinnedModels: string[]
|
||||
filter: string
|
||||
onModelRowsChange: (flavor: string, hasModelRows: boolean) => void
|
||||
}) {
|
||||
const { status, models, error, refetch } = useProviderModels({
|
||||
flavor: group.flavor,
|
||||
apiKey: group.apiKey,
|
||||
baseURL: group.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(group.flavor, hasModelRows)
|
||||
}, [group.flavor, hasModelRows, onModelRowsChange])
|
||||
if (!hasModelRows && !showStatus) return null
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
{visible.map((m) => {
|
||||
const key = `${group.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).
|
||||
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.
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
inheritDefault?: { label: string }
|
||||
/**
|
||||
* 'pill' is the composer's compact rounded trigger; 'field' is a
|
||||
* full-width bordered Select-style trigger for forms.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
providerFilter?: string
|
||||
/**
|
||||
* When the search text matches no rows, offer a `Use "<text>"` row that
|
||||
* selects the typed id — arbitrary ids for ollama / openai-compatible.
|
||||
* With providerFilter the typed id attaches to that provider. Without it,
|
||||
* "provider/model" splits on the FIRST slash (so an OpenRouter id must be
|
||||
* typed provider-qualified: "openrouter/meituan/longcat-2.0"); text with
|
||||
* no slash attaches to the global default's provider.
|
||||
*/
|
||||
allowCustom?: boolean
|
||||
/**
|
||||
* 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").
|
||||
*/
|
||||
staticOptions?: Array<{ id: string; label?: string }>
|
||||
/** Optional title attribute for the trigger button (header tooltips). */
|
||||
triggerTitle?: string
|
||||
/** Frozen selection: renders a static label + tooltip instead of the dropdown. */
|
||||
lockedModel?: ModelRef | null
|
||||
/**
|
||||
* Reasoning effort ('' = auto). The control renders only for models the
|
||||
* catalog flags as reasoning-capable. When the effective model loses
|
||||
* reasoning support, '' is reported up so a stale effort never outlives
|
||||
* its model. Omit onEffortChange to hide the effort control entirely.
|
||||
*/
|
||||
effort?: '' | ReasoningEffortLevel
|
||||
onEffortChange?: (effort: '' | ReasoningEffortLevel) => void
|
||||
}
|
||||
|
||||
// Radio 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:
|
||||
// scoped → the scoped provider; "provider/model" → split on the FIRST
|
||||
// slash; no slash → the global default's provider (matching how the
|
||||
// runtime pairs a provider-less model override).
|
||||
function parseCustomModel(text: string, providerFilter: string | undefined, defaultModel: ModelRef | null): ModelRef {
|
||||
if (providerFilter) return { provider: providerFilter, model: text }
|
||||
const slash = text.indexOf('/')
|
||||
if (slash > 0 && slash < text.length - 1) {
|
||||
return { provider: text.slice(0, slash), model: text.slice(slash + 1) }
|
||||
}
|
||||
return { provider: defaultModel?.provider ?? '', model: text }
|
||||
}
|
||||
|
||||
// Adapters for surfaces that persist a per-item override as two optional
|
||||
// strings (BackgroundTask.model/provider, LiveNote.model/provider) where
|
||||
// unset = inherit the global default. A model without a provider is legal
|
||||
// (the runtime pairs it with the default provider), so '' round-trips to
|
||||
// undefined and a null ref clears both fields.
|
||||
export function modelOverrideToRef(model: string | undefined, provider: string | undefined): ModelRef | null {
|
||||
return model ? { provider: provider ?? '', model } : null
|
||||
}
|
||||
|
||||
export function refToModelOverride(ref: ModelRef | null): { model: string | undefined; provider: string | undefined } {
|
||||
return { model: ref?.model || undefined, provider: ref?.provider || undefined }
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
value,
|
||||
onChange,
|
||||
defaultOption,
|
||||
inheritDefault,
|
||||
variant = 'pill',
|
||||
providerFilter,
|
||||
allowCustom = false,
|
||||
staticOptions,
|
||||
triggerTitle,
|
||||
lockedModel = null,
|
||||
effort = '',
|
||||
onEffortChange,
|
||||
}: ModelSelectorProps) {
|
||||
const { groups: allGroups, reasoningByKey, defaultModel, catalogByProvider } = useModels()
|
||||
|
||||
// inheritDefault is defaultOption with placeholder styling — one sentinel
|
||||
// code path, not two.
|
||||
const sentinel = defaultOption ?? inheritDefault
|
||||
const sentinelMuted = !defaultOption && Boolean(inheritDefault)
|
||||
|
||||
const groups = useMemo<ModelPickerGroup[]>(() => {
|
||||
if (!providerFilter) return allGroups
|
||||
const scoped = allGroups.filter((g) => g.flavor === providerFilter)
|
||||
if (scoped.length > 0) return scoped
|
||||
const catalogModels = catalogByProvider[providerFilter] || []
|
||||
return catalogModels.length > 0 ? [{ kind: 'catalog', flavor: providerFilter, models: catalogModels }] : []
|
||||
}, [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. Live
|
||||
// groups filter themselves and report whether they still have rows, so the
|
||||
// parent can render the global "No models match" row.
|
||||
const [modelFilter, setModelFilter] = useState('')
|
||||
const modelFilterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [liveGroupHasRows, setLiveGroupHasRows] = useState<Record<string, boolean>>({})
|
||||
const modelFilterValue = modelFilter.trim().toLowerCase()
|
||||
const handleLiveGroupRows = useCallback((flavor: string, hasRows: boolean) => {
|
||||
setLiveGroupHasRows((prev) => (prev[flavor] === hasRows ? prev : { ...prev, [flavor]: hasRows }))
|
||||
}, [])
|
||||
|
||||
// The effective default always renders even when no group carries it (the
|
||||
// gateway list failed, or its provider was removed from config) — the
|
||||
// picker must never be missing the model that actually runs. Live groups
|
||||
// pin the default themselves, so a flavor match is enough there. A
|
||||
// provider-scoped picker only shows it when it belongs to that provider.
|
||||
const standaloneDefault = useMemo<ModelRef | null>(() => {
|
||||
if (!defaultModel) return null
|
||||
if (providerFilter && defaultModel.provider !== providerFilter) return null
|
||||
const covered = groups.some((g) =>
|
||||
g.flavor === defaultModel.provider &&
|
||||
(g.kind === 'live' || g.models.includes(defaultModel.model)))
|
||||
return covered ? null : defaultModel
|
||||
}, [groups, defaultModel, providerFilter])
|
||||
|
||||
const standaloneVisible = standaloneDefault !== null &&
|
||||
(!modelFilterValue || standaloneDefault.model.toLowerCase().includes(modelFilterValue))
|
||||
// Static mode replaces all store-driven rows with the caller's list.
|
||||
const staticVisible = useMemo(() => {
|
||||
if (!staticOptions) return null
|
||||
if (!modelFilterValue) return staticOptions
|
||||
return staticOptions.filter((o) =>
|
||||
(o.label ?? o.id).toLowerCase().includes(modelFilterValue) || o.id.toLowerCase().includes(modelFilterValue))
|
||||
}, [staticOptions, modelFilterValue])
|
||||
const staticLabelFor = (id: string) => staticOptions?.find((o) => o.id === id)?.label ?? id
|
||||
// Nothing matches anywhere → "No models match". Live groups that haven't
|
||||
// reported yet (first render after opening) count as having rows so the
|
||||
// empty row never flashes.
|
||||
const anyModelRowVisible = staticVisible
|
||||
? staticVisible.length > 0
|
||||
: standaloneVisible || groups.some((g) =>
|
||||
g.kind === 'catalog'
|
||||
? g.models.some((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: liveGroupHasRows[g.flavor] !== false)
|
||||
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
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])
|
||||
|
||||
// Reasoning effort applies to the model the next message will actually use:
|
||||
// the frozen model when locked, else the picker selection, else the app
|
||||
// default. Only known-reasoning models show the control.
|
||||
const effectiveModelKey = lockedModel
|
||||
? `${lockedModel.provider}/${lockedModel.model}`
|
||||
: (value ? `${value.provider}/${value.model}` : '')
|
||||
|| (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '')
|
||||
const reasoningAvailable = reasoningByKey[effectiveModelKey] === true
|
||||
|
||||
const handleEffortChange = useCallback((raw: string) => {
|
||||
onEffortChange?.(raw === 'low' || raw === 'medium' || raw === 'high' ? raw : '')
|
||||
}, [onEffortChange])
|
||||
|
||||
// Switching to a model without reasoning support drops a stale selection —
|
||||
// otherwise the next message would carry an effort the model rejects.
|
||||
useEffect(() => {
|
||||
if (!reasoningAvailable && effort !== '') {
|
||||
onEffortChange?.('')
|
||||
}
|
||||
}, [reasoningAvailable, effort, onEffortChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
{reasoningAvailable && onEffortChange && (
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Brain className="h-3 w-3 shrink-0" />
|
||||
{effort !== '' && (
|
||||
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === effort)?.label}</span>
|
||||
)}
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Reasoning effort — applies to your next message</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={effort} onValueChange={handleEffortChange}>
|
||||
{REASONING_EFFORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
|
||||
<span>{option.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{lockedModel ? (
|
||||
<Tooltip delayDuration={TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{getModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat
|
||||
</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('')
|
||||
setLiveGroupHasRows({})
|
||||
setTimeout(() => modelFilterInputRef.current?.focus(), 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{variant === 'field' ? (
|
||||
// Styled after ui/select's SelectTrigger so it sits naturally
|
||||
// in forms next to real Select fields.
|
||||
<button
|
||||
type="button"
|
||||
title={triggerTitle}
|
||||
className="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:hover:bg-input/50"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn('truncate', !value && sentinelMuted && 'text-muted-foreground')}>
|
||||
{value
|
||||
? (staticOptions ? staticLabelFor(value.model) : value.model)
|
||||
: (sentinel?.label || defaultModel?.model || 'Select a model')}
|
||||
</span>
|
||||
{value && !providerFilter && !staticOptions && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[value.provider] || value.provider}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
title={triggerTitle}
|
||||
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{staticOptions
|
||||
? (value ? staticLabelFor(value.model) : (sentinel?.label ?? 'Model'))
|
||||
: getModelDisplayName(value?.model || defaultModel?.model || 'Model')}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={variant === 'field' ? 'start' : 'end'}
|
||||
className={cn('p-0 overflow-hidden', variant === 'field' && 'min-w-[var(--radix-dropdown-menu-trigger-width)]')}
|
||||
>
|
||||
{!staticOptions && 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
|
||||
if (g.kind === 'live') {
|
||||
// The app default leads its live group; the group's
|
||||
// own saved model follows (both stay pickable through
|
||||
// fetch loading/failure).
|
||||
const pinned: string[] = []
|
||||
if (defaultModel && defaultModel.provider === g.flavor) pinned.push(defaultModel.model)
|
||||
if (g.savedModel && !pinned.includes(g.savedModel)) pinned.push(g.savedModel)
|
||||
return (
|
||||
<LiveProviderGroupItems
|
||||
key={g.flavor}
|
||||
group={g}
|
||||
label={label}
|
||||
pinnedModels={pinned}
|
||||
filter={modelFilterValue}
|
||||
onModelRowsChange={handleLiveGroupRows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const visibleModels = modelFilterValue
|
||||
? g.models.filter((m) => m.toLowerCase().includes(modelFilterValue))
|
||||
: g.models
|
||||
if (visibleModels.length === 0) return null
|
||||
return (
|
||||
<Fragment key={g.flavor}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</DropdownMenuLabel>
|
||||
{visibleModels.map((m) => {
|
||||
const key = `${g.flavor}/${m}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,7 @@ 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 { useChatGPT } from "@/hooks/useChatGPT"
|
||||
import { ModelSelector, type ModelRef } from "@/components/model-selector"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "advanced" | "help"
|
||||
|
||||
|
|
@ -485,12 +486,6 @@ function AppearanceSettings() {
|
|||
|
||||
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
interface LlmModelOption {
|
||||
id: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
}
|
||||
|
||||
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 },
|
||||
|
|
@ -540,8 +535,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
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 [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
|
|
@ -570,8 +563,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
|
||||
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
|
||||
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
|
||||
const modelsForProvider = modelsCatalog[provider] || []
|
||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||
const isMoreProvider = moreProviders.some(p => p.id === provider)
|
||||
|
||||
const primaryModel = activeConfig.models[0] || ""
|
||||
|
|
@ -711,29 +702,6 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load models catalog
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
setModelsLoading(true)
|
||||
const result = await window.ipc.invoke("models:list", null)
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
for (const p of result.providers || []) {
|
||||
catalog[p.id] = p.models || []
|
||||
}
|
||||
setModelsCatalog(catalog)
|
||||
} catch {
|
||||
setModelsCatalog({})
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
}, [dialogOpen])
|
||||
|
||||
// A saved openai-compatible model that the server's list doesn't confirm
|
||||
// (not listed, or /models unreachable) belongs in the visible Model field,
|
||||
// where it stays editable and wins over the silent pick.
|
||||
|
|
@ -1232,144 +1200,33 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-function model overrides */}
|
||||
{/* 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 && (<>
|
||||
{/* Knowledge graph model (right column) */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
{(
|
||||
[
|
||||
{ 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>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.knowledgeGraphModel}
|
||||
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meeting notes model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.meetingNotesModel}
|
||||
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track block model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.liveNoteAgentModel}
|
||||
onChange={(e) => updateConfig(provider, { liveNoteAgentModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.liveNoteAgentModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { liveNoteAgentModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-permission model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.autoPermissionDecisionModel}
|
||||
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1734,44 +1591,19 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
|
|||
|
||||
// --- Rowboat Model Settings (when signed in via Rowboat) ---
|
||||
//
|
||||
// Hybrid mode: every dropdown lists the gateway catalog PLUS any models from
|
||||
// BYOK providers configured below. Values are provider-qualified
|
||||
// ("provider::model") and 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.
|
||||
|
||||
interface HybridModelOption {
|
||||
provider: string
|
||||
model: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const providerDisplayNames: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
google: 'Gemini',
|
||||
ollama: 'Ollama',
|
||||
openrouter: 'OpenRouter',
|
||||
aigateway: 'AI Gateway',
|
||||
'openai-compatible': 'OpenAI-Compatible',
|
||||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
const HYBRID_SEP = "::"
|
||||
const hybridKey = (provider: string, model: string) => `${provider}${HYBRID_SEP}${model}`
|
||||
|
||||
function parseHybridKey(key: string): { provider: string; model: string } | null {
|
||||
const index = key.indexOf(HYBRID_SEP)
|
||||
if (index <= 0) return null
|
||||
return { provider: key.slice(0, index), model: key.slice(index + HYBRID_SEP.length) }
|
||||
}
|
||||
// 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 [options, setOptions] = useState<HybridModelOption[]>([])
|
||||
const [selectedDefault, setSelectedDefault] = useState("")
|
||||
const [selectedKg, setSelectedKg] = useState("")
|
||||
const [selectedLiveNote, setSelectedLiveNote] = useState("")
|
||||
const [selectedAutoPermission, setSelectedAutoPermission] = useState("")
|
||||
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)
|
||||
|
||||
|
|
@ -1781,63 +1613,29 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const collected: HybridModelOption[] = []
|
||||
const seen = new Set<string>()
|
||||
const push = (provider: string, model: string, label?: string) => {
|
||||
if (!model) return
|
||||
const key = hybridKey(provider, model)
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
collected.push({ provider, model, label: label || model })
|
||||
}
|
||||
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke("models:list", null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = p.models || []
|
||||
}
|
||||
} catch { /* offline — BYOK entries below still load */ }
|
||||
for (const m of catalog["rowboat"] || []) push("rowboat", m.id, m.name || m.id)
|
||||
|
||||
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 BYOK config yet */ }
|
||||
|
||||
const providersMap = (parsed.providers ?? {}) as Record<string, Record<string, unknown>>
|
||||
for (const [flavor, entry] of Object.entries(providersMap)) {
|
||||
const hasKey = typeof entry.apiKey === "string" && (entry.apiKey as string).trim().length > 0
|
||||
const hasBaseURL = typeof entry.baseURL === "string" && (entry.baseURL as string).trim().length > 0
|
||||
if (!hasKey && !hasBaseURL) continue
|
||||
push(flavor, typeof entry.model === "string" ? entry.model : "")
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(flavor, m.id, m.name || m.id)
|
||||
} else {
|
||||
for (const m of Array.isArray(entry.models) ? entry.models as string[] : []) push(flavor, m)
|
||||
}
|
||||
}
|
||||
setOptions(collected)
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
// Current selections. Legacy string overrides pair with the BYOK
|
||||
// top-level flavor (mirrors core/models/defaults.ts).
|
||||
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
|
||||
const toKey = (value: unknown): string => {
|
||||
if (!value) return ""
|
||||
const toRef = (value: unknown): ModelRef | null => {
|
||||
if (!value) return null
|
||||
if (typeof value === "string") {
|
||||
return typeof legacyFlavor === "string" ? hybridKey(legacyFlavor, value) : ""
|
||||
return typeof legacyFlavor === "string" ? { provider: legacyFlavor, model: value } : null
|
||||
}
|
||||
const ref = value as { provider?: unknown; model?: unknown }
|
||||
return typeof ref.provider === "string" && typeof ref.model === "string"
|
||||
? hybridKey(ref.provider, ref.model)
|
||||
: ""
|
||||
? { provider: ref.provider, model: ref.model }
|
||||
: null
|
||||
}
|
||||
setSelectedDefault(toKey(parsed.defaultSelection))
|
||||
setSelectedKg(toKey(parsed.knowledgeGraphModel))
|
||||
setSelectedLiveNote(toKey(parsed.liveNoteAgentModel))
|
||||
setSelectedAutoPermission(toKey(parsed.autoPermissionDecisionModel))
|
||||
setSelectedDefault(toRef(parsed.defaultSelection))
|
||||
setSelectedKg(toRef(parsed.knowledgeGraphModel))
|
||||
setSelectedLiveNote(toRef(parsed.liveNoteAgentModel))
|
||||
setSelectedAutoPermission(toRef(parsed.autoPermissionDecisionModel))
|
||||
} catch {
|
||||
toast.error("Failed to load models")
|
||||
} finally {
|
||||
|
|
@ -1851,12 +1649,11 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const toRef = (key: string) => (key ? parseHybridKey(key) : null)
|
||||
await window.ipc.invoke("models:updateConfig", {
|
||||
defaultSelection: toRef(selectedDefault),
|
||||
knowledgeGraphModel: toRef(selectedKg),
|
||||
liveNoteAgentModel: toRef(selectedLiveNote),
|
||||
autoPermissionDecisionModel: toRef(selectedAutoPermission),
|
||||
defaultSelection: selectedDefault,
|
||||
knowledgeGraphModel: selectedKg,
|
||||
liveNoteAgentModel: selectedLiveNote,
|
||||
autoPermissionDecisionModel: selectedAutoPermission,
|
||||
})
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
toast.success("Model configuration saved")
|
||||
|
|
@ -1867,33 +1664,19 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
}
|
||||
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
|
||||
|
||||
const renderSelect = (
|
||||
const renderModelField = (
|
||||
label: string,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
defaultLabel: string,
|
||||
value: ModelRef | null,
|
||||
onChange: (v: ModelRef | null) => void,
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{label}</label>
|
||||
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">{defaultLabel}</SelectItem>
|
||||
{options.map((o) => {
|
||||
const key = hybridKey(o.provider, o.model)
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
{o.label}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{providerDisplayNames[o.provider] || o.provider}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ModelSelector
|
||||
variant="field"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
defaultOption={{ label: "Rowboat default" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -1911,10 +1694,10 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
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>
|
||||
|
||||
{renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")}
|
||||
{renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")}
|
||||
{renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")}
|
||||
{renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")}
|
||||
{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}>
|
||||
|
|
|
|||
100
apps/x/apps/renderer/src/hooks/use-models.test.tsx
Normal file
100
apps/x/apps/renderer/src/hooks/use-models.test.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { __resetModelsForTests, useModels } from './use-models'
|
||||
|
||||
// The hook wires a module-level store to window.ipc, so the tests stub the
|
||||
// preload surface: `invoke` routes by channel through a per-test handler map
|
||||
// and counts calls per channel to observe the store's fetch dedupe.
|
||||
let handlers: Record<string, (args: unknown) => Promise<unknown>> = {}
|
||||
let invokeCounts: Record<string, number> = {}
|
||||
|
||||
;(window as unknown as { ipc: unknown }).ipc = {
|
||||
on: () => () => undefined,
|
||||
invoke: (channel: string, args: unknown) => {
|
||||
invokeCounts[channel] = (invokeCounts[channel] ?? 0) + 1
|
||||
const handler = handlers[channel]
|
||||
return handler ? handler(args) : Promise.reject(new Error(`no handler: ${channel}`))
|
||||
},
|
||||
}
|
||||
|
||||
function serveConfig(providers: Record<string, unknown>): void {
|
||||
handlers['oauth:getState'] = async () => ({ config: { rowboat: { connected: false } } })
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'openai', model: 'gpt-5.4' })
|
||||
handlers['models:list'] = async () => ({
|
||||
providers: [
|
||||
{ id: 'openai', name: 'OpenAI', models: [{ id: 'gpt-5.4', reasoning: true }, { id: 'gpt-5.4-mini' }] },
|
||||
],
|
||||
})
|
||||
handlers['workspace:readFile'] = async () => ({
|
||||
data: JSON.stringify({ provider: { flavor: 'openai' }, model: 'gpt-5.4', providers }),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetModelsForTests()
|
||||
handlers = {}
|
||||
invokeCounts = {}
|
||||
})
|
||||
|
||||
describe('useModels', () => {
|
||||
it('shares one fetch across concurrently mounted consumers', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
|
||||
const first = renderHook(() => useModels())
|
||||
const second = renderHook(() => useModels())
|
||||
|
||||
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
|
||||
await waitFor(() => expect(second.result.current.groups.length).toBeGreaterThan(0))
|
||||
|
||||
expect(invokeCounts['models:list']).toBe(1)
|
||||
expect(invokeCounts['workspace:readFile']).toBe(1)
|
||||
expect(first.result.current.groups).toEqual([
|
||||
{ kind: 'catalog', flavor: 'openai', models: ['gpt-5.4', 'gpt-5.4-mini'] },
|
||||
])
|
||||
expect(first.result.current.reasoningByKey).toEqual({ 'openai/gpt-5.4': true })
|
||||
expect(first.result.current.defaultModel).toEqual({ provider: 'openai', model: 'gpt-5.4' })
|
||||
// Raw catalog is exposed for provider-scoped pickers (unconfigured
|
||||
// providers have no group but may have a catalog).
|
||||
expect(first.result.current.catalogByProvider).toEqual({ openai: ['gpt-5.4', 'gpt-5.4-mini'] })
|
||||
// Both consumers see the same store snapshot, not copies.
|
||||
expect(second.result.current.groups).toBe(first.result.current.groups)
|
||||
})
|
||||
|
||||
it('serves the cache to late mounts without refetching', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
|
||||
const first = renderHook(() => useModels())
|
||||
await waitFor(() => expect(first.result.current.groups.length).toBeGreaterThan(0))
|
||||
|
||||
const late = renderHook(() => useModels())
|
||||
// Loaded synchronously from the module cache — no loading flash, no IPC.
|
||||
expect(late.result.current.groups.length).toBeGreaterThan(0)
|
||||
expect(invokeCounts['models:list']).toBe(1)
|
||||
})
|
||||
|
||||
it('refetches on models-config-changed and updates every consumer', async () => {
|
||||
serveConfig({ openai: { apiKey: 'sk-test', model: 'gpt-5.4' } })
|
||||
|
||||
const { result } = renderHook(() => useModels())
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(1))
|
||||
|
||||
serveConfig({
|
||||
openai: { apiKey: 'sk-test', model: 'gpt-5.4' },
|
||||
ollama: { baseURL: 'http://localhost:11434' },
|
||||
})
|
||||
// The settings Save path: models:updateConfig lands first, then the
|
||||
// event fires — the refetch must see the new default (this is what
|
||||
// moves a fresh composer tab's trigger label without a restart).
|
||||
handlers['llm:getDefaultModel'] = async () => ({ provider: 'ollama', model: 'llama3' })
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.groups.length).toBe(2))
|
||||
expect(result.current.groups[1]).toEqual({
|
||||
kind: 'live', flavor: 'ollama', apiKey: '', baseURL: 'http://localhost:11434', savedModel: '',
|
||||
})
|
||||
expect(result.current.defaultModel).toEqual({ provider: 'ollama', model: 'llama3' })
|
||||
expect(invokeCounts['models:list']).toBe(2)
|
||||
})
|
||||
})
|
||||
247
apps/x/apps/renderer/src/hooks/use-models.ts
Normal file
247
apps/x/apps/renderer/src/hooks/use-models.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
import type { ProviderModelsFlavor } from './use-provider-models'
|
||||
|
||||
export interface ModelRef {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
// One picker group per connected provider. Catalog groups carry a resolved
|
||||
// model list (models:list / saved config); live groups carry credentials and
|
||||
// fetch their list from the provider inside the dropdown via
|
||||
// useProviderModels (models:listForProvider).
|
||||
export type ModelPickerGroup =
|
||||
| { kind: 'catalog'; flavor: string; models: string[] }
|
||||
| { kind: 'live'; flavor: ProviderModelsFlavor; apiKey: string; baseURL: string; savedModel: string }
|
||||
|
||||
const LIVE_PICKER_FLAVORS = new Set<string>(['openrouter', 'aigateway', 'ollama', 'openai-compatible'])
|
||||
// Catalog-preferred flavors that degrade to a live fetch when models:list has
|
||||
// no catalog for them (signed-in mode returns only the rowboat provider, or
|
||||
// the models.dev cache is empty).
|
||||
const LIVE_FALLBACK_FLAVORS = new Set<string>(['openai', 'anthropic', 'google'])
|
||||
|
||||
export interface ModelsSnapshot {
|
||||
groups: ModelPickerGroup[]
|
||||
// Per-model reasoning capability ("provider/model" → flag) from models:list.
|
||||
// Live-fetched ids carry no reasoning metadata, so lookups miss → treated
|
||||
// as non-reasoning.
|
||||
reasoningByKey: Record<string, boolean>
|
||||
// The effective runtime default (what a run actually uses when the user
|
||||
// hasn't picked a model) — shown in pickers instead of guessing from list
|
||||
// order, which can disagree with the real default.
|
||||
defaultModel: ModelRef | null
|
||||
isRowboatConnected: boolean
|
||||
// Raw models:list catalog per provider id. Groups only cover providers
|
||||
// configured in models.json; provider-scoped pickers fall back to this so
|
||||
// a provider mid-setup (key typed, not saved) still lists its catalog.
|
||||
catalogByProvider: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface UseModelsResult extends ModelsSnapshot {
|
||||
/** Force a refetch now (e.g. a composer tab becoming active). */
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: ModelsSnapshot = {
|
||||
groups: [],
|
||||
reasoningByKey: {},
|
||||
defaultModel: null,
|
||||
isRowboatConnected: false,
|
||||
catalogByProvider: {},
|
||||
}
|
||||
|
||||
// Module-level store: every mounted consumer shares one snapshot and one
|
||||
// in-flight fetch, so N pickers on screen never fan out into N identical
|
||||
// IPC round-trips.
|
||||
let snapshot: ModelsSnapshot = EMPTY_SNAPSHOT
|
||||
let loaded = false
|
||||
let fetching = false
|
||||
let fetchSeq = 0
|
||||
let wired = false
|
||||
let wiredCleanups: Array<() => void> = []
|
||||
const subscribers = new Set<() => void>()
|
||||
|
||||
// Hybrid mode: signed-in users get the gateway list AND every BYOK provider
|
||||
// configured in models.json (selecting a BYOK model routes that message
|
||||
// through the user's own key / local server). Signed-out users get BYOK only.
|
||||
async function buildSnapshot(): Promise<ModelsSnapshot> {
|
||||
let isRowboatConnected = false
|
||||
try {
|
||||
const state = await window.ipc.invoke('oauth:getState', null)
|
||||
isRowboatConnected = state.config?.rowboat?.connected ?? false
|
||||
} catch { /* treat as signed out */ }
|
||||
|
||||
let defaultModel: ModelRef | null = null
|
||||
try {
|
||||
const def = await window.ipc.invoke('llm:getDefaultModel', null)
|
||||
defaultModel = { provider: def.provider, model: def.model }
|
||||
} catch { /* no default resolvable */ }
|
||||
|
||||
const groups: ModelPickerGroup[] = []
|
||||
const reasoningByKey: Record<string, boolean> = {}
|
||||
|
||||
// Full catalog per provider (gateway + models.dev cloud providers).
|
||||
const catalog: Record<string, string[]> = {}
|
||||
try {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
for (const p of listResult.providers || []) {
|
||||
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
|
||||
for (const m of p.models || []) {
|
||||
if (typeof m.reasoning === 'boolean') {
|
||||
reasoningByKey[`${p.id}/${m.id}`] = m.reasoning
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* offline / no catalog — groups fall back to saved config below */ }
|
||||
|
||||
if (isRowboatConnected && (catalog['rowboat'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] })
|
||||
}
|
||||
|
||||
// ChatGPT subscription (codex): models:list only carries this catalog
|
||||
// while signed in with ChatGPT, so presence is the gate.
|
||||
if ((catalog['codex'] || []).length > 0) {
|
||||
groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
|
||||
// List the default provider's group first.
|
||||
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
|
||||
const flavors = Object.keys(parsed?.providers || {})
|
||||
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const e = (parsed.providers[flavor] || {}) as Record<string, unknown>
|
||||
const apiKey = typeof e.apiKey === 'string' ? e.apiKey.trim() : ''
|
||||
const baseURL = typeof e.baseURL === 'string' ? e.baseURL.trim() : ''
|
||||
if (!apiKey && !baseURL) continue // provider not configured
|
||||
const savedModel = typeof e.model === 'string' ? e.model : ''
|
||||
|
||||
// Live flavors fetch their list from the provider inside the
|
||||
// dropdown, with the credentials saved in config. Catalog flavors
|
||||
// degrade to the same live fetch when models:list carried no
|
||||
// catalog for them (signed in, or empty models.dev cache).
|
||||
const catalogModels = catalog[flavor] || []
|
||||
if (LIVE_PICKER_FLAVORS.has(flavor) || (catalogModels.length === 0 && LIVE_FALLBACK_FLAVORS.has(flavor))) {
|
||||
groups.push({ kind: 'live', flavor: flavor as ProviderModelsFlavor, apiKey, baseURL, savedModel })
|
||||
continue
|
||||
}
|
||||
|
||||
// Catalog group: the saved default model leads, then the catalog.
|
||||
// Saved models[] survives as the fallback for unknown flavors the
|
||||
// live fetch doesn't support.
|
||||
const models: string[] = []
|
||||
const push = (model: string) => {
|
||||
if (model && !models.includes(model)) models.push(model)
|
||||
}
|
||||
push(savedModel)
|
||||
if (catalogModels.length > 0) {
|
||||
for (const m of catalogModels) push(m)
|
||||
} else {
|
||||
const saved = Array.isArray(e.models) ? e.models as string[] : []
|
||||
for (const m of saved) push(m)
|
||||
}
|
||||
groups.push({ kind: 'catalog', flavor, models })
|
||||
}
|
||||
|
||||
// The user's explicit default selection leads the picker: its group
|
||||
// first and, within a catalog group, the model itself first. (Live
|
||||
// groups pin the default at the top themselves.)
|
||||
const sel = parsed?.defaultSelection
|
||||
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
|
||||
const index = groups.findIndex((g) => g.flavor === sel.provider)
|
||||
if (index >= 0) {
|
||||
const [group] = groups.splice(index, 1)
|
||||
groups.unshift(group)
|
||||
if (group.kind === 'catalog') {
|
||||
const mi = group.models.indexOf(sel.model)
|
||||
if (mi > 0) {
|
||||
group.models.splice(mi, 1)
|
||||
group.models.unshift(sel.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* no BYOK config yet */ }
|
||||
|
||||
return { groups, reasoningByKey, defaultModel, isRowboatConnected, catalogByProvider: catalog }
|
||||
}
|
||||
|
||||
function startFetch(): 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()
|
||||
.then((next) => {
|
||||
if (seq !== fetchSeq) return
|
||||
snapshot = next
|
||||
loaded = true
|
||||
for (const notify of subscribers) notify()
|
||||
})
|
||||
.catch((err) => {
|
||||
// No config yet — but surface unexpected failures for diagnosis.
|
||||
console.error('[use-models] failed to load model list', err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === fetchSeq) fetching = false
|
||||
})
|
||||
}
|
||||
|
||||
function refreshModels(): void {
|
||||
startFetch()
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (!loaded && !fetching) startFetch()
|
||||
}
|
||||
|
||||
function wireGlobalEvents(): void {
|
||||
if (wired) return
|
||||
wired = true
|
||||
// Config edits anywhere in the app (settings dialog, composer pick,
|
||||
// onboarding) announce themselves on this window event.
|
||||
window.addEventListener('models-config-changed', refreshModels)
|
||||
wiredCleanups = [
|
||||
() => window.removeEventListener('models-config-changed', refreshModels),
|
||||
// Rowboat sign-in swaps the whole hybrid list.
|
||||
window.ipc.on('oauth:didConnect', refreshModels),
|
||||
// ChatGPT subscription models appear/disappear with the ChatGPT session.
|
||||
window.ipc.on('chatgpt:statusChanged', refreshModels),
|
||||
]
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
wireGlobalEvents()
|
||||
subscribers.add(onStoreChange)
|
||||
ensureLoaded()
|
||||
return () => {
|
||||
subscribers.delete(onStoreChange)
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): ModelsSnapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function useModels(): UseModelsResult {
|
||||
const data = useSyncExternalStore(subscribe, getSnapshot)
|
||||
return useMemo(() => ({ ...data, refresh: refreshModels }), [data])
|
||||
}
|
||||
|
||||
// Test-only: drop the shared cache and event wiring so each test starts from
|
||||
// a cold store (the seq bump also invalidates any in-flight fetch).
|
||||
export function __resetModelsForTests(): void {
|
||||
snapshot = EMPTY_SNAPSHOT
|
||||
loaded = false
|
||||
fetching = false
|
||||
fetchSeq++
|
||||
subscribers.clear()
|
||||
for (const cleanup of wiredCleanups) cleanup()
|
||||
wiredCleanups = []
|
||||
wired = false
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ export function useConnectors(active: boolean) {
|
|||
// but those handlers are no longer reachable in the UI (the gating
|
||||
// condition `useComposioForGoogle` stays false).
|
||||
// TODO follow-up: drop these flags entirely and prune the dead UI branches
|
||||
// in connectors-popover, connected-accounts-settings, and onboarding-modal.
|
||||
// in connectors-popover and connected-accounts-settings.
|
||||
const [useComposioForGoogle] = useState(false)
|
||||
const [gmailConnected, setGmailConnected] = useState(false)
|
||||
const [gmailLoading, setGmailLoading] = useState(false)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue