Compare commits
14 commits
main
...
feat/chat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b6c4daf9 | ||
|
|
b09e3052e0 | ||
|
|
392c59bdc9 | ||
|
|
24507dc185 | ||
|
|
940faeaa73 | ||
|
|
1bfcfc9745 | ||
|
|
a30c7789da | ||
|
|
d26c56a43b | ||
|
|
007ffd2569 | ||
|
|
9d61147d65 | ||
|
|
ffd269344d | ||
|
|
2ea8217727 |
||
|
|
0a1f0c2e78 | ||
|
|
78b8ec8194 |
13 changed files with 1124 additions and 46 deletions
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
175
studio/frontend/src/features/chat/components/memory-panel.tsx
Normal file
175
studio/frontend/src/features/chat/components/memory-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
||||
/**
|
||||
|
|
|
|||
122
studio/frontend/src/features/chat/lib/thread-export.ts
Normal file
122
studio/frontend/src/features/chat/lib/thread-export.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
21
studio/frontend/src/lib/download.ts
Normal file
21
studio/frontend/src/lib/download.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue