From d2122c7b5cfac792615770c3d89c5e530d10b76a Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Thu, 9 Apr 2026 17:11:54 +0100 Subject: [PATCH 1/9] feat(chat): code block styling, delete with Dexie sync, settings sheet polish --- .../components/assistant-ui/markdown-text.tsx | 2 +- .../assistant-ui/model-selector/pickers.tsx | 6 +- .../src/components/assistant-ui/thread.tsx | 40 ++ .../src/features/chat/chat-settings-sheet.tsx | 564 ++++++++++++++---- .../chat/utils/delete-thread-message.ts | 111 ++++ studio/frontend/src/index.css | 10 + 6 files changed, 606 insertions(+), 127 deletions(-) create mode 100644 studio/frontend/src/features/chat/utils/delete-thread-message.ts diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 91ef78fcf9..c92da57702 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-[#181818] dark:supports-[backdrop-filter]:bg-[#181818]"; 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 4db0eda0ad..ef402b814c 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 }> = ({ @@ -622,6 +625,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); @@ -660,6 +698,7 @@ const AssistantActionBar: FC = () => { + @@ -735,6 +774,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..040330b5f8 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -10,6 +10,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; import { Input } from "@/components/ui/input"; import { Select, @@ -18,6 +24,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, @@ -43,7 +55,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 +71,13 @@ export interface Preset { params: InferenceParams; } +interface SystemPromptTemplate { + id: string; + name: string; + content: string; + updatedAt: number; +} + const BUILTIN_PRESETS: Preset[] = [ { name: "Default", params: { ...defaultInferenceParams } }, { @@ -86,6 +106,7 @@ const BUILTIN_PRESETS: Preset[] = [ const CHAT_PRESETS_KEY = "unsloth_chat_custom_presets"; const CHAT_ACTIVE_PRESET_KEY = "unsloth_chat_active_preset"; +const CHAT_SYSTEM_PROMPTS_KEY = "unsloth_chat_system_prompts"; function canUseStorage(): boolean { return typeof window !== "undefined"; @@ -130,6 +151,51 @@ function loadSavedActivePreset(): string { } } +function createSystemPromptId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `prompt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function loadSavedSystemPrompts(): SystemPromptTemplate[] { + if (!canUseStorage()) return []; + try { + const raw = localStorage.getItem(CHAT_SYSTEM_PROMPTS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter((item): item is SystemPromptTemplate => { + if (!item || typeof item !== "object") return false; + const maybe = item as Partial; + return ( + typeof maybe.id === "string" && + typeof maybe.name === "string" && + typeof maybe.content === "string" + ); + }) + .map((item) => ({ + id: item.id.trim(), + name: item.name.trim() || "Untitled prompt", + content: item.content, + updatedAt: typeof item.updatedAt === "number" ? item.updatedAt : Date.now(), + })) + .filter((item) => item.id.length > 0); + } catch { + return []; + } +} + +function saveSystemPrompts(templates: SystemPromptTemplate[]) { + if (!canUseStorage()) return; + try { + localStorage.setItem(CHAT_SYSTEM_PROMPTS_KEY, JSON.stringify(templates)); + } catch { + // ignore + } +} + function ParamSlider({ label, value, @@ -316,8 +382,20 @@ export function ChatSettingsPanel({ const [activePreset, setActivePreset] = useState(() => loadSavedActivePreset(), ); - const [savePresetOpen, setSavePresetOpen] = useState(false); - const [presetNameDraft, setPresetNameDraft] = useState(""); + const [presetNameInput, setPresetNameInput] = useState(() => + loadSavedActivePreset(), + ); + const presetControlRowRef = useRef(null); + const [presetMenuWidthPx, setPresetMenuWidthPx] = useState( + undefined, + ); + const [systemPromptManagerOpen, setSystemPromptManagerOpen] = useState(false); + const [systemPrompts, setSystemPrompts] = useState( + () => loadSavedSystemPrompts(), + ); + const [selectedSystemPromptId, setSelectedSystemPromptId] = useState(null); + const [systemPromptNameDraft, setSystemPromptNameDraft] = useState(""); + const [systemPromptContentDraft, setSystemPromptContentDraft] = useState(""); const presets = useMemo( () => [...BUILTIN_PRESETS, ...customPresets], [customPresets], @@ -348,32 +426,38 @@ export function ChatSettingsPanel({ } } - function openSavePresetDialog() { - setPresetNameDraft(activePreset === "Default" ? "" : activePreset); - setSavePresetOpen(true); - } - function savePresetWithName(rawName: string) { const trimmed = rawName.trim(); if (!trimmed) { + toast.error("Enter a preset name"); return; } if (BUILTIN_PRESETS.some((preset) => preset.name === trimmed)) { + toast.error(`"${trimmed}" is reserved. Pick a different name.`); return; } + const isBuiltinActive = BUILTIN_PRESETS.some((p) => p.name === activePreset); setCustomPresets((prev) => { - const next = [ - ...prev.filter((preset) => preset.name !== trimmed), - { name: trimmed, params: { ...params } }, - ]; + const next = prev.filter((p) => { + if (p.name === trimmed) return false; + if ( + !isBuiltinActive && + activePreset !== trimmed && + p.name === activePreset + ) { + return false; + } + return true; + }); + 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,7 +467,7 @@ export function ChatSettingsPanel({ } } setActivePreset(trimmed); - setSavePresetOpen(false); + setPresetNameInput(trimmed); } function deletePreset(name: string) { @@ -413,6 +497,103 @@ export function ChatSettingsPanel({ } } + function openSystemPromptManager() { + const available = + systemPrompts.length > 0 + ? systemPrompts + : [ + { + id: createSystemPromptId(), + name: "Prompt 1", + content: params.systemPrompt, + updatedAt: Date.now(), + }, + ]; + if (systemPrompts.length === 0) { + setSystemPrompts(available); + saveSystemPrompts(available); + } + const selected = + available.find((item) => item.id === selectedSystemPromptId) ?? available[0]; + setSelectedSystemPromptId(selected.id); + setSystemPromptNameDraft(selected.name); + setSystemPromptContentDraft(selected.content); + setSystemPromptManagerOpen(true); + } + + function saveCurrentSystemPromptTemplate() { + if (!selectedSystemPromptId) return; + const now = Date.now(); + const next = systemPrompts.map((item) => + item.id === selectedSystemPromptId + ? { + ...item, + name: systemPromptNameDraft.trim() || "Untitled prompt", + content: systemPromptContentDraft, + updatedAt: now, + } + : item, + ); + setSystemPrompts(next); + saveSystemPrompts(next); + } + + function createSystemPromptTemplate() { + const created: SystemPromptTemplate = { + id: createSystemPromptId(), + name: "Untitled prompt", + content: "", + updatedAt: Date.now(), + }; + const next = [created, ...systemPrompts]; + setSystemPrompts(next); + saveSystemPrompts(next); + setSelectedSystemPromptId(created.id); + setSystemPromptNameDraft(created.name); + setSystemPromptContentDraft(created.content); + } + + function duplicateSystemPromptTemplate() { + const duplicate: SystemPromptTemplate = { + id: createSystemPromptId(), + name: `${(systemPromptNameDraft.trim() || "Prompt")} copy`, + content: systemPromptContentDraft, + updatedAt: Date.now(), + }; + const next = [duplicate, ...systemPrompts]; + setSystemPrompts(next); + saveSystemPrompts(next); + setSelectedSystemPromptId(duplicate.id); + setSystemPromptNameDraft(duplicate.name); + setSystemPromptContentDraft(duplicate.content); + } + + function deleteCurrentSystemPromptTemplate() { + if (!selectedSystemPromptId) return; + const remaining = systemPrompts.filter((item) => item.id !== selectedSystemPromptId); + const next = + remaining.length > 0 + ? remaining + : [ + { + id: createSystemPromptId(), + name: "Prompt 1", + content: params.systemPrompt, + updatedAt: Date.now(), + }, + ]; + setSystemPrompts(next); + saveSystemPrompts(next); + const selected = next[0]; + setSelectedSystemPromptId(selected.id); + setSystemPromptNameDraft(selected.name); + setSystemPromptContentDraft(selected.content); + } + + function applySystemPromptTemplate() { + set("systemPrompt")(systemPromptContentDraft); + } + useEffect(() => { if (presets.some((preset) => preset.name === activePreset)) return; setActivePreset("Default"); @@ -425,6 +606,22 @@ export function ChatSettingsPanel({ } }, [activePreset, presets]); + useEffect(() => { + setPresetNameInput(activePreset); + }, [activePreset]); + + useLayoutEffect(() => { + const el = presetControlRowRef.current; + if (!el) return; + const measure = () => { + setPresetMenuWidthPx(el.getBoundingClientRect().width); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [open]); + const settingsContent = ( <>
@@ -440,52 +637,115 @@ 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") { + e.preventDefault(); + savePresetWithName(presetNameInput); + } + }} + placeholder="Preset name" + maxLength={80} + autoComplete="off" + className="h-full min-h-8 min-w-0 self-stretch pl-3 pr-2 text-xs" + aria-label="Inference preset name" + /> + + + + + + + + + + {presets.map((p) => ( + applyPreset(p.name)} + > + {p.name} + + ))} + + +
+
+ + +
- +
+ + +