diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 5e84b9175e..f67388f340 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -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 ( <> -
- - -
- {svgSource && } - {htmlSource && } +
+ + +
+ {svgSource && } + {htmlSource && } ); } diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index d688822815..def4a3d373 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -56,9 +56,12 @@ import { RefreshCwIcon, SquareIcon, TerminalIcon, + 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 ( + <> + handleFeedback("thumbs_up")} + className={cn( + feedback === "thumbs_up" && "text-green-600 dark:text-green-400", + )} + > + + + handleFeedback("thumbs_down")} + className={cn( + feedback === "thumbs_down" && "text-red-600 dark:text-red-400", + )} + > + + + + ); +}; + const AssistantActionBar: FC = () => { return ( { 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" > + diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 2b8a259930..6dd0bec6df 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -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); diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 07b52ebc30..2e610139e6 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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( 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 (
+ { + if (copyToClipboard(content)) { + toast.success("Prompt copied to clipboard"); + } else { + toast.error("Failed to copy prompt"); + } + }} + /> + + + + + No results found. + + { handleNewThread(); setCommandPaletteOpen(false); }}> + + New Chat + + {canCompare && ( + { handleNewCompare(); setCommandPaletteOpen(false); }}> + + Compare Mode + + )} + { setSettingsOpen(true); setCommandPaletteOpen(false); }}> + + Settings + + + + { setPromptLibraryOpen(true); setCommandPaletteOpen(false); }}> + + Prompt Library + + { handleEject(); setCommandPaletteOpen(false); }}> + + Eject Model + + + + +
- {view.mode === "single" ? ( - - ) : ( - - )} +
+ {view.mode === "single" ? ( + + ) : ( + + )} +
+ + + + { + const memories = useLiveQuery( + () => db.memory.orderBy("createdAt").toArray(), + [], + ); + const [adding, setAdding] = useState(false); + const [newContent, setNewContent] = useState(""); + const [editingId, setEditingId] = useState(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 ( +
+
+

+ Persistent context injected into every conversation. +

+ {estimatedTokens > 0 && ( + 512 ? "text-amber-500" : "text-muted-foreground"}`} + > + ~{estimatedTokens} tokens + + )} +
+
+ {items.map((m) => ( +
+ handleToggle(m.id, v)} + className="mt-0.5 scale-75" + /> +
+ {editingId === m.id ? ( +
+