From f4f780b3b725854366148bbcd62022c479522fe7 Mon Sep 17 00:00:00 2001 From: shine1i Date: Mon, 13 Apr 2026 14:33:05 +0200 Subject: [PATCH] fix chat settings presets --- .../src/features/chat/chat-settings-sheet.tsx | 603 ++++++++---------- 1 file changed, 271 insertions(+), 332 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 6949a6bc8e..89a919b964 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -10,13 +10,19 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, } from "@/components/ui/input-group"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -24,12 +30,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, @@ -72,11 +72,9 @@ export interface Preset { params: InferenceParams; } -interface SystemPromptTemplate { - id: string; +interface LegacySystemPromptTemplate { name: string; content: string; - updatedAt: number; } const BUILTIN_PRESETS: Preset[] = [ @@ -107,20 +105,134 @@ const BUILTIN_PRESETS: Preset[] = [ const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets"; const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset"; -const CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts"; +const LEGACY_CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts"; +const LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY = + "unsloth_chat_system_prompts_migrated"; function canUseStorage(): boolean { return typeof window !== "undefined"; } +function getUniquePresetName(baseName: string, usedNames: Set): string { + const normalizedBase = baseName.trim() || "Imported Prompt"; + let nextName = normalizedBase; + let suffix = 2; + while (usedNames.has(nextName)) { + nextName = `${normalizedBase} ${suffix}`; + suffix += 1; + } + usedNames.add(nextName); + return nextName; +} + +function migrateLegacySystemPromptTemplates(presets: Preset[]): Preset[] { + if (!canUseStorage()) return presets; + try { + const raw = localStorage.getItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY); + if (!raw) return presets; + if (localStorage.getItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY) === raw) { + return presets; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY); + localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); + return presets; + } + if (!Array.isArray(parsed)) { + localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY); + localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); + return presets; + } + const usedNames = new Set([ + ...BUILTIN_PRESETS.map((preset) => preset.name), + ...presets.map((preset) => preset.name), + ]); + const seenImportedConfigKeys = new Set( + [...BUILTIN_PRESETS, ...presets].map((preset) => + JSON.stringify({ + temperature: preset.params.temperature, + topP: preset.params.topP, + topK: preset.params.topK, + minP: preset.params.minP, + repetitionPenalty: preset.params.repetitionPenalty, + presencePenalty: preset.params.presencePenalty, + maxSeqLength: preset.params.maxSeqLength, + maxTokens: preset.params.maxTokens, + systemPrompt: preset.params.systemPrompt, + trustRemoteCode: preset.params.trustRemoteCode ?? false, + }), + ), + ); + const importedPresets = parsed + .filter((item): item is LegacySystemPromptTemplate => { + if (!item || typeof item !== "object") return false; + const maybe = item as Partial; + return ( + typeof maybe.name === "string" && typeof maybe.content === "string" + ); + }) + .map((template) => ({ + template, + importedParams: { + ...defaultInferenceParams, + systemPrompt: template.content, + }, + })) + .filter(({ importedParams }) => { + const configKey = JSON.stringify({ + temperature: importedParams.temperature, + topP: importedParams.topP, + topK: importedParams.topK, + minP: importedParams.minP, + repetitionPenalty: importedParams.repetitionPenalty, + presencePenalty: importedParams.presencePenalty, + maxSeqLength: importedParams.maxSeqLength, + maxTokens: importedParams.maxTokens, + systemPrompt: importedParams.systemPrompt, + trustRemoteCode: importedParams.trustRemoteCode ?? false, + }); + if (seenImportedConfigKeys.has(configKey)) return false; + seenImportedConfigKeys.add(configKey); + return true; + }) + .map(({ template, importedParams }) => ({ + name: getUniquePresetName(`${template.name} Prompt`, usedNames), + params: importedParams, + })); + if (importedPresets.length === 0) { + localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY); + localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); + return presets; + } + const mergedPresets = [...presets, ...importedPresets]; + localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(mergedPresets)); + try { + localStorage.setItem(LEGACY_CHAT_SYSTEM_PROMPTS_MIGRATED_KEY, raw); + localStorage.removeItem(LEGACY_CHAT_SYSTEM_PROMPTS_KEY); + } catch { + // ignore cleanup failure after successful import write + } + return mergedPresets; + } catch { + return presets; + } +} + function loadSavedCustomPresets(): Preset[] { if (!canUseStorage()) return []; try { const raw = localStorage.getItem(CHAT_PRESETS_KEY); - if (!raw) return []; + if (!raw) { + return migrateLegacySystemPromptTemplates([]); + } const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) return []; - return parsed + if (!Array.isArray(parsed)) { + return migrateLegacySystemPromptTemplates([]); + } + const presets = parsed .filter((item): item is Preset => { if (!item || typeof item !== "object") return false; const maybe = item as Partial; @@ -133,13 +245,10 @@ function loadSavedCustomPresets(): Preset[] { ...preset.params, }, })) - .filter( - (preset) => - preset.name.length > 0 && - !BUILTIN_PRESETS.some((builtin) => builtin.name === preset.name), - ); + .filter((preset) => preset.name.length > 0); + return migrateLegacySystemPromptTemplates(presets); } catch { - return []; + return migrateLegacySystemPromptTemplates([]); } } @@ -152,54 +261,8 @@ function loadSavedActivePreset(): string { } } -function createSystemPromptId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return `prompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function loadSavedSystemPrompts(): SystemPromptTemplate[] { - if (!canUseStorage()) return []; - try { - const raw = localStorage.getItem(CHAT_SYSTEM_PROMPTS_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) return []; - return parsed - .filter((item): item is SystemPromptTemplate => { - if (!item || typeof item !== "object") return false; - const maybe = item as Partial; - return ( - typeof maybe.id === "string" && - typeof maybe.name === "string" && - typeof maybe.content === "string" - ); - }) - .map((item) => ({ - id: item.id.trim(), - name: item.name.trim() || "Untitled prompt", - content: item.content, - updatedAt: typeof item.updatedAt === "number" ? item.updatedAt : Date.now(), - })) - .filter((item) => item.id.length > 0); - } catch { - return []; - } -} - -function saveSystemPrompts(templates: SystemPromptTemplate[]) { - if (!canUseStorage()) return; - try { - localStorage.setItem(CHAT_SYSTEM_PROMPTS_KEY, JSON.stringify(templates)); - } catch { - // ignore - } -} - type PresetSaveMode = | "disabled" - | "reserved" | "overwrite-active" | "overwrite-other" | "create"; @@ -212,14 +275,31 @@ interface PresetSaveState { title: string; } +function isSamePresetConfig(a: InferenceParams, b: InferenceParams): boolean { + return ( + a.temperature === b.temperature && + a.topP === b.topP && + a.topK === b.topK && + a.minP === b.minP && + a.repetitionPenalty === b.repetitionPenalty && + a.presencePenalty === b.presencePenalty && + a.maxSeqLength === b.maxSeqLength && + a.maxTokens === b.maxTokens && + a.systemPrompt === b.systemPrompt && + (a.trustRemoteCode ?? false) === (b.trustRemoteCode ?? false) + ); +} + function getPresetSaveState({ rawName, activePreset, - customPresets, + presets, + activePresetDirty, }: { rawName: string; activePreset: string; - customPresets: Preset[]; + presets: Preset[]; + activePresetDirty: boolean; }): PresetSaveState { const trimmedName = rawName.trim(); if (!trimmedName) { @@ -232,32 +312,19 @@ function getPresetSaveState({ }; } - if (BUILTIN_PRESETS.some((preset) => preset.name === trimmedName)) { + const matchingPreset = presets.find((preset) => preset.name === trimmedName); + if (matchingPreset) { + const isActiveMatch = matchingPreset.name === activePreset; return { - mode: "reserved", - canSubmit: true, - isSaveReady: false, - buttonLabel: "Reserved", - title: `"${trimmedName}" is reserved. Pick a different name.`, - }; - } - - const matchingCustomPreset = customPresets.find( - (preset) => preset.name === trimmedName, - ); - if (matchingCustomPreset) { - return { - mode: - matchingCustomPreset.name === activePreset - ? "overwrite-active" - : "overwrite-other", - canSubmit: true, - isSaveReady: true, - buttonLabel: "Overwrite", - title: - matchingCustomPreset.name === activePreset + mode: isActiveMatch ? "overwrite-active" : "overwrite-other", + canSubmit: !isActiveMatch || activePresetDirty, + isSaveReady: !isActiveMatch || activePresetDirty, + buttonLabel: isActiveMatch && !activePresetDirty ? "Saved" : "Overwrite", + title: isActiveMatch + ? activePresetDirty ? "Save current settings to this preset" - : `Overwrite preset "${trimmedName}"`, + : "No unsaved changes" + : `Overwrite preset "${trimmedName}"`, }; } @@ -460,39 +527,49 @@ export function ChatSettingsPanel({ loadSavedActivePreset(), ); const presetControlRowRef = useRef(null); - const [presetMenuWidthPx, setPresetMenuWidthPx] = useState( - undefined, + const [presetMenuWidthPx, setPresetMenuWidthPx] = useState< + number | undefined + >(undefined); + const [systemPromptEditorOpen, setSystemPromptEditorOpen] = useState(false); + const [systemPromptDraft, setSystemPromptDraft] = useState(""); + const presets = useMemo(() => { + const overrides = new Set(customPresets.map((preset) => preset.name)); + return [ + ...BUILTIN_PRESETS.filter((preset) => !overrides.has(preset.name)), + ...customPresets, + ]; + }, [customPresets]); + const activePresetDefinition = useMemo( + () => presets.find((preset) => preset.name === activePreset) ?? null, + [activePreset, presets], ); - const [systemPromptManagerOpen, setSystemPromptManagerOpen] = useState(false); - const [systemPrompts, setSystemPrompts] = useState( - () => loadSavedSystemPrompts(), + const activeCustomPreset = useMemo( + () => customPresets.find((preset) => preset.name === activePreset) ?? null, + [activePreset, customPresets], ); - const [selectedSystemPromptId, setSelectedSystemPromptId] = useState(null); - const [systemPromptNameDraft, setSystemPromptNameDraft] = useState(""); - const [systemPromptContentDraft, setSystemPromptContentDraft] = useState(""); - const presets = useMemo( - () => [...BUILTIN_PRESETS, ...customPresets], - [customPresets], + const activeBuiltinPreset = useMemo( + () => + BUILTIN_PRESETS.find((preset) => preset.name === activePreset) ?? null, + [activePreset], + ); + const activePresetDirty = useMemo( + () => + activePresetDefinition == null + ? false + : !isSamePresetConfig(activePresetDefinition.params, params), + [activePresetDefinition, params], ); - const isBuiltinPreset = BUILTIN_PRESETS.some((p) => p.name === activePreset); const presetSaveState = useMemo( () => getPresetSaveState({ rawName: presetNameInput, activePreset, - customPresets, + presets, + activePresetDirty, }), - [activePreset, customPresets, presetNameInput], + [activePreset, activePresetDirty, presetNameInput, presets], ); - const selectedSystemPrompt = useMemo( - () => - systemPrompts.find((item) => item.id === selectedSystemPromptId) ?? null, - [selectedSystemPromptId, systemPrompts], - ); - const systemPromptTemplateDirty = - selectedSystemPrompt != null && - (selectedSystemPrompt.name !== systemPromptNameDraft || - selectedSystemPrompt.content !== systemPromptContentDraft); + const systemPromptEditorDirty = systemPromptDraft !== params.systemPrompt; function set(key: K) { return (v: InferenceParams[K]) => onParamsChange({ ...params, [key]: v }); @@ -503,9 +580,7 @@ export function ChatSettingsPanel({ if (p) { onParamsChange({ ...p.params, - systemPrompt: params.systemPrompt, checkpoint: params.checkpoint, - trustRemoteCode: params.trustRemoteCode, }); setActivePreset(name); if (canUseStorage()) { @@ -524,10 +599,6 @@ export function ChatSettingsPanel({ toast.error("Enter a preset name"); return; } - if (BUILTIN_PRESETS.some((preset) => preset.name === trimmed)) { - toast.error(`"${trimmed}" is reserved. Pick a different name.`); - return; - } setCustomPresets((prev) => { const next = prev.filter((p) => p.name !== trimmed); const merged = [...next, { name: trimmed, params: { ...params } }]; @@ -552,7 +623,10 @@ export function ChatSettingsPanel({ } function deletePreset(name: string) { - if (BUILTIN_PRESETS.some((p) => p.name === name)) { + const hasCustomPreset = customPresets.some( + (preset) => preset.name === name, + ); + if (!hasCustomPreset) { return; } setCustomPresets((prev) => { @@ -567,114 +641,30 @@ export function ChatSettingsPanel({ return next; }); if (activePreset === name) { - setActivePreset("Default"); - if (canUseStorage()) { - try { - localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default"); - } catch { - // ignore + const builtinPreset = BUILTIN_PRESETS.find( + (preset) => preset.name === name, + ); + if (!builtinPreset) { + setActivePreset("Default"); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default"); + } catch { + // ignore + } } } } } - function openSystemPromptManager() { - const available = - systemPrompts.length > 0 - ? systemPrompts - : [ - { - id: createSystemPromptId(), - name: "Prompt 1", - content: params.systemPrompt, - updatedAt: Date.now(), - }, - ]; - if (systemPrompts.length === 0) { - setSystemPrompts(available); - saveSystemPrompts(available); - } - const selected = - available.find((item) => item.id === selectedSystemPromptId) ?? available[0]; - setSelectedSystemPromptId(selected.id); - setSystemPromptNameDraft(selected.name); - setSystemPromptContentDraft(selected.content); - setSystemPromptManagerOpen(true); + function openSystemPromptEditor() { + setSystemPromptDraft(params.systemPrompt); + setSystemPromptEditorOpen(true); } - function saveCurrentSystemPromptTemplate() { - if (!selectedSystemPromptId) return; - const now = Date.now(); - const normalizedName = systemPromptNameDraft.trim() || "Untitled prompt"; - const next = systemPrompts.map((item) => - item.id === selectedSystemPromptId - ? { - ...item, - name: normalizedName, - content: systemPromptContentDraft, - updatedAt: now, - } - : item, - ); - setSystemPrompts(next); - saveSystemPrompts(next); - setSystemPromptNameDraft(normalizedName); - } - - function createSystemPromptTemplate() { - const created: SystemPromptTemplate = { - id: createSystemPromptId(), - name: "Untitled prompt", - content: "", - updatedAt: Date.now(), - }; - const next = [created, ...systemPrompts]; - setSystemPrompts(next); - saveSystemPrompts(next); - setSelectedSystemPromptId(created.id); - setSystemPromptNameDraft(created.name); - setSystemPromptContentDraft(created.content); - } - - function duplicateSystemPromptTemplate() { - const duplicate: SystemPromptTemplate = { - id: createSystemPromptId(), - name: `${(systemPromptNameDraft.trim() || "Prompt")} copy`, - content: systemPromptContentDraft, - updatedAt: Date.now(), - }; - const next = [duplicate, ...systemPrompts]; - setSystemPrompts(next); - saveSystemPrompts(next); - setSelectedSystemPromptId(duplicate.id); - setSystemPromptNameDraft(duplicate.name); - setSystemPromptContentDraft(duplicate.content); - } - - function deleteCurrentSystemPromptTemplate() { - if (!selectedSystemPromptId) return; - const remaining = systemPrompts.filter((item) => item.id !== selectedSystemPromptId); - const next = - remaining.length > 0 - ? remaining - : [ - { - id: createSystemPromptId(), - name: "Prompt 1", - content: params.systemPrompt, - updatedAt: Date.now(), - }, - ]; - setSystemPrompts(next); - saveSystemPrompts(next); - const selected = next[0]; - setSelectedSystemPromptId(selected.id); - setSystemPromptNameDraft(selected.name); - setSystemPromptContentDraft(selected.content); - } - - function applySystemPromptTemplate() { - set("systemPrompt")(systemPromptContentDraft); + function saveSystemPromptEditor() { + set("systemPrompt")(systemPromptDraft); + setSystemPromptEditorOpen(false); } useEffect(() => { @@ -693,9 +683,15 @@ export function ChatSettingsPanel({ setPresetNameInput(activePreset); }, [activePreset]); + useEffect(() => { + if (!open) { + setSystemPromptEditorOpen(false); + } + }, [open]); + useLayoutEffect(() => { const el = presetControlRowRef.current; - if (!el) return; + if (!el || !open) return; const measure = () => { setPresetMenuWidthPx(el.getBoundingClientRect().width); }; @@ -800,8 +796,6 @@ export function ChatSettingsPanel({ "h-8 w-full text-xs", presetSaveState.isSaveReady && "bg-primary/92 text-primary-foreground hover:bg-primary", - presetSaveState.mode === "reserved" && - "border-border bg-input/20 text-muted-foreground hover:bg-input/30 hover:text-foreground", )} title={presetSaveState.title} aria-label={presetSaveState.title} @@ -814,14 +808,16 @@ export function ChatSettingsPanel({