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 */}
({ id: d.id, name: d.name }))}
onSelect={handleSelectDashboard}
onDelete={handleDashboardDelete}
onRename={openDashboardRenameDialog}
onCreate={handleCreateDashboard}
activeId={location.pathname === "/dashboard" ? activeDashboardId : null}
/>
{archivedSessions.length > 0 && (
)}
{/* 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 (
<>
} />
>
);
}