studio: persist system prompt and preset settings across navigation (#4538)

* fix(studio): harden system prompt persistence and storage fallback

* Exclude checkpoint from localStorage persistence for PR #4538

checkpoint is backend-owned state -- refresh() already syncs it from
getInferenceStatus() on every page load. Persisting it to localStorage
causes a stale model ID to survive across backend restarts, which
prevents auto-load from triggering when no model is actually loaded.

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
Lee Jackson 2026-03-24 05:21:04 +00:00 committed by GitHub
commit 01d7dce3f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 262 additions and 17 deletions

View file

@ -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);

View file

@ -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<Preset>;
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<Preset[]>(BUILTIN_PRESETS);
const [activePreset, setActivePreset] = useState("Default");
const [customPresets, setCustomPresets] = useState<Preset[]>(() =>
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<K extends keyof InferenceParams>(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 = (
<>
<div className="flex items-center gap-2 px-4 py-3">
@ -255,7 +380,7 @@ export function ChatSettingsPanel({
</Select>
<button
type="button"
onClick={savePreset}
onClick={openSavePresetDialog}
className="flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
title="Save preset"
>
@ -442,6 +567,51 @@ export function ChatSettingsPanel({
<ChatTemplateSection onReloadModel={onReloadModel} />
</div>
<Dialog
open={savePresetOpen}
onOpenChange={(nextOpen) => {
setSavePresetOpen(nextOpen);
if (!nextOpen) {
setPresetNameDraft("");
}
}}
>
<DialogContent className="corner-squircle sm:max-w-sm">
<DialogHeader>
<DialogTitle>Save Preset</DialogTitle>
<DialogDescription>
Enter a name for this inference preset.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault();
savePresetWithName(presetNameDraft);
}}
className="space-y-4"
>
<Input
autoFocus={true}
value={presetNameDraft}
onChange={(event) => setPresetNameDraft(event.target.value)}
placeholder="Preset name"
maxLength={80}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setSavePresetOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={presetNameDraft.trim().length === 0}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);

View file

@ -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<InferenceParams>;
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<ChatRuntimeStore>((set) => ({
params: DEFAULT_INFERENCE_PARAMS,
params: loadInferenceParams(),
models: [],
loras: [],
runningByThreadId: {},
@ -144,7 +206,18 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((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) =>