| 1 | import { useMemo } from 'react' |
| 2 | import { ipc } from '@renderer/lib/ipc' |
| 3 | import { |
| 4 | isStyleSwitchPageLocked, |
| 5 | hydrateStyleSwitchJob, |
| 6 | useGenerateStore, |
| 7 | useSessionDetailUiStore, |
| 8 | useSessionStore, |
| 9 | useToastStore |
| 10 | } from '@renderer/store' |
| 11 | import { useT } from '@renderer/i18n' |
| 12 | import { useModelAction } from '@renderer/hooks/useModelAction' |
| 13 | import { isPageGenerationLocked, normalizePagesForSelection } from '../shared/pageUtils' |
| 14 | import type { SessionPreviewPage } from '../shared/types' |
| 15 | import { useSessionPageActions } from '../hooks/useSessionPageActions' |
| 16 | import { useSessionReorderPages } from '../hooks/useSessionReorderPages' |
| 17 | import { isDefaultSlideSize, trySessionSlideSize } from '@shared/slide-size' |
| 18 | |
| 19 | export function usePageSidebarController(sessionId: string) { |
| 20 | const t = useT() |
| 21 | const modelAction = useModelAction() |
| 22 | const { reorder: reorderSessionPages } = useSessionReorderPages(sessionId) |
| 23 | const currentPages = useGenerateStore((state) => state.currentPages) |
| 24 | const isGenerating = useGenerateStore((state) => state.isGenerating) |
| 25 | const pageEditJob = useGenerateStore((state) => state.pageEditJobs[sessionId] || null) |
| 26 | const pageBeautifyJob = useGenerateStore((state) => state.pageBeautifyJobs[sessionId] || null) |
| 27 | const deckEditJob = useGenerateStore((state) => state.deckEditJobs[sessionId] || null) |
| 28 | const styleSwitchJob = useGenerateStore((state) => state.styleSwitchJobs[sessionId] || null) |
| 29 | const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId) |
| 30 | const interactionMode = useSessionDetailUiStore((state) => state.interactionMode) |
| 31 | const isAddingPage = useSessionDetailUiStore((state) => state.isAddingPage) |
| 32 | const addingPageId = useSessionDetailUiStore((state) => state.addingPageId) |
| 33 | const isRetryingSinglePage = useSessionDetailUiStore((state) => state.isRetryingSinglePage) |
| 34 | const retryingSinglePageId = useSessionDetailUiStore((state) => state.retryingSinglePageId) |
| 35 | const isManagingPages = useSessionDetailUiStore((state) => state.isManagingPages) |
| 36 | const sidebarCollapsed = useSessionDetailUiStore((state) => state.sidebarCollapsed) |
| 37 | const toggleSidebarCollapsed = useSessionDetailUiStore((state) => state.toggleSidebarCollapsed) |
| 38 | const setAddPageDialogOpen = useSessionDetailUiStore((state) => state.setAddPageDialogOpen) |
| 39 | const setMergeSessionPagesDialogOpen = useSessionDetailUiStore( |
| 40 | (state) => state.setMergeSessionPagesDialogOpen |
| 41 | ) |
| 42 | const setMergeTemplatePagesDialogOpen = useSessionDetailUiStore( |
| 43 | (state) => state.setMergeTemplatePagesDialogOpen |
| 44 | ) |
| 45 | const openBlankPageDialog = useSessionDetailUiStore((state) => state.openBlankPageDialog) |
| 46 | const currentSession = useSessionStore((state) => state.currentSession) |
| 47 | const slideSize = trySessionSlideSize(currentSession) |
| 48 | const toastError = useToastStore((state) => state.error) |
| 49 | const pageActions = useSessionPageActions(sessionId) |
| 50 | const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages]) |
| 51 | const selectedPage = useMemo( |
| 52 | () => pages.find((page) => page.id === selectedPageId) ?? pages[0] ?? null, |
| 53 | [pages, selectedPageId] |
| 54 | ) |
| 55 | const isStyleSwitchActive = |
| 56 | styleSwitchJob?.status === 'starting' || |
| 57 | styleSwitchJob?.status === 'running' || |
| 58 | styleSwitchJob?.status === 'cancelling' |
| 59 | const hasPageScopedGeneration = |
| 60 | isAddingPage || |
| 61 | isRetryingSinglePage || |
| 62 | Boolean(pageEditJob) || |
| 63 | Boolean(pageBeautifyJob) || |
| 64 | Boolean(styleSwitchJob) |
| 65 | const isSessionWideGenerating = isGenerating && !hasPageScopedGeneration |
| 66 | const isPageActionDisabled = (page: SessionPreviewPage): boolean => |
| 67 | isSessionWideGenerating || |
| 68 | isPageGenerationLocked(page.id, { |
| 69 | isAddingPage, |
| 70 | addingPageId, |
| 71 | isRetryingSinglePage, |
| 72 | retryingSinglePageId |
| 73 | }) || |
| 74 | pageEditJob?.pageId === page.pageId || |
| 75 | pageBeautifyJob?.pageId === page.pageId || |
| 76 | Boolean(deckEditJob) || |
| 77 | isStyleSwitchPageLocked(styleSwitchJob, page.pageId) || |
| 78 | isManagingPages |
| 79 | |
| 80 | const handleRetryFailedPage = async (page: SessionPreviewPage): Promise<void> => { |
| 81 | if (!sessionId || !page.id) return |
| 82 | if (isStyleSwitchActive) return |
| 83 | const stylePageId = page.pageId || page.id |
| 84 | const styleSwitchFailedPage = styleSwitchJob?.pages.find( |
| 85 | (item) => item.pageId === stylePageId && item.status === 'failed' |
| 86 | ) |
| 87 | if (styleSwitchJob && styleSwitchFailedPage) { |
| 88 | const modelConfigId = await modelAction.ensureModelActive() |
| 89 | if (!modelConfigId) return |
| 90 | useGenerateStore.getState().startStyleSwitch(sessionId, { |
| 91 | styleId: styleSwitchJob.styleId, |
| 92 | styleName: styleSwitchJob.styleName, |
| 93 | totalPages: 1, |
| 94 | pages: [{ ...styleSwitchFailedPage, status: 'pending', error: null }] |
| 95 | }) |
| 96 | try { |
| 97 | const result = await ipc.retryStyleSwitchPage({ |
| 98 | sessionId, |
| 99 | failedRunId: styleSwitchJob.runId, |
| 100 | pageId: styleSwitchFailedPage.pageId, |
| 101 | modelConfigId |
| 102 | }) |
| 103 | if (result.alreadyRunning) { |
| 104 | hydrateStyleSwitchJob(sessionId, await ipc.getStyleSwitchState(sessionId)) |
| 105 | return |
| 106 | } |
| 107 | if (result.runId) { |
| 108 | const currentJob = useGenerateStore.getState().styleSwitchJobs[sessionId] |
| 109 | if (currentJob) { |
| 110 | useGenerateStore.getState().updateStyleSwitchJob(sessionId, { |
| 111 | runId: result.runId, |
| 112 | status: currentJob.status === 'cancelling' ? 'cancelling' : 'running' |
| 113 | }) |
| 114 | } |
| 115 | } |
| 116 | } catch (err) { |
| 117 | const message = err instanceof Error ? err.message : t('sessionDetail.retryPageFailed') |
| 118 | useGenerateStore |
| 119 | .getState() |
| 120 | .finishStyleSwitch(sessionId, { status: 'failed', error: message }) |
| 121 | toastError(message) |
| 122 | } |
| 123 | return |
| 124 | } |
| 125 | useSessionDetailUiStore.getState().setIsRetryingSinglePage(true) |
| 126 | useSessionDetailUiStore.getState().setRetryingSinglePageId(page.id) |
| 127 | useGenerateStore.setState({ isGenerating: true, error: null, status: 'running' }) |
| 128 | useGenerateStore.getState().addPage({ ...page, status: 'generating', error: null }) |
| 129 | let handedToJob = false |
| 130 | try { |
| 131 | const modelConfigId = await modelAction.ensureModelActive() |
| 132 | if (!modelConfigId) return |
| 133 | const result = await ipc.retrySinglePage({ sessionId, pageId: page.id, modelConfigId }) |
| 134 | if (result.alreadyRunning) return |
| 135 | handedToJob = true |
| 136 | void ipc |
| 137 | .clearSpeechScript(sessionId) |
| 138 | .catch((err) => console.warn('[speech] clearSpeechScript failed', err)) |
| 139 | } catch (err) { |
| 140 | const message = err instanceof Error ? err.message : t('sessionDetail.retryPageFailed') |
| 141 | console.warn('[session-detail] retry single page failed', message) |
| 142 | } finally { |
| 143 | if (!handedToJob) { |
| 144 | useGenerateStore.getState().addPage(page) |
| 145 | useGenerateStore.getState().finishGeneration() |
| 146 | useSessionDetailUiStore.getState().setIsRetryingSinglePage(false) |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | const handleReorderPages = async ( |
| 152 | orderedPageIds: string[], |
| 153 | selectedForKeep?: string |
| 154 | ): Promise<void> => { |
| 155 | await reorderSessionPages(orderedPageIds, selectedForKeep) |
| 156 | } |
| 157 | |
| 158 | const handleUpdatePageOutline = async ( |
| 159 | page: SessionPreviewPage, |
| 160 | contentOutline: string |
| 161 | ): Promise<void> => { |
| 162 | if (!sessionId) return |
| 163 | const normalizedOutline = contentOutline.replace(/\s+/g, ' ').trim() |
| 164 | if (normalizedOutline === (page.contentOutline || '').trim()) return |
| 165 | useSessionDetailUiStore.getState().setIsManagingPages(true) |
| 166 | try { |
| 167 | const result = await ipc.updateSessionPageOutline({ |
| 168 | sessionId, |
| 169 | pageId: page.id, |
| 170 | contentOutline: normalizedOutline |
| 171 | }) |
| 172 | useGenerateStore.getState().setPages(result.generatedPages) |
| 173 | useSessionDetailUiStore.getState().setSelectedPageId(result.selectedPageId || page.id) |
| 174 | void ipc |
| 175 | .clearSpeechScript(sessionId) |
| 176 | .catch((err) => console.warn('[speech] clearSpeechScript failed', err)) |
| 177 | } catch (error) { |
| 178 | toastError(error instanceof Error ? error.message : t('pageManagement.updateOutlineFailed')) |
| 179 | throw error |
| 180 | } finally { |
| 181 | useSessionDetailUiStore.getState().setIsManagingPages(false) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | return { |
| 186 | pages, |
| 187 | disabled: (interactionMode === 'ai-inspect' && isSessionWideGenerating) || Boolean(deckEditJob), |
| 188 | pageManagementDisabled: |
| 189 | isGenerating || |
| 190 | Boolean(pageEditJob) || |
| 191 | Boolean(pageBeautifyJob) || |
| 192 | Boolean(deckEditJob) || |
| 193 | isStyleSwitchActive || |
| 194 | isAddingPage || |
| 195 | isRetryingSinglePage || |
| 196 | isManagingPages, |
| 197 | collapsed: sidebarCollapsed, |
| 198 | onAddBlankPage: () => openBlankPageDialog(selectedPage?.id || pages[0]?.id || ''), |
| 199 | onAddPage: () => setAddPageDialogOpen(true), |
| 200 | onMergeSessionPages: () => setMergeSessionPagesDialogOpen(true), |
| 201 | onMergeTemplatePages: () => setMergeTemplatePagesDialogOpen(true), |
| 202 | onRetryFailedPage: (page: SessionPreviewPage) => void handleRetryFailedPage(page), |
| 203 | onReorderPages: handleReorderPages, |
| 204 | onDeletePage: pageActions.deletePage, |
| 205 | onRenamePage: pageActions.renamePage, |
| 206 | onDuplicatePage: pageActions.duplicatePage, |
| 207 | onUpdatePageOutline: handleUpdatePageOutline, |
| 208 | onExportPagePptx: pageActions.exportPagePptx, |
| 209 | canExportPptx: slideSize ? isDefaultSlideSize(slideSize) : false, |
| 210 | onDownloadAllOutlines: pageActions.exportOutlinesMarkdown, |
| 211 | isPageActionDisabled, |
| 212 | onToggleCollapsed: toggleSidebarCollapsed |
| 213 | } |
| 214 | } |
| 215 |