import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder, Globe, Bot, Loader2, CheckCircle2, XCircle, ChevronRight } from "lucide-react"; import { useState, useRef, useEffect } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useAuthStore } from "@/store/authStore"; import { useProjectStore } from "@/store/projectStore"; import { useDashboardStore } from "@/store/dashboardStore"; import { api } from "@/lib/api"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; interface SessionInfo { key: string; created_at: string; updated_at: string; alias?: string | null; pinned?: boolean; archived?: boolean; metadata?: { title?: string; }; } function SectionHeader({ title, count, isSelectionMode, setIsSelectionMode, selectedKeys, setSelectedKeys, items, onBatchDelete }: { title: string; count: number; isSelectionMode: boolean; setIsSelectionMode: (val: boolean) => void; selectedKeys: string[]; setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void; items: SessionInfo[]; onBatchDelete: (keys: string[]) => void; }) { const { t } = useTranslation(); const handleSelectAll = (e: React.MouseEvent) => { e.stopPropagation(); if (selectedKeys.length === items.length && items.length > 0) { setSelectedKeys([]); } else { setSelectedKeys(items.map(item => item.key)); } }; const handleInvertSelection = (e: React.MouseEvent) => { e.stopPropagation(); const allKeys = items.map(item => item.key); setSelectedKeys(allKeys.filter(key => !selectedKeys.includes(key))); }; const handleBatchDelete = (e: React.MouseEvent) => { e.stopPropagation(); if (selectedKeys.length === 0) return; onBatchDelete(selectedKeys); setSelectedKeys([]); setIsSelectionMode(false); }; useEffect(() => { if (!isSelectionMode) { setSelectedKeys([]); } }, [isSelectionMode, setSelectedKeys]); return (
{title} ({count})
{isSelectionMode ? ( <> ) : ( )}
); } function Section({ items, onSelect, onDelete, onRename, onTogglePinned, onToggleArchived, activeKey, isSelectionMode, selectedKeys, setSelectedKeys }: { items: SessionInfo[]; onSelect: (key: string) => void; onDelete: (key: string) => void; onRename: (key: string, currentTitle: string) => void; onTogglePinned: (key: string, pinned: boolean) => void; onToggleArchived: (key: string, archived: boolean) => void; activeKey: string | null; isSelectionMode: boolean; selectedKeys: string[]; setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void; }) { const { t } = useTranslation(); const toggleSelect = (key: string, e: React.MouseEvent) => { e.stopPropagation(); setSelectedKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] ); }; return (
{items.map((item) => { const displayTitle = item.metadata?.title || item.key.replace("api:", ""); const isActive = activeKey === item.key; const isSelected = selectedKeys.includes(item.key); return (
isSelectionMode ? toggleSelect(item.key, e) : onSelect(item.key)} >
{isSelectionMode ? ( toggleSelect(item.key, e)} > {isSelected ? ( ) : ( )} ) : ( {item.pinned && } )} {displayTitle}
{!isSelectionMode && ( e.stopPropagation()} className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity outline-none"> { e.preventDefault(); e.stopPropagation(); onRename(item.key, displayTitle); }} onSelect={(e) => { e.preventDefault(); e.stopPropagation(); onRename(item.key, displayTitle); }} > {t('rename')} { e.preventDefault(); e.stopPropagation(); onTogglePinned(item.key, !!item.pinned); }} onSelect={(e) => { e.preventDefault(); e.stopPropagation(); onTogglePinned(item.key, !!item.pinned); }} > {item.pinned ? t('unpin') : t('pin')} { e.preventDefault(); e.stopPropagation(); onToggleArchived(item.key, !!item.archived); }} onSelect={(e) => { e.preventDefault(); e.stopPropagation(); onToggleArchived(item.key, !!item.archived); }} > {item.archived ? t('unarchive') : t('archive')} { e.preventDefault(); e.stopPropagation(); onDelete(item.key); }} onSelect={(e) => { e.preventDefault(); e.stopPropagation(); onDelete(item.key); }} className="text-red-600 focus:text-red-600 focus:bg-red-50" > {t('deleteSession')} )}
); })}
); } function DashboardSection({ title, count, items, onSelect, onDelete, onRename, onCreate, activeId }: { title: string; count: number; items: {id: string, name: string}[]; onSelect: (id: string) => void; onDelete: (id: string) => void; onRename: (id: string, currentName: string) => void; onCreate: () => void; activeId: string | null; }) { const { t } = useTranslation(); return (
{title} ({count})
{items.map((item) => { const isActive = activeId === item.id; return (
onSelect(item.id)} >
{item.name}
e.stopPropagation()} className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity outline-none"> { e.preventDefault(); e.stopPropagation(); onRename(item.id, item.name); }} > {t('rename')} { e.preventDefault(); e.stopPropagation(); onDelete(item.id); }} className="text-red-600 focus:text-red-600 focus:bg-red-50" > {t('delete')}
); })}
); } function SidebarBody() { const navigate = useNavigate(); const location = useLocation(); const { user, logout } = useAuthStore(); const { currentProject } = useProjectStore(); const { dashboards, activeDashboardId, loadDashboards, createDashboard, deleteDashboard, renameDashboard, setActiveDashboard } = useDashboardStore(); const { t, i18n } = useTranslation(); const [showUserMenu, setShowUserMenu] = useState(false); const menuRef = useRef(null); const [voiceSettingsOpen, setVoiceSettingsOpen] = useState(false); const [voiceEnabledDraft, setVoiceEnabledDraft] = useState(false); const [showKnowledgeSubmenu, setShowKnowledgeSubmenu] = useState(false); const [showMoreSubmenu, setShowMoreSubmenu] = useState(false); const [whisperUrlDraft, setWhisperUrlDraft] = useState(""); const [isTestingVoice, setIsTestingVoice] = useState(false); const [voiceTestStatus, setVoiceTestStatus] = useState<"success" | "error" | null>(null); const [voiceTestMessage, setVoiceTestMessage] = useState(""); // Session management state const [sessions, setSessions] = useState([]); const [sessionFilter, setSessionFilter] = useState(""); const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [sessionToRename, setSessionToRename] = useState<{key: string, title: string} | null>(null); const [newTitle, setNewTitle] = useState(""); const [activeSelectionMode, setActiveSelectionMode] = useState(false); const [activeSelectedKeys, setActiveSelectedKeys] = useState([]); const [archivedSelectionMode, setArchivedSelectionMode] = useState(false); const [archivedSelectedKeys, setArchivedSelectedKeys] = useState([]); const [dashboardRenameDialogOpen, setDashboardRenameDialogOpen] = useState(false); const [dashboardToRename, setDashboardToRename] = useState<{id: string, name: string} | null>(null); const [newDashboardName, setNewDashboardName] = useState(""); // Try to parse active session from URL query const queryParams = new URLSearchParams(location.search); const activeSessionKey = queryParams.get("session") || "api:default"; useEffect(() => { if (currentProject) { loadDashboards(currentProject.id); } }, [currentProject, loadDashboards]); const fetchSessions = async () => { try { const url = currentProject ? `/nanobot/sessions?project_id=${currentProject.id}` : "/nanobot/sessions"; const data = await api.get(url); setSessions(data); } catch (e) { console.error("Failed to fetch sessions", e); } }; useEffect(() => { fetchSessions(); }, [location.pathname, location.search, currentProject?.id]); useEffect(() => { const onFocus = () => fetchSessions(); const onSessionsChanged = () => fetchSessions(); window.addEventListener("focus", onFocus); window.addEventListener("nanobot:sessions-changed", onSessionsChanged); return () => { window.removeEventListener("focus", onFocus); window.removeEventListener("nanobot:sessions-changed", onSessionsChanged); }; }, []); useEffect(() => { function handleClickOutside(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setShowUserMenu(false); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const handleLogout = () => { logout(); navigate("/login"); }; const openVoiceSettings = () => { const enabled = localStorage.getItem("whisper_enabled") === "true"; const saved = (localStorage.getItem("whisper_url") || "").trim(); setVoiceEnabledDraft(enabled); setWhisperUrlDraft(saved); setVoiceTestStatus(null); setVoiceTestMessage(""); setVoiceSettingsOpen(true); }; const handleSaveVoiceSettings = () => { localStorage.setItem("whisper_enabled", String(voiceEnabledDraft)); if (!voiceEnabledDraft) { setVoiceSettingsOpen(false); return; } const normalized = whisperUrlDraft.trim(); if (!normalized) { alert(t('voiceServerRequired', '请填写语音识别服务地址')); return; } localStorage.setItem("whisper_url", normalized); setVoiceSettingsOpen(false); }; const handleTestVoiceConnection = async () => { if (!voiceEnabledDraft) { alert(t('voiceInputDisabledHint', '请先开启语音输入')); return; } const normalized = whisperUrlDraft.trim(); if (!normalized) { alert(t('voiceServerRequired', '请填写语音识别服务地址')); return; } setIsTestingVoice(true); setVoiceTestStatus(null); setVoiceTestMessage(""); try { const response = await fetch(`${normalized.replace(/\/$/, "")}/health`, { method: "GET", }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } setVoiceTestStatus("success"); setVoiceTestMessage(t('voiceConnectionSuccess', '连接成功')); } catch (error: any) { setVoiceTestStatus("error"); setVoiceTestMessage(`${t('voiceConnectionFailed', '连接失败')}: ${error?.message || t('unknownError', '未知错误')}`); } finally { setIsTestingVoice(false); } }; const handleSelectSession = (key: string) => { navigate(`/?session=${encodeURIComponent(key)}`); }; const handleNewThread = async () => { const newSessionId = `api:${Date.now()}`; try { const payload = currentProject ? { project_id: currentProject.id } : {}; await api.post(`/nanobot/sessions/${encodeURIComponent(newSessionId)}/ensure`, payload); await fetchSessions(); window.dispatchEvent(new Event("nanobot:sessions-changed")); } catch (e) { console.error("Failed to create session", e); } navigate(`/?session=${encodeURIComponent(newSessionId)}`); }; const handleDeleteSession = async (key: string) => { if (!window.confirm(t('confirmDeleteSession'))) return; try { await api.delete(`/nanobot/sessions/${encodeURIComponent(key)}`); if (activeSessionKey === key) { navigate("/"); } fetchSessions(); window.dispatchEvent(new Event("nanobot:sessions-changed")); } catch (e) { console.error("Failed to delete session", e); } }; const handleBatchDelete = async (keys: string[]) => { if (!window.confirm(t('confirmBatchDeleteSessions', { count: keys.length }))) return; try { await api.post("/nanobot/sessions/batch-delete", { session_ids: keys }); if (keys.includes(activeSessionKey)) { navigate("/"); } fetchSessions(); window.dispatchEvent(new Event("nanobot:sessions-changed")); } catch (e) { console.error("Failed to batch delete sessions", e); } }; const openRenameDialog = (key: string, currentTitle: string) => { setSessionToRename({ key, title: currentTitle }); setNewTitle(currentTitle); setRenameDialogOpen(true); }; const handleRename = async () => { if (!sessionToRename || !newTitle.trim()) return; try { const nextTitle = newTitle.trim(); await api.put(`/nanobot/sessions/${encodeURIComponent(sessionToRename.key)}`, { title: nextTitle }); setSessions((prev) => prev.map((item) => item.key === sessionToRename.key ? { ...item, alias: nextTitle, metadata: { ...(item.metadata || {}), title: nextTitle } } : item ) ); setRenameDialogOpen(false); fetchSessions(); window.dispatchEvent(new Event("nanobot:sessions-changed")); } catch (e) { console.error("Failed to rename session", e); } }; const handleTogglePinned = async (key: string, pinned: boolean) => { const nextPinned = !pinned; try { await api.put(`/nanobot/sessions/${encodeURIComponent(key)}`, { pinned: nextPinned }); setSessions((prev) => prev .map((item) => (item.key === key ? { ...item, pinned: nextPinned } : item)) .sort((a, b) => { const ap = a.pinned ? 1 : 0; const bp = b.pinned ? 1 : 0; if (bp !== ap) return bp - ap; const aa = a.archived ? 1 : 0; const ba = b.archived ? 1 : 0; if (aa !== ba) return aa - ba; return (b.updated_at || "").localeCompare(a.updated_at || ""); }) ); window.dispatchEvent(new Event("nanobot:sessions-changed")); } catch (e) { console.error("Failed to toggle pinned", e); } }; const handleToggleArchived = async (key: string, archived: boolean) => { const nextArchived = !archived; try { await api.put(`/nanobot/sessions/${encodeURIComponent(key)}`, { archived: nextArchived }); setSessions((prev) => prev .map((item) => (item.key === key ? { ...item, archived: nextArchived } : item)) .sort((a, b) => { const ap = a.pinned ? 1 : 0; const bp = b.pinned ? 1 : 0; if (bp !== ap) return bp - ap; const aa = a.archived ? 1 : 0; const ba = b.archived ? 1 : 0; if (aa !== ba) return aa - ba; return (b.updated_at || "").localeCompare(a.updated_at || ""); }) ); window.dispatchEvent(new Event("nanobot:sessions-changed")); } catch (e) { console.error("Failed to toggle archived", e); } }; const normalizedFilter = sessionFilter.trim().toLowerCase(); const activeSessions = sessions.filter((item) => { if (item.archived) return false; if (!normalizedFilter) return true; const title = (item.metadata?.title || item.key.replace("api:", "")).toLowerCase(); return title.includes(normalizedFilter); }); const archivedSessions = sessions.filter((item) => { if (!item.archived) return false; if (!normalizedFilter) return true; const title = (item.metadata?.title || item.key.replace("api:", "")).toLowerCase(); return title.includes(normalizedFilter); }); const handleCreateDashboard = () => { if (!currentProject) return; if (dashboards.length >= 3) { alert(t('dashboardLimitReached') || "You can only create up to 3 dashboards."); return; } createDashboard(t('newDashboardNameDefault'), currentProject.id); navigate(`/dashboard`); }; const handleSelectDashboard = (id: string) => { setActiveDashboard(id); navigate(`/dashboard`); }; const openDashboardRenameDialog = (id: string, name: string) => { setDashboardToRename({ id, name }); setNewDashboardName(name); setDashboardRenameDialogOpen(true); }; const handleDashboardRename = () => { if (!currentProject || !dashboardToRename || !newDashboardName.trim()) return; renameDashboard(dashboardToRename.id, newDashboardName.trim(), currentProject.id); setDashboardRenameDialogOpen(false); }; const handleDashboardDelete = (id: string) => { if (!currentProject) return; if (!window.confirm(t('confirmDeleteDashboard'))) return; deleteDashboard(id, currentProject.id); }; const filteredDashboards = dashboards.filter((d) => { if (!normalizedFilter) return true; return d.name.toLowerCase().includes(normalizedFilter); }); return (
{/* Header */}
🦞 {t('lobsterDataQA')}
({ id: d.id, name: d.name }))} onSelect={handleSelectDashboard} onDelete={handleDashboardDelete} onRename={openDashboardRenameDialog} onCreate={handleCreateDashboard} activeId={location.pathname === "/dashboard" ? activeDashboardId : null} />
setSessionFilter(e.target.value)} placeholder={t('filterSessionName')} className="pl-9 h-9 border-border bg-background text-[14px]" />
{archivedSessions.length > 0 && (
)}
{t('renameSession')}
setNewTitle(e.target.value)} placeholder={t('enterNewSessionTitle')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') { handleRename(); } }} />
{t('renameDashboard')}
setNewDashboardName(e.target.value)} placeholder={t('enterNewDashboardName')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') { handleDashboardRename(); } }} />
{t('voiceSettings', '语音输入配置')}
{t('enableVoiceInput', '启用语音输入')}
setWhisperUrlDraft(e.target.value)} placeholder="http://localhost:8001" disabled={!voiceEnabledDraft} />

{voiceEnabledDraft ? t('voiceSettingsHint', '请输入语音识别服务地址,例如:http://localhost:8001') : t('voiceSettingsDisabledHint', '请先开启语音输入,再配置服务地址')}

{voiceTestStatus && (
{voiceTestStatus === "success" ? : } {voiceTestMessage}
)}
{/* User Settings Popover Menu */} {showUserMenu && (

{user?.username}

{user?.email}

{user?.is_admin && ( )}
setShowKnowledgeSubmenu(true)} onMouseLeave={() => setShowKnowledgeSubmenu(false)} > {showKnowledgeSubmenu && (
{user?.is_admin && ( )}
)}
setShowMoreSubmenu(true)} onMouseLeave={() => setShowMoreSubmenu(false)} > {showMoreSubmenu && (
{user?.is_admin && ( )} {user?.is_admin && ( )}
)}
)}
); } export function Sidebar() { const [isOpen, setIsOpen] = useState(false); return ( <> } />
); }