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