merlyn/server/utils/PushNotifications/index.js
Marcello Fitton 41495cdabe
feat: Scheduled Jobs (#5322)
* initialize

* expand tool result text limit | add syntax highlighting and json formatting to tool result rendering

* fix onError jsdoc

* lint

* fix unread icon

* route protection

* improve form handling for NewJobModal

* safeJsonParse

* remove unneeded comments

* remove trycatch

* add truncateText helper

* add explicit fallback value tos safeJsonParse

* add shared cron constant and helpers

* reduce frontend indirection

* use isLight to compute syntax highlighting theme

* remove dead code

* remove forJob and make job limit to 50

* create recomputeNextRunAt helper method

* add comment about nextRunAt recomputation

* add job queue and concurrency control to scheduled jobs

* use p-queue

* change default max concurrent value to 1

* add comment explaining internal scheduling system

* add recomputeNextRunAt on boot

* add generated documents to run details

* Modify toolsOverride functionality where no tools selected means no tools are given to the agent

add a select all/deselect all toggle button for easily selecting all
tools in the cerate job form

* create usePolling hook

* add polling to scheduled jobs and scheduled job runs pages

* add cron generation feature in job form

* remove cron generation feature | add cron builder feature | add max active scheduled jobs limit

* set MAX_ACTIVE to null

* replace hour and minute input fields with input with type time

* simplify

* organize components

* move components to bottom of page component

* change Generated Documents to Generated Files

* add i18n to cronstrue

* add i18n

* add type="button" to button elements

* refactor fileSource retrieval logic

* one scheduled job run can have status "running"

* add protection of file retrieveal from scheduled job in multiuser mode

* fix comments

* make job status default to queued

* add queued status

* fix bug with result trace rendering

* store timeout ref and clearTimeout once race settles

* remove unneeded handlerPromise tracking

* move imports to top level

* refactor hardcoded paths to path resolve functions

* implement new job form design

* simplify

* fix button styles

* fix runJob bug

* implement styles for scheduled jobs page

* apply dark mode figma styles

* delete unused translation key

* implement light mode for new new job modal, run history, and run details

* lint

* fix light mode scroll bar in tool call card

* adjust table header contrast

* fix type in subtitle

* kill workers when job is in-flight before deleting job

* add border-none to buttons

* change locale time to iso string

* import BackgroundService module level | instatiate backgroundService singltone once and reuse across handlers

* add p-queue, @breejs/later and cron-validate as core deps

* parse cron expression to a builder state once

* add theme to day buttons in cron builder

* fix stale tools selection caption

* flip popover when popover clips screen height

* make ScheduleJob.trigger() await the run insertion | disable run now button if job is in flight

* regen table

* refactor generated file card

* refactor frontend

* remove logs

* major refactor for tool picking, fix bree/later bug

* combine action endpoints, move contine to method

* fix unoptimized query with include + take + order

* fix dangerous use, refactor job to utils

* add copy content to text response

* improve notification system subscription for browser

* remove unused translations

* prevent gen-file cleanup job from deleting active job file generated references

* rich text copy

* Scheduled Jobs: Translations (#5482)

* add locales for scheduled jobs

* i18n

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* add config flag with UI notice

* update README

* telemetry datapoints

* Always use UTC on backend, convert to local in frontend

* fix tz render

* Add job killing

* cleanup thinking text in job notifications and break out reasoning in response text.
Also hide zero metrics since that is useless

* Port generatedFile schema to the normalized workspace chat `outputs` file format so porting to thread is simple and implem between chats <> jobs is 1:1

* what the fuck

* compiled bug

* fixed thinking oddity in complied frontend

* supress multi-toast

* fix duration call

* Revert "fix duration call"

This reverts commit 0491bc71f4223e65ea4046561b15b268fefb8da2.

* revert and reapply fix

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
2026-04-29 12:05:46 -07:00

229 lines
8.1 KiB
JavaScript

const webpush = require("web-push");
const fs = require("fs");
const path = require("path");
const { User } = require("../../models/user");
const { SystemSettings } = require("../../models/systemSettings");
const { safeJsonParse } = require("../http");
/**
* For more options, see:
* https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification#options
* @typedef {Object} PushNotificationPayload
* @property {string} title - The title of the notification.
* @property {string} body - The message of the notification.
* @property {Object} data - Unstructured data for the notification. Use this for anything non-standard.
* @property {string} [data.onClickUrl] - The URL to open when the notification is clicked. Note: Can be relative or absolute.
* @property {Object[]} actions - The actions for the notification.
* @property {string} [actions[].action] - The action to perform when the notification is clicked. Handled in the service worker.
* @property {string} [actions[].title] - The title of the action to show in the Options dropdown
* @property {string} image - A string containing the URL of an image to be displayed in the notification.
*/
class PushNotifications {
static mailTo = "anythingllm@localhost";
/**
* @type {PushNotifications}
*/
static instance = null;
/**
* The VAPID keys for the push notification service.
* @type {{publicKey: string | null, privateKey: string | null}}
*/
#vapidKeys = {
publicKey: null,
privateKey: null,
};
/**
* The subscriptions for the push notification service.
* @type {Map<string, Object>}
*/
#subscriptions = new Map();
constructor() {
if (PushNotifications.instance) return PushNotifications.instance;
PushNotifications.instance = this;
}
#log(text, ...args) {
console.log(`\x1b[36m[PushNotifications]\x1b[0m ${text}`, ...args);
}
get pushService() {
try {
const vapidKeys = this.existingVapidKeys;
if (!vapidKeys.publicKey || !vapidKeys.privateKey)
throw new Error(
"VAPID keys not found. Make sure they are generated in the main process first."
);
webpush.setVapidDetails(
`mailto:${this.mailTo}`,
vapidKeys.publicKey,
vapidKeys.privateKey
);
return webpush;
} catch (e) {
console.error("Failed to set VAPID details", e);
return null;
}
}
get storagePath() {
return process.env.NODE_ENV === "development"
? path.resolve(__dirname, `../../storage`, "push-notifications")
: path.resolve(process.env.STORAGE_DIR, "push-notifications");
}
get primarySubscriptionPath() {
return path.resolve(this.storagePath, `primary-subscription.json`);
}
get existingVapidKeys() {
// Already loaded and binded to the instance
if (this.#vapidKeys.publicKey && this.#vapidKeys.privateKey)
return this.#vapidKeys;
const vapidKeysPath = path.resolve(this.storagePath, `vapid-keys.json`);
if (!fs.existsSync(vapidKeysPath))
return { publicKey: null, privateKey: null };
const existingVapidKeys = JSON.parse(
fs.readFileSync(vapidKeysPath, "utf8")
);
this.#log(`Loaded existing VAPID keys!`);
this.#vapidKeys.publicKey = existingVapidKeys.publicKey;
this.#vapidKeys.privateKey = existingVapidKeys.privateKey;
return this.#vapidKeys;
}
get publicVapidKey() {
return this.existingVapidKeys.publicKey;
}
/**
* Load the subscriptions for the push notification service.
* In single user mode, the subscription is stored in the primary-subscription.json file.
* In multi user mode, the subscriptions are stored in the database so we grab them from there
* and store them in the #subscriptions map for reference later.
* @returns {Promise<void>}
*/
async loadSubscriptions() {
const isMultiUserMode = await SystemSettings.isMultiUserMode();
if (isMultiUserMode) {
const users = await User._where({
web_push_subscription_config: { not: null },
});
for (const user of users) {
const subscription = safeJsonParse(
user.web_push_subscription_config,
null
);
if (subscription) this.#subscriptions.set(user.id, subscription);
}
this.#log(`Loaded ${this.#subscriptions.size} existing subscriptions.`);
return;
}
this.#log("Loading single user mode subscriptions...");
if (!fs.existsSync(this.primarySubscriptionPath)) return;
const subscription = JSON.parse(
fs.readFileSync(this.primarySubscriptionPath, "utf8")
);
if (subscription) this.#subscriptions.set("primary", subscription);
this.#log(`Loaded primary user's existing subscription.`);
}
/**
* Register a new subscription for a user.
* In single user mode, the userId is mapped to "primary"
* In multi user mode, the userId is the user's id in the database
*
* @param {Object|null} user - The user to register the subscription for.
* @param {Object} subscription - The subscription to register.
* @returns {Promise<PushNotifications>}
*/
async registerSubscription(user = null, subscription) {
let userId = user?.id || "primary";
this.#subscriptions.set(userId, subscription);
// If this was a real user, write the subscription to the database
if (!!user) {
await User._update(user.id, {
web_push_subscription_config: JSON.stringify(subscription),
});
this.#log(`Registered or updated subscription for user - ${user.id}`);
} else {
if (!fs.existsSync(this.storagePath))
fs.mkdirSync(this.storagePath, { recursive: true });
fs.writeFileSync(
this.primarySubscriptionPath,
JSON.stringify(subscription, null, 2)
);
this.#log(`Registered or updated primary user's subscription.`);
}
return this;
}
/**
* Send a push notification to all subscribed clients.
* @param {Object} options - The options for the notification.
* @param {"primary"|number} [options.to] - The subscription to send the notification to. "all" sends to all subscriptions, "primary" sends to the primary user (single user mode only), a number sends subscription to specific user
* @param {PushNotificationPayload} [options.payload] - The payload to send to the clients.
* @returns {Promise<void>}
*/
sendNotification({ to = "primary", payload = {} } = {}) {
if (this.#subscriptions.size === 0)
return this.#log(".sendNotification() - No subscriptions found");
if (!this.#subscriptions.has(to))
return this.#log(
`.sendNotification() - Subscription for user ${to} not found`
);
this.#log(`.sendNotification() - Sending notification to user ${to}`);
return this.pushService
.sendNotification(this.#subscriptions.get(to), JSON.stringify(payload))
.then((res) => {
this.#log(
`.sendNotification() - Delivered (status: ${res.statusCode})`
);
})
.catch((err) => {
this.#log(`.sendNotification() - Failed: ${err.message}`);
});
}
/**
* Setup the push notification service.
* This will generate new VAPID keys if they don't exist and save them to the storage path.
* It will also load the subscriptions from the database or the primary-subscription.json file.
* @returns {Promise<void>}
*/
static async setupPushNotificationService() {
const instance = PushNotifications.instance;
const existingVapidKeys = instance.existingVapidKeys;
if (!existingVapidKeys.publicKey || !existingVapidKeys.privateKey) {
instance.#log("Generating new VAPID keys...");
const vapidKeys = webpush.generateVAPIDKeys();
instance.#vapidKeys.publicKey = vapidKeys.publicKey;
instance.#vapidKeys.privateKey = vapidKeys.privateKey;
instance.#log(`New VAPID keys generated!`);
if (!fs.existsSync(instance.storagePath))
fs.mkdirSync(instance.storagePath, { recursive: true });
fs.writeFileSync(
path.resolve(instance.storagePath, `vapid-keys.json`),
JSON.stringify(vapidKeys, null, 2)
);
}
await instance.loadSubscriptions();
instance.pushService;
return;
}
}
module.exports = {
pushNotificationService: new PushNotifications(),
PushNotifications,
};