Skip to content

Commit 726abd5

Browse files
authored
Add Tauri native notifications (unslothai#5273)
1 parent 5533bdb commit 726abd5

9 files changed

Lines changed: 470 additions & 7 deletions

File tree

studio/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@tanstack/react-table": "^8.21.3",
4545
"@tauri-apps/api": "^2.10.1",
4646
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
47+
"@tauri-apps/plugin-notification": "^2.3.3",
4748
"@tauri-apps/plugin-opener": "^2.5.3",
4849
"@tauri-apps/plugin-process": "^2.3.1",
4950
"@tauri-apps/plugin-updater": "^2.10.1",

studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
import { createElement, useCallback, useRef, useState } from "react";
55
import { toast } from "sonner";
66
import { consumeNativePathToken } from "@/features/native-intents/api";
7+
import {
8+
notifyNative,
9+
primeNativeNotificationPermission,
10+
safeNotificationLabel,
11+
sanitizeNotificationBody,
12+
} from "@/lib/native-notifications";
713
import { ModelLoadDescription } from "../components/model-load-status";
814
import {
915
getDownloadProgress,
@@ -168,6 +174,7 @@ export function useChatModelRuntime() {
168174
const loadAbortRef = useRef<AbortController | null>(null);
169175
const loadingModelRef = useRef<typeof loadingModel>(null);
170176
const loadToastIdRef = useRef<string | number | null>(null);
177+
const loadAttemptRef = useRef(0);
171178
const loadToastDismissedRef = useRef(false);
172179
const cancelUnloadPendingRef = useRef(false);
173180

@@ -369,6 +376,10 @@ export function useChatModelRuntime() {
369376
const isLora =
370377
explicitIsLora ?? model?.isLora ?? loraIsAdapter ?? false;
371378
const displayName = model?.name || lora?.name || modelId;
379+
const loadAttemptId = ++loadAttemptRef.current;
380+
primeNativeNotificationPermission().catch(() => undefined);
381+
const notificationModelKey = `${modelId}:${ggufVariant ?? ""}:${loadAttemptId}`;
382+
const safeModelName = safeNotificationLabel(displayName, "The model");
372383
const currentCheckpoint =
373384
useChatRuntimeStore.getState().params.checkpoint;
374385
const previousCheckpoint = currentCheckpoint;
@@ -813,6 +824,12 @@ export function useChatModelRuntime() {
813824
},
814825
});
815826
}
827+
notifyNative({
828+
key: `model-downloaded:${notificationModelKey}`,
829+
title: "Model downloaded",
830+
body: `${safeModelName} finished downloading and is loading into memory.`,
831+
requestPermission: false,
832+
}).catch(() => undefined);
816833
// Keep polling: the mmap branch below takes over from here.
817834
}
818835
} catch {
@@ -898,6 +915,12 @@ export function useChatModelRuntime() {
898915
duration: 2000,
899916
});
900917
}
918+
notifyNative({
919+
key: `model-loaded:${notificationModelKey}`,
920+
title: "Model ready",
921+
body: `${safeModelName} is loaded and ready to chat.`,
922+
requestPermission: false,
923+
}).catch(() => undefined);
901924
} catch (err) {
902925
if (!abortCtrl.signal.aborted) {
903926
const message =
@@ -912,6 +935,12 @@ export function useChatModelRuntime() {
912935
duration: 5000,
913936
});
914937
}
938+
notifyNative({
939+
key: `model-load-failed:${notificationModelKey}`,
940+
title: "Model failed to load",
941+
body: sanitizeNotificationBody(message, "The model failed to load."),
942+
requestPermission: false,
943+
}).catch(() => undefined);
915944
}
916945
throw err;
917946
} finally {

studio/frontend/src/features/training/hooks/use-training-actions.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
// SPDX-License-Identifier: AGPL-3.0-only
22
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
33

4+
import { primeNativeNotificationPermission } from "@/lib/native-notifications";
45
import { useCallback } from "react";
6+
import { toast } from "sonner";
57
import { checkDatasetFormat } from "../api/datasets-api";
68
import { getTrainingRun } from "../api/history-api";
79
import { buildTrainingStartPayload } from "../api/mappers";
8-
import { startTraining, stopTraining, resetTraining } from "../api/train-api";
10+
import { resetTraining, startTraining, stopTraining } from "../api/train-api";
911
import { syncTrainingRuntimeFromBackend } from "../lib/sync-runtime";
1012
import { validateTrainingConfig } from "../lib/validation";
1113
import { useDatasetPreviewDialogStore } from "../stores/dataset-preview-dialog-store";
1214
import { useTrainingConfigStore } from "../stores/training-config-store";
1315
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
1416
import type { TrainingStartRequest } from "../types/api";
1517
import type { TrainingConfigState } from "../types/config";
16-
import { toast } from "sonner";
1718

1819
/** Chatml → format-specific role remap (only for formats that differ from chatml). */
1920
const ROLE_REMAP: Record<string, Record<string, string>> = {
@@ -50,6 +51,8 @@ export function useTrainingActions() {
5051
return false;
5152
}
5253

54+
primeNativeNotificationPermission().catch(() => undefined);
55+
5356
runtimeStore.setStartResources(
5457
config.selectedModel ?? null,
5558
getHfDatasetName(config),
@@ -174,6 +177,8 @@ export function useTrainingActions() {
174177
throw new Error("Only stopped runs with a saved checkpoint can be resumed.");
175178
}
176179

180+
primeNativeNotificationPermission().catch(() => undefined);
181+
177182
const config = useTrainingConfigStore.getState();
178183
const savedConfig = detail.config as Partial<TrainingStartRequest>;
179184
const payload = {

studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,98 @@
22
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
33

44
import { hasAuthToken } from "@/features/auth";
5+
import {
6+
notifyNative,
7+
safeNotificationLabel,
8+
sanitizeNotificationBody,
9+
} from "@/lib/native-notifications";
510
import { useEffect } from "react";
611
import {
712
getTrainingMetrics,
813
getTrainingStatus,
914
isAbortError,
1015
streamTrainingProgress,
1116
} from "../api/train-api";
17+
import { useTrainingConfigStore } from "../stores/training-config-store";
1218
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
1319
import type { TrainingRuntimeStore } from "../types/runtime";
1420

1521
const STATUS_POLL_INTERVAL_MS = 3000;
1622
const METRICS_POLL_INTERVAL_MS = 5000;
1723
const IDLE_POLL_INTERVAL_MS = 30000;
1824
const STREAM_RECONNECT_DELAY_MS = 1500;
25+
const GENERIC_TRAINING_STREAM_ERROR = "Training stream error";
26+
const DEFAULT_TRAINING_ERROR_BODY = "Your training run stopped with an error.";
1927

2028
function shouldUseLiveSync(state: TrainingRuntimeStore): boolean {
2129
return state.isTrainingRunning || state.phase === "training";
2230
}
2331

32+
function getTrainingErrorBody(state: TrainingRuntimeStore): string {
33+
return sanitizeNotificationBody(
34+
state.error ?? state.message,
35+
DEFAULT_TRAINING_ERROR_BODY,
36+
);
37+
}
38+
39+
function shouldNotifyTrainingError(
40+
before: TrainingRuntimeStore,
41+
after: TrainingRuntimeStore,
42+
): boolean {
43+
if (after.phase !== "error") {
44+
return false;
45+
}
46+
if (before.phase !== "error" || before.jobId !== after.jobId) {
47+
return true;
48+
}
49+
if (before.error !== GENERIC_TRAINING_STREAM_ERROR) {
50+
return false;
51+
}
52+
53+
return getTrainingErrorBody(before) !== getTrainingErrorBody(after);
54+
}
55+
56+
function maybeNotifyTrainingTerminalTransition(
57+
before: TrainingRuntimeStore,
58+
after: TrainingRuntimeStore,
59+
): void {
60+
if (!after.jobId) {
61+
return;
62+
}
63+
if (
64+
before.stopRequested ||
65+
after.stopRequested ||
66+
after.phase === "stopped"
67+
) {
68+
return;
69+
}
70+
71+
const sameJob = before.jobId === after.jobId;
72+
const samePhase = before.phase === after.phase;
73+
74+
if (after.phase === "completed" && !(sameJob && samePhase)) {
75+
const model = safeNotificationLabel(
76+
after.startModelName ?? useTrainingConfigStore.getState().selectedModel,
77+
"Your training run",
78+
);
79+
notifyNative({
80+
key: `training-completed:${after.jobId}`,
81+
title: "Training finished",
82+
body: `${model} is complete.`,
83+
requestPermission: false,
84+
}).catch(() => undefined);
85+
}
86+
87+
if (shouldNotifyTrainingError(before, after)) {
88+
notifyNative({
89+
key: `training-error:${after.jobId}`,
90+
title: "Training failed",
91+
body: getTrainingErrorBody(after),
92+
requestPermission: false,
93+
}).catch(() => undefined);
94+
}
95+
}
96+
2497
export function useTrainingRuntimeLifecycle(): void {
2598
useEffect(() => {
2699
let disposed = false;
@@ -62,7 +135,9 @@ export function useTrainingRuntimeLifecycle(): void {
62135
}
63136
};
64137

65-
const pollStatus = async () => {
138+
const pollStatus = async (options?: {
139+
suppressNativeNotifications?: boolean;
140+
}) => {
66141
if (!hasAuthToken()) return;
67142
const gen = runtimeStore.getState().resetGeneration;
68143
try {
@@ -71,9 +146,13 @@ export function useTrainingRuntimeLifecycle(): void {
71146
return;
72147
}
73148

149+
const previousState = runtimeStore.getState();
74150
runtimeStore.getState().applyStatus(status);
75151

76152
const nextState = runtimeStore.getState();
153+
if (!options?.suppressNativeNotifications) {
154+
maybeNotifyTrainingTerminalTransition(previousState, nextState);
155+
}
77156
if (shouldUseLiveSync(nextState)) {
78157
void ensureStream();
79158
} else {
@@ -124,7 +203,7 @@ export function useTrainingRuntimeLifecycle(): void {
124203
}
125204

126205
if (event.event === "error") {
127-
liveStore.setRuntimeError("Training stream error");
206+
liveStore.setRuntimeError(GENERIC_TRAINING_STREAM_ERROR);
128207
stopStream();
129208
}
130209
},
@@ -154,7 +233,10 @@ export function useTrainingRuntimeLifecycle(): void {
154233
const hydrate = async () => {
155234
runtimeStore.getState().setHydrating(true);
156235
try {
157-
await Promise.all([pollStatus(), pollMetrics()]);
236+
await Promise.all([
237+
pollStatus({ suppressNativeNotifications: true }),
238+
pollMetrics(),
239+
]);
158240
} finally {
159241
if (!disposed) {
160242
runtimeStore.getState().setHydrating(false);
@@ -172,7 +254,12 @@ export function useTrainingRuntimeLifecycle(): void {
172254

173255
const statusTimer = setInterval(() => {
174256
const s = runtimeStore.getState();
175-
if (isIdle() && s.hasHydrated) return;
257+
if (!s.hasHydrated || s.isHydrating) {
258+
return;
259+
}
260+
if (isIdle()) {
261+
return;
262+
}
176263
void pollStatus();
177264
}, STATUS_POLL_INTERVAL_MS);
178265

@@ -187,7 +274,13 @@ export function useTrainingRuntimeLifecycle(): void {
187274
// Low-frequency background poll to recover from failed hydration or detect
188275
// out-of-band state changes (e.g. training started from another client).
189276
const idleTimer = setInterval(() => {
190-
if (!isIdle()) return;
277+
const s = runtimeStore.getState();
278+
if (!s.hasHydrated || s.isHydrating) {
279+
return;
280+
}
281+
if (!isIdle()) {
282+
return;
283+
}
191284
void pollStatus();
192285
}, IDLE_POLL_INTERVAL_MS);
193286

0 commit comments

Comments
 (0)