Spaces:
Sleeping
Sleeping
Download src/components/QuickSwitcher.tsx from ihatebaselines/purcar-chat-api: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/spaces/ihatebaselines/purcar-chat-api/resolve/main/src/components/QuickSwitcher.tsx
- Command line
-
hf download hf://spaces/ihatebaselines/purcar-chat-api/src/components/QuickSwitcher.tsx
-
curl -L -o QuickSwitcher.tsx https://huggingface.co/spaces/ihatebaselines/purcar-chat-api/resolve/main/src/components/QuickSwitcher.tsx
12 kB
| "use client"; | |
| import React, { useState, useEffect, useRef, useMemo } from "react"; | |
| import { | |
| Search, MessageSquare, Folder, Brain, Database, | |
| Sliders, Plus, Network, Volume2, VolumeX, ArrowRight, | |
| CornerDownLeft, Zap, Pin, Users | |
| } from "lucide-react"; | |
| import type { Conversation, Project } from "@/types"; | |
| import { playClickSound, toggleSoundMute, isSoundMuted } from "@/lib/soundEffects"; | |
| interface QuickSwitcherProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| conversations: Conversation[]; | |
| projects: Project[]; | |
| activeConvId: string; | |
| onSelectConversation: (id: string) => void; | |
| onSelectProject: (id: string | null) => void; | |
| onNewSession: () => void; | |
| onOpenKnowledge: () => void; | |
| onOpenSettings: () => void; | |
| onInsertCommand: (command: string) => void; | |
| enableReasoning: boolean; | |
| onToggleReasoning: () => void; | |
| } | |
| interface SearchItem { | |
| id: string; | |
| title: string; | |
| subtitle?: string; | |
| category: "session" | "project" | "command" | "action"; | |
| icon: React.ReactNode; | |
| badge?: string; | |
| action: () => void; | |
| } | |
| export const QuickSwitcher: React.FC<QuickSwitcherProps> = ({ | |
| isOpen, | |
| onClose, | |
| conversations, | |
| projects, | |
| activeConvId, | |
| onSelectConversation, | |
| onSelectProject, | |
| onNewSession, | |
| onOpenKnowledge, | |
| onOpenSettings, | |
| onInsertCommand, | |
| enableReasoning, | |
| onToggleReasoning, | |
| }) => { | |
| const [query, setQuery] = useState(""); | |
| const [selectedIndex, setSelectedIndex] = useState(0); | |
| const inputRef = useRef<HTMLInputElement>(null); | |
| const listRef = useRef<HTMLDivElement>(null); | |
| // Focus input when opened | |
| useEffect(() => { | |
| if (isOpen) { | |
| setQuery(""); | |
| setSelectedIndex(0); | |
| setTimeout(() => inputRef.current?.focus(), 50); | |
| } | |
| }, [isOpen]); | |
| const items = useMemo<SearchItem[]>(() => { | |
| const list: SearchItem[] = []; | |
| // Core actions | |
| list.push({ | |
| id: "action_new_session", | |
| title: "New Session", | |
| subtitle: "Start a fresh chat with Purcar", | |
| category: "action", | |
| icon: <Plus size={14} className="text-[#a0e4a0]" />, | |
| action: () => { onNewSession(); onClose(); }, | |
| }); | |
| list.push({ | |
| id: "action_toggle_reasoning", | |
| title: enableReasoning ? "Disable Reasoning Mode" : "Enable Reasoning Mode", | |
| subtitle: enableReasoning ? "Switch to direct high-speed responses" : "Enable <think> cognitive chain", | |
| category: "action", | |
| icon: <Brain size={14} className={enableReasoning ? "text-[#a0e4a0]" : "text-zinc-400"} />, | |
| badge: enableReasoning ? "ON" : "OFF", | |
| action: () => { onToggleReasoning(); onClose(); }, | |
| }); | |
| list.push({ | |
| id: "action_toggle_sound", | |
| title: isSoundMuted() ? "Unmute Audio Haptics" : "Mute Audio Haptics", | |
| subtitle: "Tactile Discord-inspired audio cues", | |
| category: "action", | |
| icon: isSoundMuted() ? <VolumeX size={14} className="text-zinc-400" /> : <Volume2 size={14} className="text-[#a0e4a0]" />, | |
| action: () => { toggleSoundMute(); onClose(); }, | |
| }); | |
| list.push({ | |
| id: "action_knowledge", | |
| title: "Knowledge Base (RAG)", | |
| subtitle: "Manage private documents and embeddings", | |
| category: "action", | |
| icon: <Database size={14} className="text-cyan-400" />, | |
| action: () => { onOpenKnowledge(); onClose(); }, | |
| }); | |
| list.push({ | |
| id: "action_settings", | |
| title: "Engine Hyperparameters", | |
| subtitle: "Temperature, Top-K, Repetition Penalty, Max Tokens", | |
| category: "action", | |
| icon: <Sliders size={14} className="text-amber-400" />, | |
| action: () => { onOpenSettings(); onClose(); }, | |
| }); | |
| // Commands | |
| [ | |
| { name: "@web_search", desc: "Live web search & source citations", icon: <Network size={14} className="text-[#a0e4a0]" /> }, | |
| { name: "@rag", desc: "Retrieve answers from knowledge base", icon: <Database size={14} className="text-cyan-400" /> }, | |
| { name: "@reasoning", desc: "Force cognitive step-by-step thinking", icon: <Brain size={14} className="text-purple-400" /> }, | |
| { name: "@raw", desc: "Bypass reasoning, RAG & web search", icon: <Zap size={14} className="text-amber-400" /> }, | |
| ].forEach((cmd) => { | |
| list.push({ | |
| id: `cmd_${cmd.name}`, | |
| title: cmd.name, | |
| subtitle: cmd.desc, | |
| category: "command", | |
| icon: cmd.icon, | |
| badge: "command", | |
| action: () => { onInsertCommand(`${cmd.name} `); onClose(); }, | |
| }); | |
| }); | |
| // Projects | |
| projects.forEach((proj) => { | |
| list.push({ | |
| id: `proj_${proj.id}`, | |
| title: proj.name, | |
| subtitle: "Filter workspace by this project", | |
| category: "project", | |
| icon: <Folder size={14} className="text-blue-400" />, | |
| badge: "project", | |
| action: () => { onSelectProject(proj.id); onClose(); }, | |
| }); | |
| }); | |
| // Conversations | |
| conversations.forEach((conv) => { | |
| list.push({ | |
| id: `conv_${conv.id}`, | |
| title: conv.title || "Untitled Session", | |
| subtitle: `${conv.messages?.length || 0} messages · ${new Date(conv.updatedAt || conv.createdAt).toLocaleDateString()}`, | |
| category: "session", | |
| icon: conv.isGroup ? <Users size={14} className="text-[#a0e4a0]" /> : <MessageSquare size={14} className="text-zinc-400" />, | |
| badge: conv.id === activeConvId ? "active" : conv.isPinned ? "pinned" : undefined, | |
| action: () => { onSelectConversation(conv.id); onClose(); }, | |
| }); | |
| }); | |
| return list; | |
| }, [ | |
| conversations, | |
| projects, | |
| activeConvId, | |
| enableReasoning, | |
| onNewSession, | |
| onToggleReasoning, | |
| onOpenKnowledge, | |
| onOpenSettings, | |
| onInsertCommand, | |
| onSelectProject, | |
| onSelectConversation, | |
| onClose, | |
| ]); | |
| // Filter items by query | |
| const filtered = useMemo(() => { | |
| const q = query.trim().toLowerCase(); | |
| if (!q) return items; | |
| return items.filter( | |
| (item) => | |
| item.title.toLowerCase().includes(q) || | |
| (item.subtitle && item.subtitle.toLowerCase().includes(q)) || | |
| item.category.includes(q) | |
| ); | |
| }, [items, query]); | |
| // Handle keyboard navigation | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === "ArrowDown") { | |
| e.preventDefault(); | |
| setSelectedIndex((prev) => (prev + 1) % (filtered.length || 1)); | |
| } else if (e.key === "ArrowUp") { | |
| e.preventDefault(); | |
| setSelectedIndex((prev) => (prev - 1 + filtered.length) % (filtered.length || 1)); | |
| } else if (e.key === "Enter") { | |
| e.preventDefault(); | |
| if (filtered[selectedIndex]) { | |
| playClickSound(); | |
| filtered[selectedIndex].action(); | |
| } | |
| } else if (e.key === "Escape") { | |
| e.preventDefault(); | |
| onClose(); | |
| } | |
| }; | |
| // Keep selected element visible | |
| useEffect(() => { | |
| if (listRef.current) { | |
| const selectedEl = listRef.current.children[selectedIndex] as HTMLElement; | |
| if (selectedEl) { | |
| selectedEl.scrollIntoView({ block: "nearest" }); | |
| } | |
| } | |
| }, [selectedIndex]); | |
| if (!isOpen) return null; | |
| return ( | |
| <div | |
| className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4 bg-black/75 backdrop-blur-sm" | |
| onClick={onClose} | |
| > | |
| <div | |
| className="w-full max-w-xl rounded-lg border border-white/15 bg-[#171414] shadow-2xl overflow-hidden font-mono text-xs text-[#f5f0eb]" | |
| onClick={(e) => e.stopPropagation()} | |
| onKeyDown={handleKeyDown} | |
| > | |
| {/* Search Input Bar */} | |
| <div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.09] bg-[#1a1717]"> | |
| <Search size={15} className="text-zinc-400 shrink-0" /> | |
| <input | |
| ref={inputRef} | |
| type="text" | |
| value={query} | |
| onChange={(e) => { | |
| setQuery(e.target.value); | |
| setSelectedIndex(0); | |
| }} | |
| placeholder="Search sessions, commands (@), projects, or actions..." | |
| className="flex-1 bg-transparent text-[#f5f0eb] placeholder-zinc-500 text-xs outline-none border-none" | |
| /> | |
| <kbd className="px-1.5 py-0.5 rounded bg-white/[0.08] text-[10px] text-zinc-400 border border-white/10"> | |
| ESC | |
| </kbd> | |
| </div> | |
| {/* Results List */} | |
| <div | |
| ref={listRef} | |
| className="max-h-80 overflow-y-auto p-1.5 space-y-0.5 divide-y divide-transparent" | |
| > | |
| {filtered.length === 0 ? ( | |
| <div className="p-8 text-center text-zinc-500 text-xs"> | |
| No matching sessions, commands, or actions found for "{query}" | |
| </div> | |
| ) : ( | |
| filtered.map((item, index) => { | |
| const isSelected = index === selectedIndex; | |
| return ( | |
| <div | |
| key={item.id} | |
| onClick={() => { | |
| playClickSound(); | |
| item.action(); | |
| }} | |
| onMouseEnter={() => setSelectedIndex(index)} | |
| className={`flex items-center justify-between px-3 py-2 rounded-md cursor-pointer transition-colors ${ | |
| isSelected | |
| ? "bg-white/[0.08] text-[#f5f0eb] border border-white/10" | |
| : "text-zinc-300 hover:bg-white/[0.04] border border-transparent" | |
| }`} | |
| > | |
| <div className="flex items-center gap-2.5 overflow-hidden"> | |
| <div className="shrink-0">{item.icon}</div> | |
| <div className="overflow-hidden"> | |
| <div className="font-medium truncate flex items-center gap-2"> | |
| <span>{item.title}</span> | |
| {item.badge && ( | |
| <span | |
| className={`px-1.5 py-0.2 rounded text-[9px] uppercase tracking-wider ${ | |
| item.badge === "active" | |
| ? "bg-[#a0e4a0]/15 text-[#a0e4a0] border border-[#a0e4a0]/30" | |
| : item.badge === "pinned" | |
| ? "bg-amber-500/15 text-amber-300 border border-amber-500/30" | |
| : "bg-white/[0.06] text-zinc-400 border border-white/10" | |
| }`} | |
| > | |
| {item.badge} | |
| </span> | |
| )} | |
| </div> | |
| {item.subtitle && ( | |
| <div className="text-[10px] text-zinc-500 truncate"> | |
| {item.subtitle} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1.5 text-zinc-500 shrink-0 text-[10px]"> | |
| {isSelected && ( | |
| <span className="flex items-center gap-1 text-[#a0e4a0]"> | |
| <span>Select</span> | |
| <CornerDownLeft size={10} /> | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| {/* Footer info */} | |
| <div className="flex items-center justify-between px-3 py-2 bg-[#141212] border-t border-white/[0.07] text-[10px] text-zinc-500"> | |
| <div className="flex items-center gap-3"> | |
| <span className="flex items-center gap-1"> | |
| <kbd className="px-1 py-0.2 rounded bg-white/[0.06] border border-white/10">↑</kbd> | |
| <kbd className="px-1 py-0.2 rounded bg-white/[0.06] border border-white/10">↓</kbd> | |
| <span>navigate</span> | |
| </span> | |
| <span className="flex items-center gap-1"> | |
| <kbd className="px-1 py-0.2 rounded bg-white/[0.06] border border-white/10">↵</kbd> | |
| <span>select</span> | |
| </span> | |
| </div> | |
| <div>Discord Quick Switcher · <span className="text-[#a0e4a0]">Ctrl+K</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |