Notify from the installed web app when a generation finishes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,6 +48,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="gpuMessage" class="text-xs" :class="gpuError ? 'text-red-300' : 'text-zinc-500'">{{ gpuMessage }}</p>
|
<p v-if="gpuMessage" class="text-xs" :class="gpuError ? 'text-red-300' : 'text-zinc-500'">{{ gpuMessage }}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section v-if="activePanel === 'storage'" class="glass rounded-3xl p-5 sm:p-6">
|
<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 gpuWaking = ref(false)
|
||||||
const gpuError = ref(false)
|
const gpuError = ref(false)
|
||||||
const gpuMessage = ref('')
|
const gpuMessage = ref('')
|
||||||
|
const notifyComplete = ref(true)
|
||||||
|
const notifyMessage = ref('')
|
||||||
const storage = ref<{
|
const storage = ref<{
|
||||||
libraryBytes: number
|
libraryBytes: number
|
||||||
breakdown: { clips: number; stills: number; tmp: number; other: number }
|
breakdown: { clips: number; stills: number; tmp: number; other: number }
|
||||||
@@ -533,6 +553,7 @@ async function loadAccount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
notifyComplete.value = generationNotifyPref()
|
||||||
void loadAccount()
|
void loadAccount()
|
||||||
void loadPresets()
|
void loadPresets()
|
||||||
void loadStorage()
|
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() {
|
async function sleepGpu() {
|
||||||
if (gpuSleeping.value || gpuWaking.value) return
|
if (gpuSleeping.value || gpuWaking.value) return
|
||||||
gpuSleeping.value = true
|
gpuSleeping.value = true
|
||||||
|
|||||||
@@ -5743,6 +5743,7 @@ async function generateExtension() {
|
|||||||
statusMessage.value = 'Extracting last frame & stitching extension...'
|
statusMessage.value = 'Extracting last frame & stitching extension...'
|
||||||
startTimer('video')
|
startTimer('video')
|
||||||
try {
|
try {
|
||||||
|
await ensureGenerationNotifyPermission()
|
||||||
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', {
|
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
@@ -6463,6 +6464,7 @@ async function editImage() {
|
|||||||
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
|
const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() }))
|
||||||
const hideOut = hideThumbnail.value
|
const hideOut = hideThumbnail.value
|
||||||
try {
|
try {
|
||||||
|
await ensureGenerationNotifyPermission()
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', file.value)
|
body.append('image', file.value)
|
||||||
if (editRefFile.value) body.append('image2', editRefFile.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 extraPasses = v2PassChaining.value ? editPassQueue.value.map(item => ({ prompt: item.prompt.trim() })) : []
|
||||||
const hideOut = hideThumbnail.value
|
const hideOut = hideThumbnail.value
|
||||||
try {
|
try {
|
||||||
|
await ensureGenerationNotifyPermission()
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('mode', v2Mode.value)
|
body.append('mode', v2Mode.value)
|
||||||
body.append('task', v2Mode.value === 'generate' ? 't2i' : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene')
|
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 hideOut = hideThumbnail.value
|
||||||
const folderLockedPref = false
|
const folderLockedPref = false
|
||||||
try {
|
try {
|
||||||
|
await ensureGenerationNotifyPermission()
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
if (file.value) body.append('image', file.value)
|
if (file.value) body.append('image', file.value)
|
||||||
if (inputStillId.value) body.append('stillId', inputStillId.value)
|
if (inputStillId.value) body.append('stillId', inputStillId.value)
|
||||||
@@ -7132,6 +7136,7 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
|
|||||||
scheduleLibraryRefresh()
|
scheduleLibraryRefresh()
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
void refreshStudioQueue()
|
void refreshStudioQueue()
|
||||||
|
void notifyGenerationComplete('image')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7246,6 +7251,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
toast(locked
|
toast(locked
|
||||||
? 'Saved to the locked folder. Unlock it to view.'
|
? 'Saved to the locked folder. Unlock it to view.'
|
||||||
: 'New clip saved to the library')
|
: 'New clip saved to the library')
|
||||||
|
void notifyGenerationComplete('video')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -7277,6 +7283,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
|
|||||||
scheduleLibraryRefresh()
|
scheduleLibraryRefresh()
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
void refreshStudioQueue()
|
void refreshStudioQueue()
|
||||||
|
void notifyGenerationComplete('video')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -727,11 +727,14 @@ function listen(queue: Queue) {
|
|||||||
sources.set(queue.id, source)
|
sources.set(queue.id, source)
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
try {
|
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.message) liveMessage.value = { ...liveMessage.value, [queue.id]: payload.message }
|
||||||
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
|
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
|
||||||
void loadQueues()
|
void loadQueues()
|
||||||
}
|
}
|
||||||
|
if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) {
|
||||||
|
void notifyGenerationComplete('video')
|
||||||
|
}
|
||||||
if (payload.type === 'complete' || payload.type === 'error') {
|
if (payload.type === 'complete' || payload.type === 'error') {
|
||||||
source.close()
|
source.close()
|
||||||
sources.delete(queue.id)
|
sources.delete(queue.id)
|
||||||
@@ -759,11 +762,14 @@ function listenJob(job: StudioJobRow) {
|
|||||||
sources.set(job.id, source)
|
sources.set(job.id, source)
|
||||||
source.onmessage = (event) => {
|
source.onmessage = (event) => {
|
||||||
try {
|
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.message) liveMessage.value = { ...liveMessage.value, [job.id]: payload.message }
|
||||||
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
|
if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') {
|
||||||
void loadQueues()
|
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') {
|
if (payload.type === 'complete' || payload.type === 'error') {
|
||||||
source.close()
|
source.close()
|
||||||
sources.delete(job.id)
|
sources.delete(job.id)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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('/')
|
||||||
|
})())
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 */ }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user