Compare commits

...
Sign in to create a new pull request.

14 commits

Author SHA1 Message Date
Daniel Han
b0b6c4daf9 Trim whitespace in JSONL export content
Ensures whitespace-only message content is treated as empty and skipped
in JSONL exports, preventing invalid SFT training samples.
2026-03-31 14:24:52 +00:00
Daniel Han
b09e3052e0 Improve export quality and clean up prompt library
- JSONL export: skip messages with empty text content to avoid invalid
  SFT training samples with blank assistant turns
- Prompt library: remove variable detection UI and description since
  substitution was removed; templates are copied as-is
- Memory injection: use createdAt ordering so memories appear in the
  order the user created them
- Clipboard copy: check return value and show error toast on failure
2026-03-31 13:52:40 +00:00
Daniel Han
392c59bdc9 Fix dead code, isEditing guard, and empty state condition
- Remove unused hashCode function from markdown-text.tsx (leftover from
  removed artifacts feature)
- Include editing state in isEditing check so Cancel button stays visible
  when user clears name/content fields while editing an existing prompt
- Fix empty state message to only show when no items exist in any section
  (pinned, folders, or unfiled), not just when filteredItems is empty
2026-03-31 13:32:03 +00:00
Daniel Han
24507dc185 Merge remote-tracking branch 'origin/main' into feat/chat-ui-enhancements 2026-03-31 13:24:40 +00:00
Daniel Han
940faeaa73 Remove problematic features: wake lock, artifacts panel, keyboard shortcuts, variable substitution
- Remove screen wake lock from chat-adapter.ts (marginal value, doesn't
  survive tab switches)
- Remove artifacts panel, artifact store, and ArtifactEmitter (session-scoped
  only, hash collision issues, duplicates existing code block actions)
- Remove keyboard shortcuts except Cmd/Ctrl+K for command palette (other
  shortcuts conflict with browser-native bindings on Linux/Windows)
- Remove keyboard-shortcut-help.tsx (no longer needed)
- Simplify prompt library to copy raw template content instead of using
  window.prompt() for variable substitution (blocking dialog, broken in
  some embedded contexts)
- Remove shortcut labels from command palette items
- Clean up unused imports

Kept: search, feedback, export, command palette (Cmd+K), memory, prompt
library (without variable substitution), folders/pinning.
2026-03-31 13:14:27 +00:00
Daniel Han
1bfcfc9745 Skip artifact clear when re-selecting the same thread
Only clear artifact store when navigating to a different thread,
not when clicking the already-active thread in the sidebar.
2026-03-31 12:15:01 +00:00
Daniel Han
a30c7789da Fix review cycle 3 findings and lint cleanup
- thread-sidebar: fix Collapsible chevron CSS selector to use descendant
  combinator instead of direct child (folder chevron now rotates correctly)
- prompt-library-sheet: use split/join instead of replaceAll to avoid
  JavaScript replacement string $-specials in variable substitution
- prompt-library-sheet: fix tooltip to say "Copy to clipboard" (matches
  actual behavior), remove unused handleStartCreate callback
- thread-sidebar: remove unused FolderRecord import
- markdown-text: remove unused useCallback import
2026-03-31 12:03:23 +00:00
Daniel Han
d26c56a43b Fix review cycle 2 findings
- thread-sidebar: use callback-based modify+delete in handleDeleteFolder
  to properly clear folderId (Dexie ignores undefined in object-form modify)
- chat-page: clear artifact store on compare enter/exit/new transitions
- artifact-panel: remove redundant viewedContent from useEffect deps to
  prevent unnecessary re-syncs during store updates
- db.ts: use toArray()+sort() instead of sortBy() inside upgrade transaction
  for better Dexie compatibility
- thread-export: prefer modelType over modelId for compare-export filename
  suffix to differentiate LoRA compare pairs
2026-03-31 11:46:54 +00:00
Daniel Han
007ffd2569 Fix review findings for chat UI enhancements
- artifact-panel: add initial value to useRef (fixes TS2554), sync editor
  on version navigation, use localValue for copy/download, cleanup timer
- thread.tsx: use Dexie modify+delete to properly clear feedback field
- thread-sidebar: use .filter()/.some() for compare-thread search, use
  Dexie modify+delete to properly unfile threads from folders
- thread-export: add model suffix to compare-export filenames, support
  Unicode in sanitizeFilename
- db.ts: use sequential for-of loop instead of Promise.all in v4
  upgrade to avoid IndexedDB transaction auto-commit
- download.ts: increase revocation delay to 1s, wrap in try/finally
- chat-page: clear artifact store on thread switch
2026-03-31 11:28:04 +00:00
Daniel Han
9d61147d65 Merge main into feat/chat-ui-enhancements and resolve db.ts conflict
Combined the extended type imports (FolderRecord, MemoryRecord, PromptRecord)
from the PR branch with the useRef addition from main.
2026-03-31 11:11:37 +00:00
Daniel Han
ffd269344d fix(chat): only reset artifact editor on tab switch, not content updates
Remove active?.content from the useEffect dependency array so that
store-side content updates (e.g. from streaming) do not overwrite the
user's unsaved in-progress edits in the textarea.
2026-03-25 11:06:42 +00:00
Daniel Han
2ea8217727
Merge branch 'main' into feat/chat-ui-enhancements 2026-03-25 04:00:36 -07:00
Daniel Han
0a1f0c2e78 fix(chat): address review feedback from Gemini, Codex, and internal review
1. ArtifactPanel textarea: use local state with onChange/onBlur instead
   of empty onChange handler that made the editor unwritable

2. Artifact emission: move store.addArtifact from render body into a
   dedicated ArtifactEmitter component using useEffect

3. searchText for new threads: write searchText during generateTitle
   so post-migration threads are searchable by content

4. PromptLibrarySheet: wire onInsertPrompt with clipboard fallback and
   toast notification; handle prompt() cancel (null) as abort

5. download.ts: defer URL.revokeObjectURL with setTimeout to avoid
   failed downloads on Firefox

6. DB migration: replace serial for-of await loop with Promise.all to
   prevent transaction timeout on large databases; skip empty text

7. handleDeleteFolder: use Dexie transaction with .modify() for atomic
   unfile-then-delete; use undefined instead of "" for unfiled folderId

8. Keyboard shortcuts: remove Cmd+Shift+F from help dialog since the
   handler is not wired

Also:
- Remove unused aui hook, SearchIcon import, and prompt-library imports
- Fix stale artifactPanelOpen closure in command palette toggle
- Fix JSONL export: remove non-standard feedback field from message
  objects; use application/x-ndjson MIME type
- Fix nested button in ArtifactTab: use div with role="tab"
- Use static db import instead of redundant dynamic import in adapter
- Log memory injection errors instead of silent catch
- Add cancelled flag to feedback useEffect to prevent stale setState
- Use functional setState in handleFeedback to avoid stale closure
- Add .catch() on feedback DB write
2026-03-25 10:47:17 +00:00
Daniel Han
78b8ec8194 feat(chat): add search, feedback, export, shortcuts, memory, prompts, folders, artifacts
Comprehensive set of chat UI enhancements for ML power users.

Search: debounced sidebar search filtering threads by title and first
user message content.

Feedback: thumbs up/down buttons on assistant messages, persisted to
IndexedDB for preference dataset collection (DPO/RLHF).

Export: per-thread Markdown, JSON, and JSONL export from the sidebar
dropdown. JSONL uses OpenAI chat format for direct SFT use.

Keyboard shortcuts: Cmd+K command palette (cmdk), Cmd+Shift+N new chat,
Cmd+Shift+C compare, Cmd+Shift+S settings, ? help dialog.

Session memory: persistent context entries injected into system prompt.
CRUD panel in settings with per-entry toggle and token budget indicator.

Prompt library: reusable prompt templates with {{variable}} substitution,
stored in IndexedDB with tag support.

Folders and pinning: collapsible folder groups in sidebar, pin threads
to top, move-to-folder via dropdown menu.

Artifacts panel: auto-detected code blocks (20+ lines), HTML, and SVG
open in a right-side panel with version history and inline editing.

Screen wake lock: prevents screen sleep during long inference runs.

DB changes: Dexie version 4 adds folders, prompts, memory tables and
searchText/folderId/pinned/feedback fields with migration backfill.
2026-03-25 10:21:50 +00:00
13 changed files with 1124 additions and 46 deletions

View file

@ -373,18 +373,19 @@ function StreamdownBlock(props: BlockProps) {
if (codeFence) {
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
return (
<>
<div className="relative isolate">
<Block {...props} />
<CodeBlockActions
disabled={props.isIncomplete}
language={codeFence.language}
source={codeFence.source}
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
<div className="relative isolate">
<Block {...props} />
<CodeBlockActions
disabled={props.isIncomplete}
language={codeFence.language}
source={codeFence.source}
/>
</div>
{svgSource && <SvgPreview source={svgSource} />}
{htmlSource && <HtmlPreview source={htmlSource} />}
</>
);
}

View file

@ -56,9 +56,12 @@ import {
RefreshCwIcon,
SquareIcon,
TerminalIcon,
ThumbsDownIcon,
ThumbsUpIcon,
XIcon,
} from "lucide-react";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { db } from "@/features/chat/db";
import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store";
export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
@ -615,6 +618,81 @@ const CopyButton: FC = () => {
);
};
const FeedbackButtons: FC = () => {
const messageId = useAuiState(({ message }) => message.id);
const [feedback, setFeedback] = useState<"thumbs_up" | "thumbs_down" | null>(
null,
);
// Load existing feedback from DB
useEffect(() => {
if (!messageId) return;
setFeedback(null);
let cancelled = false;
void db.messages.get(messageId).then((msg) => {
if (!cancelled && msg?.feedback) setFeedback(msg.feedback);
});
return () => { cancelled = true; };
}, [messageId]);
const handleFeedback = useCallback(
(value: "thumbs_up" | "thumbs_down") => {
setFeedback((prev) => {
const next = prev === value ? null : value;
if (messageId) {
if (next) {
void db.messages
.update(messageId, { feedback: next })
.catch((err) => console.error("Failed to save feedback:", err));
} else {
// Dexie ignores undefined values in update(), so use modify+delete
void db.messages
.where("id")
.equals(messageId)
.modify((msg) => { delete msg.feedback; })
.catch((err) => console.error("Failed to clear feedback:", err));
}
}
return next;
});
},
[messageId],
);
return (
<>
<TooltipIconButton
tooltip="Good response"
onClick={() => handleFeedback("thumbs_up")}
className={cn(
feedback === "thumbs_up" && "text-green-600 dark:text-green-400",
)}
>
<ThumbsUpIcon
className={cn(
"size-4",
feedback === "thumbs_up" && "fill-current",
)}
/>
</TooltipIconButton>
<TooltipIconButton
tooltip="Bad response"
onClick={() => handleFeedback("thumbs_down")}
className={cn(
feedback === "thumbs_down" && "text-red-600 dark:text-red-400",
)}
>
<ThumbsDownIcon
className={cn(
"size-4",
feedback === "thumbs_down" && "fill-current",
)}
/>
</TooltipIconButton>
</>
);
};
const AssistantActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
@ -624,6 +702,7 @@ const AssistantActionBar: FC = () => {
className="aui-assistant-action-bar-root col-start-3 row-start-2 -ml-1 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
>
<CopyButton />
<FeedbackButtons />
<ActionBarPrimitive.Reload asChild={true}>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />

View file

@ -454,12 +454,31 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
Boolean(message),
);
const safeSystemPrompt =
typeof params.systemPrompt === "string" ? params.systemPrompt : "";
if (safeSystemPrompt.trim()) {
// Build system prompt with memory injection
let systemContent =
typeof params.systemPrompt === "string" ? params.systemPrompt.trim() : "";
try {
const allMemories = await db.memory.orderBy("createdAt").toArray();
const enabledMemories = allMemories.filter(
(m: { enabled: boolean }) => m.enabled,
);
if (enabledMemories.length > 0) {
const memoryBlock =
"[Memory]\n" +
enabledMemories
.map((m: { content: string }) => `- ${m.content}`)
.join("\n");
systemContent = systemContent
? `${memoryBlock}\n\n${systemContent}`
: memoryBlock;
}
} catch (err) {
console.warn("Memory injection skipped:", err);
}
if (systemContent) {
outboundMessages.unshift({
role: "system",
content: safeSystemPrompt.trim(),
content: systemContent,
});
}
const imageBase64 = findLatestUserImageBase64(messages);

View file

@ -8,6 +8,15 @@ import {
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Sheet,
@ -17,6 +26,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import {
ColumnInsertIcon,
@ -24,6 +34,13 @@ import {
Settings04Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
BookOpenIcon,
BrainIcon,
ColumnsIcon,
PencilIcon,
SettingsIcon,
} from "lucide-react";
import {
type CSSProperties,
type ReactElement,
@ -36,6 +53,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import { PromptLibrarySheet } from "./components/prompt-library-sheet";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
@ -422,6 +440,8 @@ export function ChatPage(): ReactElement {
const [viewBeforeCompare, setViewBeforeCompare] = useState<ChatView | null>(
null,
);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [promptLibraryOpen, setPromptLibraryOpen] = useState(false);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const activeGgufVariant = useChatRuntimeStore((state) => state.activeGgufVariant);
@ -710,9 +730,67 @@ export function ChatPage(): ReactElement {
return () => window.clearTimeout(timeoutId);
}, [modelSelectorLocked, tour.open]);
// Global keyboard shortcut: Cmd/Ctrl+K for command palette
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key === "k") {
e.preventDefault();
setCommandPaletteOpen((o) => !o);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<div className="h-[calc(100dvh-4rem)] bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
<PromptLibrarySheet
open={promptLibraryOpen}
onOpenChange={setPromptLibraryOpen}
onInsertPrompt={(content) => {
if (copyToClipboard(content)) {
toast.success("Prompt copied to clipboard");
} else {
toast.error("Failed to copy prompt");
}
}}
/>
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<Command>
<CommandInput placeholder="Type a command..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => { handleNewThread(); setCommandPaletteOpen(false); }}>
<PencilIcon className="mr-2 size-4" />
New Chat
</CommandItem>
{canCompare && (
<CommandItem onSelect={() => { handleNewCompare(); setCommandPaletteOpen(false); }}>
<ColumnsIcon className="mr-2 size-4" />
Compare Mode
</CommandItem>
)}
<CommandItem onSelect={() => { setSettingsOpen(true); setCommandPaletteOpen(false); }}>
<SettingsIcon className="mr-2 size-4" />
Settings
</CommandItem>
</CommandGroup>
<CommandGroup heading="Actions">
<CommandItem onSelect={() => { setPromptLibraryOpen(true); setCommandPaletteOpen(false); }}>
<BookOpenIcon className="mr-2 size-4" />
Prompt Library
</CommandItem>
<CommandItem onSelect={() => { handleEject(); setCommandPaletteOpen(false); }}>
<BrainIcon className="mr-2 size-4" />
Eject Model
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</CommandDialog>
<SidebarProvider
defaultOpen={true}
open={sidebarOpen}
@ -802,15 +880,17 @@ export function ChatPage(): ReactElement {
</button>
</div>
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? view.newThreadNonce ?? "new"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>
) : (
<CompareContent key={view.pairId} pairId={view.pairId} models={models} loraModels={loraModels} />
)}
<div className="min-h-0 flex-1">
{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? view.newThreadNonce ?? "new"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>
) : (
<CompareContent key={view.pairId} pairId={view.pairId} models={models} loraModels={loraModels} />
)}
</div>
</div>
<ChatSettingsPanel

View file

@ -49,6 +49,7 @@ import {
} from "./types/runtime";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { Switch } from "@/components/ui/switch";
import { MemoryPanel } from "./components/memory-panel";
export const defaultInferenceParams = DEFAULT_INFERENCE_PARAMS;
export type { InferenceParams } from "./types/runtime";
@ -688,6 +689,10 @@ export function ChatSettingsPanel({
</div>
</CollapsibleSection>
<CollapsibleSection icon={UserSettings01Icon} label="Memory" defaultOpen={false}>
<MemoryPanel />
</CollapsibleSection>
<ChatTemplateSection onReloadModel={onReloadModel} />
</div>
<Dialog

View file

@ -0,0 +1,175 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { PlusIcon, Trash2Icon, PencilIcon, CheckIcon, XIcon } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { db, useLiveQuery } from "../db";
import type { MemoryRecord } from "../types";
const MAX_MEMORIES = 20;
export const MemoryPanel: FC = () => {
const memories = useLiveQuery(
() => db.memory.orderBy("createdAt").toArray(),
[],
);
const [adding, setAdding] = useState(false);
const [newContent, setNewContent] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const estimatedTokens = (memories ?? [])
.filter((m) => m.enabled)
.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0);
const handleAdd = useCallback(async () => {
if (!newContent.trim()) return;
await db.memory.add({
id: crypto.randomUUID(),
content: newContent.trim(),
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now(),
});
setNewContent("");
setAdding(false);
}, [newContent]);
const handleToggle = useCallback(async (id: string, enabled: boolean) => {
await db.memory.update(id, { enabled, updatedAt: Date.now() });
}, []);
const handleDelete = useCallback(async (id: string) => {
await db.memory.delete(id);
}, []);
const handleStartEdit = useCallback((m: MemoryRecord) => {
setEditingId(m.id);
setEditContent(m.content);
}, []);
const handleSaveEdit = useCallback(async () => {
if (!editingId || !editContent.trim()) return;
await db.memory.update(editingId, {
content: editContent.trim(),
updatedAt: Date.now(),
});
setEditingId(null);
setEditContent("");
}, [editingId, editContent]);
const items = memories ?? [];
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Persistent context injected into every conversation.
</p>
{estimatedTokens > 0 && (
<span
className={`text-xs tabular-nums ${estimatedTokens > 512 ? "text-amber-500" : "text-muted-foreground"}`}
>
~{estimatedTokens} tokens
</span>
)}
</div>
<div className="space-y-2">
{items.map((m) => (
<div
key={m.id}
className="flex items-start gap-2 rounded-md border p-2"
>
<Switch
checked={m.enabled}
onCheckedChange={(v) => handleToggle(m.id, v)}
className="mt-0.5 scale-75"
/>
<div className="min-w-0 flex-1">
{editingId === m.id ? (
<div className="space-y-1">
<Textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="min-h-[3rem] text-xs"
rows={2}
/>
<div className="flex gap-1">
<Button size="sm" variant="ghost" className="h-6 px-2" onClick={handleSaveEdit}>
<CheckIcon className="size-3" />
</Button>
<Button size="sm" variant="ghost" className="h-6 px-2" onClick={() => setEditingId(null)}>
<XIcon className="size-3" />
</Button>
</div>
</div>
) : (
<p className="text-xs leading-relaxed">{m.content}</p>
)}
</div>
{editingId !== m.id && (
<div className="flex shrink-0 gap-0.5">
<button
type="button"
onClick={() => handleStartEdit(m)}
className="p-1 text-muted-foreground hover:text-foreground"
>
<PencilIcon className="size-3" />
</button>
<button
type="button"
onClick={() => handleDelete(m.id)}
className="p-1 text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-3" />
</button>
</div>
)}
</div>
))}
</div>
{adding ? (
<div className="space-y-2">
<Textarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="e.g., I train on medical data, My GPU is A100 40GB..."
className="min-h-[3rem] text-xs"
rows={2}
autoFocus
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd} disabled={!newContent.trim()}>
Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setAdding(false);
setNewContent("");
}}
>
Cancel
</Button>
</div>
</div>
) : (
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => setAdding(true)}
disabled={items.length >= MAX_MEMORIES}
>
<PlusIcon className="mr-1 size-3" />
Add memory ({items.length}/{MAX_MEMORIES})
</Button>
)}
</div>
);
};

View file

@ -0,0 +1,199 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Trash2Icon, PencilIcon, CopyIcon } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { db, useLiveQuery } from "../db";
import type { PromptRecord } from "../types";
export const PromptLibrarySheet: FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
onInsertPrompt?: (content: string) => void;
}> = ({ open, onOpenChange, onInsertPrompt }) => {
const prompts = useLiveQuery(
() => db.prompts.orderBy("createdAt").reverse().toArray(),
[],
);
const [editing, setEditing] = useState<PromptRecord | null>(null);
const [name, setName] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const handleStartEdit = useCallback((p: PromptRecord) => {
setEditing(p);
setName(p.name);
setContent(p.content);
setTags(p.tags.join(", "));
}, []);
const handleSave = useCallback(async () => {
if (!name.trim() || !content.trim()) return;
const variables: string[] = [];
const tagList = tags
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (editing) {
await db.prompts.update(editing.id, {
name: name.trim(),
content: content.trim(),
variables,
tags: tagList,
});
} else {
await db.prompts.add({
id: crypto.randomUUID(),
name: name.trim(),
content: content.trim(),
variables,
tags: tagList,
createdAt: Date.now(),
});
}
setEditing(null);
setName("");
setContent("");
setTags("");
}, [name, content, tags, editing]);
const handleDelete = useCallback(async (id: string) => {
await db.prompts.delete(id);
}, []);
const handleInsert = useCallback(
(p: PromptRecord) => {
onInsertPrompt?.(p.content);
onOpenChange(false);
},
[onInsertPrompt, onOpenChange],
);
const items = prompts ?? [];
const isEditing = editing !== null || name !== "" || content !== "";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[24rem] overflow-y-auto">
<SheetHeader>
<SheetTitle>Prompt Library</SheetTitle>
<SheetDescription>
Reusable prompt templates. Copied to clipboard on select.
</SheetDescription>
</SheetHeader>
<div className="mt-4 space-y-4">
{/* Editor */}
<div className="space-y-2 rounded-md border p-3">
<Input
placeholder="Prompt name"
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm"
/>
<Textarea
placeholder="e.g., Evaluate the current model on GSM8K..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[6rem] text-sm"
rows={4}
/>
<Input
placeholder="Tags (comma separated)"
value={tags}
onChange={(e) => setTags(e.target.value)}
className="text-sm"
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} disabled={!name.trim() || !content.trim()}>
{editing ? "Update" : "Save"}
</Button>
{isEditing && (
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditing(null);
setName("");
setContent("");
setTags("");
}}
>
Cancel
</Button>
)}
</div>
</div>
{/* List */}
<div className="space-y-2">
{items.map((p) => (
<div
key={p.id}
className="group rounded-md border p-3 transition-colors hover:bg-accent/50"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{p.name}</span>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => handleInsert(p)}
className="p-1 text-muted-foreground hover:text-primary"
title="Copy to clipboard"
>
<CopyIcon className="size-3" />
</button>
<button
type="button"
onClick={() => handleStartEdit(p)}
className="p-1 text-muted-foreground hover:text-foreground"
>
<PencilIcon className="size-3" />
</button>
<button
type="button"
onClick={() => handleDelete(p.id)}
className="p-1 text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-3" />
</button>
</div>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{p.content}
</p>
{p.tags.length > 0 && (
<div className="mt-1 flex gap-1">
{p.tags.map((tag) => (
<span
key={tag}
className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
</div>
))}
{items.length === 0 && !isEditing && (
<p className="py-4 text-center text-xs text-muted-foreground">
No saved prompts yet. Create one above.
</p>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
};

View file

@ -3,11 +3,20 @@
import Dexie, { type EntityTable, liveQuery } from "dexie";
import { useEffect, useRef, useState } from "react";
import type { MessageRecord, ThreadRecord } from "./types";
import type {
FolderRecord,
MemoryRecord,
MessageRecord,
PromptRecord,
ThreadRecord,
} from "./types";
const db = new Dexie("unsloth-chat") as Dexie & {
threads: EntityTable<ThreadRecord, "id">;
messages: EntityTable<MessageRecord, "id">;
folders: EntityTable<FolderRecord, "id">;
prompts: EntityTable<PromptRecord, "id">;
memory: EntityTable<MemoryRecord, "id">;
};
db.version(1).stores({
@ -36,6 +45,43 @@ db.version(3)
}),
);
db.version(4)
.stores({
threads: "id, modelType, pairId, archived, createdAt, folderId, pinned",
messages: "id, threadId, createdAt",
folders: "id, createdAt",
prompts: "id, createdAt",
memory: "id, createdAt",
})
.upgrade(async (tx) => {
// Backfill searchText from first user message in each thread.
// Process sequentially to avoid IndexedDB transaction auto-commit.
const threads = await tx.table("threads").toArray();
for (const thread of threads) {
const msgs = await tx
.table("messages")
.where("threadId")
.equals(thread.id)
.toArray();
msgs.sort((a: MessageRecord, b: MessageRecord) => a.createdAt - b.createdAt);
const firstUser = msgs.find(
(m: MessageRecord) => m.role === "user",
);
if (!firstUser) continue;
const textParts = Array.isArray(firstUser.content)
? firstUser.content
.filter((p: { type: string }) => p.type === "text")
.map((p: { text: string }) => p.text)
.join(" ")
: "";
if (textParts.trim()) {
await tx
.table("threads")
.update(thread.id, { searchText: textParts.slice(0, 500) });
}
}
});
export { db };
/**

View file

@ -0,0 +1,122 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { db } from "../db";
import type { MessageRecord, ThreadRecord } from "../types";
import { downloadTextFile } from "@/lib/download";
function extractText(content: MessageRecord["content"]): string {
if (!Array.isArray(content)) return "";
return content
.filter((p) => p.type === "text")
.map((p) => (p as { type: "text"; text: string }).text)
.join("\n");
}
async function getThreadData(threadId: string) {
const thread = await db.threads.get(threadId);
if (!thread) return null;
const messages = await db.messages
.where("threadId")
.equals(threadId)
.sortBy("createdAt");
return { thread, messages };
}
export async function exportAsMarkdown(threadId: string): Promise<void> {
const data = await getThreadData(threadId);
if (!data) return;
const { thread, messages } = data;
const lines: string[] = [
`# ${thread.title}`,
"",
`> Exported from Unsloth Studio on ${new Date().toISOString()}`,
`> Model: ${thread.modelId || "unknown"}`,
"",
];
for (const msg of messages) {
const role = msg.role === "user" ? "User" : "Assistant";
const text = extractText(msg.content);
const feedback =
msg.feedback ? ` [${msg.feedback === "thumbs_up" ? "+" : "-"}]` : "";
lines.push(`## ${role}${feedback}`, "", text, "");
}
downloadTextFile(
buildExportFilename(thread, "md"),
lines.join("\n"),
"text/markdown",
);
}
export async function exportAsJSON(threadId: string): Promise<void> {
const data = await getThreadData(threadId);
if (!data) return;
const { thread, messages } = data;
const payload = {
thread: { ...thread },
messages: messages.map((m) => ({ ...m })),
exportedAt: new Date().toISOString(),
};
downloadTextFile(
buildExportFilename(thread, "json"),
JSON.stringify(payload, null, 2),
"application/json",
);
}
export async function exportAsJSONL(threadId: string): Promise<void> {
const data = await getThreadData(threadId);
if (!data) return;
const { thread, messages } = data;
// OpenAI chat format -- standard SFT structure, skip empty turns
const chatMessages = messages.flatMap((m) => {
const content = extractText(m.content).trim();
if (!content) return [];
return [{ role: m.role === "user" ? "user" : "assistant", content }];
});
if (chatMessages.length === 0) return;
const line = JSON.stringify({ messages: chatMessages });
downloadTextFile(
buildExportFilename(thread, "jsonl"),
line + "\n",
"application/x-ndjson",
);
}
function sanitizeFilename(name: string): string {
return name
.replace(/[^\p{L}\p{N}_\- ]/gu, "")
.replace(/\s+/g, "_")
.slice(0, 80)
|| "chat_export";
}
function buildExportFilename(
thread: ThreadRecord,
ext: "md" | "json" | "jsonl",
): string {
const base = sanitizeFilename(thread.title);
const suffix = thread.pairId
? `_${sanitizeFilename(thread.modelType || thread.modelId || "compare")}`
: "";
return `${base}${suffix}.${ext}`;
}
export async function getExportThreadIds(
threadOrPairId: string,
type: "single" | "compare",
): Promise<string[]> {
if (type === "single") return [threadOrPairId];
const paired = await db.threads
.where("pairId")
.equals(threadOrPairId)
.toArray();
return paired.map((t: ThreadRecord) => t.id);
}

View file

@ -481,6 +481,11 @@ function createDexieAdapter(
const firstUser = messages.find((m) => m.role === "user");
const userText = extractTextParts(firstUser) || defaultTitle;
// Backfill searchText for new threads (once, on first title generation)
if (!thread.searchText && userText !== defaultTitle) {
await db.threads.update(remoteId, { searchText: userText.slice(0, 500) });
}
if (!autoTitle) {
const title = fallbackTitleFromUserText(userText);
await persistTitle(title);

View file

@ -21,14 +21,46 @@ import {
PencilEdit02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ChevronRightIcon,
DownloadIcon,
FolderIcon,
FolderPlusIcon,
MoreHorizontalIcon,
PinIcon,
PinOffIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { db, useLiveQuery } from "./db";
import type { ChatView, ThreadRecord } from "./types";
import {
exportAsJSON,
exportAsJSONL,
exportAsMarkdown,
getExportThreadIds,
} from "./lib/thread-export";
interface SidebarItem {
type: "single" | "compare";
id: string;
title: string;
createdAt: number;
folderId?: string;
pinned?: boolean;
}
function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
@ -49,6 +81,8 @@ function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
id: t.pairId,
title: t.title,
createdAt: t.createdAt,
folderId: t.folderId,
pinned: t.pinned,
});
} else if (!t.pairId) {
items.push({
@ -56,6 +90,8 @@ function groupThreads(threads: ThreadRecord[]): SidebarItem[] {
id: t.id,
title: t.title,
createdAt: t.createdAt,
folderId: t.folderId,
pinned: t.pinned,
});
}
}
@ -76,13 +112,61 @@ export function ThreadSidebar({
onNewCompare: () => void;
showCompare: boolean;
}) {
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebouncedValue(searchQuery, 150);
const searchInputRef = useRef<HTMLInputElement>(null);
const allThreads = useLiveQuery(
() => db.threads.orderBy("createdAt").reverse().toArray(),
[],
);
const items = groupThreads(allThreads ?? []);
const allFolders = useLiveQuery(
() => db.folders.orderBy("createdAt").toArray(),
[],
);
const items = useMemo(() => groupThreads(allThreads ?? []), [allThreads]);
const activeId = view.mode === "single" ? view.threadId : view.pairId;
const filteredItems = useMemo(() => {
if (!debouncedQuery.trim()) return items;
const q = debouncedQuery.toLowerCase();
// Filter items by matching title or searchText from underlying threads
return items.filter((item) => {
if (item.title.toLowerCase().includes(q)) return true;
// Check searchText on all underlying thread records (both sides of compare pairs)
const relatedThreads = (allThreads ?? []).filter((t) =>
item.type === "single" ? t.id === item.id : t.pairId === item.id,
);
return relatedThreads.some(
(t) => t.searchText?.toLowerCase().includes(q),
);
});
}, [items, debouncedQuery, allThreads]);
// Group items: pinned, then by folder, then unfiled
const pinnedItems = useMemo(
() => filteredItems.filter((i) => i.pinned),
[filteredItems],
);
const folderedItems = useMemo(() => {
const map = new Map<string, SidebarItem[]>();
for (const item of filteredItems) {
if (item.pinned || !item.folderId) continue;
const list = map.get(item.folderId) ?? [];
list.push(item);
map.set(item.folderId, list);
}
return map;
}, [filteredItems]);
const unfiledItems = useMemo(
() => filteredItems.filter((i) => !i.pinned && !i.folderId),
[filteredItems],
);
const folders = allFolders ?? [];
function viewForItem(item: SidebarItem): ChatView {
return item.type === "single"
? { mode: "single", threadId: item.id }
@ -105,10 +189,177 @@ export function ThreadSidebar({
}
}
async function handlePin(item: SidebarItem) {
const next = !item.pinned;
if (item.type === "single") {
await db.threads.update(item.id, { pinned: next });
} else {
const paired = await db.threads.where("pairId").equals(item.id).toArray();
for (const t of paired) {
await db.threads.update(t.id, { pinned: next });
}
}
}
async function handleMoveToFolder(item: SidebarItem, folderId: string | undefined) {
if (item.type === "single") {
await db.threads.where("id").equals(item.id).modify((thread) => {
if (folderId) {
thread.folderId = folderId;
} else {
delete thread.folderId;
}
});
} else {
await db.threads.where("pairId").equals(item.id).modify((thread) => {
if (folderId) {
thread.folderId = folderId;
} else {
delete thread.folderId;
}
});
}
}
async function handleExport(
item: SidebarItem,
format: "md" | "json" | "jsonl",
) {
const ids = await getExportThreadIds(item.id, item.type);
for (const id of ids) {
if (format === "md") await exportAsMarkdown(id);
else if (format === "json") await exportAsJSON(id);
else await exportAsJSONL(id);
}
}
const handleNewFolder = useCallback(async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
await db.folders.add({
id: crypto.randomUUID(),
name: name.trim(),
createdAt: Date.now(),
});
}, []);
const handleDeleteFolder = useCallback(async (folderId: string) => {
await db.transaction("rw", db.threads, db.folders, async () => {
await db.threads.where("folderId").equals(folderId).modify((thread) => {
delete thread.folderId;
});
await db.folders.delete(folderId);
});
}, []);
const toggleSearch = useCallback(() => {
setSearchOpen((o) => {
if (!o) {
setTimeout(() => searchInputRef.current?.focus(), 50);
} else {
setSearchQuery("");
}
return !o;
});
}, []);
const renderItem = (item: SidebarItem) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={activeId === item.id}
onClick={() => onSelect(viewForItem(item))}
>
<span className="truncate">{item.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover={true}
title="More actions"
>
<MoreHorizontalIcon className="size-4" />
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="w-48">
<DropdownMenuItem onClick={() => handlePin(item)}>
{item.pinned ? (
<><PinOffIcon className="mr-2 size-4" />Unpin</>
) : (
<><PinIcon className="mr-2 size-4" />Pin to top</>
)}
</DropdownMenuItem>
{folders.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderIcon className="mr-2 size-4" />Move to folder
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleMoveToFolder(item, undefined)}>
None (unfiled)
</DropdownMenuItem>
{folders.map((f) => (
<DropdownMenuItem key={f.id} onClick={() => handleMoveToFolder(item, f.id)}>
{f.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<DownloadIcon className="mr-2 size-4" />Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleExport(item, "md")}>
Markdown (.md)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport(item, "json")}>
JSON (.json)
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport(item, "jsonl")}>
JSONL (.jsonl)
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleDelete(item)}
>
<HugeiconsIcon icon={Delete02Icon} className="mr-2 size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
return (
<>
<SidebarHeader className="px-4 py-3">
<span className="text-base font-semibold tracking-tight">Playground</span>
<div className="flex items-center justify-between">
<span className="text-base font-semibold tracking-tight">Playground</span>
<button
type="button"
onClick={toggleSearch}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Search conversations"
>
{searchOpen ? <XIcon className="size-4" /> : <SearchIcon className="size-4" />}
</button>
</div>
{searchOpen && (
<div className="mt-2">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search chats..."
className="w-full rounded-md border bg-background px-3 py-1.5 text-sm outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring"
/>
</div>
)}
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="px-4 pt-1">
@ -128,34 +379,78 @@ export function ThreadSidebar({
</SidebarMenuButton>
</SidebarMenuItem>
) : null}
<SidebarMenuItem>
<SidebarMenuButton onClick={handleNewFolder}>
<FolderPlusIcon className="size-4" />
<span>New Folder</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* Pinned threads */}
{pinnedItems.length > 0 && (
<SidebarGroup className="px-4">
<SidebarGroupLabel className="text-xs font-medium text-muted-foreground/80">
Pinned
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>{pinnedItems.map(renderItem)}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Folders */}
{folders.map((folder) => {
const folderItems = folderedItems.get(folder.id) ?? [];
if (folderItems.length === 0 && debouncedQuery.trim()) return null;
return (
<SidebarGroup key={folder.id} className="px-4">
<Collapsible defaultOpen={true}>
<div className="flex items-center justify-between">
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground/80 hover:text-foreground">
<ChevronRightIcon className="size-3 transition-transform [[data-state=open]_&]:rotate-90" />
<FolderIcon className="size-3" />
{folder.name}
<span className="text-muted-foreground/50">({folderItems.length})</span>
</CollapsibleTrigger>
<button
type="button"
onClick={() => handleDeleteFolder(folder.id)}
className="size-5 flex items-center justify-center rounded text-muted-foreground/50 hover:text-destructive"
title="Delete folder"
>
<XIcon className="size-3" />
</button>
</div>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>{folderItems.map(renderItem)}</SidebarMenu>
{folderItems.length === 0 && (
<p className="px-2 py-2 text-center text-xs text-muted-foreground/50">
Empty
</p>
)}
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</SidebarGroup>
);
})}
{/* Unfiled threads */}
<SidebarGroup className="flex-1 px-4">
<SidebarGroupLabel className="text-xs font-medium text-muted-foreground/80">Your Chats</SidebarGroupLabel>
<SidebarGroupLabel className="text-xs font-medium text-muted-foreground/80">
{folders.length > 0 ? "Unfiled" : "Your Chats"}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={activeId === item.id}
onClick={() => onSelect(viewForItem(item))}
>
<span>{item.title}</span>
</SidebarMenuButton>
<SidebarMenuAction
showOnHover={true}
onClick={() => handleDelete(item)}
title="Delete"
>
<HugeiconsIcon icon={Delete02Icon} />
</SidebarMenuAction>
</SidebarMenuItem>
))}
{unfiledItems.map(renderItem)}
</SidebarMenu>
{items.length === 0 && (
{unfiledItems.length === 0 && pinnedItems.length === 0 && folderedItems.size === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
No threads yet
{debouncedQuery.trim() ? "No matching threads" : "No threads yet"}
</p>
)}
</SidebarGroupContent>

View file

@ -15,6 +15,12 @@ export interface ThreadRecord {
pairId?: string;
archived: boolean;
createdAt: number;
/** First ~500 chars of first user message for search indexing */
searchText?: string;
/** Folder this thread belongs to */
folderId?: string;
/** Pin thread to top of sidebar */
pinned?: boolean;
}
export interface MessageRecord {
@ -25,4 +31,29 @@ export interface MessageRecord {
attachments?: import("@assistant-ui/react").ThreadMessage["attachments"];
metadata?: Record<string, unknown>;
createdAt: number;
/** User feedback on assistant messages */
feedback?: "thumbs_up" | "thumbs_down";
}
export interface FolderRecord {
id: string;
name: string;
createdAt: number;
}
export interface PromptRecord {
id: string;
name: string;
content: string;
variables: string[];
tags: string[];
createdAt: number;
}
export interface MemoryRecord {
id: string;
content: string;
enabled: boolean;
createdAt: number;
updatedAt: number;
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export function downloadTextFile(
filename: string,
content: string,
mimeType = "text/plain",
): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}