diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 91ef78fcf9..c7974db365 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -41,7 +41,7 @@ const COPY_RESET_MS = 2000; const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i; const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/; const ACTION_PANEL_CLASS = - "pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"; + "pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur dark:border-white/10 dark:bg-code-block dark:supports-[backdrop-filter]:bg-code-block"; const ACTION_BUTTON_CLASS = "cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"; diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 74ca2542d4..87d59dfaa3 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -841,7 +841,7 @@ export function HubModelPicker({ (cachedGguf.length > 0 || (!chatOnly && cachedModels.length > 0)) ? ( <> - {"\uD83E\uDDA5"} Downloaded + Downloaded {cachedGguf.map((c) => (
- {"\uD83E\uDDA5"} Recommended + Recommended {visibleRecommendedIds.length === 0 ? (
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} + + ))} + + +
+
+ + +
- +
+ + +