| 1 | import { useEffect, useRef } from 'react' |
| 2 | import { useNavigate } from 'react-router-dom' |
| 3 | import type { GenerateChunkEvent } from '@shared/generation' |
| 4 | import { ipc } from '@renderer/lib/ipc' |
| 5 | import { useToastStore } from '@renderer/store' |
| 6 | import { useT } from '@renderer/i18n' |
| 7 | import { createGenerationNotificationToast } from './generationNotificationToast' |
| 8 | |
| 9 | const MAX_NOTIFIED_RUNS = 200 |
| 10 | const MAX_CACHED_TITLES = 100 |
| 11 | |
| 12 | const addBoundedEntry = <T>(map: Map<string, T>, key: string, value: T, limit: number): void => { |
| 13 | map.delete(key) |
| 14 | map.set(key, value) |
| 15 | while (map.size > limit) { |
| 16 | const oldestKey = map.keys().next().value |
| 17 | if (typeof oldestKey !== 'string') break |
| 18 | map.delete(oldestKey) |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | export function useGenerationNotifications(): void { |
| 23 | const navigate = useNavigate() |
| 24 | const t = useT() |
| 25 | const notifiedKeysRef = useRef(new Map<string, true>()) |
| 26 | const sessionTitlesRef = useRef(new Map<string, string>()) |
| 27 | |
| 28 | useEffect(() => { |
| 29 | const readSessionTitle = async (sessionId: string): Promise<string> => { |
| 30 | const cached = sessionTitlesRef.current.get(sessionId) |
| 31 | if (cached) return cached |
| 32 | try { |
| 33 | const { session } = await ipc.getSession(sessionId) |
| 34 | const title = |
| 35 | session && typeof session === 'object' && 'title' in session |
| 36 | ? String((session as { title?: unknown }).title || '').trim() |
| 37 | : '' |
| 38 | const resolvedTitle = title || t('generationNotifications.untitled') |
| 39 | addBoundedEntry(sessionTitlesRef.current, sessionId, resolvedTitle, MAX_CACHED_TITLES) |
| 40 | return resolvedTitle |
| 41 | } catch { |
| 42 | return t('generationNotifications.untitled') |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | const notify = async (event: GenerateChunkEvent): Promise<void> => { |
| 47 | const sessionId = event.payload.sessionId |
| 48 | if (!sessionId) return |
| 49 | |
| 50 | const isCompleted = event.type === 'run_completed' |
| 51 | const isFailed = event.type === 'run_error' |
| 52 | if (!isCompleted && !isFailed) return |
| 53 | if (isFailed && event.payload.cancelled === true) return |
| 54 | |
| 55 | // Edit-job activities (page-beautify, page-edit, deck-edit, style-switch) emit their own |
| 56 | // dedicated toasts from session-detail.tsx. Showing the global "生成完成 / 查看" notification |
| 57 | // on top would double up toasts for the same run, which is noisy and redundant. The global |
| 58 | // notification is reserved for full-deck generation runs where the user may have navigated |
| 59 | // away from the session and needs a pull-back cue. |
| 60 | const activityKind = 'activityKind' in event.payload ? event.payload.activityKind : undefined |
| 61 | if ( |
| 62 | activityKind === 'page-beautify' || |
| 63 | activityKind === 'page-edit' || |
| 64 | activityKind === 'deck-edit' || |
| 65 | activityKind === 'style-switch' || |
| 66 | activityKind === 'single-page-retry' || |
| 67 | activityKind === 'addPage' |
| 68 | ) { |
| 69 | return |
| 70 | } |
| 71 | |
| 72 | const notificationType = isCompleted ? 'completed' : 'failed' |
| 73 | const notificationKey = `${event.payload.runId}:${notificationType}` |
| 74 | if (notifiedKeysRef.current.has(notificationKey)) return |
| 75 | addBoundedEntry(notifiedKeysRef.current, notificationKey, true, MAX_NOTIFIED_RUNS) |
| 76 | |
| 77 | const title = await readSessionTitle(sessionId) |
| 78 | const action = { |
| 79 | label: t('generationNotifications.view'), |
| 80 | onClick: () => navigate(`/sessions/${sessionId}`) |
| 81 | } |
| 82 | |
| 83 | const toast = createGenerationNotificationToast({ |
| 84 | event, |
| 85 | title, |
| 86 | action, |
| 87 | t |
| 88 | }) |
| 89 | useToastStore.getState()[toast.type](toast.message, toast.options) |
| 90 | } |
| 91 | |
| 92 | const unsubscribe = ipc.onGenerateChunk((event) => { |
| 93 | void notify(event).catch((error) => { |
| 94 | console.warn( |
| 95 | '[generation-notification] failed', |
| 96 | error instanceof Error ? error.message : String(error) |
| 97 | ) |
| 98 | }) |
| 99 | }) |
| 100 | return () => unsubscribe?.() |
| 101 | }, [navigate, t]) |
| 102 | } |
| 103 |