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.
310 lines
9.9 KiB
TypeScript
310 lines
9.9 KiB
TypeScript
import React from 'react';
|
|
import { toast } from '@/components/ui';
|
|
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import type { ProjectEntry } from '@/lib/api/types';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
|
|
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
|
|
|
const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
};
|
|
|
|
export type ProjectIdentitySaveData = {
|
|
label: string;
|
|
icon: string | null;
|
|
color: string | null;
|
|
iconBackground: string | null;
|
|
defaultModel: string | null;
|
|
defaultVariant: string | null;
|
|
};
|
|
|
|
type EditableProject = Pick<
|
|
ProjectEntry,
|
|
'id' | 'label' | 'icon' | 'color' | 'iconBackground' | 'defaultModel' | 'defaultVariant' | 'iconImage' | 'path'
|
|
>;
|
|
|
|
export const useProjectIdentityForm = (project: EditableProject | null) => {
|
|
const { t } = useI18n();
|
|
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
|
|
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
|
|
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
|
|
const currentIconImage = useProjectsStore((state) =>
|
|
project ? state.projects.find((entry) => entry.id === project.id)?.iconImage ?? null : null,
|
|
);
|
|
|
|
const [name, setName] = React.useState('');
|
|
const [icon, setIcon] = React.useState<string | null>(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);
|
|
const [pendingRemoveImageIcon, setPendingRemoveImageIcon] = React.useState(false);
|
|
const [pendingUploadIconFile, setPendingUploadIconFile] = React.useState<File | null>(null);
|
|
const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState<string | null>(null);
|
|
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
|
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
|
|
const clearPendingUploadIcon = React.useCallback(() => {
|
|
setPendingUploadIconFile(null);
|
|
setPendingUploadIconPreviewUrl((previousUrl) => {
|
|
if (previousUrl) {
|
|
URL.revokeObjectURL(previousUrl);
|
|
}
|
|
return null;
|
|
});
|
|
}, []);
|
|
|
|
const projectId = project?.id ?? null;
|
|
|
|
React.useEffect(() => {
|
|
if (!project) {
|
|
setName('');
|
|
setIcon(null);
|
|
setColor(null);
|
|
setIconBackground(null);
|
|
setDefaultModel(undefined);
|
|
setDefaultVariant(undefined);
|
|
return;
|
|
}
|
|
setName(project.label ?? '');
|
|
setIcon(project.icon ?? null);
|
|
setColor(project.color ?? null);
|
|
setIconBackground(project.iconBackground ?? null);
|
|
setDefaultModel(project.defaultModel);
|
|
setDefaultVariant(project.defaultVariant);
|
|
setPendingRemoveImageIcon(false);
|
|
clearPendingUploadIcon();
|
|
setPreviewImageFailed(false);
|
|
}, [project, clearPendingUploadIcon]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
clearPendingUploadIcon();
|
|
};
|
|
}, [clearPendingUploadIcon]);
|
|
|
|
const parsedDefaultModel = React.useMemo(() => {
|
|
const parsed = parseModelIdentifier(defaultModel);
|
|
return parsed ?? { providerId: '', modelId: '' };
|
|
}, [defaultModel]);
|
|
|
|
const hasStoredImageIcon = Boolean(project?.iconImage);
|
|
const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl);
|
|
const hasCustomIcon = project?.iconImage?.source === 'custom';
|
|
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
|
|
const hasRemovableImageIcon = effectiveHasImageIcon;
|
|
const showStoredImagePreview = Boolean(project && hasStoredImageIcon && !pendingRemoveImageIcon);
|
|
const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview);
|
|
|
|
const hasChanges = Boolean(project) && (
|
|
name.trim() !== (project?.label ?? '').trim()
|
|
|| icon !== (project?.icon ?? 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) => {
|
|
if (!project || !file || isUploadingIcon) {
|
|
return;
|
|
}
|
|
|
|
setPendingRemoveImageIcon(false);
|
|
setPreviewImageFailed(false);
|
|
setPendingUploadIconFile(file);
|
|
setPendingUploadIconPreviewUrl((previousUrl) => {
|
|
if (previousUrl) {
|
|
URL.revokeObjectURL(previousUrl);
|
|
}
|
|
return URL.createObjectURL(file);
|
|
});
|
|
}, [isUploadingIcon, project]);
|
|
|
|
const handleRemoveImageIcon = React.useCallback(() => {
|
|
if (!project || !hasRemovableImageIcon || isRemovingCustomIcon) {
|
|
return;
|
|
}
|
|
|
|
if (hasPendingUploadImageIcon) {
|
|
clearPendingUploadIcon();
|
|
}
|
|
if (hasStoredImageIcon) {
|
|
setPendingRemoveImageIcon(true);
|
|
} else {
|
|
setPendingRemoveImageIcon(false);
|
|
}
|
|
setPreviewImageFailed(false);
|
|
}, [
|
|
clearPendingUploadIcon,
|
|
hasPendingUploadImageIcon,
|
|
hasRemovableImageIcon,
|
|
hasStoredImageIcon,
|
|
isRemovingCustomIcon,
|
|
project,
|
|
]);
|
|
|
|
const handleDiscoverIcon = React.useCallback(async () => {
|
|
if (!project || isDiscoveringIcon) {
|
|
return;
|
|
}
|
|
|
|
clearPendingUploadIcon();
|
|
setPendingRemoveImageIcon(false);
|
|
setPreviewImageFailed(false);
|
|
|
|
setIsDiscoveringIcon(true);
|
|
try {
|
|
const result = await discoverProjectIcon(project.id);
|
|
if (!result.ok) {
|
|
toast.error(result.error || t('settings.projects.page.toast.discoverIconFailed'));
|
|
return;
|
|
}
|
|
if (result.skipped) {
|
|
toast.success(t('settings.projects.page.toast.customIconAlreadySet'));
|
|
return;
|
|
}
|
|
toast.success(t('settings.projects.page.toast.iconDiscovered'));
|
|
} finally {
|
|
setIsDiscoveringIcon(false);
|
|
}
|
|
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, project, t]);
|
|
|
|
const prepareSaveData = React.useCallback(async (options?: { silent?: boolean }): Promise<ProjectIdentitySaveData | null> => {
|
|
const silent = options?.silent === true;
|
|
if (!project) {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
if (pendingUploadIconFile) {
|
|
setIsUploadingIcon(true);
|
|
const uploadResult = await uploadProjectIcon(project.id, pendingUploadIconFile);
|
|
setIsUploadingIcon(false);
|
|
if (!uploadResult.ok) {
|
|
toast.error(uploadResult.error || t('settings.projects.page.toast.uploadIconFailed'));
|
|
return null;
|
|
}
|
|
if (!silent) {
|
|
toast.success(t('settings.projects.page.toast.iconUpdated'));
|
|
}
|
|
clearPendingUploadIcon();
|
|
setPendingRemoveImageIcon(false);
|
|
}
|
|
|
|
const willRemoveImageIcon = pendingRemoveImageIcon && Boolean(project.iconImage);
|
|
|
|
if (willRemoveImageIcon) {
|
|
setIsRemovingCustomIcon(true);
|
|
const removeResult = await removeProjectIcon(project.id);
|
|
setIsRemovingCustomIcon(false);
|
|
if (!removeResult.ok) {
|
|
toast.error(removeResult.error || t('settings.projects.page.toast.removeIconFailed'));
|
|
return null;
|
|
}
|
|
if (!silent) {
|
|
toast.success(t('settings.projects.page.toast.iconRemoved'));
|
|
}
|
|
setPendingRemoveImageIcon(false);
|
|
setIconBackground(null);
|
|
}
|
|
|
|
return {
|
|
label: trimmed,
|
|
icon,
|
|
color,
|
|
iconBackground: normalizeProjectIconBackground(willRemoveImageIcon ? null : iconBackground),
|
|
defaultModel: defaultModel ?? null,
|
|
defaultVariant: defaultModel ? defaultVariant ?? null : null,
|
|
};
|
|
}, [
|
|
clearPendingUploadIcon,
|
|
color,
|
|
defaultModel,
|
|
defaultVariant,
|
|
icon,
|
|
iconBackground,
|
|
name,
|
|
pendingRemoveImageIcon,
|
|
pendingUploadIconFile,
|
|
project,
|
|
removeProjectIcon,
|
|
t,
|
|
uploadProjectIcon,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
setPreviewImageFailed(false);
|
|
}, [projectId, currentIconImage?.updatedAt]);
|
|
|
|
return {
|
|
name,
|
|
setName,
|
|
icon,
|
|
setIcon,
|
|
color,
|
|
setColor,
|
|
iconBackground,
|
|
setIconBackground,
|
|
defaultModel,
|
|
defaultVariant,
|
|
parsedDefaultModel,
|
|
handleDefaultModelChange,
|
|
handleDefaultVariantChange,
|
|
isUploadingIcon,
|
|
isRemovingCustomIcon,
|
|
isDiscoveringIcon,
|
|
pendingRemoveImageIcon,
|
|
setPendingRemoveImageIcon,
|
|
pendingUploadIconFile,
|
|
pendingUploadIconPreviewUrl,
|
|
previewImageFailed,
|
|
setPreviewImageFailed,
|
|
hasStoredImageIcon,
|
|
hasPendingUploadImageIcon,
|
|
hasCustomIcon,
|
|
effectiveHasImageIcon,
|
|
hasRemovableImageIcon,
|
|
showStoredImagePreview,
|
|
showImagePreview,
|
|
fileInputRef,
|
|
clearPendingUploadIcon,
|
|
handleUploadIcon,
|
|
handleRemoveImageIcon,
|
|
handleDiscoverIcon,
|
|
hasChanges,
|
|
prepareSaveData,
|
|
currentIconImage,
|
|
project,
|
|
};
|
|
};
|