Move to threads based system.
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'convex/react';
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
@@ -24,17 +26,9 @@ import {
|
||||
} from '@spoon/ui';
|
||||
|
||||
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
const modelOptions = [
|
||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
||||
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
||||
] as const;
|
||||
type AgentModel = (typeof modelOptions)[number]['value'];
|
||||
|
||||
type AgentSettings = {
|
||||
enabled: boolean;
|
||||
runtime?: 'opencode' | 'openai_direct';
|
||||
defaultBaseBranch?: string;
|
||||
branchPrefix: string;
|
||||
installCommand?: string;
|
||||
@@ -42,13 +36,14 @@ type AgentSettings = {
|
||||
testCommand?: string;
|
||||
agentModel: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
envFilePath?: string;
|
||||
customEnvFilePath?: string;
|
||||
materializeEnvFileByDefault?: boolean;
|
||||
autoDetectCommands?: boolean;
|
||||
allowUserFileEditing?: boolean;
|
||||
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||
};
|
||||
|
||||
const toAgentModel = (value?: string): AgentModel =>
|
||||
modelOptions.some((option) => option.value === value)
|
||||
? (value as AgentModel)
|
||||
: 'gpt-5.1-codex';
|
||||
|
||||
export const SpoonAgentSettingsForm = ({
|
||||
spoon,
|
||||
settings,
|
||||
@@ -57,6 +52,13 @@ export const SpoonAgentSettingsForm = ({
|
||||
settings?: AgentSettings | null;
|
||||
}) => {
|
||||
const update = useMutation(api.spoonAgentSettings.update);
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
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 ??
|
||||
@@ -73,29 +75,113 @@ export const SpoonAgentSettingsForm = ({
|
||||
settings?.checkCommand ?? '',
|
||||
);
|
||||
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
|
||||
const [agentModel, setAgentModel] = useState<AgentModel>(
|
||||
toAgentModel(settings?.agentModel),
|
||||
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 [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
|
||||
[],
|
||||
);
|
||||
const [agentModel, setAgentModel] = useState(
|
||||
settings?.aiProviderProfileId ? settings.agentModel : '',
|
||||
);
|
||||
const [reasoningEffort, setReasoningEffort] = useState<
|
||||
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||
>(
|
||||
settings?.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: (settings?.reasoningEffort ?? 'high'),
|
||||
!settings?.aiProviderProfileId
|
||||
? 'medium'
|
||||
: settings.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: settings.reasoningEffort,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProfile?.configured) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(selectedProfile.provider)
|
||||
.then((models) => {
|
||||
if (cancelled) return;
|
||||
setAvailableModels(models);
|
||||
setAgentModel((current) =>
|
||||
current && models.some((model) => model.id === current)
|
||||
? current
|
||||
: models.some((model) => model.id === selectedProfile.defaultModel)
|
||||
? selectedProfile.defaultModel
|
||||
: (models[0]?.id ?? ''),
|
||||
);
|
||||
setReasoningEffort(
|
||||
selectedProfile.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: selectedProfile.reasoningEffort,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
if (!cancelled) setAvailableModels([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
selectedProfile?.configured,
|
||||
selectedProfile?.defaultModel,
|
||||
selectedProfile?.provider,
|
||||
selectedProfile?.reasoningEffort,
|
||||
]);
|
||||
const selectableModels = selectedProfile?.configured ? availableModels : [];
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await update({
|
||||
spoonId: spoon._id,
|
||||
enabled,
|
||||
runtime: 'opencode',
|
||||
defaultBaseBranch,
|
||||
branchPrefix,
|
||||
installCommand: installCommand || undefined,
|
||||
checkCommand: checkCommand || undefined,
|
||||
testCommand: testCommand || undefined,
|
||||
agentModel,
|
||||
agentModel: agentModel.trim()
|
||||
? agentModel
|
||||
: (selectableModels[0]?.id ?? 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) {
|
||||
@@ -122,6 +208,50 @@ export const SpoonAgentSettingsForm = ({
|
||||
/>
|
||||
</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'>
|
||||
OpenCode jobs and maintenance review threads use this profile.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
|
||||
<Input
|
||||
@@ -142,19 +272,26 @@ export const SpoonAgentSettingsForm = ({
|
||||
<Label htmlFor='agentModel'>Model</Label>
|
||||
<Select
|
||||
value={agentModel}
|
||||
onValueChange={(value) => setAgentModel(value as AgentModel)}
|
||||
onValueChange={setAgentModel}
|
||||
disabled={!selectableModels.length}
|
||||
>
|
||||
<SelectTrigger id='agentModel'>
|
||||
<SelectValue />
|
||||
<SelectValue placeholder='Choose a configured model' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{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 in Settings before
|
||||
choosing a model.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Reasoning effort</Label>
|
||||
@@ -215,8 +352,80 @@ export const SpoonAgentSettingsForm = ({
|
||||
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}>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={save}
|
||||
disabled={
|
||||
!selectedProfile?.configured ||
|
||||
!selectableModels.some((model) => model.id === agentModel)
|
||||
}
|
||||
>
|
||||
Save agent settings
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user