Move to threads based system.
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
'use client';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { KeyRound, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type Provider =
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'google'
|
||||
| 'openrouter'
|
||||
| 'requesty'
|
||||
| 'litellm'
|
||||
| 'cloudflare_ai_gateway'
|
||||
| 'custom_openai_compatible'
|
||||
| 'opencode_openai_login';
|
||||
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
|
||||
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
const saveProfileRef = makeFunctionReference<
|
||||
'action',
|
||||
{
|
||||
profileId?: Id<'aiProviderProfiles'>;
|
||||
name: string;
|
||||
provider: Provider;
|
||||
authType: AuthType;
|
||||
secret?: string;
|
||||
baseUrl?: string;
|
||||
defaultModel: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
enabled: boolean;
|
||||
},
|
||||
Id<'aiProviderProfiles'>
|
||||
>('aiProviderProfilesNode:save');
|
||||
|
||||
const setDefaultProfileRef = makeFunctionReference<
|
||||
'mutation',
|
||||
{ profileId: Id<'aiProviderProfiles'> },
|
||||
{ success: true }
|
||||
>('aiProviderProfiles:setDefault');
|
||||
|
||||
const providerOptions: {
|
||||
value: Provider;
|
||||
label: string;
|
||||
authType: AuthType;
|
||||
}[] = [
|
||||
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
|
||||
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
|
||||
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
|
||||
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
|
||||
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
|
||||
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
|
||||
{
|
||||
value: 'cloudflare_ai_gateway',
|
||||
label: 'Cloudflare AI Gateway',
|
||||
authType: 'api_key',
|
||||
},
|
||||
{
|
||||
value: 'custom_openai_compatible',
|
||||
label: 'Custom OpenAI-compatible',
|
||||
authType: 'api_key',
|
||||
},
|
||||
{
|
||||
value: 'opencode_openai_login',
|
||||
label: 'OpenCode OpenAI login',
|
||||
authType: 'opencode_auth_json',
|
||||
},
|
||||
];
|
||||
|
||||
const reasoningOptions: ReasoningEffort[] = [
|
||||
'none',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
];
|
||||
|
||||
export const AiProviderProfilesPanel = () => {
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const saveProfile = useAction(saveProfileRef);
|
||||
const setDefaultProfile = useMutation(setDefaultProfileRef);
|
||||
const removeProfile = useMutation(api.aiProviderProfiles.remove);
|
||||
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
|
||||
const [name, setName] = useState('OpenAI');
|
||||
const [provider, setProvider] = useState<Provider>('openai');
|
||||
const selectedProvider = useMemo(
|
||||
() =>
|
||||
providerOptions.find((option) => option.value === provider) ??
|
||||
({
|
||||
value: 'openai',
|
||||
label: 'OpenAI API key',
|
||||
authType: 'api_key',
|
||||
} satisfies (typeof providerOptions)[number]),
|
||||
[provider],
|
||||
);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [defaultModelValue, setDefaultModelValue] = useState('');
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
|
||||
const [reasoningEffort, setReasoningEffort] =
|
||||
useState<ReasoningEffort>('medium');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(provider)
|
||||
.then((options) => {
|
||||
if (cancelled) return;
|
||||
setModelOptions(options);
|
||||
setDefaultModelValue((current) =>
|
||||
current && options.some((option) => option.id === current)
|
||||
? current
|
||||
: (options[0]?.id ?? ''),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
if (!cancelled) setModelOptions([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const reset = () => {
|
||||
setProfileId(undefined);
|
||||
setProvider('openai');
|
||||
setSecret('');
|
||||
setBaseUrl('');
|
||||
setDefaultModelValue('');
|
||||
setReasoningEffort('medium');
|
||||
setEnabled(true);
|
||||
setName('OpenAI');
|
||||
};
|
||||
|
||||
const edit = (profile: (typeof profiles)[number]) => {
|
||||
setProfileId(profile._id);
|
||||
setName(profile.name);
|
||||
setProvider(profile.provider as Provider);
|
||||
setSecret('');
|
||||
setBaseUrl(profile.baseUrl ?? '');
|
||||
setDefaultModelValue(profile.defaultModel);
|
||||
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
||||
setEnabled(profile.enabled);
|
||||
};
|
||||
|
||||
const save = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveProfile({
|
||||
profileId,
|
||||
name,
|
||||
provider,
|
||||
authType: selectedProvider.authType,
|
||||
secret: secret.trim() ? secret : undefined,
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
defaultModel: defaultModelValue,
|
||||
reasoningEffort,
|
||||
enabled,
|
||||
});
|
||||
toast.success('AI provider saved.');
|
||||
reset();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not save AI provider.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProfile = profileId
|
||||
? profiles.find((profile) => profile._id === profileId)
|
||||
: undefined;
|
||||
const hasCredential =
|
||||
selectedProvider.authType === 'none' ||
|
||||
Boolean(secret.trim()) ||
|
||||
Boolean(selectedProfile?.configured);
|
||||
const canSelectModel = hasCredential && modelOptions.length > 0;
|
||||
const configuredProfiles = profiles.filter(
|
||||
(profile) => profile.enabled && profile.configured,
|
||||
);
|
||||
const defaultProfile = configuredProfiles.find(
|
||||
(profile) => profile.isDefault,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-[1fr_0.9fr]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<KeyRound className='size-4' />
|
||||
Provider profiles
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{configuredProfiles.length > 1 ? (
|
||||
<div className='grid gap-2 rounded-md border p-3'>
|
||||
<Label>Default provider</Label>
|
||||
<Select
|
||||
value={defaultProfile?._id ?? ''}
|
||||
onValueChange={async (value) => {
|
||||
await setDefaultProfile({
|
||||
profileId: value as Id<'aiProviderProfiles'>,
|
||||
});
|
||||
toast.success('Default AI provider updated.');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Choose default provider' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{configuredProfiles.map((profile) => (
|
||||
<SelectItem key={profile._id} value={profile._id}>
|
||||
{profile.name} · {profile.defaultModel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Spoons using account default will use this provider.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{profiles.length ? (
|
||||
profiles.map((profile) => (
|
||||
<div
|
||||
key={profile._id}
|
||||
className='border-border flex items-center justify-between gap-3 rounded-md border p-3'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='min-w-0 text-left'
|
||||
onClick={() => edit(profile)}
|
||||
>
|
||||
<p className='truncate text-sm font-medium'>{profile.name}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{profile.provider.replaceAll('_', ' ')} ·{' '}
|
||||
{profile.secretPreview ?? 'not configured'} ·{' '}
|
||||
{profile.defaultModel}
|
||||
{profile.isDefault ? ' · default' : ''}
|
||||
</p>
|
||||
</button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
aria-label='Remove provider'
|
||||
onClick={async () => {
|
||||
await removeProfile({ profileId: profile._id });
|
||||
toast.success('AI provider removed.');
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Add API-key providers for OpenCode, or store an OpenCode OpenAI
|
||||
login profile for the next auth-file injection pass.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>
|
||||
{profileId ? 'Edit provider' : 'Add provider'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={save} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
onValueChange={(value) => {
|
||||
const nextProvider = value as Provider;
|
||||
setProvider(nextProvider);
|
||||
setName(
|
||||
providerOptions
|
||||
.find((option) => option.value === nextProvider)
|
||||
?.label.replace(' API key', '') ?? 'AI provider',
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>
|
||||
{selectedProvider.authType === 'opencode_auth_json'
|
||||
? 'OpenCode auth JSON'
|
||||
: 'API key'}
|
||||
</Label>
|
||||
{selectedProvider.authType === 'opencode_auth_json' ? (
|
||||
<>
|
||||
<Textarea
|
||||
value={secret}
|
||||
placeholder='Paste the full auth.json contents.'
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Copy your Codex auth file from{' '}
|
||||
<code className='bg-muted rounded px-1 py-0.5'>
|
||||
~/.codex/auth.json
|
||||
</code>
|
||||
. It is stored encrypted and should be treated like a
|
||||
password.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
type='password'
|
||||
value={secret}
|
||||
placeholder={
|
||||
profileId ? 'Leave blank to keep current secret' : 'sk-...'
|
||||
}
|
||||
onChange={(event) => setSecret(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Base URL</Label>
|
||||
<Input
|
||||
value={baseUrl}
|
||||
placeholder='Optional for LiteLLM, Requesty, custom providers'
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Default model</Label>
|
||||
<Select
|
||||
value={defaultModelValue}
|
||||
onValueChange={setDefaultModelValue}
|
||||
disabled={!canSelectModel}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
hasCredential
|
||||
? 'Choose a model'
|
||||
: 'Add credentials first'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Models are loaded from Models.dev, the catalog OpenCode uses
|
||||
for provider/model metadata.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Thinking</Label>
|
||||
<Select
|
||||
value={reasoningEffort}
|
||||
onValueChange={(value) =>
|
||||
setReasoningEffort(value as ReasoningEffort)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reasoningOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||
<div>
|
||||
<Label>Enabled</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Disabled profiles cannot be selected for new jobs.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={saving || !hasCredential || !defaultModelValue}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save provider'}
|
||||
</Button>
|
||||
{profileId ? (
|
||||
<Button type='button' variant='outline' onClick={reset}>
|
||||
New provider
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user