| 1 | import { useEffect, useMemo, useState } from 'react' |
| 2 | import { useT } from '@renderer/i18n' |
| 3 | import { ipc } from '@renderer/lib/ipc' |
| 4 | import { useModelAction } from '@renderer/hooks/useModelAction' |
| 5 | import { useGenerateStore, useSessionDetailUiStore, useSessionStore } from '@renderer/store' |
| 6 | import { ModelSplitButton } from '../../model/ModelActionButton' |
| 7 | import { normalizePagesForSelection } from '../shared/pageUtils' |
| 8 | |
| 9 | interface AddPageDialogProps { |
| 10 | sessionId: string |
| 11 | } |
| 12 | |
| 13 | export function AddPageDialog({ sessionId }: AddPageDialogProps): React.JSX.Element | null { |
| 14 | const t = useT() |
| 15 | const modelAction = useModelAction() |
| 16 | const open = useSessionDetailUiStore((state) => state.addPageDialogOpen) |
| 17 | const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId) |
| 18 | const setOpen = useSessionDetailUiStore((state) => state.setAddPageDialogOpen) |
| 19 | const setIsAddingPage = useSessionDetailUiStore((state) => state.setIsAddingPage) |
| 20 | const setAddingPageId = useSessionDetailUiStore((state) => state.setAddingPageId) |
| 21 | const currentPages = useGenerateStore((state) => state.currentPages) |
| 22 | const loadSession = useSessionStore((state) => state.loadSession) |
| 23 | const [value, setValue] = useState('') |
| 24 | |
| 25 | const normalizedPages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages]) |
| 26 | const selectedPage = useMemo( |
| 27 | () => normalizedPages.find((page) => page.id === selectedPageId) ?? normalizedPages[0] ?? null, |
| 28 | [normalizedPages, selectedPageId] |
| 29 | ) |
| 30 | |
| 31 | useEffect(() => { |
| 32 | if (open) setValue('') |
| 33 | }, [open]) |
| 34 | |
| 35 | const handleAddPage = async (selectedModelConfigId?: string): Promise<void> => { |
| 36 | if (!sessionId || !value.trim()) return |
| 37 | const description = value.trim() |
| 38 | setOpen(false) |
| 39 | setValue('') |
| 40 | const insertAfter = selectedPage?.pageNumber ?? normalizedPages.length |
| 41 | let targetSelection: string | null | undefined = undefined |
| 42 | let targetPageId: string | null = null |
| 43 | let started = false |
| 44 | let handedToJob = false |
| 45 | |
| 46 | try { |
| 47 | const modelConfigId = await modelAction.ensureModelActive(selectedModelConfigId) |
| 48 | if (!modelConfigId) return |
| 49 | const sourcePageId = selectedPage?.id || normalizedPages.at(-1)?.id |
| 50 | if (!sourcePageId) return |
| 51 | const blankPage = await ipc.createBlankSessionPage({ sessionId, sourcePageId }) |
| 52 | targetPageId = blankPage.selectedPageId |
| 53 | if (!targetPageId) throw new Error(t('sessionDetail.addPageFailed')) |
| 54 | targetSelection = targetPageId |
| 55 | setIsAddingPage(true) |
| 56 | setAddingPageId(targetPageId) |
| 57 | useGenerateStore.setState({ isGenerating: true, error: null, status: 'running' }) |
| 58 | started = true |
| 59 | useGenerateStore |
| 60 | .getState() |
| 61 | .setPages( |
| 62 | blankPage.generatedPages.map((page) => |
| 63 | page.id === targetPageId ? { ...page, status: 'generating' } : page |
| 64 | ) |
| 65 | ) |
| 66 | useSessionDetailUiStore.getState().setSelectedPageId(targetPageId) |
| 67 | |
| 68 | const result = await ipc.addPage({ |
| 69 | sessionId, |
| 70 | modelConfigId, |
| 71 | userMessage: description, |
| 72 | insertAfterPageNumber: insertAfter, |
| 73 | targetPageId |
| 74 | }) |
| 75 | if (result.alreadyRunning) { |
| 76 | throw new Error(t('sessionDetail.addPageFailed')) |
| 77 | } |
| 78 | handedToJob = true |
| 79 | void ipc |
| 80 | .clearSpeechScript(sessionId) |
| 81 | .catch((err) => console.warn('[speech] clearSpeechScript failed', err)) |
| 82 | } catch (err) { |
| 83 | const message = err instanceof Error ? err.message : t('sessionDetail.addPageFailed') |
| 84 | targetSelection = targetPageId |
| 85 | console.warn('[session-detail] add generated page failed', message) |
| 86 | } finally { |
| 87 | if (!handedToJob && started) { |
| 88 | if (targetPageId) { |
| 89 | try { |
| 90 | await loadSession(sessionId) |
| 91 | useGenerateStore.getState().setPages(useSessionStore.getState().currentGeneratedPages) |
| 92 | } catch (error) { |
| 93 | console.warn('[session-detail] reload blank page failed', error) |
| 94 | } |
| 95 | } |
| 96 | useSessionDetailUiStore.getState().finishAddPage(targetSelection) |
| 97 | useGenerateStore.getState().finishGeneration() |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | if (!open) return null |
| 103 | |
| 104 | return ( |
| 105 | <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> |
| 106 | <div className="w-[520px] rounded-2xl bg-white p-6 shadow-2xl"> |
| 107 | <h3 className="mb-3 text-base font-semibold text-[#2f3a2a]"> |
| 108 | {t('sessionDetail.addPage')} |
| 109 | </h3> |
| 110 | <p className="mb-3 text-xs text-[#8a9a7b]">{t('sessionDetail.addPageHint')}</p> |
| 111 | <textarea |
| 112 | value={value} |
| 113 | onChange={(event) => setValue(event.target.value)} |
| 114 | placeholder={t('sessionDetail.addPageDescription')} |
| 115 | className="mb-4 h-40 w-full resize-none rounded-xl border border-[#d4e4c1]/60 bg-[#f8f6f0] px-4 py-3 text-sm leading-relaxed text-[#2f3a2a] placeholder:text-[#8a9a7b] focus:border-[#5d6b4d] focus:outline-none" |
| 116 | autoFocus |
| 117 | onKeyDown={(event) => { |
| 118 | if ( |
| 119 | event.key === 'Enter' && |
| 120 | !event.shiftKey && |
| 121 | !event.nativeEvent.isComposing && |
| 122 | value.trim() |
| 123 | ) { |
| 124 | event.preventDefault() |
| 125 | void handleAddPage() |
| 126 | } |
| 127 | if (event.key === 'Escape') { |
| 128 | setOpen(false) |
| 129 | } |
| 130 | }} |
| 131 | /> |
| 132 | <div className="flex items-center justify-end gap-2"> |
| 133 | <button |
| 134 | type="button" |
| 135 | onClick={() => setOpen(false)} |
| 136 | className="cursor-pointer rounded-xl px-4 py-2 text-sm font-medium text-[#5d6b4d] transition-colors hover:bg-[#f0ece3]" |
| 137 | > |
| 138 | {t('sessionDetail.addPageCancel')} |
| 139 | </button> |
| 140 | <ModelSplitButton |
| 141 | modelAction={modelAction} |
| 142 | label={t('sessionDetail.addPageGenerate')} |
| 143 | disabled={!value.trim()} |
| 144 | tone="primary" |
| 145 | size="sm" |
| 146 | className="rounded-xl" |
| 147 | mainClassName="min-w-[104px] justify-center text-sm" |
| 148 | triggerClassName="h-9" |
| 149 | onRun={(modelConfigId) => void handleAddPage(modelConfigId)} |
| 150 | /> |
| 151 | </div> |
| 152 | </div> |
| 153 | </div> |
| 154 | ) |
| 155 | } |
| 156 |