412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useMutation, useQuery } from 'convex/react';
|
|
import { Bot } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import type { Doc, 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,
|
|
} from '@spoon/ui';
|
|
|
|
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
|
type AgentSettings = {
|
|
enabled: boolean;
|
|
runtime?: 'opencode' | 'openai_direct';
|
|
defaultBaseBranch?: string;
|
|
branchPrefix: string;
|
|
installCommand?: string;
|
|
checkCommand?: string;
|
|
testCommand?: string;
|
|
agentModel: string;
|
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
envFilePath?: string;
|
|
customEnvFilePath?: string;
|
|
materializeEnvFileByDefault?: boolean;
|
|
autoDetectCommands?: boolean;
|
|
allowUserFileEditing?: boolean;
|
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
|
};
|
|
|
|
export const SpoonAgentSettingsForm = ({
|
|
spoon,
|
|
settings,
|
|
}: {
|
|
spoon: Doc<'spoons'>;
|
|
settings?: AgentSettings | null;
|
|
}) => {
|
|
const update = useMutation(api.spoonAgentSettings.update);
|
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
|
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
|
|
const configuredProfiles = profiles.filter(
|
|
(profile) => profile.enabled && profile.configured,
|
|
);
|
|
const defaultProfile = configuredProfiles.find(
|
|
(profile) => profile.isDefault,
|
|
);
|
|
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
|
|
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
|
|
settings?.defaultBaseBranch ??
|
|
spoon.forkDefaultBranch ??
|
|
spoon.upstreamDefaultBranch,
|
|
);
|
|
const [branchPrefix, setBranchPrefix] = useState(
|
|
settings?.branchPrefix ?? 'spoon/agent',
|
|
);
|
|
const [installCommand, setInstallCommand] = useState(
|
|
settings?.installCommand ?? '',
|
|
);
|
|
const [checkCommand, setCheckCommand] = useState(
|
|
settings?.checkCommand ?? '',
|
|
);
|
|
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
|
|
const [envFilePath, setEnvFilePath] = useState(
|
|
settings?.envFilePath ?? '.env.local',
|
|
);
|
|
const [customEnvFilePath, setCustomEnvFilePath] = useState(
|
|
settings?.customEnvFilePath ?? '',
|
|
);
|
|
const [materializeEnvFileByDefault, setMaterializeEnvFileByDefault] =
|
|
useState(settings?.materializeEnvFileByDefault ?? false);
|
|
const [autoDetectCommands, setAutoDetectCommands] = useState(
|
|
settings?.autoDetectCommands ?? true,
|
|
);
|
|
const [allowUserFileEditing, setAllowUserFileEditing] = useState(
|
|
settings?.allowUserFileEditing ?? true,
|
|
);
|
|
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
|
settings?.aiProviderProfileId ?? '__default',
|
|
);
|
|
const selectedProfile = profiles.find(
|
|
(profile) =>
|
|
profile._id ===
|
|
(aiProviderProfileId === '__default'
|
|
? defaultProfile?._id
|
|
: aiProviderProfileId),
|
|
);
|
|
const selectedModelProfile = modelCatalog?.profiles.find(
|
|
(profile) =>
|
|
profile.profileId ===
|
|
(aiProviderProfileId === '__default'
|
|
? defaultProfile?._id
|
|
: aiProviderProfileId),
|
|
);
|
|
const [agentModel, setAgentModel] = useState(
|
|
settings?.aiProviderProfileId ? settings.agentModel : '',
|
|
);
|
|
const [reasoningEffort, setReasoningEffort] = useState<
|
|
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
|
>(
|
|
!settings?.aiProviderProfileId
|
|
? 'medium'
|
|
: settings.reasoningEffort === 'none'
|
|
? 'minimal'
|
|
: settings.reasoningEffort,
|
|
);
|
|
|
|
const selectableModels = selectedModelProfile?.configured
|
|
? selectedModelProfile.models
|
|
: [];
|
|
const selectedAgentModel =
|
|
agentModel && selectableModels.some((model) => model.id === agentModel)
|
|
? agentModel
|
|
: selectableModels.some(
|
|
(model) => model.id === selectedModelProfile?.defaultModel,
|
|
)
|
|
? (selectedModelProfile?.defaultModel ?? '')
|
|
: (selectableModels[0]?.id ?? '');
|
|
|
|
const save = async () => {
|
|
try {
|
|
await update({
|
|
spoonId: spoon._id,
|
|
enabled,
|
|
runtime: 'opencode',
|
|
defaultBaseBranch,
|
|
branchPrefix,
|
|
installCommand: installCommand || undefined,
|
|
checkCommand: checkCommand || undefined,
|
|
testCommand: testCommand || undefined,
|
|
agentModel: selectedAgentModel || undefined,
|
|
reasoningEffort,
|
|
envFilePath: envFilePath as
|
|
| '.env'
|
|
| '.env.local'
|
|
| '.env.production'
|
|
| '.env.production.local'
|
|
| 'custom',
|
|
customEnvFilePath: customEnvFilePath || undefined,
|
|
materializeEnvFileByDefault,
|
|
autoDetectCommands,
|
|
allowUserFileEditing,
|
|
aiProviderProfileId:
|
|
aiProviderProfileId === '__default'
|
|
? undefined
|
|
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
|
|
clearAiProviderProfile: aiProviderProfileId === '__default',
|
|
});
|
|
toast.success('Agent settings saved.');
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error('Could not save agent settings.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className='shadow-none'>
|
|
<CardHeader>
|
|
<CardTitle className='flex items-center gap-2 text-base'>
|
|
<Bot className='size-4' />
|
|
Agent runtime
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className='space-y-4'>
|
|
<div className='flex items-center justify-between gap-4'>
|
|
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
|
|
<Switch
|
|
id='agentEnabled'
|
|
checked={enabled}
|
|
onCheckedChange={setEnabled}
|
|
/>
|
|
</div>
|
|
<div className='grid gap-3 md:grid-cols-2'>
|
|
<div className='grid gap-2'>
|
|
<Label>Runtime</Label>
|
|
<Input value='OpenCode workspace' disabled />
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label>AI provider profile</Label>
|
|
<Select
|
|
value={aiProviderProfileId}
|
|
onValueChange={(value) => {
|
|
setAiProviderProfileId(value);
|
|
const nextProfile = profiles.find(
|
|
(profile) =>
|
|
profile._id ===
|
|
(value === '__default' ? defaultProfile?._id : value),
|
|
);
|
|
if (!nextProfile?.configured) setAgentModel('');
|
|
if (nextProfile?.configured) {
|
|
setReasoningEffort(
|
|
nextProfile.reasoningEffort === 'none'
|
|
? 'minimal'
|
|
: nextProfile.reasoningEffort,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value='__default'>
|
|
Use account default
|
|
{defaultProfile ? ` (${defaultProfile.name})` : ''}
|
|
</SelectItem>
|
|
{configuredProfiles.map((profile) => (
|
|
<SelectItem key={profile._id} value={profile._id}>
|
|
{profile.name} · {profile.provider.replaceAll('_', ' ')}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className='text-muted-foreground text-xs'>
|
|
Workspaces use this profile. Use default resolves to your account
|
|
default provider.
|
|
</p>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
|
|
<Input
|
|
id='defaultBaseBranch'
|
|
value={defaultBaseBranch}
|
|
onChange={(event) => setDefaultBaseBranch(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='branchPrefix'>Branch prefix</Label>
|
|
<Input
|
|
id='branchPrefix'
|
|
value={branchPrefix}
|
|
onChange={(event) => setBranchPrefix(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='agentModel'>Model</Label>
|
|
<Select
|
|
value={selectedAgentModel}
|
|
onValueChange={setAgentModel}
|
|
disabled={!selectableModels.length}
|
|
>
|
|
<SelectTrigger id='agentModel'>
|
|
<SelectValue placeholder='Choose a configured model' />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectableModels.map((option) => (
|
|
<SelectItem key={option.id} value={option.id}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{!selectableModels.length ? (
|
|
<p className='text-muted-foreground text-xs'>
|
|
Configure an enabled AI provider profile with saved model
|
|
options in Settings before choosing a model.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label>Reasoning effort</Label>
|
|
<Select
|
|
value={reasoningEffort}
|
|
onValueChange={(value) =>
|
|
setReasoningEffort(
|
|
value as 'minimal' | 'low' | 'medium' | 'high' | 'xhigh',
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{efforts.map((effort) => (
|
|
<SelectItem key={effort} value={effort}>
|
|
{effort}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='installCommand'>Install command</Label>
|
|
<Input
|
|
id='installCommand'
|
|
value={installCommand}
|
|
placeholder='Auto-detect from lockfile'
|
|
onChange={(event) => setInstallCommand(event.target.value)}
|
|
/>
|
|
<p className='text-muted-foreground text-xs'>
|
|
Leave blank to inspect the repository and choose bun, pnpm, yarn,
|
|
or npm.
|
|
</p>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='checkCommand'>Check command</Label>
|
|
<Input
|
|
id='checkCommand'
|
|
value={checkCommand}
|
|
placeholder='Auto-detect typecheck or lint'
|
|
onChange={(event) => setCheckCommand(event.target.value)}
|
|
/>
|
|
<p className='text-muted-foreground text-xs'>
|
|
Leave blank to read package.json scripts after cloning.
|
|
</p>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='testCommand'>Test command</Label>
|
|
<Input
|
|
id='testCommand'
|
|
value={testCommand}
|
|
placeholder='Auto-detect test script'
|
|
onChange={(event) => setTestCommand(event.target.value)}
|
|
/>
|
|
<p className='text-muted-foreground text-xs'>
|
|
Leave blank to run the detected test script when one exists.
|
|
</p>
|
|
</div>
|
|
<div className='grid gap-2'>
|
|
<Label>Env file path</Label>
|
|
<Select value={envFilePath} onValueChange={setEnvFilePath}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value='.env'>.env</SelectItem>
|
|
<SelectItem value='.env.local'>.env.local</SelectItem>
|
|
<SelectItem value='.env.production'>.env.production</SelectItem>
|
|
<SelectItem value='.env.production.local'>
|
|
.env.production.local
|
|
</SelectItem>
|
|
<SelectItem value='custom'>Custom path</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{envFilePath === 'custom' ? (
|
|
<div className='grid gap-2'>
|
|
<Label htmlFor='customEnvFilePath'>Custom env path</Label>
|
|
<Input
|
|
id='customEnvFilePath'
|
|
value={customEnvFilePath}
|
|
placeholder='.env.spoon'
|
|
onChange={(event) => setCustomEnvFilePath(event.target.value)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
|
<div>
|
|
<Label>Materialize env file by default</Label>
|
|
<p className='text-muted-foreground text-xs'>
|
|
Write all Spoon secrets into the chosen .env file for new
|
|
workspaces.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={materializeEnvFileByDefault}
|
|
onCheckedChange={setMaterializeEnvFileByDefault}
|
|
/>
|
|
</div>
|
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
|
<div>
|
|
<Label>Auto-detect commands</Label>
|
|
<p className='text-muted-foreground text-xs'>
|
|
Inspect package files after cloning.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={autoDetectCommands}
|
|
onCheckedChange={setAutoDetectCommands}
|
|
/>
|
|
</div>
|
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
|
<div>
|
|
<Label>Allow browser file editing</Label>
|
|
<p className='text-muted-foreground text-xs'>
|
|
Let users edit workspace files manually.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={allowUserFileEditing}
|
|
onCheckedChange={setAllowUserFileEditing}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type='button'
|
|
onClick={save}
|
|
disabled={
|
|
!selectedProfile?.configured ||
|
|
!selectableModels.some((model) => model.id === selectedAgentModel)
|
|
}
|
|
>
|
|
Save agent settings
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|