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:
parent
2b330e2f24
commit
01d7dce3f4
3 changed files with 262 additions and 17 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue