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, RadioTower, Settings as SettingsIcon, Ticket, 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 { AppError } from '@/lib/error-model'; import { fetchWorksResetCards, fetchWorksTokenPointBalance, redeemWorksResetCard, type WorksResetCard, type WorksTokenPointBalance, } from '@/lib/works-square'; import { formatWorksTokenPointValue, isWorksTokenPointBalanceExhausted, } from '@/lib/works-square-token-points'; 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 { 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 type { CodingProjectSummary } from '@/types/coding-project'; import { useProjectConfigStore } from '@/stores/project-config'; import { toast } from 'sonner'; type OpenDialogResult = { canceled: boolean; filePaths: string[]; }; type TokenPointState = { status: 'idle' | 'loading' | 'loaded' | 'error'; balance: WorksTokenPointBalance | null; }; type ResetCardState = { status: 'idle' | 'loading' | 'loaded' | 'error'; cards: WorksResetCard[]; }; 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; } 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 formatResetCardTime(value: string | null | undefined): string | null { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return `${date.getFullYear()}-${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`; } function getEffectiveResetCardStatus(card: WorksResetCard): WorksResetCard['status'] { if (card.status === 'available' && Date.parse(card.expires_at) <= Date.now()) return 'expired'; return card.status; } function getResetCardErrorMessage(error: unknown): string { const details = error instanceof AppError ? error.details : undefined; const backendCode = typeof details?.backendCode === 'string' ? details.backendCode : null; const status = typeof details?.status === 'number' ? details.status : null; const messages: Record = { reset_card_expired: '这张重置卡已过期,卡包已刷新。', token_point_wallet_owner_required: '当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。', billing_temporarily_paused: '计费服务正在切换,请稍后再试。', token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。', }; if (backendCode && messages[backendCode]) return messages[backendCode]; if (status === 404) return '重置卡不存在或已不可用。'; if (status === 409) return '当前有待处理的计费操作,暂时不能使用重置卡。'; return '重置卡暂时无法使用,请稍后重试。'; } function isTokenPointBalanceExhausted(state: TokenPointState): boolean { if (state.status !== 'loaded' || !state.balance) return false; return isWorksTokenPointBalanceExhausted(state.balance); } function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string { if (!user) return '未登录'; if (state.balance && !state.balance.can_manage_membership) { return state.balance.family_shared ? '共享会员' : '青少年账户'; } const planName = state.balance?.plan_name?.trim(); if (planName) return planName; const planCode = state.balance?.plan_code?.trim(); if (planCode) return planCode; return '会员'; } function isExperienceSubscriptionPlan(state: TokenPointState): boolean { const planCode = state.balance?.plan_code?.trim().toLowerCase(); const planName = state.balance?.plan_name?.trim().toLowerCase(); return planCode === 'experience' || planName === '体验'; } function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string { const levelLabel = getSubscriptionPlanLabel(user, state); if ( !user || isExperienceSubscriptionPlan(state) || levelLabel === '会员' || levelLabel === '共享会员' || levelLabel === '青少年账户' ) return levelLabel; return `${levelLabel} 会员`; } function getWeeklyPointLabel(balance: WorksTokenPointBalance | null): string { const remaining = formatWorksTokenPointValue(balance?.weekly_remaining); const allowance = formatWorksTokenPointValue(balance?.weekly_allowance); if (remaining && allowance) return `${remaining} / ${allowance} 点`; return remaining ? `${remaining} 点` : '—'; } function getTotalPointLabel(balance: WorksTokenPointBalance | null): string { const remaining = formatWorksTokenPointValue(balance?.total_remaining); return remaining ? `${remaining} 点` : '—'; } function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string { if (balance?.shared_available === true) return '可用'; if (balance?.shared_available === false) return '已用尽'; return balance?.family_shared ? '由家庭管理员管理' : '由家长管理'; } 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 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 [resetCardDrawerOpen, setResetCardDrawerOpen] = useState(false); const [tokenPointState, setTokenPointState] = useState({ status: 'idle', balance: null }); const [resetCardState, setResetCardState] = useState({ status: 'idle', cards: [] }); const [redeemingResetCardId, setRedeemingResetCardId] = useState(null); 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 tokenPointRequestIdRef = useRef(0); const resetCardRequestIdRef = useRef(0); 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 memberLabel = getSubscriptionPlanLabel(authUser, tokenPointState); const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenPointState); const tokenPointBalanceExhausted = isTokenPointBalanceExhausted(tokenPointState); const tokenPointBalance = tokenPointState.balance; const tokenPointsCoarse = Boolean( tokenPointBalance && !tokenPointBalance.can_manage_membership, ); const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at); const availableResetCardCount = resetCardState.cards.filter( (card) => getEffectiveResetCardStatus(card) === 'available', ).length; const resetCardRedemptionBlocked = tokenPointBalance?.entitlement_source === 'shared_group'; 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); setResetCardDrawerOpen(false); } }, [accountMenuOpen]); const refreshTokenPoints = useCallback(() => { if (!authUser) { tokenPointRequestIdRef.current += 1; setTokenPointState({ status: 'idle', balance: null }); return; } const requestId = tokenPointRequestIdRef.current + 1; tokenPointRequestIdRef.current = requestId; setTokenPointState((current) => ({ status: 'loading', balance: current.balance })); void getValidAccessToken() .then((accessToken) => { if (!accessToken) { throw new Error('请先登录后再查看用量'); } return fetchWorksTokenPointBalance(accessToken); }) .then((balance) => { if (tokenPointRequestIdRef.current === requestId) { setTokenPointState({ status: 'loaded', balance }); } }) .catch(() => { if (tokenPointRequestIdRef.current === requestId) { setTokenPointState({ status: 'error', balance: null }); } }); }, [authUser, getValidAccessToken]); const refreshResetCards = useCallback(async (): Promise => { if (!authUser) { resetCardRequestIdRef.current += 1; setResetCardState({ status: 'idle', cards: [] }); return; } const requestId = resetCardRequestIdRef.current + 1; resetCardRequestIdRef.current = requestId; setResetCardState((current) => ({ status: 'loading', cards: current.cards })); try { const accessToken = await getValidAccessToken(); if (!accessToken) throw new Error('请先登录后再查看重置卡'); const cards = await fetchWorksResetCards(accessToken); if (resetCardRequestIdRef.current === requestId) { setResetCardState({ status: 'loaded', cards }); } } catch { if (resetCardRequestIdRef.current === requestId) { setResetCardState((current) => ({ status: 'error', cards: current.cards })); } } }, [authUser, getValidAccessToken]); useEffect(() => { refreshTokenPoints(); }, [refreshTokenPoints]); useEffect(() => { resetCardRequestIdRef.current += 1; setResetCardState({ status: 'idle', cards: [] }); setRedeemingResetCardId(null); }, [authUser?.userId]); useEffect(() => { if (!authUser) return undefined; const handleFocus = () => { refreshTokenPoints(); if (resetCardDrawerOpen) void refreshResetCards(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { refreshTokenPoints(); if (resetCardDrawerOpen) void refreshResetCards(); } }; window.addEventListener('focus', handleFocus); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]); useEffect(() => { if (authUser && accountMenuOpen) { refreshTokenPoints(); } }, [accountMenuOpen, authUser, refreshTokenPoints]); useEffect(() => { if (authUser && resetCardDrawerOpen) { void refreshResetCards(); } }, [authUser, refreshResetCards, resetCardDrawerOpen]); 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); setUsageDrawerOpen(false); setResetCardDrawerOpen(false); navigate(path); }; const openProfileDialog = () => { setAccountMenuOpen(false); setUsageDrawerOpen(false); setResetCardDrawerOpen(false); setProfileDialogOpen(true); void syncProfileNow().catch(() => undefined); }; const openSubscriptionUpgrade = () => { void openWorksSquareSubscriptionUpgrade().catch(() => undefined); }; const handleRedeemResetCard = async (card: WorksResetCard) => { if (redeemingResetCardId) return; if (getEffectiveResetCardStatus(card) !== 'available') { toast.error('这张重置卡已过期。'); void refreshResetCards(); return; } if (resetCardRedemptionBlocked) { toast.error('当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。'); return; } setRedeemingResetCardId(card.id); try { const accessToken = await getValidAccessToken(); if (!accessToken) throw new Error('请先登录后再使用重置卡'); const redeemedCard = await redeemWorksResetCard(accessToken, card.id); setResetCardState((current) => ({ status: 'loaded', cards: current.cards.map((item) => item.id === redeemedCard.id ? redeemedCard : item), })); toast.success('重置卡已使用,本周额度已刷新。'); refreshTokenPoints(); void refreshResetCards(); } catch (error) { toast.error(getResetCardErrorMessage(error)); void refreshResetCards(); } finally { setRedeemingResetCardId((current) => current === card.id ? null : current); } }; const handleAccountLogout = async () => { setAccountMenuOpen(false); setUsageDrawerOpen(false); setResetCardDrawerOpen(false); resetCardRequestIdRef.current += 1; setResetCardState({ status: 'idle', cards: [] }); await logout(); navigate('/login'); }; const sidebarPeekPreview = sidebarPinnedCollapsed && sidebarPeekOpen; return ( ); }