feat(provider): align provider and model workflows (#324)
* feat(provider): align provider and model workflows * fix(provider): clear gemini/github flags and use local ollama default * fix(provider): preserve explicit startup provider selection * fix(provider): clear env when deleting last profile * chore(provider): apply review nits in ProviderManager * fix(provider): preserve explicit env on last-profile delete * fix(provider): preserve explicit env when profile marker is stale --------- Co-authored-by: Gitlawb <gitlawb@users.noreply.github.com>
This commit is contained in:
613
src/components/ProviderManager.tsx
Normal file
613
src/components/ProviderManager.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '../ink.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import type { ProviderProfile } from '../utils/config.js'
|
||||
import {
|
||||
addProviderProfile,
|
||||
deleteProviderProfile,
|
||||
getActiveProviderProfile,
|
||||
getProviderPresetDefaults,
|
||||
getProviderProfiles,
|
||||
setActiveProviderProfile,
|
||||
type ProviderPreset,
|
||||
type ProviderProfileInput,
|
||||
updateProviderProfile,
|
||||
} from '../utils/providerProfiles.js'
|
||||
import { Select } from './CustomSelect/index.js'
|
||||
import { Pane } from './design-system/Pane.js'
|
||||
import TextInput from './TextInput.js'
|
||||
|
||||
export type ProviderManagerResult = {
|
||||
action: 'saved' | 'cancelled'
|
||||
activeProfileId?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mode: 'first-run' | 'manage'
|
||||
onDone: (result?: ProviderManagerResult) => void
|
||||
}
|
||||
|
||||
type Screen =
|
||||
| 'menu'
|
||||
| 'select-preset'
|
||||
| 'form'
|
||||
| 'select-active'
|
||||
| 'select-edit'
|
||||
| 'select-delete'
|
||||
|
||||
type DraftField = 'name' | 'baseUrl' | 'model' | 'apiKey'
|
||||
|
||||
type ProviderDraft = Record<DraftField, string>
|
||||
|
||||
const FORM_STEPS: Array<{
|
||||
key: DraftField
|
||||
label: string
|
||||
placeholder: string
|
||||
helpText: string
|
||||
optional?: boolean
|
||||
}> = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Provider name',
|
||||
placeholder: 'e.g. Ollama Home, OpenAI Work',
|
||||
helpText: 'A short label shown in /provider and startup setup.',
|
||||
},
|
||||
{
|
||||
key: 'baseUrl',
|
||||
label: 'Base URL',
|
||||
placeholder: 'e.g. http://localhost:11434/v1',
|
||||
helpText: 'API base URL used for this provider profile.',
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
label: 'Default model',
|
||||
placeholder: 'e.g. llama3.1:8b',
|
||||
helpText: 'Model name to use when this provider is active.',
|
||||
},
|
||||
{
|
||||
key: 'apiKey',
|
||||
label: 'API key',
|
||||
placeholder: 'Leave empty if your provider does not require one',
|
||||
helpText: 'Optional. Press Enter with empty value to skip.',
|
||||
optional: true,
|
||||
},
|
||||
]
|
||||
|
||||
function toDraft(profile: ProviderProfile): ProviderDraft {
|
||||
return {
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
model: profile.model,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function presetToDraft(preset: ProviderPreset): ProviderDraft {
|
||||
const defaults = getProviderPresetDefaults(preset)
|
||||
return {
|
||||
name: defaults.name,
|
||||
baseUrl: defaults.baseUrl,
|
||||
model: defaults.model,
|
||||
apiKey: defaults.apiKey ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function profileSummary(profile: ProviderProfile, isActive: boolean): string {
|
||||
const activeSuffix = isActive ? ' (active)' : ''
|
||||
const keyInfo = profile.apiKey ? 'key set' : 'no key'
|
||||
const providerKind =
|
||||
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
|
||||
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
|
||||
}
|
||||
|
||||
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||
const [profiles, setProfiles] = React.useState(() => getProviderProfiles())
|
||||
const [activeProfileId, setActiveProfileId] = React.useState(
|
||||
() => getActiveProviderProfile()?.id,
|
||||
)
|
||||
const [screen, setScreen] = React.useState<Screen>(
|
||||
mode === 'first-run' ? 'select-preset' : 'menu',
|
||||
)
|
||||
const [editingProfileId, setEditingProfileId] = React.useState<string | null>(null)
|
||||
const [draftProvider, setDraftProvider] = React.useState<ProviderProfile['provider']>(
|
||||
'openai',
|
||||
)
|
||||
const [draft, setDraft] = React.useState<ProviderDraft>(() =>
|
||||
presetToDraft('ollama'),
|
||||
)
|
||||
const [formStepIndex, setFormStepIndex] = React.useState(0)
|
||||
const [cursorOffset, setCursorOffset] = React.useState(0)
|
||||
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
|
||||
|
||||
const currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0]
|
||||
const currentStepKey = currentStep.key
|
||||
const currentValue = draft[currentStepKey]
|
||||
|
||||
function refreshProfiles(): void {
|
||||
const nextProfiles = getProviderProfiles()
|
||||
setProfiles(nextProfiles)
|
||||
setActiveProfileId(getActiveProviderProfile()?.id)
|
||||
}
|
||||
|
||||
function closeWithCancelled(message: string): void {
|
||||
onDone({ action: 'cancelled', message })
|
||||
}
|
||||
|
||||
function startCreateFromPreset(preset: ProviderPreset): void {
|
||||
const defaults = getProviderPresetDefaults(preset)
|
||||
const nextDraft = {
|
||||
name: defaults.name,
|
||||
baseUrl: defaults.baseUrl,
|
||||
model: defaults.model,
|
||||
apiKey: defaults.apiKey ?? '',
|
||||
}
|
||||
setEditingProfileId(null)
|
||||
setDraftProvider(defaults.provider ?? 'openai')
|
||||
setDraft(nextDraft)
|
||||
setFormStepIndex(0)
|
||||
setCursorOffset(nextDraft.name.length)
|
||||
setErrorMessage(undefined)
|
||||
setScreen('form')
|
||||
}
|
||||
|
||||
function startEditProfile(profileId: string): void {
|
||||
const existing = profiles.find(profile => profile.id === profileId)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextDraft = toDraft(existing)
|
||||
setEditingProfileId(profileId)
|
||||
setDraftProvider(existing.provider ?? 'openai')
|
||||
setDraft(nextDraft)
|
||||
setFormStepIndex(0)
|
||||
setCursorOffset(nextDraft.name.length)
|
||||
setErrorMessage(undefined)
|
||||
setScreen('form')
|
||||
}
|
||||
|
||||
function persistDraft(): void {
|
||||
const payload: ProviderProfileInput = {
|
||||
provider: draftProvider,
|
||||
name: draft.name,
|
||||
baseUrl: draft.baseUrl,
|
||||
model: draft.model,
|
||||
apiKey: draft.apiKey,
|
||||
}
|
||||
|
||||
const saved = editingProfileId
|
||||
? updateProviderProfile(editingProfileId, payload)
|
||||
: addProviderProfile(payload, { makeActive: true })
|
||||
|
||||
if (!saved) {
|
||||
setErrorMessage('Could not save provider. Fill all required fields.')
|
||||
return
|
||||
}
|
||||
|
||||
refreshProfiles()
|
||||
setStatusMessage(
|
||||
editingProfileId
|
||||
? `Updated provider: ${saved.name}`
|
||||
: `Added provider: ${saved.name} (now active)`,
|
||||
)
|
||||
|
||||
if (mode === 'first-run') {
|
||||
onDone({
|
||||
action: 'saved',
|
||||
activeProfileId: saved.id,
|
||||
message: `Provider configured: ${saved.name}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setEditingProfileId(null)
|
||||
setFormStepIndex(0)
|
||||
setErrorMessage(undefined)
|
||||
setScreen('menu')
|
||||
}
|
||||
|
||||
function handleFormSubmit(value: string): void {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!currentStep.optional && trimmed.length === 0) {
|
||||
setErrorMessage(`${currentStep.label} is required.`)
|
||||
return
|
||||
}
|
||||
|
||||
const nextDraft = {
|
||||
...draft,
|
||||
[currentStepKey]: trimmed,
|
||||
}
|
||||
|
||||
setDraft(nextDraft)
|
||||
setErrorMessage(undefined)
|
||||
|
||||
if (formStepIndex < FORM_STEPS.length - 1) {
|
||||
const nextIndex = formStepIndex + 1
|
||||
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
|
||||
setFormStepIndex(nextIndex)
|
||||
setCursorOffset(nextDraft[nextKey].length)
|
||||
return
|
||||
}
|
||||
|
||||
persistDraft()
|
||||
}
|
||||
|
||||
function handleBackFromForm(): void {
|
||||
setErrorMessage(undefined)
|
||||
|
||||
if (formStepIndex > 0) {
|
||||
const nextIndex = formStepIndex - 1
|
||||
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
|
||||
setFormStepIndex(nextIndex)
|
||||
setCursorOffset(draft[nextKey].length)
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'first-run') {
|
||||
setScreen('select-preset')
|
||||
return
|
||||
}
|
||||
|
||||
setScreen('menu')
|
||||
}
|
||||
|
||||
useKeybinding('confirm:no', handleBackFromForm, {
|
||||
context: 'Settings',
|
||||
isActive: screen === 'form',
|
||||
})
|
||||
|
||||
function renderPresetSelection(): React.ReactNode {
|
||||
const options = [
|
||||
{
|
||||
value: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
description: 'Native Claude API (x-api-key auth)',
|
||||
},
|
||||
{
|
||||
value: 'ollama',
|
||||
label: 'Ollama',
|
||||
description: 'Local or remote Ollama endpoint',
|
||||
},
|
||||
{
|
||||
value: 'openai',
|
||||
label: 'OpenAI',
|
||||
description: 'OpenAI API with API key',
|
||||
},
|
||||
{
|
||||
value: 'moonshotai',
|
||||
label: 'Moonshot AI',
|
||||
description: 'Kimi OpenAI-compatible endpoint',
|
||||
},
|
||||
{
|
||||
value: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
description: 'DeepSeek OpenAI-compatible endpoint',
|
||||
},
|
||||
{
|
||||
value: 'gemini',
|
||||
label: 'Google Gemini',
|
||||
description: 'Gemini OpenAI-compatible endpoint',
|
||||
},
|
||||
{
|
||||
value: 'together',
|
||||
label: 'Together AI',
|
||||
description: 'Together chat/completions endpoint',
|
||||
},
|
||||
{
|
||||
value: 'groq',
|
||||
label: 'Groq',
|
||||
description: 'Groq OpenAI-compatible endpoint',
|
||||
},
|
||||
{
|
||||
value: 'mistral',
|
||||
label: 'Mistral',
|
||||
description: 'Mistral OpenAI-compatible endpoint',
|
||||
},
|
||||
{
|
||||
value: 'azure-openai',
|
||||
label: 'Azure OpenAI',
|
||||
description: 'Azure OpenAI endpoint (model=deployment name)',
|
||||
},
|
||||
{
|
||||
value: 'openrouter',
|
||||
label: 'OpenRouter',
|
||||
description: 'OpenRouter OpenAI-compatible endpoint',
|
||||
},
|
||||
{
|
||||
value: 'lmstudio',
|
||||
label: 'LM Studio',
|
||||
description: 'Local LM Studio endpoint',
|
||||
},
|
||||
{
|
||||
value: 'custom',
|
||||
label: 'Custom',
|
||||
description: 'Any OpenAI-compatible provider',
|
||||
},
|
||||
...(mode === 'first-run'
|
||||
? [
|
||||
{
|
||||
value: 'skip',
|
||||
label: 'Skip for now',
|
||||
description: 'Continue with current defaults',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
{mode === 'first-run' ? 'Set up provider' : 'Choose provider preset'}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Pick a preset, then confirm base URL, model, and API key.
|
||||
</Text>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
if (value === 'skip') {
|
||||
closeWithCancelled('Provider setup skipped')
|
||||
return
|
||||
}
|
||||
startCreateFromPreset(value as ProviderPreset)
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (mode === 'first-run') {
|
||||
closeWithCancelled('Provider setup skipped')
|
||||
return
|
||||
}
|
||||
setScreen('menu')
|
||||
}}
|
||||
visibleOptionCount={Math.min(12, options.length)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderForm(): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
{editingProfileId ? 'Edit provider profile' : 'Create provider profile'}
|
||||
</Text>
|
||||
<Text dimColor>{currentStep.helpText}</Text>
|
||||
<Text dimColor>
|
||||
Provider type:{' '}
|
||||
{draftProvider === 'anthropic'
|
||||
? 'Anthropic native API'
|
||||
: 'OpenAI-compatible API'}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Step {formStepIndex + 1} of {FORM_STEPS.length}: {currentStep.label}
|
||||
</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text>{figures.pointer}</Text>
|
||||
<TextInput
|
||||
value={currentValue}
|
||||
onChange={value =>
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
[currentStepKey]: value,
|
||||
}))
|
||||
}
|
||||
onSubmit={handleFormSubmit}
|
||||
focus={true}
|
||||
showCursor={true}
|
||||
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
|
||||
columns={80}
|
||||
cursorOffset={cursorOffset}
|
||||
onChangeCursorOffset={setCursorOffset}
|
||||
/>
|
||||
</Box>
|
||||
{errorMessage && <Text color="error">{errorMessage}</Text>}
|
||||
<Text dimColor>
|
||||
Press Enter to continue. Press Esc to go back.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMenu(): React.ReactNode {
|
||||
const hasProfiles = profiles.length > 0
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'add',
|
||||
label: 'Add provider',
|
||||
description: 'Create a new provider profile',
|
||||
},
|
||||
{
|
||||
value: 'activate',
|
||||
label: 'Set active provider',
|
||||
description: 'Switch the active provider profile',
|
||||
disabled: !hasProfiles,
|
||||
},
|
||||
{
|
||||
value: 'edit',
|
||||
label: 'Edit provider',
|
||||
description: 'Update URL, model, or key',
|
||||
disabled: !hasProfiles,
|
||||
},
|
||||
{
|
||||
value: 'delete',
|
||||
label: 'Delete provider',
|
||||
description: 'Remove a provider profile',
|
||||
disabled: !hasProfiles,
|
||||
},
|
||||
{
|
||||
value: 'done',
|
||||
label: 'Done',
|
||||
description: 'Return to chat',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
Provider manager
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Active profile controls base URL, model, and API key used by this session.
|
||||
</Text>
|
||||
{statusMessage && <Text>{statusMessage}</Text>}
|
||||
<Box flexDirection="column">
|
||||
{profiles.length === 0 ? (
|
||||
<Text dimColor>No provider profiles configured yet.</Text>
|
||||
) : (
|
||||
profiles.map(profile => (
|
||||
<Text key={profile.id} dimColor>
|
||||
- {profile.name}: {profileSummary(profile, profile.id === activeProfileId)}
|
||||
</Text>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
setErrorMessage(undefined)
|
||||
switch (value) {
|
||||
case 'add':
|
||||
setScreen('select-preset')
|
||||
break
|
||||
case 'activate':
|
||||
if (profiles.length > 0) {
|
||||
setScreen('select-active')
|
||||
}
|
||||
break
|
||||
case 'edit':
|
||||
if (profiles.length > 0) {
|
||||
setScreen('select-edit')
|
||||
}
|
||||
break
|
||||
case 'delete':
|
||||
if (profiles.length > 0) {
|
||||
setScreen('select-delete')
|
||||
}
|
||||
break
|
||||
default:
|
||||
closeWithCancelled('Provider manager closed')
|
||||
break
|
||||
}
|
||||
}}
|
||||
onCancel={() => closeWithCancelled('Provider manager closed')}
|
||||
visibleOptionCount={options.length}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderProfileSelection(
|
||||
title: string,
|
||||
emptyMessage: string,
|
||||
onSelect: (profileId: string) => void,
|
||||
): React.ReactNode {
|
||||
if (profiles.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
{title}
|
||||
</Text>
|
||||
<Text dimColor>{emptyMessage}</Text>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: 'back',
|
||||
label: 'Back',
|
||||
description: 'Return to provider manager',
|
||||
},
|
||||
]}
|
||||
onChange={() => setScreen('menu')}
|
||||
onCancel={() => setScreen('menu')}
|
||||
visibleOptionCount={1}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const options = profiles.map(profile => ({
|
||||
value: profile.id,
|
||||
label:
|
||||
profile.id === activeProfileId
|
||||
? `${profile.name} (active)`
|
||||
: profile.name,
|
||||
description: `${profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'} · ${profile.baseUrl} · ${profile.model}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="remember" bold>
|
||||
{title}
|
||||
</Text>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={onSelect}
|
||||
onCancel={() => setScreen('menu')}
|
||||
visibleOptionCount={Math.min(10, Math.max(2, options.length))}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
let content: React.ReactNode
|
||||
|
||||
switch (screen) {
|
||||
case 'select-preset':
|
||||
content = renderPresetSelection()
|
||||
break
|
||||
case 'form':
|
||||
content = renderForm()
|
||||
break
|
||||
case 'select-active':
|
||||
content = renderProfileSelection(
|
||||
'Set active provider',
|
||||
'No providers available. Add one first.',
|
||||
profileId => {
|
||||
const active = setActiveProviderProfile(profileId)
|
||||
if (!active) {
|
||||
setErrorMessage('Could not change active provider.')
|
||||
setScreen('menu')
|
||||
return
|
||||
}
|
||||
refreshProfiles()
|
||||
setStatusMessage(`Active provider: ${active.name}`)
|
||||
setScreen('menu')
|
||||
},
|
||||
)
|
||||
break
|
||||
case 'select-edit':
|
||||
content = renderProfileSelection(
|
||||
'Edit provider',
|
||||
'No providers available. Add one first.',
|
||||
profileId => {
|
||||
startEditProfile(profileId)
|
||||
},
|
||||
)
|
||||
break
|
||||
case 'select-delete':
|
||||
content = renderProfileSelection(
|
||||
'Delete provider',
|
||||
'No providers available. Add one first.',
|
||||
profileId => {
|
||||
const result = deleteProviderProfile(profileId)
|
||||
if (!result.removed) {
|
||||
setErrorMessage('Could not delete provider.')
|
||||
} else {
|
||||
refreshProfiles()
|
||||
setStatusMessage('Provider deleted')
|
||||
}
|
||||
setScreen('menu')
|
||||
},
|
||||
)
|
||||
break
|
||||
case 'menu':
|
||||
default:
|
||||
content = renderMenu()
|
||||
break
|
||||
}
|
||||
|
||||
return <Pane color="permission">{content}</Pane>
|
||||
}
|
||||
Reference in New Issue
Block a user