No default models.
@@ -1128,7 +1128,7 @@ export function HubModelPicker({
{showHfSection && filteredRecommendedIds.length > 0 ? (
<>
-
{"\uD83E\uDDA5"} Recommended
+
Recommended
{filteredRecommendedIds.map((id) => {
const vram = recommendedVramMap.get(id);
return (
diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 0d99654b89..8f41987fbf 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -56,9 +56,12 @@ import {
RefreshCwIcon,
SquareIcon,
TerminalIcon,
+ Trash2Icon,
XIcon,
} from "lucide-react";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { deleteThreadMessage } from "@/features/chat/utils/delete-thread-message";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
@@ -635,6 +638,41 @@ const AssistantMessage: FC = () => {
const COPY_RESET_MS = 2000;
+const DeleteMessageButton: FC = () => {
+ const aui = useAui();
+ const messageId = useAuiState(({ message }) => message.id);
+ const isRunning = useAuiState(({ thread }) => thread.isRunning);
+
+ const handleDelete = async () => {
+ const remoteId = aui.threadListItem().getState().remoteId;
+ const thread = aui.thread();
+ try {
+ await deleteThreadMessage({
+ thread: {
+ export: () => thread.export(),
+ import: (data) => thread.import(data),
+ },
+ messageId,
+ remoteId,
+ });
+ } catch (error) {
+ console.error("Failed to delete message", error);
+ toast.error("Failed to delete message");
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
const CopyButton: FC = () => {
const aui = useAui();
const [copied, setCopied] = useState(false);
@@ -673,6 +711,7 @@ const AssistantActionBar: FC = () => {
+
@@ -748,6 +787,7 @@ const UserActionBar: FC = () => {
+
);
};
diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
index 550df2bf7c..823a99b697 100644
--- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx
+++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx
@@ -10,7 +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 {
Select,
SelectContent,
@@ -29,6 +41,7 @@ import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useIsMobile } from "@/hooks/use-mobile";
+import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
CodeIcon,
@@ -43,7 +56,8 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
import type { ReactNode } from "react";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
DEFAULT_INFERENCE_PARAMS,
@@ -58,6 +72,11 @@ export interface Preset {
params: InferenceParams;
}
+interface LegacySystemPromptTemplate {
+ name: string;
+ content: string;
+}
+
const BUILTIN_PRESETS: Preset[] = [
{ name: "Default", params: { ...defaultInferenceParams } },
{
@@ -86,19 +105,134 @@ const BUILTIN_PRESETS: Preset[] = [
const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets";
const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset";
+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;
@@ -111,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([]);
}
}
@@ -130,6 +261,82 @@ function loadSavedActivePreset(): string {
}
}
+type PresetSaveMode =
+ | "disabled"
+ | "overwrite-active"
+ | "overwrite-other"
+ | "create";
+
+interface PresetSaveState {
+ mode: PresetSaveMode;
+ canSubmit: boolean;
+ isSaveReady: boolean;
+ buttonLabel: string;
+ 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,
+ presets,
+ activePresetDirty,
+}: {
+ rawName: string;
+ activePreset: string;
+ presets: Preset[];
+ activePresetDirty: boolean;
+}): PresetSaveState {
+ const trimmedName = rawName.trim();
+ if (!trimmedName) {
+ return {
+ mode: "disabled",
+ canSubmit: false,
+ isSaveReady: false,
+ buttonLabel: "Save",
+ title: "Enter a preset name",
+ };
+ }
+
+ const matchingPreset = presets.find((preset) => preset.name === trimmedName);
+ if (matchingPreset) {
+ const isActiveMatch = matchingPreset.name === activePreset;
+ return {
+ 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"
+ : "No unsaved changes"
+ : `Overwrite preset "${trimmedName}"`,
+ };
+ }
+
+ return {
+ mode: "create",
+ canSubmit: true,
+ isSaveReady: true,
+ buttonLabel: "Save as New",
+ title: `Save current settings as "${trimmedName}"`,
+ };
+}
+
function ParamSlider({
label,
value,
@@ -316,13 +523,53 @@ export function ChatSettingsPanel({
const [activePreset, setActivePreset] = useState(() =>
loadSavedActivePreset(),
);
- const [savePresetOpen, setSavePresetOpen] = useState(false);
- const [presetNameDraft, setPresetNameDraft] = useState("");
- const presets = useMemo(
- () => [...BUILTIN_PRESETS, ...customPresets],
- [customPresets],
+ const [presetNameInput, setPresetNameInput] = useState(() =>
+ loadSavedActivePreset(),
);
- const isBuiltinPreset = BUILTIN_PRESETS.some((p) => p.name === activePreset);
+ const presetControlRowRef = useRef(null);
+ 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 activeCustomPreset = useMemo(
+ () => customPresets.find((preset) => preset.name === activePreset) ?? null,
+ [activePreset, 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 presetSaveState = useMemo(
+ () =>
+ getPresetSaveState({
+ rawName: presetNameInput,
+ activePreset,
+ presets,
+ activePresetDirty,
+ }),
+ [activePreset, activePresetDirty, presetNameInput, presets],
+ );
+ const systemPromptEditorDirty = systemPromptDraft !== params.systemPrompt;
function set(key: K) {
return (v: InferenceParams[K]) => onParamsChange({ ...params, [key]: v });
@@ -333,9 +580,7 @@ export function ChatSettingsPanel({
if (p) {
onParamsChange({
...p.params,
- systemPrompt: params.systemPrompt,
checkpoint: params.checkpoint,
- trustRemoteCode: params.trustRemoteCode,
});
setActivePreset(name);
if (canUseStorage()) {
@@ -348,32 +593,23 @@ export function ChatSettingsPanel({
}
}
- function openSavePresetDialog() {
- setPresetNameDraft(activePreset === "Default" ? "" : activePreset);
- setSavePresetOpen(true);
- }
-
function savePresetWithName(rawName: string) {
const trimmed = rawName.trim();
if (!trimmed) {
- return;
- }
- if (BUILTIN_PRESETS.some((preset) => preset.name === trimmed)) {
+ toast.error("Enter a preset name");
return;
}
setCustomPresets((prev) => {
- const next = [
- ...prev.filter((preset) => preset.name !== trimmed),
- { name: trimmed, params: { ...params } },
- ];
+ const next = prev.filter((p) => p.name !== trimmed);
+ const merged = [...next, { name: trimmed, params: { ...params } }];
if (canUseStorage()) {
try {
- localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(next));
+ localStorage.setItem(CHAT_PRESETS_KEY, JSON.stringify(merged));
} catch {
// ignore
}
}
- return next;
+ return merged;
});
if (canUseStorage()) {
try {
@@ -383,11 +619,14 @@ export function ChatSettingsPanel({
}
}
setActivePreset(trimmed);
- setSavePresetOpen(false);
+ setPresetNameInput(trimmed);
}
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) => {
@@ -402,17 +641,39 @@ 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,
+ );
+ const fallbackPreset =
+ builtinPreset ??
+ BUILTIN_PRESETS.find((preset) => preset.name === "Default");
+ if (fallbackPreset) {
+ onParamsChange({
+ ...fallbackPreset.params,
+ checkpoint: params.checkpoint,
+ });
+ setActivePreset(fallbackPreset.name);
+ if (canUseStorage()) {
+ try {
+ localStorage.setItem(CHAT_ACTIVE_PRESET_KEY, fallbackPreset.name);
+ } catch {
+ // ignore
+ }
}
}
}
}
+ function openSystemPromptEditor() {
+ setSystemPromptDraft(params.systemPrompt);
+ setSystemPromptEditorOpen(true);
+ }
+
+ function saveSystemPromptEditor() {
+ set("systemPrompt")(systemPromptDraft);
+ setSystemPromptEditorOpen(false);
+ }
+
useEffect(() => {
if (presets.some((preset) => preset.name === activePreset)) return;
setActivePreset("Default");
@@ -425,6 +686,28 @@ export function ChatSettingsPanel({
}
}, [activePreset, presets]);
+ useEffect(() => {
+ setPresetNameInput(activePreset);
+ }, [activePreset]);
+
+ useEffect(() => {
+ if (!open) {
+ setSystemPromptEditorOpen(false);
+ }
+ }, [open]);
+
+ useLayoutEffect(() => {
+ const el = presetControlRowRef.current;
+ if (!el || !open) return;
+ const measure = () => {
+ setPresetMenuWidthPx(el.getBoundingClientRect().width);
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [open]);
+
const settingsContent = (
<>
@@ -440,52 +723,138 @@ export function ChatSettingsPanel({
{/* mt-4 matches the Playground sidebar gap (SidebarHeader py-3 + SidebarGroup pt-1) */}
-
-
-
-
+
+
+
+
+ setPresetNameInput(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && presetSaveState.canSubmit) {
+ e.preventDefault();
+ savePresetWithName(presetNameInput);
+ }
+ }}
+ placeholder="Preset name"
+ maxLength={80}
+ autoComplete="off"
+ className={cn(
+ "!h-8 min-h-0 min-w-0 self-stretch !pl-2.5 !pr-2 pt-1 pb-1 text-sm leading-10 md:text-sm",
+ presetSaveState.isSaveReady &&
+ "text-foreground placeholder:text-primary/45",
+ )}
+ aria-label="Inference preset name"
+ />
+
+
+
+
+
+
+
+
+
+ {presets.map((p) => (
+ applyPreset(p.name)}
+ >
+ {p.name}
+
+ ))}
+
+
+
+
+
+
+
-
+
+
+
+
-
+
KV Cache Dtype
Quantize KV cache to reduce VRAM.
-
+
+
+
{!currentModelIsVision && (
-
+
Speculative Decoding
@@ -600,20 +973,22 @@ export function ChatSettingsPanel({
Speed up generation with no VRAM cost.
-
+
+
+
)}
{modelSettingsDirty && (
@@ -774,51 +1149,57 @@ export function ChatSettingsPanel({
>
@@ -963,7 +1344,7 @@ function ChatTemplateSection({