返回 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-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
103 lines TYPESCRIPT