import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { Bot, Cpu, ChevronRight, FolderKanban, LogOut, Plus, RadioTower, Settings as SettingsIcon, UserCircle2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DisclosureContent } from '@/components/ui/disclosure'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; import { getAiHardwareOverview, type AiHardwareOverview } from '@/lib/ai-hardware'; import { AI_HARDWARE_NAVIGATION_EVENT, AI_HARDWARE_OVERVIEW_EVENT, announceAiHardwareNavigation, readAiHardwareAgentId, } from '@/lib/ai-hardware-navigation'; import { getAiModuleForPath } from '@/lib/ai-modules'; import { invokeIpc } from '@/lib/api-client'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; import { subscribeCodingProjectCreationRequest, subscribeCodingProjectOpenRequest, } from '@/lib/coding-project-entry'; import { PointWallet } from '@/components/account/PointWallet'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; import { ModuleSwitcher } from './ModuleSwitcher'; import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar'; import { SidebarUpdateButton } from './SidebarUpdateButton'; import { UserAvatar } from '@/components/profile/UserAvatar'; import { getAccountInitial } from '@/components/profile/user-avatar-utils'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; import { useSettingsStore } from '@/stores/settings'; import { useAuthStore } from '@/stores/auth'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProviderStore } from '@/stores/providers'; import type { CodingProjectSummary } from '@/types/coding-project'; import { useProjectConfigStore } from '@/stores/project-config'; import { toast } from 'sonner'; type OpenDialogResult = { canceled: boolean; filePaths: string[]; }; type ProjectEntryError = { project: CodingProjectSummary; message: string; }; type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory'; function getFolderName(pathValue: string): string { const trimmed = pathValue.trim(); const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/u, ''); return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed; } type SidebarProps = { workspaceLayout?: boolean; hideOnCompact?: boolean; forceExpanded?: boolean; sidebarPeekOpen?: boolean; onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void; }; export function Sidebar({ workspaceLayout = false, hideOnCompact = false, forceExpanded = false, sidebarPeekOpen = false, onSidebarPeekChange, }: SidebarProps = {}) { const { t } = useTranslation('common'); const storedSidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const sidebarPinnedCollapsed = !forceExpanded && storedSidebarCollapsed; const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen; const projects = useCodingWorkspaceStore((state) => state.projects); const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const loadProjectList = useCodingWorkspaceStore((state) => state.load); const setActiveProject = useCodingWorkspaceStore((state) => state.setActiveProject); const removeProject = useCodingWorkspaceStore((state) => state.removeProject); const createProject = useCodingWorkspaceStore((state) => state.createProject); const projectConfigs = useProjectConfigStore((state) => state.configsByProjectId); const loadProjectConfig = useProjectConfigStore((state) => state.load); const removeProjectConfig = useProjectConfigStore((state) => state.remove); const authUser = useAuthStore((state) => state.user); const logout = useAuthStore((state) => state.logout); const { userProfile, profileRequired, profileSyncError, syncProfileNow, } = useCurrentUserProfile(); const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot); const [projectsOpen, setProjectsOpen] = useState(true); const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(''); const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState('use-selected-directory'); const [createProjectError, setCreateProjectError] = useState(null); const [creatingProject, setCreatingProject] = useState(false); const [projectEntryError, setProjectEntryError] = useState(null); const [removeProjectCandidate, setRemoveProjectCandidate] = useState(null); const [robotOverview, setRobotOverview] = useState(null); const [robotOverviewLoading, setRobotOverviewLoading] = useState(false); const [robotSelectedAgentId, setRobotSelectedAgentId] = useState(() => readAiHardwareAgentId()); const accountButtonRef = useRef(null); const accountMenuRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); const activeModule = getAiModuleForPath(location.pathname); const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; const isRobotModule = activeModule === 'robot'; const visibleProjects = projects; const selectedProjectFolderName = getFolderName(newProjectSelectedPath); const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户'; const accountFullName = accountName; const accountInitial = getAccountInitial(accountName); const accountAvatarUrl = userProfile?.avatarUrl ?? null; const settingsLabel = t('sidebar.settings', '设置'); const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null; const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => { const params = new URLSearchParams(); if (agentId) params.set('agent', agentId); if (openCreate) params.set('create', '1'); const search = params.toString(); navigate({ pathname: '/ai-hardware', search: search ? `?${search}` : '' }); announceAiHardwareNavigation({ agentId, openCreate }); }, [navigate]); useEffect(() => { if (!isProgrammingModule) return; void loadProjectList().catch(() => undefined); }, [isProgrammingModule, loadProjectList]); useEffect(() => { if (!isProgrammingModule) return; for (const project of visibleProjects) { void loadProjectConfig(project.id).catch(() => undefined); } }, [isProgrammingModule, loadProjectConfig, visibleProjects]); useEffect(() => { if (!isProgrammingModule) return; void refreshProviderSnapshot().catch(() => undefined); }, [isProgrammingModule, refreshProviderSnapshot]); useEffect(() => { if (!isRobotModule) { setRobotOverview(null); setRobotOverviewLoading(false); return undefined; } let active = true; const handleOverview = (event: Event) => { const next = (event as CustomEvent).detail; if (!next || !Array.isArray(next.agents) || !Array.isArray(next.devices)) return; setRobotOverview(next); setRobotOverviewLoading(false); }; window.addEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview); setRobotOverviewLoading(true); void getAiHardwareOverview() .then((next) => { if (!active) return; setRobotOverview(next); setRobotOverviewLoading(false); }) .catch(() => { if (!active) return; setRobotOverviewLoading(false); }); return () => { active = false; window.removeEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview); }; }, [isRobotModule]); useEffect(() => { if (!isRobotModule) { setRobotSelectedAgentId(null); return undefined; } setRobotSelectedAgentId(readAiHardwareAgentId(location.search)); const handleNavigation = (event: Event) => { const detail = (event as CustomEvent<{ agentId?: string | null; openCreate?: boolean }>).detail; if (!detail?.openCreate && detail?.agentId !== undefined) setRobotSelectedAgentId(detail.agentId); }; window.addEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation); return () => window.removeEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation); }, [isRobotModule, location.search]); useEffect(() => { if (profileRequired || profileSyncError) setProfileDialogOpen(true); }, [profileRequired, profileSyncError]); useEffect(() => { if (!accountMenuOpen) return undefined; const handleMouseDown = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Node)) return; if (accountButtonRef.current?.contains(target)) return; if (accountMenuRef.current?.contains(target)) return; setAccountMenuOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setAccountMenuOpen(false); } }; document.addEventListener('mousedown', handleMouseDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('keydown', handleKeyDown); }; }, [accountMenuOpen]); const openCreateProject = useCallback(() => { setNewProjectName(''); setNewProjectSelectedPath(''); setNewProjectDirectoryMode('use-selected-directory'); setCreateProjectError(null); setCreateDialogOpen(true); }, []); useEffect(() => { if (!isProgrammingModule) return undefined; return subscribeCodingProjectCreationRequest(openCreateProject); }, [isProgrammingModule, openCreateProject]); const closeCreateProject = () => { if (creatingProject) return; setCreateDialogOpen(false); setCreateProjectError(null); }; const chooseProjectPath = async () => { const result = await invokeIpc('dialog:open', { properties: ['openDirectory'], title: newProjectDirectoryMode === 'use-selected-directory' ? '选择要直接使用的项目文件夹' : '选择项目保存位置', }); if (result.canceled || !result.filePaths[0]) return; setNewProjectSelectedPath(result.filePaths[0]); setCreateProjectError(null); }; const showProjectEntryError = useCallback((project: CodingProjectSummary, message: string) => { setProjectEntryError({ project, message }); }, []); const enterProject = useCallback(async (project: CodingProjectSummary) => { const result = await loadProjectConfig(project.id); if (result.status !== 'valid' || !result.config) { showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效'); return; } await setActiveProject(project.id); navigate('/chat'); }, [loadProjectConfig, navigate, setActiveProject, showProjectEntryError]); const readAndEnterProject = useCallback(async (project: CodingProjectSummary) => { setProjectEntryError(null); try { await enterProject(project); } catch (error) { showProjectEntryError(project, error instanceof Error ? error.message : String(error)); } }, [enterProject, showProjectEntryError]); const confirmCreateProject = async () => { const projectName = newProjectName.trim(); const selectedPath = newProjectSelectedPath.trim(); if (newProjectDirectoryMode === 'create-child-directory' && !projectName) { setCreateProjectError('请输入项目名称'); return; } if (!selectedPath) { setCreateProjectError('请选择项目路径'); return; } setCreatingProject(true); setCreateProjectError(null); try { const project = await createProject({ projectType: 'interactive_ai_app', identity: { kind: 'create' }, ...(newProjectDirectoryMode === 'create-child-directory' ? { parentPath: selectedPath, projectName } : { projectPath: selectedPath }), }); setCreateDialogOpen(false); await enterProject(project); } catch (error) { setCreateProjectError(error instanceof Error ? error.message : String(error)); } finally { setCreatingProject(false); } }; const openProject = useCallback(async (project: CodingProjectSummary) => { await readAndEnterProject(project); }, [readAndEnterProject]); useEffect(() => { if (!isProgrammingModule) return undefined; return subscribeCodingProjectOpenRequest((projectId) => { const project = visibleProjects.find((candidate) => candidate.id === projectId); if (project) void openProject(project); }); }, [isProgrammingModule, openProject, visibleProjects]); const requestRemoveUnavailableProject = () => { if (!projectEntryError) return; setProjectEntryError(null); setRemoveProjectCandidate(projectEntryError.project); }; const handleRemoveUnavailableProject = async () => { if (!removeProjectCandidate) return; const project = removeProjectCandidate; await removeProject(project.id); removeProjectConfig(project.id); setRemoveProjectCandidate(null); toast.success(`已移除项目 ${project.name}`); }; const navigateFromAccountMenu = (path: string) => { setAccountMenuOpen(false); navigate(path); }; const openProfileDialog = () => { setAccountMenuOpen(false); setProfileDialogOpen(true); void syncProfileNow().catch(() => undefined); }; const handleAccountLogout = async () => { setAccountMenuOpen(false); await logout(); navigate('/login'); }; const sidebarPeekPreview = sidebarPinnedCollapsed && sidebarPeekOpen; return ( ); }