feat (notification): add notification for iOS

This commit is contained in:
Darkatse 2026-03-21 01:31:45 -04:00
parent 78c10f0d6e
commit 8f15a21211
7 changed files with 515 additions and 134 deletions

View file

@ -218,7 +218,29 @@ pub struct ShowSystemNotificationDto {
pub body: String,
}
fn ensure_notification_permission(app: &tauri::AppHandle) -> Result<bool, CommandError> {
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationPermissionStateDto {
Granted,
Denied,
Prompt,
}
fn normalize_notification_permission_state(
state: PermissionState,
) -> NotificationPermissionStateDto {
match state {
PermissionState::Granted => NotificationPermissionStateDto::Granted,
PermissionState::Denied => NotificationPermissionStateDto::Denied,
PermissionState::Prompt | PermissionState::PromptWithRationale => {
NotificationPermissionStateDto::Prompt
}
}
}
fn get_notification_permission_state_inner(
app: &tauri::AppHandle,
) -> Result<NotificationPermissionStateDto, CommandError> {
let notification = app.notification();
let current_state = notification.permission_state().map_err(|error| {
CommandError::InternalServerError(format!(
@ -227,22 +249,38 @@ fn ensure_notification_permission(app: &tauri::AppHandle) -> Result<bool, Comman
))
})?;
if matches!(current_state, PermissionState::Granted) {
return Ok(true);
Ok(normalize_notification_permission_state(current_state))
}
#[tauri::command]
pub fn get_notification_permission_state(
app: tauri::AppHandle,
) -> Result<NotificationPermissionStateDto, CommandError> {
log_command("get_notification_permission_state");
get_notification_permission_state_inner(&app)
}
#[tauri::command]
pub fn request_notification_permission(
app: tauri::AppHandle,
) -> Result<NotificationPermissionStateDto, CommandError> {
log_command("request_notification_permission");
if !matches!(
get_notification_permission_state_inner(&app)?,
NotificationPermissionStateDto::Prompt
) {
return get_notification_permission_state_inner(&app);
}
if matches!(current_state, PermissionState::Denied) {
return Ok(false);
}
let requested_state = notification.request_permission().map_err(|error| {
let requested_state = app.notification().request_permission().map_err(|error| {
CommandError::InternalServerError(format!(
"Failed to request notification permission: {}",
error
))
})?;
Ok(matches!(requested_state, PermissionState::Granted))
Ok(normalize_notification_permission_state(requested_state))
}
#[tauri::command]
@ -256,11 +294,18 @@ pub fn show_system_notification(
let body = dto.body.trim();
if title.is_empty() && body.is_empty() {
return Ok(());
return Err(CommandError::BadRequest(
"Notification title and body cannot both be empty".to_string(),
));
}
if !ensure_notification_permission(&app)? {
return Ok(());
if !matches!(
get_notification_permission_state_inner(&app)?,
NotificationPermissionStateDto::Granted
) {
return Err(CommandError::Unauthorized(
"Notification permission is not granted".to_string(),
));
}
app.notification()

View file

@ -216,6 +216,8 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
super::bridge::read_dev_web_resource,
super::bridge::read_frontend_template,
super::bridge::read_frontend_extension_template,
super::bridge::get_notification_permission_state,
super::bridge::request_notification_permission,
super::bridge::show_system_notification,
]
}

View file

@ -88,6 +88,7 @@ export {};
* | 'read_thumbnail_asset'
* | 'read_user_avatar_asset'
* | 'read_user_file_asset'
* | 'request_notification_permission'
* | 'rename_background'
* | 'rename_character'
* | 'rename_chat'
@ -136,5 +137,6 @@ export {};
* | 'get_group_chat_payload_tail'
* | 'get_group_chat_payload_before'
* | 'get_group_chat_payload_before_pages'
* | 'get_notification_permission_state'
* )} TauriInvokeCommand
*/

View file

@ -1,6 +1,9 @@
import { createTokenCountBroker, estimateTokenCount, trimOpenAiMessage } from '../brokers/token-count-broker.js';
import { createAndroidGenerationBridge } from '../adapters/android/android-generation-bridge.js';
import { translateSillyTavern } from '../adapters/st/sillytavern-i18n.js';
import { createGenerationLifecycleService } from '../services/ai/generation-lifecycle-service.js';
import { createGenerationStatusBridge } from '../services/ai/generation-status-bridge.js';
import { createSystemNotificationService } from '../services/notifications/system-notification-service.js';
import { listen } from '../../../tauri-bridge.js';
import { stripCommandErrorPrefixes } from '../../../scripts/util/command-error-utils.js';
@ -64,11 +67,9 @@ const i18nNotificationFallbacks = Object.freeze({
failureTitle: 'AI reply failed',
failureBody: 'Generation failed. Tap to return to TauriTavern',
});
const mobileGenerationState = {
activeCount: 0,
};
const androidGenerationBridge = createAndroidGenerationBridge({ bridgeName: ANDROID_GENERATION_BRIDGE_NAME });
const generationStatusBridge = createGenerationStatusBridge({ bridge: androidGenerationBridge });
function extractHttpStatusCode(errorMessage) {
const text = String(errorMessage || '');
@ -117,22 +118,6 @@ function getGenerationNotificationTexts() {
};
}
function showSystemNotification(context, title, body) {
if (typeof context?.safeInvoke !== 'function') {
return;
}
void context.safeInvoke('show_system_notification', {
dto: {
title: String(title || ''),
body: String(body || ''),
},
})
.catch((error) => {
console.debug('Failed to show system notification:', error);
})
}
function pickFirstStringValue(source) {
if (typeof source === 'string') {
const value = source.trim();
@ -196,65 +181,6 @@ function normalizeFailureNotificationBody(errorMessage) {
return normalized;
}
function createGenerationLifecycle(context, payload) {
const shouldNotifyResult = !isQuietRequest(payload);
let active = false;
return {
begin() {
if (active) {
return;
}
active = true;
mobileGenerationState.activeCount += 1;
if (mobileGenerationState.activeCount === 1) {
androidGenerationBridge.call('onGenerationStart');
}
},
finish({ success = false, errorMessage = '', notifyFailure = true } = {}) {
if (!active) {
return;
}
active = false;
mobileGenerationState.activeCount = Math.max(0, mobileGenerationState.activeCount - 1);
if (mobileGenerationState.activeCount === 0) {
const shouldNotify = shouldNotifyResult && shouldNotifyCompletion();
const statusCode = notifyFailure ? extractHttpStatusCode(errorMessage) : 0;
const supportsLiveUpdates = androidGenerationBridge.get('supportsLiveUpdates') === true;
if (supportsLiveUpdates && androidGenerationBridge.has('onGenerationFinish')) {
androidGenerationBridge.call(
'onGenerationFinish',
JSON.stringify({
success: Boolean(success),
status_code: statusCode,
show_completion_notification: Boolean(shouldNotify && (success || notifyFailure)),
}),
);
return;
}
if (success && shouldNotify) {
const texts = getGenerationNotificationTexts();
showSystemNotification(context, texts.successTitle, texts.successBody);
}
if (!success && notifyFailure && shouldNotify) {
const texts = getGenerationNotificationTexts();
const normalizedBody = normalizeFailureNotificationBody(errorMessage) || texts.failureBody;
showSystemNotification(context, texts.failureTitle, normalizedBody);
}
androidGenerationBridge.call('onGenerationStop');
}
},
};
}
function getChatCompletionSource(payload) {
return String(asObject(payload).chat_completion_source || '').trim().toLowerCase();
}
@ -460,13 +386,6 @@ async function createChatCompletionStreamResponse(context, payload, signal, life
const streamId = createStreamId();
const eventName = `chat-completion-stream:${streamId}`;
const encoder = new TextEncoder();
const androidSupportsLiveUpdates = androidGenerationBridge.get('supportsLiveUpdates') === true;
const androidCanReportTokens = androidSupportsLiveUpdates && androidGenerationBridge.has('onGenerationProgress');
const androidOutputChunks = [];
let androidOutputChars = 0;
let androidLastTokenCount = 0;
let androidLastTokenCharCount = 0;
let androidLastTokenReportAt = 0;
let isClosed = false;
let sawDone = false;
@ -567,7 +486,7 @@ async function createChatCompletionStreamResponse(context, payload, signal, life
const isSuccessfulCompletion = sawDone && !cancelUpstream && !errorPayload;
const shouldNotifyFailure = !isSuccessfulCompletion && !cancelUpstream && Boolean(failureMessage || errorPayload);
lifecycle?.finish({
await lifecycle?.finish({
success: isSuccessfulCompletion,
errorMessage: failureMessage,
notifyFailure: shouldNotifyFailure,
@ -597,39 +516,7 @@ async function createChatCompletionStreamResponse(context, payload, signal, life
return;
}
if (androidCanReportTokens) {
try {
const parsed = JSON.parse(data);
const delta = asObject(parsed?.choices?.[0]?.delta);
const chunkText = typeof delta.content === 'string' ? delta.content : '';
if (chunkText) {
androidOutputChunks.push(chunkText);
androidOutputChars += chunkText.length;
const now = Date.now();
const shouldCompute = shouldNotifyCompletion()
&& now - androidLastTokenReportAt >= ANDROID_LIVE_UPDATE_TOKEN_THROTTLE_MS
&& androidOutputChars - androidLastTokenCharCount >= ANDROID_LIVE_UPDATE_TOKEN_MIN_CHARS_DELTA;
if (shouldCompute) {
androidLastTokenReportAt = now;
const charCountAtRequest = androidOutputChars;
const textSnapshot = androidOutputChunks.join('');
androidOutputChunks.length = 0;
androidOutputChunks.push(textSnapshot);
const normalized = estimateTokenCount(textSnapshot);
androidLastTokenCharCount = charCountAtRequest;
if (!isClosed && normalized !== androidLastTokenCount) {
androidLastTokenCount = normalized;
androidGenerationBridge.call('onGenerationProgress', normalized);
}
}
}
} catch {
// Ignore non-JSON chunks (e.g. keep-alives).
}
}
lifecycle?.reportStreamChunk(data);
scheduleFlush();
return;
@ -717,6 +604,18 @@ async function createChatCompletionStreamResponse(context, payload, signal, life
export function registerAiRoutes(router, context, { jsonResponse }) {
const tokenCountBroker = createTokenCountBroker({ context });
const notificationService = createSystemNotificationService({ safeInvoke: context.safeInvoke });
const generationLifecycleService = createGenerationLifecycleService({
notificationService,
statusBridge: generationStatusBridge,
shouldNotifyCompletion,
getNotificationTexts: getGenerationNotificationTexts,
normalizeFailureNotificationBody,
extractFailureStatusCode: extractHttpStatusCode,
estimateTokenCount,
progressThrottleMs: ANDROID_LIVE_UPDATE_TOKEN_THROTTLE_MS,
progressMinCharsDelta: ANDROID_LIVE_UPDATE_TOKEN_MIN_CHARS_DELTA,
});
router.post('/api/backends/chat-completions/status', async ({ body }) => {
const payload = asObject(body);
@ -748,7 +647,9 @@ export function registerAiRoutes(router, context, { jsonResponse }) {
router.post('/api/backends/chat-completions/generate', async ({ body, init }) => {
const payload = { ...asObject(body) };
const wantsStream = Boolean(payload.stream);
const lifecycle = createGenerationLifecycle(context, payload);
const lifecycle = generationLifecycleService.createLifecycle({
quiet: isQuietRequest(payload),
});
lifecycle.begin();
try {
@ -757,14 +658,14 @@ export function registerAiRoutes(router, context, { jsonResponse }) {
}
const completion = await invokeChatCompletionWithAbort(context, payload, init?.signal);
lifecycle.finish({ success: true });
await lifecycle.finish({ success: true });
return jsonResponse(completion || {});
} catch (error) {
const errorMessage = getErrorMessage(error);
const aborted = isAbortError(error)
|| /generation cancelled by user/i.test(errorMessage);
lifecycle.finish({
await lifecycle.finish({
success: false,
errorMessage: aborted ? '' : errorMessage,
notifyFailure: !aborted,

View file

@ -0,0 +1,280 @@
// @ts-check
/**
* @typedef {import('../notifications/system-notification-service.js').NotificationPermissionState} NotificationPermissionState
*/
/**
* @param {unknown} value
* @returns {string}
*/
function extractOpenAiChunkText(value) {
const parsed = JSON.parse(String(value || ''));
const delta = parsed?.choices?.[0]?.delta;
return typeof delta?.content === 'string' ? delta.content : '';
}
/**
* @param {{
* enabled: boolean;
* statusBridge: {
* supportsProgressUpdates: () => boolean;
* reportProgress: (outputTokens: number) => boolean;
* };
* shouldNotifyCompletion: () => boolean;
* estimateTokenCount: (text: string) => number;
* progressThrottleMs: number;
* progressMinCharsDelta: number;
* }} deps
*/
function createStreamProgressReporter({
enabled,
statusBridge,
shouldNotifyCompletion,
estimateTokenCount,
progressThrottleMs,
progressMinCharsDelta,
}) {
if (!enabled || !statusBridge.supportsProgressUpdates()) {
return {
reportStreamChunk() {
// no-op
},
};
}
/** @type {string[]} */
const outputChunks = [];
let outputChars = 0;
let lastTokenCount = 0;
let lastTokenCharCount = 0;
let lastTokenReportAt = 0;
return {
/** @param {string} data */
reportStreamChunk(data) {
if (!shouldNotifyCompletion()) {
return;
}
try {
const chunkText = extractOpenAiChunkText(data);
if (!chunkText) {
return;
}
outputChunks.push(chunkText);
outputChars += chunkText.length;
const now = Date.now();
const shouldCompute = now - lastTokenReportAt >= progressThrottleMs
&& outputChars - lastTokenCharCount >= progressMinCharsDelta;
if (!shouldCompute) {
return;
}
lastTokenReportAt = now;
const charCountAtRequest = outputChars;
const textSnapshot = outputChunks.join('');
outputChunks.length = 0;
outputChunks.push(textSnapshot);
const normalized = estimateTokenCount(textSnapshot);
lastTokenCharCount = charCountAtRequest;
if (normalized === lastTokenCount) {
return;
}
lastTokenCount = normalized;
statusBridge.reportProgress(normalized);
} catch {
// Ignore non-JSON chunks (for example keep-alives).
}
},
};
}
/**
* @param {{
* notificationService: {
* getPermissionState: () => Promise<NotificationPermissionState>;
* preparePermission: () => Promise<NotificationPermissionState>;
* show: (params: { title: string; body: string }) => Promise<void>;
* };
* statusBridge: {
* start: () => void;
* supportsProgressUpdates: () => boolean;
* reportProgress: (outputTokens: number) => boolean;
* finish: (params: { success: boolean; statusCode: number; showCompletionNotification: boolean }) => boolean;
* stop: () => void;
* };
* shouldNotifyCompletion: () => boolean;
* getNotificationTexts: () => {
* successTitle: string;
* successBody: string;
* failureTitle: string;
* failureBody: string;
* };
* normalizeFailureNotificationBody: (errorMessage: string) => string;
* extractFailureStatusCode: (errorMessage: string) => number;
* estimateTokenCount: (text: string) => number;
* progressThrottleMs: number;
* progressMinCharsDelta: number;
* }} deps
*/
export function createGenerationLifecycleService({
notificationService,
statusBridge,
shouldNotifyCompletion,
getNotificationTexts,
normalizeFailureNotificationBody,
extractFailureStatusCode,
estimateTokenCount,
progressThrottleMs,
progressMinCharsDelta,
}) {
let activeCount = 0;
/**
* @param {Promise<NotificationPermissionState> | null} permissionPreparation
* @returns {Promise<NotificationPermissionState>}
*/
async function resolvePermissionState(permissionPreparation) {
if (permissionPreparation) {
return permissionPreparation;
}
return notificationService.getPermissionState();
}
/**
* @param {{
* success: boolean;
* errorMessage: string;
* notifyFailure: boolean;
* permissionPreparation: Promise<NotificationPermissionState> | null;
* }} params
*/
async function showCompletionNotification({
success,
errorMessage,
notifyFailure,
permissionPreparation,
}) {
const permissionState = await resolvePermissionState(permissionPreparation);
if (permissionState !== 'granted') {
return;
}
const texts = getNotificationTexts();
if (success) {
await notificationService.show({
title: texts.successTitle,
body: texts.successBody,
});
return;
}
if (!notifyFailure) {
return;
}
await notificationService.show({
title: texts.failureTitle,
body: normalizeFailureNotificationBody(errorMessage) || texts.failureBody,
});
}
return {
/**
* @param {{ quiet: boolean }} params
*/
createLifecycle({ quiet }) {
let active = false;
/** @type {Promise<NotificationPermissionState> | null} */
let permissionPreparation = null;
const progressReporter = createStreamProgressReporter({
enabled: !quiet,
statusBridge,
shouldNotifyCompletion,
estimateTokenCount,
progressThrottleMs,
progressMinCharsDelta,
});
return {
begin() {
if (active) {
return;
}
active = true;
activeCount += 1;
if (!quiet) {
permissionPreparation = notificationService.preparePermission().catch((error) => {
console.error('Failed to prepare notification permission:', error);
return 'prompt';
});
}
if (activeCount === 1) {
statusBridge.start();
}
},
/**
* @param {{
* success?: boolean;
* errorMessage?: string;
* notifyFailure?: boolean;
* }} [params]
*/
async finish({ success = false, errorMessage = '', notifyFailure = true } = {}) {
if (!active) {
return;
}
active = false;
activeCount = Math.max(0, activeCount - 1);
if (activeCount !== 0) {
return;
}
const shouldNotify = !quiet && shouldNotifyCompletion();
const handledByNative = statusBridge.finish({
success: Boolean(success),
statusCode: notifyFailure ? extractFailureStatusCode(errorMessage) : 0,
showCompletionNotification: Boolean(shouldNotify && (success || notifyFailure)),
});
if (!handledByNative && shouldNotify) {
try {
await showCompletionNotification({
success: Boolean(success),
errorMessage,
notifyFailure: Boolean(notifyFailure),
permissionPreparation,
});
} catch (error) {
console.error('Failed to show generation completion notification:', error);
}
}
if (!handledByNative) {
statusBridge.stop();
}
},
/** @param {string} data */
reportStreamChunk(data) {
progressReporter.reportStreamChunk(data);
},
};
},
};
}

View file

@ -0,0 +1,76 @@
// @ts-check
/**
* @typedef {{
* call: (methodName: string, ...args: any[]) => boolean;
* get: (methodName: string, ...args: any[]) => any;
* has: (methodName: string) => boolean;
* }} NativeGenerationBridge
*/
/**
* @param {{
* bridge: NativeGenerationBridge;
* }} deps
*/
export function createGenerationStatusBridge({ bridge }) {
/** @type {boolean | null} */
let liveUpdatesSupported = null;
function supportsLiveUpdates() {
if (liveUpdatesSupported === null) {
liveUpdatesSupported = bridge.get('supportsLiveUpdates') === true;
}
return liveUpdatesSupported;
}
function supportsProgressUpdates() {
return supportsLiveUpdates() && bridge.has('onGenerationProgress');
}
function supportsNativeCompletion() {
return supportsLiveUpdates() && bridge.has('onGenerationFinish');
}
/**
* @param {{
* success: boolean;
* statusCode: number;
* showCompletionNotification: boolean;
* }} params
*/
function finish({ success, statusCode, showCompletionNotification }) {
if (!supportsNativeCompletion()) {
return false;
}
return bridge.call(
'onGenerationFinish',
JSON.stringify({
success: Boolean(success),
status_code: Number.isInteger(statusCode) ? statusCode : 0,
show_completion_notification: Boolean(showCompletionNotification),
}),
);
}
return {
start() {
bridge.call('onGenerationStart');
},
supportsProgressUpdates,
/** @param {number} outputTokens */
reportProgress(outputTokens) {
if (!supportsProgressUpdates()) {
return false;
}
return bridge.call('onGenerationProgress', outputTokens);
},
finish,
stop() {
bridge.call('onGenerationStop');
},
};
}

View file

@ -0,0 +1,75 @@
// @ts-check
/**
* @typedef {'granted' | 'denied' | 'prompt'} NotificationPermissionState
* @typedef {(command: import('../../context/types.js').TauriInvokeCommand, args?: any) => Promise<any>} SafeInvokeFn
*/
const NOTIFICATION_PERMISSION_STATES = new Set(['granted', 'denied', 'prompt']);
/**
* @param {unknown} value
* @returns {NotificationPermissionState}
*/
function normalizePermissionState(value) {
const normalized = String(value || '').trim().toLowerCase();
if (NOTIFICATION_PERMISSION_STATES.has(normalized)) {
return /** @type {NotificationPermissionState} */ (normalized);
}
throw new Error(`Unsupported notification permission state: ${String(value || '')}`);
}
/**
* @param {{
* safeInvoke: SafeInvokeFn;
* }} deps
*/
export function createSystemNotificationService({ safeInvoke }) {
/** @type {Promise<NotificationPermissionState> | null} */
let permissionRequestPromise = null;
async function getPermissionState() {
return normalizePermissionState(await safeInvoke('get_notification_permission_state'));
}
async function requestPermission() {
if (!permissionRequestPromise) {
permissionRequestPromise = safeInvoke('request_notification_permission')
.then(normalizePermissionState)
.finally(() => {
permissionRequestPromise = null;
});
}
return permissionRequestPromise;
}
async function preparePermission() {
const currentState = await getPermissionState();
if (currentState !== 'prompt') {
return currentState;
}
return requestPermission();
}
/**
* @param {{ title: string; body: string }} params
*/
async function show({ title, body }) {
await safeInvoke('show_system_notification', {
dto: {
title: String(title ?? '').trim(),
body: String(body ?? '').trim(),
},
});
}
return {
getPermissionState,
requestPermission,
preparePermission,
show,
};
}