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 { BarChart3, Bot, Cpu, ChevronRight, Crown, ExternalLink, FolderKanban, LogOut, Plus, Puzzle, 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 { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events'; import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square'; import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage'; import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade'; 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 { LearningSidebar } from './LearningSidebar'; 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, type AuthUser } from '@/stores/auth'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProviderStore } from '@/stores/providers'; import { isCanonicalCodingProjectId } from '@/lib/coding-projects'; import type { CodingProjectSummary } from '@/types/coding-project'; import { useProjectConfigStore } from '@/stores/project-config'; import type { ProjectType } from '../../../shared/project-config'; import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; import { toast } from 'sonner'; type OpenDialogResult = { canceled: boolean; filePaths: string[]; }; type TokenUsageState = { status: 'idle' | 'loading' | 'loaded' | 'error'; usage: WorksTokenUsage | null; error?: string; }; type ProjectEntryError = { project: CodingProjectSummary; message: string; }; type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory'; type ProjectIdentityKind = ProjectIdentityChoice['kind']; function getFolderName(pathValue: string): string { const trimmed = pathValue.trim(); const withoutTrailingSeparators = trimmed.replace(/[\\/]+$/u, ''); return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed; } function formatRemainingPercent(value: number | null | undefined): string { if (typeof value !== 'number' || !Number.isFinite(value)) return '不限'; const clamped = Math.min(100, Math.max(0, value)); return `${Math.round(clamped)}%`; } function padTimePart(value: number): string { return String(value).padStart(2, '0'); } function formatRefreshTime(value: string | null | undefined): string | null { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return `${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`; } function getRefreshTimeLabel(value: string | null | undefined): string | null { const formatted = formatRefreshTime(value); return formatted ? `刷新时间 ${formatted}` : null; } function isTokenUsageExhausted(state: TokenUsageState): boolean { if (state.status !== 'loaded' || !state.usage) return false; return isWorksTokenUsageExhausted(state.usage); } function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenUsageState): string { if (!user) return '未登录'; const planName = state.usage?.plan_name?.trim(); if (planName) return planName; const planCode = state.usage?.plan_code?.trim(); if (planCode) return planCode; return '会员'; } function isFreeSubscriptionPlan(state: TokenUsageState): boolean { const planCode = state.usage?.plan_code?.trim().toLowerCase(); const planName = state.usage?.plan_name?.trim().toLowerCase(); return planCode === 'free' || planName === 'free'; } function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenUsageState): string { const levelLabel = getSubscriptionPlanLabel(user, state); if (!user || isFreeSubscriptionPlan(state) || levelLabel === '会员') return levelLabel; return `${levelLabel} 会员`; } type SidebarProps = { workspaceLayout?: boolean; sidebarPeekOpen?: boolean; onSidebarPeekChange?: (open: boolean, source: SidebarPeekSource) => void; }; export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSidebarPeekChange }: SidebarProps = {}) { const { t } = useTranslation('common'); const sidebarPinnedCollapsed = useSettingsStore((state) => state.sidebarCollapsed); 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 getValidAccessToken = useAuthStore((state) => state.getValidAccessToken); 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 [usageDrawerOpen, setUsageDrawerOpen] = useState(false); const [tokenUsageState, setTokenUsageState] = useState({ status: 'idle', usage: null }); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(''); const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState('use-selected-directory'); const [newProjectType, setNewProjectType] = useState('mini_game'); const [newProjectIdentityKind, setNewProjectIdentityKind] = useState('create'); const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState(''); 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 tokenUsageRequestIdRef = useRef(0); const navigate = useNavigate(); const location = useLocation(); const activeModule = getAiModuleForPath(location.pathname); const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; const isLearningModule = activeModule === 'learning'; const isRobotModule = activeModule === 'robot'; const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const projectConfigPath = '/project-config'; 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 memberLabel = getSubscriptionPlanLabel(authUser, tokenUsageState); const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenUsageState); const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState); const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at); const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at); 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]); useEffect(() => { if (!accountMenuOpen) { setUsageDrawerOpen(false); } }, [accountMenuOpen]); const refreshTokenUsage = useCallback(() => { if (!authUser) { tokenUsageRequestIdRef.current += 1; setTokenUsageState({ status: 'idle', usage: null }); return; } const requestId = tokenUsageRequestIdRef.current + 1; tokenUsageRequestIdRef.current = requestId; setTokenUsageState((current) => ({ status: 'loading', usage: current.usage })); void getValidAccessToken() .then((accessToken) => { if (!accessToken) { throw new Error('请先登录后再查看用量'); } return fetchWorksTokenUsage(accessToken); }) .then((usage) => { if (tokenUsageRequestIdRef.current === requestId) { setTokenUsageState({ status: 'loaded', usage }); } }) .catch((error) => { if (tokenUsageRequestIdRef.current === requestId) { setTokenUsageState({ status: 'error', usage: null, error: error instanceof Error ? error.message : '暂时无法获取套餐额度', }); } }); }, [authUser, getValidAccessToken]); useEffect(() => { refreshTokenUsage(); }, [refreshTokenUsage]); useEffect(() => { if (!authUser) return undefined; const handleFocus = () => { refreshTokenUsage(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { refreshTokenUsage(); } }; window.addEventListener('focus', handleFocus); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [authUser, refreshTokenUsage]); useEffect(() => { if (authUser && accountMenuOpen) { refreshTokenUsage(); } }, [accountMenuOpen, authUser, refreshTokenUsage]); useEffect(() => { if (!authUser) return undefined; const retryTimers = new Set(); const handleTokenUsageStale = () => { refreshTokenUsage(); const retryTimer = window.setTimeout(() => { retryTimers.delete(retryTimer); refreshTokenUsage(); }, 1500); retryTimers.add(retryTimer); }; window.addEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale); return () => { window.removeEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale); for (const retryTimer of retryTimers) { window.clearTimeout(retryTimer); } }; }, [authUser, refreshTokenUsage]); const openCreateProject = () => { setNewProjectName(''); setNewProjectSelectedPath(''); setNewProjectDirectoryMode('use-selected-directory'); setNewProjectType('mini_game'); setNewProjectIdentityKind('create'); setNewProjectIdentityProjectId(''); setCreateProjectError(null); setCreateDialogOpen(true); }; const closeCreateProject = () => { if (creatingProject) return; setCreateDialogOpen(false); setNewProjectType('mini_game'); setNewProjectIdentityKind('create'); setNewProjectIdentityProjectId(''); 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 = (project: CodingProjectSummary, message: string) => { setProjectEntryError({ project, message }); }; const enterProject = async (project: CodingProjectSummary) => { const result = await loadProjectConfig(project.id); if (result.status !== 'valid' || !result.config) { showProjectEntryError(project, result.status === 'missing' ? '项目缺少 .niancode/project.json' : result.error ?? '项目配置无效'); return; } await setActiveProject(project.id); navigate(result.config.initialized ? '/chat' : projectConfigPath); }; const readAndEnterProject = async (project: CodingProjectSummary) => { setProjectEntryError(null); try { await enterProject(project); } catch (error) { showProjectEntryError(project, error instanceof Error ? error.message : String(error)); } }; const confirmCreateProject = async () => { const projectName = newProjectName.trim(); const selectedPath = newProjectSelectedPath.trim(); if (newProjectDirectoryMode === 'create-child-directory' && !projectName) { setCreateProjectError('请输入项目名称'); return; } if (!selectedPath) { setCreateProjectError('请选择项目路径'); return; } const boundProjectId = newProjectIdentityProjectId.trim(); if (newProjectIdentityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) { setCreateProjectError('请输入有效的项目 ID(小写 UUID)。'); return; } setCreatingProject(true); setCreateProjectError(null); try { const project = await createProject({ projectType: newProjectType, identity: newProjectIdentityKind === 'create' ? { kind: 'create' } : { kind: 'bind', projectId: boundProjectId }, ...(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 = async (project: CodingProjectSummary) => { await readAndEnterProject(project); }; 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); setUsageDrawerOpen(false); navigate(path); }; const openProfileDialog = () => { setAccountMenuOpen(false); setUsageDrawerOpen(false); setProfileDialogOpen(true); void syncProfileNow().catch(() => undefined); }; const openSubscriptionUpgrade = () => { void openWorksSquareSubscriptionUpgrade().catch(() => undefined); }; const handleAccountLogout = async () => { setAccountMenuOpen(false); setUsageDrawerOpen(false); await logout(); navigate('/login'); }; const sidebarPeekPreview = sidebarPinnedCollapsed && sidebarPeekOpen; return ( ); }