mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
feat (notification): add notification for iOS
This commit is contained in:
parent
78c10f0d6e
commit
8f15a21211
7 changed files with 515 additions and 134 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
280
src/tauri/main/services/ai/generation-lifecycle-service.js
Normal file
280
src/tauri/main/services/ai/generation-lifecycle-service.js
Normal 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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
76
src/tauri/main/services/ai/generation-status-bridge.js
Normal file
76
src/tauri/main/services/ai/generation-status-bridge.js
Normal 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue