返回 oh-my-ppt
useChatPanelController.ts
根目录 / src / renderer / src / components / session-detail / hooks / useChatPanelController.ts
1 import { useMemo, useRef } from 'react'
2 import { useT } from '@renderer/i18n'
3 import { ipc, type UploadAssetsPayload } from '@renderer/lib/ipc'
4 import {
5 useGenerateStore,
6 useEditHistoryStore,
7 useSessionDetailUiStore,
8 useSessionStore,
9 useToastStore
10 } from '@renderer/store'
11 import type { GenerateStartPayload } from '@shared/generation.js'
12 import type { ChatPanelController } from '@renderer/types/session-detail'
13 import { normalizePagesForSelection } from '../shared/pageUtils'
14 import { isChatSendBlocked, resolveChatSendContext, resolveMainSessionEdit } from './chatSendUtils'
15 import { useCancelStyleSwitch } from './useCancelStyleSwitch'
16
17 const isSupportedMediaFile = (file: File): boolean => {
18 if (file.type.startsWith('image/')) return true
19 if (/^video\/(mp4|webm|ogg)$/i.test(file.type)) return true
20 return /\.(png|jpe?g|webp|gif|svg|mp4|webm|ogg)$/i.test(file.name)
21 }
22
23 const isCancellationMessage = (message: string): boolean =>
24 /^(生成已取消|Generation cancelled|Generation canceled)$/i.test(message.trim())
25
26 export function useChatPanelController(sessionId: string): ChatPanelController {
27 const t = useT()
28 const currentPages = useGenerateStore((state) => state.currentPages)
29 const isGenerating = useGenerateStore((state) => state.isGenerating)
30 const progress = useGenerateStore((state) => state.progress)
31 const pageEditJob = useGenerateStore((state) => state.pageEditJobs[sessionId] || null)
32 const deckEditJob = useGenerateStore((state) => state.deckEditJobs[sessionId] || null)
33 const styleSwitchJob = useGenerateStore((state) => state.styleSwitchJobs[sessionId] || null)
34 const deckEditRetry = useGenerateStore((state) => state.deckEditRetries[sessionId] || null)
35 const pageEditPlanning = useGenerateStore((state) => state.pageEditPlanning[sessionId] || null)
36 const error = useGenerateStore((state) => state.sessionErrors[sessionId] || null)
37 const selectedPageEntityId = useSessionDetailUiStore((state) => state.selectedPageId)
38 const addMessage = useSessionStore((state) => state.addMessage)
39 const toastSuccess = useToastStore((state) => state.success)
40 const toastError = useToastStore((state) => state.error)
41 const toastWarning = useToastStore((state) => state.warning)
42 const cancelStyleSwitch = useCancelStyleSwitch(sessionId)
43 const sendingMessageRef = useRef(false)
44
45 const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages])
46 const selectedPage = useMemo(
47 () => pages.find((page) => page.id === selectedPageEntityId) ?? pages[0] ?? null,
48 [pages, selectedPageEntityId]
49 )
50 const selectedPageId = selectedPage?.pageId
51 const isPageEditing = pageEditJob?.pageId === selectedPageId
52 const isPlanningCurrentPage =
53 pageEditPlanning?.isAssessing === true && pageEditPlanning.pageId === selectedPageId
54 const pendingPageEditPlanForCurrentPage =
55 pageEditPlanning?.pendingPlan?.targetPageId === selectedPageId
56 ? pageEditPlanning.pendingPlan
57 : null
58 const hasActivePageEditJob = Boolean(pageEditJob)
59 const isDeckEditing = Boolean(deckEditJob)
60 const isStyleSwitching =
61 styleSwitchJob?.status === 'starting' ||
62 styleSwitchJob?.status === 'running' ||
63 styleSwitchJob?.status === 'cancelling'
64 const isSending =
65 isGenerating ||
66 isPlanningCurrentPage ||
67 isPageEditing ||
68 Boolean(pendingPageEditPlanForCurrentPage) ||
69 isDeckEditing ||
70 isStyleSwitching
71
72 const uploadFiles = async (files: File[]): Promise<void> => {
73 const generateState = useGenerateStore.getState()
74 const planning = generateState.pageEditPlanning[sessionId]
75 const isAssessingSelectedPage =
76 Boolean(selectedPageId) &&
77 planning?.isAssessing === true &&
78 planning.pageId === selectedPageId
79 if (
80 !sessionId ||
81 files.length === 0 ||
82 generateState.isGenerating ||
83 isStyleSwitching ||
84 isAssessingSelectedPage
85 )
86 return
87 const mediaFiles = files.filter(isSupportedMediaFile).slice(0, 10)
88 if (mediaFiles.length === 0) {
89 toastWarning(t('sessionDetail.mediaOnly'))
90 return
91 }
92 const payloadFiles: UploadAssetsPayload['files'] = mediaFiles
93 .map((file) => ({
94 path: window.electron?.getPathForFile?.(file) || '',
95 name: file.name
96 }))
97 .filter((file) => file.path)
98 if (payloadFiles.length === 0) {
99 toastError(t('sessionDetail.mediaPathFailed'))
100 return
101 }
102 useSessionDetailUiStore.getState().setIsUploadingAssets(true)
103 try {
104 const result = await ipc.uploadAssets({ sessionId, files: payloadFiles })
105 if (result.assets.length > 0) {
106 useSessionDetailUiStore.getState().addPendingAssets(result.assets)
107 toastSuccess(t('sessionDetail.assetsAdded', { count: result.assets.length }))
108 }
109 } catch (uploadError) {
110 toastError(
111 uploadError instanceof Error ? uploadError.message : t('sessionDetail.assetUploadFailed')
112 )
113 } finally {
114 useSessionDetailUiStore.getState().setIsUploadingAssets(false)
115 useSessionDetailUiStore.getState().setAssetDragActive(false)
116 }
117 }
118
119 const chooseAssets = async (assetType: 'image' | 'video'): Promise<void> => {
120 const generateState = useGenerateStore.getState()
121 const planning = generateState.pageEditPlanning[sessionId]
122 const isAssessingSelectedPage =
123 Boolean(selectedPageId) &&
124 planning?.isAssessing === true &&
125 planning.pageId === selectedPageId
126 if (
127 !sessionId ||
128 useSessionDetailUiStore.getState().isUploadingAssets ||
129 generateState.isGenerating ||
130 isStyleSwitching ||
131 isAssessingSelectedPage
132 )
133 return
134 useSessionDetailUiStore.getState().setIsUploadingAssets(true)
135 try {
136 const result = await ipc.chooseAndUploadAssets(sessionId, assetType)
137 if (result.cancelled) return
138 if (result.assets.length > 0) {
139 useSessionDetailUiStore.getState().addPendingAssets(result.assets)
140 toastSuccess(t('sessionDetail.assetsAdded', { count: result.assets.length }))
141 }
142 } catch (uploadError) {
143 toastError(
144 uploadError instanceof Error ? uploadError.message : t('sessionDetail.assetUploadFailed')
145 )
146 } finally {
147 useSessionDetailUiStore.getState().setIsUploadingAssets(false)
148 }
149 }
150
151 const send = async (modelConfigId: string, selectPageIds: string[] = []): Promise<boolean> => {
152 const detailState = useSessionDetailUiStore.getState()
153 const initialGenerateState = useGenerateStore.getState()
154 if (
155 isChatSendBlocked({
156 sessionId,
157 sending: sendingMessageRef.current,
158 generating:
159 initialGenerateState.isGenerating ||
160 initialGenerateState.styleSwitchJobs[sessionId]?.status === 'starting' ||
161 initialGenerateState.styleSwitchJobs[sessionId]?.status === 'running' ||
162 initialGenerateState.styleSwitchJobs[sessionId]?.status === 'cancelling',
163 input: detailState.input,
164 pendingAssetCount: detailState.pendingAssets.length
165 })
166 ) {
167 return false
168 }
169
170 const content = detailState.input.trim() || t('sessionDetail.useUploadedAssets')
171 const assetsForMessage = detailState.pendingAssets
172 const imagePaths = assetsForMessage
173 .map((asset) => asset.relativePath)
174 .filter((item) => item.startsWith('./images/'))
175 const videoPaths = assetsForMessage
176 .map((asset) => asset.relativePath)
177 .filter((item) => item.startsWith('./videos/'))
178 const context = resolveChatSendContext({
179 selectedSelector: detailState.selectedSelector,
180 chatType: detailState.chatType,
181 selectedPage,
182 firstPage: pages[0] ?? null
183 })
184 if (!context.ready) {
185 toastError(t('sessionDetail.selectPageFirst'))
186 return false
187 }
188 const generateState = useGenerateStore.getState()
189 const sessionPageEditJob = generateState.pageEditJobs[sessionId]
190 const sessionDeckEditJob = generateState.deckEditJobs[sessionId]
191 const sessionPlanning = generateState.pageEditPlanning[sessionId]
192 if (sessionPageEditJob) {
193 toastWarning(
194 t(
195 sessionPageEditJob.pageId === selectedPage?.pageId
196 ? 'sessionDetail.pageEditPlanWaitForJob'
197 : 'sessionDetail.pageEditOtherPageBusy'
198 )
199 )
200 return false
201 }
202 if (sessionPlanning?.isAssessing === true || sessionPlanning?.pendingPlan) {
203 toastWarning(t('sessionDetail.pageEditAssessmentBusy'))
204 return false
205 }
206 if (generateState.isGenerating || sessionDeckEditJob) {
207 return false
208 }
209 if (context.hasSelector && detailState.chatType !== 'page') detailState.setChatType('page')
210
211 const mainEditResolution =
212 context.chatType === 'main' && pages.length > 0
213 ? resolveMainSessionEdit(content, pages, selectedPage?.pageId, selectPageIds)
214 : { ready: true as const, selectPageIds: [] }
215 if (!mainEditResolution.ready) {
216 toastWarning(
217 t(
218 mainEditResolution.reason === 'page-structure'
219 ? 'sessionDetail.mainPageStructureUnsupported'
220 : 'sessionDetail.mainPageScopeNotFound'
221 )
222 )
223 return false
224 }
225 const effectiveSelectPageIds =
226 context.chatType === 'main' ? mainEditResolution.selectPageIds : []
227 const selectedScopePages =
228 context.chatType === 'main' && effectiveSelectPageIds.length > 0
229 ? pages
230 .filter((page) => effectiveSelectPageIds.includes(page.pageId))
231 .map((page) => `P${page.pageNumber}`)
232 : []
233 const scopedMessageContent =
234 selectedScopePages.length > 0
235 ? `${t('sessionDetail.mainPageScopeMessagePrefix', { pages: selectedScopePages.join('、') })}\n${content}`
236 : content
237 const clientMessageId = crypto.randomUUID()
238 const generatePayload: GenerateStartPayload = {
239 sessionId,
240 modelConfigId,
241 userMessage: scopedMessageContent,
242 clientMessageId,
243 type: pages.length > 0 ? 'page' : 'deck',
244 chatType: context.chatType,
245 selectPageIds: context.chatType === 'main' ? effectiveSelectPageIds : undefined,
246 chatPageId: context.targetPageId,
247 selectedPageId:
248 pages.length > 0 && context.chatType === 'page' ? context.targetPageId : undefined,
249 htmlPath:
250 pages.length > 0 && context.chatType === 'page' ? context.targetPagePath : undefined,
251 selector: context.selector || undefined,
252 elementTag: context.hasSelector ? detailState.elementTag || undefined : undefined,
253 elementText: context.hasSelector ? detailState.elementText || undefined : undefined,
254 selectedElementContext: context.hasSelector
255 ? detailState.selectedElementContext || undefined
256 : undefined,
257 imagePaths,
258 videoPaths
259 }
260
261 sendingMessageRef.current = true
262 let assessmentId: string | undefined
263 try {
264 const isPageEdit = context.chatType === 'page' && pages.length > 0
265 const isDeckEdit = context.chatType === 'main' && pages.length > 0
266 const targetPage = pages.find((page) => page.id === context.targetPageId)
267 if (isPageEdit) {
268 const targetPageId = targetPage?.pageId || context.targetPageId || ''
269 if (useEditHistoryStore.getState().hasPendingEdits(targetPageId)) {
270 toastWarning(t('sessionDetail.pageEditPlanRequiresSavedEdits'))
271 return false
272 }
273 addMessage({
274 id: clientMessageId,
275 session_id: sessionId,
276 chat_scope: 'page',
277 page_id: context.messagePageId,
278 selector: context.selector,
279 image_paths: imagePaths,
280 video_paths: videoPaths,
281 role: 'user',
282 content: scopedMessageContent,
283 type: 'text',
284 tool_name: null,
285 tool_call_id: null,
286 token_count: null,
287 created_at: Math.floor(Date.now() / 1000)
288 })
289 assessmentId = crypto.randomUUID()
290 generateState.startPageEditPlanning(sessionId, targetPageId, assessmentId)
291 const assessment = await ipc.assessPageEdit(generatePayload)
292 const latestGenerateState = useGenerateStore.getState()
293 const latestPlanning = latestGenerateState.pageEditPlanning[sessionId]
294 const assessmentIsCurrent =
295 latestPlanning?.isAssessing === true &&
296 latestPlanning.pageId === targetPageId &&
297 latestPlanning.assessmentId === assessmentId
298 if (!assessmentIsCurrent) return false
299 if (
300 latestGenerateState.isGenerating ||
301 latestGenerateState.pageEditJobs[sessionId] ||
302 latestGenerateState.deckEditJobs[sessionId]
303 ) {
304 toastWarning(t('sessionDetail.pageEditPlanWaitForJob'))
305 return false
306 }
307
308 if (!assessment.requiresConfirmation) {
309 const autoApplyPayload = { ...generatePayload, autoApply: true }
310 latestGenerateState.startPageEdit(sessionId, {
311 pageId: targetPageId,
312 pageNumber: targetPage?.pageNumber
313 })
314 detailState.setInput('')
315 detailState.clearPendingAssets()
316 detailState.clearSelectedElement()
317 const result = await ipc.startPageEdit(autoApplyPayload)
318 if (result.alreadyRunning) {
319 useGenerateStore.getState().finishPageEdit(sessionId)
320 return false
321 }
322 const currentJob = useGenerateStore.getState().pageEditJobs[sessionId]
323 if (currentJob && result.runId) {
324 useGenerateStore.getState().updatePageEdit(sessionId, {
325 runId: result.runId,
326 status: currentJob.status === 'cancelling' ? 'cancelling' : 'running'
327 })
328 }
329 return true
330 }
331 latestGenerateState.setPendingPageEditPlan(sessionId, {
332 plan: assessment.plan,
333 payload: generatePayload,
334 targetPageId: assessment.targetPageId,
335 targetPageNumber: assessment.targetPageNumber
336 })
337 detailState.setInput('')
338 detailState.clearPendingAssets()
339 detailState.clearSelectedElement()
340 return true
341 } else if (isDeckEdit) {
342 generateState.startDeckEdit(sessionId, {
343 totalPages: effectiveSelectPageIds.length || pages.length,
344 payload: generatePayload
345 })
346 } else {
347 useGenerateStore.getState().clearSessionError(sessionId)
348 useGenerateStore.setState({ isGenerating: true, error: null, status: 'running' })
349 }
350 addMessage({
351 id: crypto.randomUUID(),
352 session_id: sessionId,
353 chat_scope: context.chatType,
354 page_id: context.messagePageId,
355 selector: context.chatType === 'page' ? context.selector : null,
356 image_paths: imagePaths,
357 video_paths: videoPaths,
358 role: 'user',
359 content: scopedMessageContent,
360 type: 'text',
361 tool_name: null,
362 tool_call_id: null,
363 token_count: null,
364 created_at: Math.floor(Date.now() / 1000)
365 })
366 detailState.setInput('')
367 detailState.clearPendingAssets()
368 detailState.clearSelectedElement()
369 const result = isDeckEdit
370 ? await ipc.startDeckEdit(generatePayload)
371 : await ipc.startGenerate(generatePayload)
372 if (result.alreadyRunning) {
373 if (isPageEdit) useGenerateStore.getState().finishPageEdit(sessionId)
374 else if (isDeckEdit) useGenerateStore.getState().finishDeckEdit(sessionId)
375 else {
376 useGenerateStore.getState().finishGeneration()
377 }
378 return false
379 }
380 if (isDeckEdit && result.runId) {
381 const currentJob = useGenerateStore.getState().deckEditJobs[sessionId]
382 if (currentJob) {
383 useGenerateStore.getState().updateDeckEdit(sessionId, {
384 runId: result.runId,
385 status: currentJob.status === 'cancelling' ? 'cancelling' : 'running'
386 })
387 }
388 }
389 return true
390 } catch (sendError) {
391 const message = sendError instanceof Error ? sendError.message : t('generating.failed')
392 if (isCancellationMessage(message)) {
393 const cancelledState = useGenerateStore.getState()
394 if (cancelledState.pageEditJobs[sessionId]) cancelledState.finishPageEdit(sessionId)
395 if (cancelledState.deckEditJobs[sessionId]) cancelledState.finishDeckEdit(sessionId)
396 if (cancelledState.isGenerating) cancelledState.cancelGeneration(message)
397 cancelledState.finishPageEditPlanning(sessionId, assessmentId)
398 return false
399 }
400 const hadGlobalGeneration = useGenerateStore.getState().isGenerating
401 if (useGenerateStore.getState().pageEditJobs[sessionId]?.pageId === selectedPage?.pageId) {
402 useGenerateStore.getState().finishPageEdit(sessionId)
403 }
404 if (useGenerateStore.getState().deckEditJobs[sessionId]) {
405 useGenerateStore.getState().finishDeckEdit(sessionId)
406 }
407 useGenerateStore.getState().finishPageEditPlanning(sessionId, assessmentId)
408 useGenerateStore.getState().setSessionError(sessionId, message)
409 if (hadGlobalGeneration) {
410 useGenerateStore.setState({ status: 'failed', isGenerating: false, progress: null })
411 }
412 toastError(message)
413 return false
414 } finally {
415 useGenerateStore.getState().finishPageEditPlanning(sessionId, assessmentId)
416 sendingMessageRef.current = false
417 }
418 }
419
420 const confirmPageEditPlan = async (): Promise<boolean> => {
421 const pendingPlan = useGenerateStore.getState().pageEditPlanning[sessionId]?.pendingPlan
422 if (!pendingPlan || !sessionId || sendingMessageRef.current) return false
423 const currentPageId = selectedPage?.pageId
424 if (currentPageId !== pendingPlan.targetPageId) {
425 toastWarning(t('sessionDetail.pageEditPlanReturnToTarget'))
426 return false
427 }
428 const generateState = useGenerateStore.getState()
429 if (
430 generateState.isGenerating ||
431 generateState.pageEditJobs[sessionId] ||
432 generateState.deckEditJobs[sessionId]
433 ) {
434 toastWarning(t('sessionDetail.pageEditPlanWaitForJob'))
435 return false
436 }
437 if (useEditHistoryStore.getState().hasPendingEdits(pendingPlan.targetPageId)) {
438 toastWarning(t('sessionDetail.pageEditPlanRequiresSavedEdits'))
439 return false
440 }
441
442 sendingMessageRef.current = true
443 try {
444 generateState.startPageEdit(sessionId, {
445 pageId: pendingPlan.targetPageId,
446 pageNumber: pendingPlan.targetPageNumber
447 })
448 useGenerateStore.getState().clearPendingPageEditPlan(sessionId)
449 const result = await ipc.startPageEdit({
450 ...pendingPlan.payload,
451 approvedPlan: pendingPlan.plan
452 })
453 if (result.alreadyRunning) {
454 useGenerateStore.getState().finishPageEdit(sessionId)
455 return false
456 }
457 const currentJob = useGenerateStore.getState().pageEditJobs[sessionId]
458 if (currentJob && result.runId) {
459 useGenerateStore.getState().updatePageEdit(sessionId, {
460 runId: result.runId,
461 status: currentJob.status === 'cancelling' ? 'cancelling' : 'running'
462 })
463 }
464 return true
465 } catch (sendError) {
466 const message = sendError instanceof Error ? sendError.message : t('generating.failed')
467 useGenerateStore.getState().finishPageEdit(sessionId)
468 if (isCancellationMessage(message)) return false
469 useGenerateStore.getState().setSessionError(sessionId, message)
470 toastError(message)
471 return false
472 } finally {
473 sendingMessageRef.current = false
474 }
475 }
476
477 const cancelPageEditPlan = (): void => {
478 useGenerateStore.getState().clearPendingPageEditPlan(sessionId)
479 }
480
481 const retryDeckEdit = async (modelConfigId: string): Promise<boolean> => {
482 const retry = useGenerateStore.getState().deckEditRetries[sessionId]
483 if (!retry || !sessionId || sendingMessageRef.current) return false
484
485 sendingMessageRef.current = true
486 try {
487 const payload = { ...retry.payload, sessionId, modelConfigId }
488 useGenerateStore.getState().startDeckEdit(sessionId, {
489 totalPages: retry.failedPageCount,
490 payload
491 })
492 const result = await ipc.retryDeckEdit({
493 ...payload,
494 failedRunId: retry.runId
495 })
496 if (result.alreadyRunning || (!result.runId && result.failedPageCount === 0)) {
497 useGenerateStore.getState().finishDeckEdit(sessionId, retry)
498 return false
499 }
500 const currentJob = useGenerateStore.getState().deckEditJobs[sessionId]
501 if (currentJob && result.runId) {
502 useGenerateStore.getState().updateDeckEdit(sessionId, {
503 runId: result.runId,
504 status: currentJob.status === 'cancelling' ? 'cancelling' : 'running'
505 })
506 }
507 return true
508 } catch (retryError) {
509 const message = retryError instanceof Error ? retryError.message : t('generating.failed')
510 useGenerateStore.getState().finishDeckEdit(sessionId, retry)
511 if (isCancellationMessage(message)) return false
512 useGenerateStore.getState().setSessionError(sessionId, message)
513 toastError(message)
514 return false
515 } finally {
516 sendingMessageRef.current = false
517 }
518 }
519
520 const reconcilePageEditState = async (options?: {
521 clearOnFailure?: boolean
522 preserveCancelling?: boolean
523 }): Promise<void> => {
524 try {
525 const state = await ipc.getPageEditState(sessionId)
526 if (!state.hasActiveRun) {
527 useGenerateStore.getState().finishPageEdit(sessionId)
528 return
529 }
530 const currentJob = useGenerateStore.getState().pageEditJobs[sessionId]
531 if (currentJob) {
532 useGenerateStore.getState().updatePageEdit(sessionId, {
533 runId: state.runId || currentJob.runId,
534 status:
535 options?.preserveCancelling && currentJob.status === 'cancelling'
536 ? 'cancelling'
537 : state.status === 'queued'
538 ? 'queued'
539 : 'running',
540 progress: state.progress ?? currentJob.progress
541 })
542 }
543 } catch {
544 if (options?.clearOnFailure) useGenerateStore.getState().finishPageEdit(sessionId)
545 }
546 }
547
548 const reconcileDeckEditState = async (options?: {
549 clearOnFailure?: boolean
550 preserveCancelling?: boolean
551 }): Promise<void> => {
552 try {
553 const state = await ipc.getDeckEditState(sessionId)
554 if (!state.hasActiveRun) {
555 useGenerateStore.getState().finishDeckEdit(sessionId)
556 return
557 }
558 const currentJob = useGenerateStore.getState().deckEditJobs[sessionId]
559 if (currentJob) {
560 useGenerateStore.getState().updateDeckEdit(sessionId, {
561 runId: state.runId || currentJob.runId,
562 status:
563 options?.preserveCancelling && currentJob.status === 'cancelling'
564 ? 'cancelling'
565 : state.status === 'queued'
566 ? 'queued'
567 : 'running',
568 progress: state.progress ?? currentJob.progress,
569 totalPages: state.totalPages || currentJob.totalPages
570 })
571 }
572 } catch {
573 if (options?.clearOnFailure) useGenerateStore.getState().finishDeckEdit(sessionId)
574 }
575 }
576
577 const cancel = async (): Promise<void> => {
578 if (!sessionId) return
579 const generateState = useGenerateStore.getState()
580 const planning = generateState.pageEditPlanning[sessionId]
581 if (planning?.pendingPlan) {
582 generateState.clearPendingPageEditPlan(sessionId)
583 return
584 }
585 if (planning?.isAssessing) {
586 try {
587 await ipc.cancelPageEdit(sessionId)
588 } catch (cancelError) {
589 toastError(cancelError instanceof Error ? cancelError.message : t('generating.failed'))
590 } finally {
591 useGenerateStore.getState().finishPageEditPlanning(sessionId, planning.assessmentId)
592 }
593 return
594 }
595 if (isStyleSwitching) {
596 await cancelStyleSwitch()
597 return
598 }
599 if (useGenerateStore.getState().pageEditJobs[sessionId]) {
600 if (useGenerateStore.getState().pageEditJobs[sessionId]?.status === 'cancelling') return
601 useGenerateStore.getState().updatePageEdit(sessionId, {
602 status: 'cancelling',
603 label: t('sessionDetail.activityCancelling')
604 })
605 try {
606 const result = await ipc.cancelPageEdit(sessionId)
607 await reconcilePageEditState({
608 clearOnFailure: !result.success,
609 preserveCancelling: result.success
610 })
611 } catch (cancelError) {
612 toastError(cancelError instanceof Error ? cancelError.message : t('generating.failed'))
613 await reconcilePageEditState({ clearOnFailure: true })
614 }
615 return
616 }
617 if (useGenerateStore.getState().deckEditJobs[sessionId]) {
618 if (useGenerateStore.getState().deckEditJobs[sessionId]?.status === 'cancelling') return
619 useGenerateStore.getState().updateDeckEdit(sessionId, {
620 status: 'cancelling',
621 label: t('sessionDetail.activityCancelling')
622 })
623 try {
624 const result = await ipc.cancelDeckEdit(sessionId)
625 await reconcileDeckEditState({
626 clearOnFailure: !result.success,
627 preserveCancelling: result.success
628 })
629 } catch (cancelError) {
630 toastError(cancelError instanceof Error ? cancelError.message : t('generating.failed'))
631 await reconcileDeckEditState({ clearOnFailure: true })
632 }
633 return
634 }
635 try {
636 const result = await ipc.cancelGenerate(sessionId)
637 if (result.success) {
638 useGenerateStore.getState().cancelGeneration()
639 return
640 }
641 const state = await ipc.getGenerateState(sessionId)
642 if (!state.hasActiveRun) useGenerateStore.getState().cancelGeneration()
643 } catch (cancelError) {
644 try {
645 const state = await ipc.getGenerateState(sessionId)
646 if (!state.hasActiveRun) useGenerateStore.getState().cancelGeneration()
647 } catch {
648 useGenerateStore.getState().cancelGeneration()
649 }
650 toastError(cancelError instanceof Error ? cancelError.message : t('generating.failed'))
651 }
652 }
653
654 return {
655 selectedPageExists: Boolean(selectedPage?.pageId),
656 selectedPageNumber: selectedPage?.pageNumber,
657 isGenerating: isSending,
658 isPageEditing,
659 isDeckEditing,
660 deckEditRetry,
661 isPlanningPageEdit: isPlanningCurrentPage,
662 pendingPageEditPlan: pendingPageEditPlanForCurrentPage,
663 hasActivePageEditJob,
664 progress:
665 isPageEditing && pageEditJob
666 ? {
667 stage: pageEditJob.status,
668 label: pageEditJob.label,
669 currentPage: pageEditJob.pageNumber,
670 totalPages: 1,
671 progress: pageEditJob.progress
672 }
673 : isDeckEditing && deckEditJob
674 ? {
675 stage: deckEditJob.status,
676 label: deckEditJob.label,
677 totalPages: deckEditJob.totalPages,
678 progress: deckEditJob.progress
679 }
680 : progress,
681 error,
682 uploadFiles,
683 chooseAssets,
684 send,
685 confirmPageEditPlan,
686 cancelPageEditPlan,
687 retryDeckEdit,
688 cancel
689 }
690 }
691
691 lines TYPESCRIPT