返回 oh-my-ppt
useGenerationNotifications.ts
根目录 / src / renderer / src / hooks / useGenerationNotifications.ts
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-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-edit' ||
63 activityKind === 'deck-edit' ||
64 activityKind === 'style-switch' ||
65 activityKind === 'single-page-retry' ||
66 activityKind === 'addPage'
67 ) {
68 return
69 }
70
71 const notificationType = isCompleted ? 'completed' : 'failed'
72 const notificationKey = `${event.payload.runId}:${notificationType}`
73 if (notifiedKeysRef.current.has(notificationKey)) return
74 addBoundedEntry(notifiedKeysRef.current, notificationKey, true, MAX_NOTIFIED_RUNS)
75
76 const title = await readSessionTitle(sessionId)
77 const action = {
78 label: t('generationNotifications.view'),
79 onClick: () => navigate(`/sessions/${sessionId}`)
80 }
81
82 const toast = createGenerationNotificationToast({
83 event,
84 title,
85 action,
86 t
87 })
88 useToastStore.getState()[toast.type](toast.message, toast.options)
89 }
90
91 const unsubscribe = ipc.onGenerateChunk((event) => {
92 void notify(event).catch((error) => {
93 console.warn(
94 '[generation-notification] failed',
95 error instanceof Error ? error.message : String(error)
96 )
97 })
98 })
99 return () => unsubscribe?.()
100 }, [navigate, t])
101 }
102
102 lines TYPESCRIPT