diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index d99a7a0de6..15ac416b1f 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -442,10 +442,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { Boolean(message), ); - if (params.systemPrompt.trim()) { + const safeSystemPrompt = + typeof params.systemPrompt === "string" ? params.systemPrompt : ""; + if (safeSystemPrompt.trim()) { outboundMessages.unshift({ role: "system", - content: params.systemPrompt.trim(), + content: safeSystemPrompt.trim(), }); } const imageBase64 = findLatestUserImageBase64(messages); diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 5a25a1536e..45c9d17888 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -10,6 +10,16 @@ import { } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { ArrowDown01Icon, CodeIcon, @@ -30,7 +40,7 @@ import { } from "@/components/ui/sheet"; import { useIsMobile } from "@/hooks/use-mobile"; import type { ReactNode } from "react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { DEFAULT_INFERENCE_PARAMS, type InferenceParams, @@ -72,6 +82,52 @@ const BUILTIN_PRESETS: Preset[] = [ }, ]; +const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets"; +const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset"; + +function canUseStorage(): boolean { + return typeof window !== "undefined"; +} + +function loadSavedCustomPresets(): Preset[] { + if (!canUseStorage()) return []; + try { + const raw = localStorage.getItem(CHAT_PRESETS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item): item is Preset => { + if (!item || typeof item !== "object") return false; + const maybe = item as Partial; + return typeof maybe.name === "string" && !!maybe.params; + }) + .map((preset) => ({ + name: preset.name.trim(), + params: { + ...defaultInferenceParams, + ...preset.params, + }, + })) + .filter( + (preset) => + preset.name.length > 0 && + !BUILTIN_PRESETS.some((builtin) => builtin.name === preset.name), + ); + } catch { + return []; + } +} + +function loadSavedActivePreset(): string { + if (!canUseStorage()) return "Default"; + try { + return localStorage.getItem(CHAT_ACTIVE_PRESET_KEY) ?? "Default"; + } catch { + return "Default"; + } +} + function ParamSlider({ label, value, @@ -181,8 +237,16 @@ export function ChatSettingsPanel({ const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype); const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype); - const [presets, setPresets] = useState(BUILTIN_PRESETS); - const [activePreset, setActivePreset] = useState("Default"); + const [customPresets, setCustomPresets] = useState(() => + loadSavedCustomPresets(), + ); + const [activePreset, setActivePreset] = useState(() => loadSavedActivePreset()); + const [savePresetOpen, setSavePresetOpen] = useState(false); + const [presetNameDraft, setPresetNameDraft] = useState(""); + const presets = useMemo( + () => [...BUILTIN_PRESETS, ...customPresets], + [customPresets], + ); const isBuiltinPreset = BUILTIN_PRESETS.some((p) => p.name === activePreset); function set(key: K) { @@ -199,32 +263,93 @@ export function ChatSettingsPanel({ trustRemoteCode: params.trustRemoteCode, }); setActivePreset(name); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, name); + } catch { + // ignore + } + } } } - function savePreset() { - const name = prompt("Preset name:"); - if (!name?.trim()) { + function openSavePresetDialog() { + setPresetNameDraft(activePreset === "Default" ? "" : activePreset); + setSavePresetOpen(true); + } + + function savePresetWithName(rawName: string) { + const trimmed = rawName.trim(); + if (!trimmed) { return; } - const trimmed = name.trim(); - setPresets((prev) => [ - ...prev.filter((p) => p.name !== trimmed), - { name: trimmed, params: { ...params } }, - ]); + if (BUILTIN_PRESETS.some((preset) => preset.name === trimmed)) { + return; + } + setCustomPresets((prev) => { + const next = [ + ...prev.filter((preset) => preset.name !== trimmed), + { name: trimmed, params: { ...params } }, + ]; + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(next)); + } catch { + // ignore + } + } + return next; + }); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, trimmed); + } catch { + // ignore + } + } setActivePreset(trimmed); + setSavePresetOpen(false); } function deletePreset(name: string) { if (BUILTIN_PRESETS.some((p) => p.name === name)) { return; } - setPresets((prev) => prev.filter((p) => p.name !== name)); + setCustomPresets((prev) => { + const next = prev.filter((preset) => preset.name !== name); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(next)); + } catch { + // ignore + } + } + return next; + }); if (activePreset === name) { setActivePreset("Default"); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default"); + } catch { + // ignore + } + } } } + useEffect(() => { + if (presets.some((preset) => preset.name === activePreset)) return; + setActivePreset("Default"); + if (canUseStorage()) { + try { + localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, "Default"); + } catch { + // ignore + } + } + }, [activePreset, presets]); + const settingsContent = ( <>
@@ -255,7 +380,7 @@ export function ChatSettingsPanel({
+ { + setSavePresetOpen(nextOpen); + if (!nextOpen) { + setPresetNameDraft(""); + } + }} + > + + + Save Preset + + Enter a name for this inference preset. + + +
{ + event.preventDefault(); + savePresetWithName(presetNameDraft); + }} + className="space-y-4" + > + setPresetNameDraft(event.target.value)} + placeholder="Preset name" + maxLength={80} + /> + + + + +
+
+
); diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index fea5442187..920737a279 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { create } from "zustand"; +import { toast } from "sonner"; import { DEFAULT_INFERENCE_PARAMS, type ChatLoraSummary, @@ -13,6 +14,8 @@ const AUTO_TITLE_KEY = "unsloth_chat_auto_title"; const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls"; const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message"; const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout"; +const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params"; +let hasShownInferencePersistenceWarning = false; function canUseStorage(): boolean { return typeof window !== "undefined"; @@ -59,6 +62,65 @@ function saveInt(key: string, value: number): void { } } +function asFiniteNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function asString(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; +} + +function asBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function loadInferenceParams(): InferenceParams { + if (!canUseStorage()) return DEFAULT_INFERENCE_PARAMS; + try { + const raw = localStorage.getItem(INFERENCE_PARAMS_KEY); + if (!raw) return DEFAULT_INFERENCE_PARAMS; + const parsed = JSON.parse(raw) as Partial; + return { + temperature: asFiniteNumber(parsed.temperature, DEFAULT_INFERENCE_PARAMS.temperature), + topP: asFiniteNumber(parsed.topP, DEFAULT_INFERENCE_PARAMS.topP), + topK: asFiniteNumber(parsed.topK, DEFAULT_INFERENCE_PARAMS.topK), + minP: asFiniteNumber(parsed.minP, DEFAULT_INFERENCE_PARAMS.minP), + repetitionPenalty: asFiniteNumber( + parsed.repetitionPenalty, + DEFAULT_INFERENCE_PARAMS.repetitionPenalty, + ), + presencePenalty: asFiniteNumber( + parsed.presencePenalty, + DEFAULT_INFERENCE_PARAMS.presencePenalty, + ), + maxSeqLength: asFiniteNumber( + parsed.maxSeqLength, + DEFAULT_INFERENCE_PARAMS.maxSeqLength, + ), + maxTokens: asFiniteNumber(parsed.maxTokens, DEFAULT_INFERENCE_PARAMS.maxTokens), + systemPrompt: asString(parsed.systemPrompt, DEFAULT_INFERENCE_PARAMS.systemPrompt), + checkpoint: DEFAULT_INFERENCE_PARAMS.checkpoint, + trustRemoteCode: asBoolean( + parsed.trustRemoteCode, + DEFAULT_INFERENCE_PARAMS.trustRemoteCode ?? false, + ), + }; + } catch { + return DEFAULT_INFERENCE_PARAMS; + } +} + +function saveInferenceParams(params: InferenceParams): boolean { + if (!canUseStorage()) return false; + try { + const { checkpoint: _, ...rest } = params; + localStorage.setItem(INFERENCE_PARAMS_KEY, JSON.stringify(rest)); + return true; + } catch { + return false; + } +} + type ChatRuntimeStore = { params: InferenceParams; models: ChatModelSummary[]; @@ -117,7 +179,7 @@ type ChatRuntimeStore = { }; export const useChatRuntimeStore = create((set) => ({ - params: DEFAULT_INFERENCE_PARAMS, + params: loadInferenceParams(), models: [], loras: [], runningByThreadId: {}, @@ -144,7 +206,18 @@ export const useChatRuntimeStore = create((set) => ({ contextUsage: null, modelLoading: false, setModelLoading: (loading) => set({ modelLoading: loading }), - setParams: (params) => set({ params }), + setParams: (params) => + set(() => { + const persisted = saveInferenceParams(params); + if (!persisted && !hasShownInferencePersistenceWarning) { + hasShownInferencePersistenceWarning = true; + toast.warning("Chat settings could not be persisted", { + description: + "Your changes apply now, but may reset after refresh.", + }); + } + return { params }; + }), setModels: (models) => set({ models }), setLoras: (loras) => set({ loras }), setThreadRunning: (threadId, running) =>