diff --git a/components/SettingsForm.vue b/components/SettingsForm.vue
index 12dee74..a5eac37 100644
--- a/components/SettingsForm.vue
+++ b/components/SettingsForm.vue
@@ -48,6 +48,24 @@
{{ gpuMessage }}
+
+ {{ notifyMessage }}
@@ -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
diff --git a/pages/index.vue b/pages/index.vue
index 149025a..4f35382 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -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, hidden: boolean, folderLoc
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
+ void notifyGenerationComplete('image')
return
}
@@ -7246,6 +7251,7 @@ function applyVideoEvent(payload: Record, 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, hidden: boolean, folderLo
scheduleLibraryRefresh()
void pollComfyHealth()
void refreshStudioQueue()
+ void notifyGenerationComplete('video')
return
}
diff --git a/pages/queue.vue b/pages/queue.vue
index e6212ea..3d6f57d 100644
--- a/pages/queue.vue
+++ b/pages/queue.vue
@@ -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)
diff --git a/plugins/pwa.client.ts b/plugins/pwa.client.ts
new file mode 100644
index 0000000..34f9a10
--- /dev/null
+++ b/plugins/pwa.client.ts
@@ -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)
+})
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..2431cad
--- /dev/null
+++ b/public/sw.js
@@ -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('/')
+ })())
+})
diff --git a/server/routes/manifest.webmanifest.ts b/server/routes/manifest.webmanifest.ts
new file mode 100644
index 0000000..8f19926
--- /dev/null
+++ b/server/routes/manifest.webmanifest.ts
@@ -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'
+ }]
+ }
+})
diff --git a/utils/generationNotify.ts b/utils/generationNotify.ts
new file mode 100644
index 0000000..a4c855c
--- /dev/null
+++ b/utils/generationNotify.ts
@@ -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 */ }
+}