Notify from the installed web app when a generation finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-01 07:11:12 -05:00
co-authored by Cursor
parent fa32fb0a8e
commit 2635b8838c
7 changed files with 195 additions and 2 deletions
+40
View File
@@ -48,6 +48,24 @@
</div>
<p v-if="gpuMessage" class="text-xs" :class="gpuError ? 'text-red-300' : 'text-zinc-500'">{{ gpuMessage }}</p>
</div>
<label class="mt-4 flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3 text-sm">
<span>
<span class="block text-zinc-200">Notify when a generation finishes</span>
<span class="mt-0.5 block text-xs text-zinc-500">Works in the installed web app. Asks for notification permission the first time you turn this on or generate.</span>
</span>
<input
v-model="notifyComplete"
type="checkbox"
class="peer sr-only"
role="switch"
@change="onNotifyCompleteChange"
>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<span class="absolute inset-0 rounded-full transition" :class="notifyComplete ? 'bg-amber-400' : 'bg-zinc-700'" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition" :class="notifyComplete ? 'translate-x-5' : ''" />
</span>
</label>
<p v-if="notifyMessage" class="mt-2 text-xs text-zinc-500">{{ notifyMessage }}</p>
</section>
<section v-if="activePanel === 'storage'" class="glass rounded-3xl p-5 sm:p-6">
@@ -460,6 +478,8 @@ const gpuSleeping = ref(false)
const gpuWaking = ref(false)
const gpuError = ref(false)
const gpuMessage = ref('')
const notifyComplete = ref(true)
const notifyMessage = ref('')
const storage = ref<{
libraryBytes: number
breakdown: { clips: number; stills: number; tmp: number; other: number }
@@ -533,6 +553,7 @@ async function loadAccount() {
}
onMounted(() => {
notifyComplete.value = generationNotifyPref()
void loadAccount()
void loadPresets()
void loadStorage()
@@ -620,6 +641,25 @@ async function loadStorage() {
}
}
async function onNotifyCompleteChange() {
const on = notifyComplete.value
setGenerationNotifyPref(on)
notifyMessage.value = ''
if (!on) return
if (!generationNotifySupported()) {
notifyMessage.value = 'This browser does not support notifications.'
return
}
const granted = await ensureGenerationNotifyPermission()
if (!granted) {
notifyComplete.value = false
setGenerationNotifyPref(false)
notifyMessage.value = Notification.permission === 'denied'
? 'Notifications are blocked in the browser. Allow them for this site, then turn this on again.'
: 'Could not enable notifications.'
}
}
async function sleepGpu() {
if (gpuSleeping.value || gpuWaking.value) return
gpuSleeping.value = true
+7
View File
@@ -5743,6 +5743,7 @@ async function generateExtension() {
statusMessage.value = 'Extracting last frame & stitching extension...'
startTimer('video')
try {
await ensureGenerationNotifyPermission()
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', {
method: 'POST',
body: {
@@ -6463,6 +6464,7 @@ async function editImage() {
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
const hideOut = hideThumbnail.value
try {
await ensureGenerationNotifyPermission()
const body = new FormData()
body.append('image', file.value)
if (editRefFile.value) body.append('image2', editRefFile.value)
@@ -6679,6 +6681,7 @@ async function editImageV2() {
const extraPasses = v2PassChaining.value ? editPassQueue.value.map(item => ({ prompt: item.prompt.trim() })) : []
const hideOut = hideThumbnail.value
try {
await ensureGenerationNotifyPermission()
const body = new FormData()
body.append('mode', v2Mode.value)
body.append('task', v2Mode.value === 'generate' ? 't2i' : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene')
@@ -6900,6 +6903,7 @@ async function generate() {
const hideOut = hideThumbnail.value
const folderLockedPref = false
try {
await ensureGenerationNotifyPermission()
const body = new FormData()
if (file.value) body.append('image', file.value)
if (inputStillId.value) body.append('stillId', inputStillId.value)
@@ -7132,6 +7136,7 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
void notifyGenerationComplete('image')
return
}
@@ -7246,6 +7251,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
toast(locked
? 'Saved to the locked folder. Unlock it to view.'
: 'New clip saved to the library')
void notifyGenerationComplete('video')
return
}
if (locked) {
@@ -7277,6 +7283,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
void notifyGenerationComplete('video')
return
}
+8 -2
View File
@@ -727,11 +727,14 @@ function listen(queue: Queue) {
sources.set(queue.id, source)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { message?: string; type?: string }
const payload = JSON.parse(event.data) as { message?: string; type?: string; queuedRemaining?: number }
if (payload.message) liveMessage.value = { ...liveMessage.value, [queue.id]: payload.message }
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
void loadQueues()
}
if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) {
void notifyGenerationComplete('video')
}
if (payload.type === 'complete' || payload.type === 'error') {
source.close()
sources.delete(queue.id)
@@ -759,11 +762,14 @@ function listenJob(job: StudioJobRow) {
sources.set(job.id, source)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { message?: string; type?: string }
const payload = JSON.parse(event.data) as { message?: string; type?: string; queuedRemaining?: number; stillId?: string }
if (payload.message) liveMessage.value = { ...liveMessage.value, [job.id]: payload.message }
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
void loadQueues()
}
if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) {
void notifyGenerationComplete(job.kind === 'edit' || payload.stillId ? 'image' : 'video')
}
if (payload.type === 'complete' || payload.type === 'error') {
source.close()
sources.delete(job.id)
+16
View File
@@ -0,0 +1,16 @@
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const name = String(config.public.instanceName || 'AIGen')
useHead({
meta: [
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-title', content: name },
{ name: 'mobile-web-app-capable', content: 'yes' }
],
link: [
{ rel: 'manifest', href: '/manifest.webmanifest' }
]
})
if (!('serviceWorker' in navigator)) return
void navigator.serviceWorker.register('/sw.js').catch(() => null)
})
+18
View File
@@ -0,0 +1,18 @@
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting())
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
for (const client of windows) {
if ('focus' in client) return client.focus()
}
if (self.clients.openWindow) return self.clients.openWindow('/')
})())
})
+24
View File
@@ -0,0 +1,24 @@
import { isXaigenStudio } from '~/utils/videoModels'
export default defineEventHandler((event) => {
const pub = useRuntimeConfig().public as { instanceName?: string; studio?: string }
const xaigen = isXaigenStudio(pub)
const name = String(pub.instanceName || (xaigen ? 'xAIGen' : 'AIGen'))
setHeader(event, 'Content-Type', 'application/manifest+json')
setHeader(event, 'Cache-Control', 'no-cache')
return {
name,
short_name: name,
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#09090b',
theme_color: '#09090b',
icons: [{
src: xaigen ? '/favicon.svg' : '/favicon-aigen.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any'
}]
}
})
+82
View File
@@ -0,0 +1,82 @@
import { isXaigenStudio } from '~/utils/videoModels'
const PREF_KEY = 'aigen-notify-complete'
const TAG = 'aigen-generation-complete'
function appName() {
try {
const pub = useRuntimeConfig().public as { instanceName?: string; studio?: string }
return String(pub.instanceName || (isXaigenStudio(pub) ? 'xAIGen' : 'AIGen'))
} catch {
return 'AIGen'
}
}
function appIcon() {
try {
const pub = useRuntimeConfig().public as { instanceName?: string; studio?: string }
return isXaigenStudio(pub) ? '/favicon.svg' : '/favicon-aigen.svg'
} catch {
return '/favicon-aigen.svg'
}
}
export function generationNotifySupported() {
return typeof window !== 'undefined' && 'Notification' in window
}
export function generationNotifyPref() {
if (typeof window === 'undefined') return true
try {
return localStorage.getItem(PREF_KEY) !== '0'
} catch {
return true
}
}
export function setGenerationNotifyPref(on: boolean) {
try {
localStorage.setItem(PREF_KEY, on ? '1' : '0')
} catch { /* ignore */ }
}
export async function ensureGenerationNotifyPermission() {
if (!generationNotifySupported() || !generationNotifyPref()) return false
if (Notification.permission === 'granted') return true
if (Notification.permission === 'denied') return false
try {
const result = await Notification.requestPermission()
return result === 'granted'
} catch {
return false
}
}
export async function notifyGenerationComplete(kind: 'video' | 'image' = 'video') {
if (!generationNotifySupported() || !generationNotifyPref()) return
if (Notification.permission !== 'granted') return
if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return
const title = appName()
const body = kind === 'image' ? 'Image generation is complete.' : 'Generation is complete.'
const options: NotificationOptions = {
body,
icon: appIcon(),
tag: TAG,
renotify: true,
silent: false
}
try {
const registration = await navigator.serviceWorker?.ready
if (registration?.showNotification) {
await registration.showNotification(title, options)
return
}
} catch { /* fall through */ }
try {
const note = new Notification(title, options)
note.onclick = () => {
window.focus()
note.close()
}
} catch { /* ignore */ }
}