From 78b8ec8194a9f94e2d86bab8cc8e36e990674d62 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 10:21:50 +0000 Subject: [PATCH 01/11] 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. --- .../components/assistant-ui/markdown-text.tsx | 32 ++ .../src/components/assistant-ui/thread.tsx | 66 ++++ .../src/features/chat/api/chat-adapter.ts | 43 ++- .../frontend/src/features/chat/chat-page.tsx | 153 +++++++- .../src/features/chat/chat-settings-sheet.tsx | 5 + .../chat/components/artifact-panel.tsx | 171 +++++++++ .../components/keyboard-shortcut-help.tsx | 53 +++ .../features/chat/components/memory-panel.tsx | 175 ++++++++++ .../chat/components/prompt-library-sheet.tsx | 234 +++++++++++++ studio/frontend/src/features/chat/db.ts | 45 ++- .../src/features/chat/lib/thread-export.ts | 110 ++++++ .../features/chat/stores/artifact-store.ts | 101 ++++++ .../src/features/chat/thread-sidebar.tsx | 330 ++++++++++++++++-- studio/frontend/src/features/chat/types.ts | 31 ++ studio/frontend/src/lib/download.ts | 18 + 15 files changed, 1530 insertions(+), 37 deletions(-) create mode 100644 studio/frontend/src/features/chat/components/artifact-panel.tsx create mode 100644 studio/frontend/src/features/chat/components/keyboard-shortcut-help.tsx create mode 100644 studio/frontend/src/features/chat/components/memory-panel.tsx create mode 100644 studio/frontend/src/features/chat/components/prompt-library-sheet.tsx create mode 100644 studio/frontend/src/features/chat/lib/thread-export.ts create mode 100644 studio/frontend/src/features/chat/stores/artifact-store.ts create mode 100644 studio/frontend/src/lib/download.ts diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 5e84b9175e..7c14aa150a 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -16,6 +16,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Block, type BlockProps, Streamdown } from "streamdown"; import "katex/dist/katex.min.css"; import { AudioPlayer } from "./audio-player"; +import { useArtifactStore } from "@/features/chat/stores/artifact-store"; const math = createMathPlugin({ singleDollarTextMath: true }); const { withSmoothContextProvider } = INTERNAL; @@ -50,6 +51,15 @@ type CodeFence = { source: string; }; +function hashCode(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash + char) | 0; + } + return Math.abs(hash).toString(36); +} + function getMermaidSource(blockContent: string): string | null { const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim(); return source && source.length > 0 ? source : null; @@ -373,6 +383,28 @@ 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; + + // Emit artifact for large code blocks or HTML/SVG/Mermaid + const lineCount = codeFence.source.split("\n").length; + const isArtifactWorthy = + !props.isIncomplete && + (lineCount >= 20 || svgSource !== null || htmlSource !== null); + if (isArtifactWorthy) { + const artifactId = `artifact-${hashCode(codeFence.source)}`; + const store = useArtifactStore.getState(); + if (!store.artifacts.some((a) => a.id === artifactId)) { + store.addArtifact({ + id: artifactId, + title: codeFence.language + ? `${codeFence.language} snippet` + : "Code snippet", + language: codeFence.language, + content: codeFence.source, + createdAt: Date.now(), + }); + } + } + return ( <>
diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index e5528f7ee0..eccd8b6716 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,68 @@ const CopyButton: FC = () => { ); }; +const FeedbackButtons: FC = () => { + const aui = useAui(); + 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; + void db.messages.get(messageId).then((msg) => { + if (msg?.feedback) setFeedback(msg.feedback); + }); + }, [messageId]); + + const handleFeedback = useCallback( + (value: "thumbs_up" | "thumbs_down") => { + const next = feedback === value ? null : value; + setFeedback(next); + if (messageId) { + void db.messages.update(messageId, { + feedback: next ?? undefined, + }); + } + }, + [feedback, 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 15ac416b1f..1f5f6b10b8 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -442,12 +442,32 @@ 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 { db: chatDb } = await import("../db"); + const allMemories = await chatDb.memory.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 { + // Memory table may not exist yet during migration + } + if (systemContent) { outboundMessages.unshift({ role: "system", - content: safeSystemPrompt.trim(), + content: systemContent, }); } const imageBase64 = findLatestUserImageBase64(messages); @@ -515,6 +535,16 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { return; } + // Screen wake lock: keep screen on during long generations + let wakeLock: WakeLockSentinel | null = null; + try { + if ("wakeLock" in navigator) { + wakeLock = await navigator.wakeLock.request("screen"); + } + } catch { + // Wake lock not available or denied -- continue without it + } + const threadKey = unstable_threadId || "__default"; let waitingFirstChunk = true; let firstTokenSettled = false; @@ -768,6 +798,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } } runtime.setThreadRunning(threadKey, false); + // Release screen wake lock + if (wakeLock) { + void wakeLock.release().catch(() => {}); + wakeLock = null; + } } }, }; diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index c04cfbc89c..8a796a23e7 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -8,6 +8,16 @@ 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, + CommandShortcut, +} from "@/components/ui/command"; import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; import { Sheet, @@ -24,6 +34,16 @@ import { Settings04Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import { + BookOpenIcon, + BrainIcon, + ColumnsIcon, + KeyboardIcon, + PanelRightIcon, + PencilIcon, + SearchIcon, + SettingsIcon, +} from "lucide-react"; import { type CSSProperties, type ReactElement, @@ -36,6 +56,10 @@ import { useState, } from "react"; import { toast } from "sonner"; +import { KeyboardShortcutHelp } from "./components/keyboard-shortcut-help"; +import { ArtifactPanel } from "./components/artifact-panel"; +import { useArtifactStore } from "./stores/artifact-store"; +import { PromptLibrarySheet } from "./components/prompt-library-sheet"; import { GuidedTour, useGuidedTourController } from "@/features/tour"; import { ChatSettingsPanel } from "./chat-settings-sheet"; import { ContextUsageBar } from "./components/context-usage-bar"; @@ -421,6 +445,10 @@ export function ChatPage(): ReactElement { const [viewBeforeCompare, setViewBeforeCompare] = useState( null, ); + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false); + const [promptLibraryOpen, setPromptLibraryOpen] = useState(false); + const artifactPanelOpen = useArtifactStore((s) => s.panelOpen); const inferenceParams = useChatRuntimeStore((state) => state.params); const setInferenceParams = useChatRuntimeStore((state) => state.setParams); const activeGgufVariant = useChatRuntimeStore((state) => state.activeGgufVariant); @@ -695,9 +723,107 @@ export function ChatPage(): ReactElement { return () => window.clearTimeout(timeoutId); }, [modelSelectorLocked, tour.open]); + // Global keyboard shortcuts + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + const mod = e.metaKey || e.ctrlKey; + const target = e.target as HTMLElement; + const isInput = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable; + + if (mod && e.key === "k") { + e.preventDefault(); + setCommandPaletteOpen((o) => !o); + return; + } + if (mod && e.shiftKey && e.key === "N") { + e.preventDefault(); + handleNewThread(); + return; + } + if (mod && e.shiftKey && e.key === "C") { + e.preventDefault(); + if (canCompare) handleNewCompare(); + return; + } + if (mod && e.shiftKey && e.key === "S") { + e.preventDefault(); + setSettingsOpen((o) => !o); + return; + } + if (e.key === "Escape") { + setCommandPaletteOpen(false); + setShortcutHelpOpen(false); + return; + } + if (e.key === "?" && !isInput) { + e.preventDefault(); + setShortcutHelpOpen((o) => !o); + return; + } + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [canCompare, handleNewThread, handleNewCompare]); + return (
+ + + + + + + No results found. + + { handleNewThread(); setCommandPaletteOpen(false); }}> + + New Chat + Shift+N + + {canCompare && ( + { handleNewCompare(); setCommandPaletteOpen(false); }}> + + Compare Mode + Shift+C + + )} + { setSettingsOpen(true); setCommandPaletteOpen(false); }}> + + Settings + Shift+S + + + + { setPromptLibraryOpen(true); setCommandPaletteOpen(false); }}> + + Prompt Library + + { useArtifactStore.getState().setPanelOpen(!artifactPanelOpen); setCommandPaletteOpen(false); }}> + + Toggle Artifacts Panel + + { handleEject(); setCommandPaletteOpen(false); }}> + + Eject Model + + + + { setShortcutHelpOpen(true); setCommandPaletteOpen(false); }}> + + Keyboard Shortcuts + ? + + + + +
- {view.mode === "single" ? ( - - ) : ( - - )} +
+
+ {view.mode === "single" ? ( + + ) : ( + + )} +
+ {artifactPanelOpen && ( +
+ +
+ )} +
+ + + + = ({ + artifact, + isActive, +}) => { + const setActive = useArtifactStore((s) => s.setActiveArtifact); + const remove = useArtifactStore((s) => s.removeArtifact); + + return ( + + + ); +}; + +export const ArtifactPanel: FC = () => { + const artifacts = useArtifactStore((s) => s.artifacts); + const activeId = useArtifactStore((s) => s.activeArtifactId); + const panelOpen = useArtifactStore((s) => s.panelOpen); + const setPanelOpen = useArtifactStore((s) => s.setPanelOpen); + const setVersion = useArtifactStore((s) => s.setActiveVersion); + const updateContent = useArtifactStore((s) => s.updateArtifactContent); + + const [copied, setCopied] = useState(false); + const resetRef = useRef>(); + const textareaRef = useRef(null); + + if (!panelOpen || artifacts.length === 0) return null; + + const active = artifacts.find((a) => a.id === activeId) ?? artifacts[0]; + if (!active) return null; + + const handleCopy = () => { + if (copyToClipboard(active.content)) { + setCopied(true); + if (resetRef.current) clearTimeout(resetRef.current); + resetRef.current = setTimeout(() => setCopied(false), COPY_RESET_MS); + } + }; + + const handleDownload = () => { + const ext = active.language === "html" + ? ".html" + : active.language === "svg" + ? ".svg" + : active.language + ? `.${active.language}` + : ".txt"; + downloadTextFile(`${active.title}${ext}`, active.content); + }; + + const canPrev = active.activeVersion > 0; + const canNext = active.activeVersion < active.history.length - 1; + + return ( +
+ {/* Tab bar */} +
+
+ {artifacts.map((a) => ( + + ))} +
+ +
+ + {/* Toolbar */} +
+ {active.title} + {active.history.length > 1 && ( +
+ + + v{active.activeVersion + 1}/{active.history.length} + + +
+ )} + + +
+ + {/* Editor */} +
+