feat(projects): pin a thinking level next to a project's model
A project could pin the model new chats start on, but not the level to run it at: the default cascade dropped any variant as soon as a project model won, and only ever considered the global one — which belongs to the global model. Projects now carry `defaultVariant` alongside `defaultModel`, stored and sanitized only next to that model, and the cascade passes it through. Both controls sit in one "Defaults for new chats" group laid out like the Sessions defaults, and the level appears only for models that offer them.
This commit is contained in:
@@ -3,6 +3,15 @@ import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
SettingsFieldRow,
|
||||
SETTINGS_CUSTOM_TRIGGER_CLASS,
|
||||
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { modelVariantNames } from '@/lib/modelVariants';
|
||||
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -19,9 +28,14 @@ type ProjectIdentityFieldsProps = {
|
||||
form: ProjectIdentityFormState;
|
||||
};
|
||||
|
||||
const NO_VARIANT_VALUE = '__default__';
|
||||
|
||||
const formatVariantLabel = (variant: string): string => variant.charAt(0).toUpperCase() + variant.slice(1);
|
||||
|
||||
export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ form }) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
@@ -32,7 +46,9 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
|
||||
iconBackground,
|
||||
setIconBackground,
|
||||
parsedDefaultModel,
|
||||
defaultVariant,
|
||||
handleDefaultModelChange,
|
||||
handleDefaultVariantChange,
|
||||
isUploadingIcon,
|
||||
isRemovingCustomIcon,
|
||||
isDiscoveringIcon,
|
||||
@@ -53,6 +69,15 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
|
||||
project,
|
||||
} = form;
|
||||
|
||||
const availableVariants = React.useMemo(() => {
|
||||
const { providerId, modelId } = parsedDefaultModel;
|
||||
if (!providerId || !modelId) return [];
|
||||
const model = providers
|
||||
.find((provider) => provider.id === providerId)
|
||||
?.models.find((entry) => entry.id === modelId);
|
||||
return modelVariantNames(model);
|
||||
}, [parsedDefaultModel, providers]);
|
||||
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
@@ -75,16 +100,51 @@ export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ fo
|
||||
</ProjectSettingsSubsection>
|
||||
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.field.defaultModel')}
|
||||
info={t('settings.projects.page.field.defaultModelDescription')}
|
||||
settingsItem="projects.default-model"
|
||||
title={t('settings.projects.page.section.chatDefaults')}
|
||||
info={t('settings.projects.page.section.chatDefaultsDescription')}
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedDefaultModel.providerId}
|
||||
modelId={parsedDefaultModel.modelId}
|
||||
onChange={handleDefaultModelChange}
|
||||
className={cn('h-8 min-h-8 rounded-md px-3 max-w-48', PROJECT_SETTINGS_CONTROL_WIDTH)}
|
||||
/>
|
||||
<SettingsFieldRow
|
||||
settingsItem="projects.default-model"
|
||||
label={t('settings.projects.page.field.projectModel')}
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedDefaultModel.providerId}
|
||||
modelId={parsedDefaultModel.modelId}
|
||||
onChange={handleDefaultModelChange}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
|
||||
{availableVariants.length > 0 ? (
|
||||
<SettingsFieldRow
|
||||
settingsItem="projects.default-thinking"
|
||||
label={t('settings.projects.page.field.projectThinking')}
|
||||
>
|
||||
<Select
|
||||
value={defaultVariant ?? NO_VARIANT_VALUE}
|
||||
onValueChange={(value) => handleDefaultVariantChange(value === NO_VARIANT_VALUE ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={SETTINGS_SELECT_SIZE}
|
||||
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
|
||||
aria-label={t('settings.projects.page.field.projectThinking')}
|
||||
>
|
||||
<SelectValue>
|
||||
{defaultVariant
|
||||
? formatVariantLabel(defaultVariant)
|
||||
: t('settings.projects.page.option.thinkingDefault')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_VARIANT_VALUE}>{t('settings.projects.page.option.thinkingDefault')}</SelectItem>
|
||||
{availableVariants.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>{formatVariantLabel(variant)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
) : null}
|
||||
</ProjectSettingsSubsection>
|
||||
|
||||
<ProjectSettingsSubsection
|
||||
|
||||
@@ -37,6 +37,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
color: data.color,
|
||||
iconBackground: data.iconBackground,
|
||||
defaultModel: data.defaultModel ?? null,
|
||||
defaultVariant: data.defaultVariant ?? null,
|
||||
});
|
||||
}, [selectedProject, updateProjectMeta]);
|
||||
|
||||
|
||||
@@ -24,11 +24,12 @@ export type ProjectIdentitySaveData = {
|
||||
color: string | null;
|
||||
iconBackground: string | null;
|
||||
defaultModel: string | null;
|
||||
defaultVariant: string | null;
|
||||
};
|
||||
|
||||
type EditableProject = Pick<
|
||||
ProjectEntry,
|
||||
'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'iconImage' | 'path'
|
||||
'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'defaultVariant' | 'iconImage' | 'path'
|
||||
>;
|
||||
|
||||
export const useProjectIdentityForm = (project: EditableProject | null) => {
|
||||
@@ -45,6 +46,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|
||||
const [color, setColor] = React.useState<string | null>(null);
|
||||
const [iconBackground, setIconBackground] = React.useState<string | null>(null);
|
||||
const [defaultModel, setDefaultModel] = React.useState<string | undefined>(undefined);
|
||||
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>(undefined);
|
||||
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
|
||||
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
|
||||
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
|
||||
@@ -73,6 +75,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|
||||
setColor(null);
|
||||
setIconBackground(null);
|
||||
setDefaultModel(undefined);
|
||||
setDefaultVariant(undefined);
|
||||
return;
|
||||
}
|
||||
setName(project.label ?? '');
|
||||
@@ -80,6 +83,7 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|
||||
setColor(project.color ?? null);
|
||||
setIconBackground(project.iconBackground ?? null);
|
||||
setDefaultModel(project.defaultModel);
|
||||
setDefaultVariant(project.defaultVariant);
|
||||
setPendingRemoveImageIcon(false);
|
||||
clearPendingUploadIcon();
|
||||
setPreviewImageFailed(false);
|
||||
@@ -110,12 +114,20 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|
||||
|| color !== (project?.color ?? null)
|
||||
|| iconBackground !== (project?.iconBackground ?? null)
|
||||
|| (defaultModel ?? undefined) !== (project?.defaultModel ?? undefined)
|
||||
|| (defaultVariant ?? undefined) !== (project?.defaultVariant ?? undefined)
|
||||
|| pendingRemoveImageIcon
|
||||
|| Boolean(pendingUploadIconFile)
|
||||
);
|
||||
|
||||
const handleDefaultModelChange = React.useCallback((providerId: string, modelId: string) => {
|
||||
setDefaultModel(providerId && modelId ? `${providerId}/${modelId}` : undefined);
|
||||
// Variants belong to a model. Carrying the old one over would pin a name
|
||||
// the new model may not have.
|
||||
setDefaultVariant(undefined);
|
||||
}, []);
|
||||
|
||||
const handleDefaultVariantChange = React.useCallback((variant: string | undefined) => {
|
||||
setDefaultVariant(variant);
|
||||
}, []);
|
||||
|
||||
const handleUploadIcon = React.useCallback((file: File | null) => {
|
||||
@@ -232,11 +244,13 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|
||||
color,
|
||||
iconBackground: normalizeProjectIconBackground(willRemoveImageIcon ? null : iconBackground),
|
||||
defaultModel: defaultModel ?? null,
|
||||
defaultVariant: defaultModel ? defaultVariant ?? null : null,
|
||||
};
|
||||
}, [
|
||||
clearPendingUploadIcon,
|
||||
color,
|
||||
defaultModel,
|
||||
defaultVariant,
|
||||
icon,
|
||||
iconBackground,
|
||||
name,
|
||||
@@ -262,8 +276,10 @@ export const useProjectIdentityForm = (project: EditableProject | null) => {
|
||||
iconBackground,
|
||||
setIconBackground,
|
||||
defaultModel,
|
||||
defaultVariant,
|
||||
parsedDefaultModel,
|
||||
handleDefaultModelChange,
|
||||
handleDefaultVariantChange,
|
||||
isUploadingIcon,
|
||||
isRemovingCustomIcon,
|
||||
isDiscoveringIcon,
|
||||
|
||||
Reference in New Issue
Block a user