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>
This commit is contained in:
Marcello Fitton 2026-04-29 12:05:46 -07:00 committed by GitHub
parent 02c2db4ee3
commit 41495cdabe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 9031 additions and 101 deletions

View File

@ -55,6 +55,7 @@ AnythingLLM supports multiple users as well where you can control the access and
## Cool features of AnythingLLM
- [Scheduled Tasks](https://docs.anythingllm.com/scheduled-jobs/overview)
- [Intelligent Skill Selection](https://docs.anythingllm.com/agent/intelligent-tool-selection) Enable **unlimited** tools for your models while reducing token usage by up to 80% per query
- [**No-code AI Agent builder**](https://docs.anythingllm.com/agent-flows/overview)
- [**Full MCP-compatibility**](https://docs.anythingllm.com/mcp-compatibility/overview)

View File

@ -17,6 +17,7 @@
"@mintplex-labs/piper-tts-web": "^1.0.4",
"@phosphor-icons/react": "^2.1.7",
"@tremor/react": "^3.15.1",
"cronstrue": "^2.50.0",
"dompurify": "^3.0.8",
"file-saver": "^2.0.5",
"he": "^1.2.0",

View File

@ -126,6 +126,25 @@ export function ManagerRoute({ Component }) {
);
}
// Allows access only in single user mode redirects to home in multi-user mode
export function SingleUserRoute({ Component }) {
const { isAuthd, shouldRedirectToOnboarding, multiUserMode } =
useIsAuthenticated();
if (isAuthd === null) return <FullScreenLoader />;
if (shouldRedirectToOnboarding) {
return <Navigate to={paths.onboarding.home()} />;
}
return isAuthd && !multiUserMode ? (
<KeyboardShortcutWrapper>
<Component />
</KeyboardShortcutWrapper>
) : (
<Navigate to={paths.home()} />
);
}
export default function PrivateRoute({ Component }) {
const { isAuthd, shouldRedirectToOnboarding } = useIsAuthenticated();
if (isAuthd === null) return <FullScreenLoader />;

View File

@ -395,6 +395,12 @@ const SidebarOptions = ({ user = null, t }) => (
flex: true,
roles: ["admin"],
},
{
btnText: t("settings.scheduled-jobs"),
href: paths.settings.scheduledJobs(),
flex: true,
hidden: !!user,
},
{
btnText: t("settings.api-keys"),
href: paths.settings.apiKeys(),

View File

@ -1,25 +1,11 @@
import { formatDateTimeAsMoment } from "@/utils/directories";
import { numberWithCommas } from "@/utils/numbers";
import { formatDuration, numberWithCommas } from "@/utils/numbers";
import React, { useEffect, useState, useContext } from "react";
import { isMobile } from "react-device-detect";
const MetricsContext = React.createContext();
const SHOW_METRICS_KEY = "anythingllm_show_chat_metrics";
const SHOW_METRICS_EVENT = "anythingllm_show_metrics_change";
/**
* @param {number} duration - duration in milliseconds
* @returns {string}
*/
function formatDuration(duration) {
try {
return duration < 1
? `${(duration * 1000).toFixed(0)}ms`
: `${duration.toFixed(3)}s`;
} catch {
return "";
}
}
/**
* Format the output TPS to a string
* @param {number} outputTps - output TPS

View File

@ -1,4 +1,5 @@
import { THOUGHT_REGEX_COMPLETE } from "@/components/WorkspaceChat/ChatContainer/ChatHistory/ThoughtContainer";
import { copyMarkdownAsRichText } from "@/utils/clipboard";
import { useState } from "react";
export default function useCopyText(delay = 2500) {
@ -8,11 +9,9 @@ export default function useCopyText(delay = 2500) {
// Filter thinking blocks from the content if they exist
const nonThinkingContent = content.replace(THOUGHT_REGEX_COMPLETE, "");
navigator?.clipboard?.writeText(nonThinkingContent);
setCopied(nonThinkingContent);
setTimeout(() => {
setCopied(false);
}, delay);
await copyMarkdownAsRichText(nonThinkingContent);
setCopied(true);
setTimeout(() => setCopied(false), delay);
};
return { copyText, copied };

View File

@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
/**
* Polls a callback on an interval, but only while the tab is visible.
* Automatically pauses when the user switches away and resumes on return.
*
* @param {() => void | Promise<void>} callback - The function to invoke on each tick
* @param {number} intervalMs - Polling interval in milliseconds
* @param {boolean} [enabled=true] - When false, polling is suspended
*/
export default function usePolling(callback, intervalMs, enabled = true) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (!enabled || !intervalMs) return;
let timerId = null;
const start = () => {
if (timerId) return;
timerId = setInterval(() => savedCallback.current(), intervalMs);
};
const stop = () => {
if (!timerId) return;
clearInterval(timerId);
timerId = null;
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
// Fire immediately on return so the UI feels fresh, then resume interval
savedCallback.current();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
stop();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [intervalMs, enabled]);
}

View File

@ -19,20 +19,28 @@ function log(message, ...args) {
/**
* Subscribes to push notifications for the current client - can be called multiple times without re-subscribing
* or generating infinite tokens.
* @returns {void}
* @returns {Promise<void>}
*/
export async function subscribeToPushNotifications() {
export async function subscribeToPushNotifications(askToEnable = true) {
try {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
log("Push notifications not supported");
return;
}
// Check current permission status
const permission = await Notification.requestPermission();
if (permission !== "granted") {
log("Notification permission not granted");
return;
if (askToEnable) {
// Check current permission status
const permission = await Notification.requestPermission();
if (permission !== "granted") {
log("Notification permission not granted");
return;
}
} else {
const permission = Notification.permission;
if (permission !== "granted") {
log("Notification permission not granted");
return;
}
}
const publicKey = await fetch(PUSH_PUBKEY_URL, { headers: baseHeaders() })
@ -107,9 +115,9 @@ export async function subscribeToPushNotifications() {
* Hook that registers a service worker for push notifications.
* @returns {void}
*/
export default function useWebPushNotifications() {
export default function useWebPushNotifications(askToEnable = true) {
useEffect(() => {
subscribeToPushNotifications();
subscribeToPushNotifications(askToEnable);
}, []);
}

View File

@ -945,6 +945,36 @@ does not extend the close button beyond the viewport. */
background-color: #cccccc;
}
/* Scoped to the scheduled jobs tool call card the default white-scrollbar
is tuned for dark surfaces and looks harsh over light backgrounds. */
.tool-call-scrollbar {
scrollbar-width: thin;
}
[data-theme="light"] .tool-call-scrollbar {
scrollbar-color: #cbd5e1 transparent;
}
[data-theme="light"] .tool-call-scrollbar::-webkit-scrollbar {
width: 3px;
height: 3px;
background-color: transparent;
}
[data-theme="light"] .tool-call-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
[data-theme="light"] .tool-call-scrollbar::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 4px;
border: 2px solid transparent;
}
[data-theme="light"] .tool-call-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
/* Recharts rendering styles */
.recharts-text > * {
fill: #fff;

View File

@ -107,6 +107,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "تليجرام",
},
"scheduled-jobs": "المهام المجدولة",
},
login: {
"multi-user": {
@ -1485,6 +1486,163 @@ const TRANSLATIONS = {
unknown: "غير معروف",
},
},
scheduledJobs: {
title: "المهام المجدولة",
enableNotifications: "قم بتمكين إشعارات المتصفح لنتائج البحث عن وظائف.",
description:
"إنشاء مهام ذكاء اصطناعي متكررة تعمل وفق جدول زمني. تقوم كل مهمة بتشغيل استعلام مع أدوات اختيارية، وتخزين النتيجة للمراجعة.",
newJob: "وظيفة جديدة",
loading: "جاري التحميل...",
emptyTitle: "لا توجد مهام محددة حتى الآن.",
emptySubtitle: "ابدأ بإنشاء واحد.",
table: {
name: "الاسم",
schedule: "الجدول الزمني",
status: "الحالة",
lastRun: "آخر مرة",
nextRun: "الرحلة التالية",
actions: "الإجراءات",
},
confirmDelete: "هل أنت متأكد من أنك تريد حذف هذه المهمة المجدولة؟",
toast: {
deleted: "تم حذف الوظيفة",
triggered: "تم تنفيذ المهمة بنجاح.",
triggerFailed: "لم يتم تشغيل المهمة.",
triggerSkipped: "تم بالفعل البدء في تنفيذ هذا المشروع.",
killed: "تم إيقاف الوظيفة بنجاح.",
killFailed: "فشل في إيقاف العمل",
},
row: {
neverRun: "لا تفرط",
viewRuns: "تسجيلات",
runNow: "ابدأ الآن",
enable: "تمكين",
disable: "تعطيل",
edit: "تحرير",
delete: "حذف",
},
modal: {
titleEdit: "تعديل المهمة المجدولة",
titleNew: "وظيفة جديدة مُجدولة",
nameLabel: "الاسم",
namePlaceholder: "على سبيل المثال: ملخص الأخبار اليومية",
promptLabel: "طلب",
promptPlaceholder: "التوجيه بتشغيل البرنامج في كل مرة يتم تنفيذها...",
scheduleLabel: "الجدول الزمني",
modeBuilder: "مقاول",
modeCustom: "مخصص",
cronPlaceholder: "تعبير الوقت (مثل 0 9 * * *)",
currentSchedule: "الجدول الزمني الحالي:",
toolsLabel: "الأدوات (اختياري)",
toolsDescription:
"حدد الأدوات التي يمكن لهذه المهمة استخدامها. إذا لم يتم تحديد أي أدوات، فستتم إكمال المهمة بدون استخدام أي أدوات.",
toolsSearch: "البحث",
toolsNoResults: "لا توجد أدوات مطابقة",
required: "مطلوب",
requiredFieldsBanner: "يرجى ملء جميع الحقول المطلوبة لإنشاء الوظيفة.",
cancel: "إلغاء",
saving: "حفظ...",
updateJob: "تحديث الوظيفة",
createJob: "إنشاء وظيفة",
jobUpdated: "تم تحديث الوظيفة",
jobCreated: "تم إنشاء وظيفة",
},
builder: {
fallbackWarning:
'لا يمكن تعديل هذا النص بصريًا. إذا كنت ترغب في الاحتفاظ به، فاختر "مخصص". وإلا، يمكنك تغيير أي شيء أسفله لاستبداله.',
run: "شغل",
frequency: {
minute: "كل دقيقة",
hour: "ساعي",
day: "يوميًا",
week: "أسبوعي",
month: "شهريًا",
},
every: "كل",
minuteOne: "دقيقة واحدة",
minuteOther: "{{count}} دقيقة",
atMinute: "في الدقيقة",
pastEveryHour: "كل ساعة",
at: "في",
on: "على",
onDay: "في يوم",
ofEveryMonth: "في كل شهر",
weekdays: {
sun: "الشمس",
mon: "الاثنين",
tue: "الثلاثاء",
wed: "الخميس",
thu: "الخميس",
fri: "يوم الجمعة",
sat: "السبت",
},
},
runHistory: {
back: "العودة إلى الوظائف",
title: "سجل التشغيل: {{name}}",
schedule: "الجدول الزمني:",
emptyTitle: "لم يتم تحقيق أي تقدم حتى الآن في هذا المشروع.",
emptySubtitle: "ابدأ تنفيذ المهمة الآن، وشاهد نتائجها.",
runNow: "ابدأ الآن",
table: {
status: "الحالة",
started: "بدأ",
duration: "المدة",
error: "خطأ",
},
stopJob: "إيقاف العمل",
},
runDetail: {
loading: "تحميل تفاصيل الجولة...",
notFound: "لم يتم العثور على الأمر.",
back: "الرجوع",
unknownJob: "وظيفة غير محددة",
runHeading: "{{name}} — تشغيل المهمة رقم {{id}}",
duration: "المدة: {{value}}",
continueInThread: "تابع في هذا الموضوع/النقاش",
creating: "إنشاء...",
threadFailed: "فشل في إنشاء سلسلة (thread).",
sections: {
prompt: "طلب",
error: "خطأ",
thinking: "الأفكار ({{count}})",
toolCalls: "استدعاء الأدوات ({{count}})",
files: "الملفات ({{count}})",
response: "الرد",
metrics: "المقاييس",
},
metrics: {
promptTokens: "رموز التذكير:",
completionTokens: "رموز الإكمال:",
},
stopJob: "إيقاف التوظيف",
killing: "التوقف...",
},
toolCall: {
arguments: "الحجج:",
showResult: "اعرض النتيجة",
hideResult: "إخفاء النتيجة",
},
file: {
unknown: "ملف غير معروف",
download: "تنزيل",
downloadFailed: "فشل تنزيل الملف.",
types: {
powerpoint: "برنامج باوربوينت",
pdf: "ملف PDF",
word: "ملف مستند (Word)",
spreadsheet: "جدول البيانات",
generic: "الملف",
},
},
status: {
completed: "تمت",
failed: "فشل",
timed_out: "انتهت المدة المحددة",
running: "الجري",
queued: "في قائمة الانتظار",
},
},
};
export default TRANSLATIONS;

View File

@ -116,6 +116,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Feines programades",
},
login: {
"multi-user": {
@ -1534,6 +1535,165 @@ const TRANSLATIONS = {
},
},
},
scheduledJobs: {
title: "Feines programades",
enableNotifications:
"Activa les notificacions del navegador per als resultats de la cerca de feina.",
description:
"Crea tasques d'IA recurrents que s'executen segons un horari. Cada tasca executa una consulta amb eines opcionals i guarda el resultat per a la seva revisió.",
newJob: "Nou lloc de treball",
loading: "Cargant...",
emptyTitle: "No hi ha tasques programades.",
emptySubtitle: "Creeu un per començar.",
table: {
name: "Nom",
schedule: "Horari",
status: "Estat",
lastRun: "Última sortida",
nextRun: "Proper següent",
actions: "Accions",
},
confirmDelete: "Estàs segur que vols eliminar aquesta tasca programada?",
toast: {
deleted: "Emple o eliminat",
triggered: "La tasca s'ha completat amb èxit.",
triggerFailed: "No s'ha pogut iniciar la tasca.",
triggerSkipped: "Ja s'ha iniciat la feina per a aquest projecte.",
killed: "La feina s'ha completat amb èxit.",
killFailed: "No va poder evitar que es fes la feina.",
},
row: {
neverRun: "Mai no corres",
viewRuns: "Horaris de funcionament",
runNow: "Corre ara",
enable: "Activar",
disable: "Desactivar",
edit: "Editar",
delete: "Eliminar",
},
modal: {
titleEdit: "Modificar tasca programada",
titleNew: "Nova tasca programada",
nameLabel: "Nom",
namePlaceholder: 'Per exemple, "Resum diari de notícies"',
promptLabel: "Indicació",
promptPlaceholder: "L'instrucció per executar-se en cada execució...",
scheduleLabel: "Horari",
modeBuilder: "Constructor",
modeCustom: "Personalitzat",
cronPlaceholder: "Execució de la tasca (per exemple, 0 9 * * *)",
currentSchedule: "Horari actual:",
toolsLabel: "Eines (opcional)",
toolsDescription:
"Seleccioneu quins eines d'agent pot utilitzar aquest treball. Si cap, seleccioneu, el treball es realitzarà sense cap eina.",
toolsSearch: "Cerca",
toolsNoResults: "No hi ha cap eina que coincideixi.",
required: "Obligatori",
requiredFieldsBanner:
"Si us plau, compliu tots els camps obligatoris per crear l'oferta de treball.",
cancel: "Cancel·lar",
saving: "Guardant...",
updateJob: "Actualitzar lloc de treball",
createJob: "Crear un lloc de treball",
jobUpdated: "Pàgina actualitzada",
jobCreated: "Creació d'un lloc de treball",
},
builder: {
fallbackWarning:
'Aquesta expressió no es pot modificar visualment. Per mantenir-la, utilitzeu l\'opció "Personalitzat". Si voleu, podeu modificar qualsevol element de sota per sobrescribir-la.',
run: "Corre",
frequency: {
minute: "cada minut",
hour: "per hora",
day: "diari",
week: "setmanal",
month: "mensal",
},
every: "Cada",
minuteOne: "1 minut",
minuteOther: "{{count}} minuts",
atMinute: "En el moment",
pastEveryHour: "cada hora",
at: "A",
on: "Sobre",
onDay: "En un dia",
ofEveryMonth: "de cada mes",
weekdays: {
sun: "Sol",
mon: "Mon",
tue: "Dimarts",
wed: "Dijous",
thu: "Dijous",
fri: "Divendres",
sat: "Dissabte",
},
},
runHistory: {
back: "Torna a les feines",
title: "Històric de curses: {{name}}",
schedule: "Horari:",
emptyTitle: "Aún no hi ha candidats per a aquesta posició.",
emptySubtitle: "Inicia la tasca ara i consulta els resultats.",
runNow: "Comença ara",
table: {
status: "Estat",
started: "Comencat",
duration: "Durada",
error: "Error",
},
stopJob: "Aturar la feina",
},
runDetail: {
loading: "Càrrec detalls de la sessió...",
notFound: "No s'ha trobat la sortida.",
back: "Cap endavant",
unknownJob: "Posició sense especificar",
runHeading: "{{name}} — Executar la tasca {{id}}",
duration: "Durada: {{value}}",
continueInThread: "Segueix la discussió",
creating: "Creant...",
threadFailed: "No s'ha pogut crear el fil.",
sections: {
prompt: "Indicació",
error: "Error",
thinking: "Pensaments ({{count}})",
toolCalls: "Crides a les eines ({{count}})",
files: "Fitxers ({{count}})",
response: "Resposta",
metrics: "Mètriques",
},
metrics: {
promptTokens: "Tokens de desencadenament:",
completionTokens: "Tokens de finalització:",
},
stopJob: "Finalitzar feina",
killing: "Aturar...",
},
toolCall: {
arguments: "Argumentacions:",
showResult: "Mostrar resultat",
hideResult: "Ocultar resultat",
},
file: {
unknown: "Fitxer desconegut",
download: "Descarregar",
downloadFailed: "No s'ha pogut descarregar el fitxer.",
types: {
powerpoint: "PowerPoint",
pdf: "Document en format PDF",
word: "Document de text",
spreadsheet: "Fulla de càlcul",
generic: "Fitxer",
},
},
status: {
completed: "Complet",
failed: "Fallit",
timed_out: "Ha expirat el temps",
running: "Correu",
queued: "En la fila d'espera",
},
},
};
export default TRANSLATIONS;

View File

@ -116,6 +116,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Naplánované úlohy",
},
login: {
"multi-user": {
@ -1507,6 +1508,166 @@ const TRANSLATIONS = {
unknown: "Neznámé",
},
},
scheduledJobs: {
title: "Naplánované úkoly",
enableNotifications:
"Povolte oznámení v prohlížeči pro výsledky hledání práce",
description:
"Vytvořte opakující se úkoly s umělou inteligencí, které se spouští podle stanoveného harmonogramu. Každý úkol provede zadaný požadavek s volitelnými nástroji a uloží výsledek pro pozdější kontrolu.",
newJob: "Nová pracovní pozice",
loading: "Načítání...",
emptyTitle: "V současné době nejsou naplánovány žádné úkoly.",
emptySubtitle: "Vytvořte si jeden, abyste začali.",
table: {
name: "Jméno",
schedule: "Harmonogram",
status: "Stav",
lastRun: "Poslední běh",
nextRun: "Další běh",
actions: "Akce",
},
confirmDelete: "Jste si jisti, že chcete tento naplánovaný úkol smazat?",
toast: {
deleted: "Práce smazána",
triggered: "Úkol byl úspěšně spuštěn.",
triggerFailed: "Nepodařilo se spustit danou úlohu.",
triggerSkipped: "Práce na tomto projektu již probíhá.",
killed: "Práce byla úspěšně ukončena",
killFailed: "Nebylo možné zastavit pracovní činnost.",
},
row: {
neverRun: "Nikdy nespěchejte",
viewRuns: "Prohlídky",
runNow: "Začněte hned",
enable: "Povolit",
disable: "Vypnout",
edit: "Upravit",
delete: "Smazat",
},
modal: {
titleEdit: "Upravit naplánovanou úlohu",
titleNew: "Nový naplánovaný úkol",
nameLabel: "Jméno",
namePlaceholder: "např. Denní přehled novinek",
promptLabel: "Výzva",
promptPlaceholder: "Instrukce k provedení při každém spuštění...",
scheduleLabel: "Harmonogram",
modeBuilder: "Stavební firma",
modeCustom: "Na míru vyrobené",
cronPlaceholder:
"Výraz pro vyjadřování časového intervalu (např. 0 9 * * *)",
currentSchedule: "Současný harmonogram:",
toolsLabel: "Nářadí (volitelné)",
toolsDescription:
"Vyberte, které nástroje lze pro tuto úlohu použít. Pokud žádný není vybrán, úloha bude spuštěna bez použití jakýchkoli nástrojů.",
toolsSearch: "Vyhledávání",
toolsNoResults: "Žádný nástroj neodpovídá",
required: "Nutné",
requiredFieldsBanner:
"Prosím, vyplňte všechny povinné pole, abyste mohli vytvořit inzerát.",
cancel: "Zrušit",
saving: "Úspora...",
updateJob: "Aktualizovat pracovní pozici",
createJob: "Vytvořte pracovní pozici",
jobUpdated: "Pozice byla aktualizována",
jobCreated: "Vytvořena pozice",
},
builder: {
fallbackWarning:
'Tento výraz nelze upravit vizuálně. Pokud jej chcete zachovat, přejděte do režimu "Custom". Jinak můžete změnit cokoliv níže, abyste jej nahradili.',
run: "Běhat",
frequency: {
minute: "každou minutu",
hour: "za hodinu",
day: "denně",
week: "každý týden",
month: "měsíční",
},
every: "Každý",
minuteOne: "1 minuta",
minuteOther: "{{count}} minut",
atMinute: "V minutě",
pastEveryHour: "v každou hodinu",
at: "V",
on: "Na",
onDay: "Jednoho dne",
ofEveryMonth: "každého měsíce",
weekdays: {
sun: "Slunce",
mon: "Pondělí",
tue: "Úterý",
wed: "Středa",
thu: "Čtvrtek",
fri: "Pátek",
sat: "Sobota",
},
},
runHistory: {
back: "Zpět na nabídku práce",
title: "Historie běhu: {{name}}",
schedule: "Harmonogram:",
emptyTitle: "Dosud nebyla žádná úspěšná realizace tohoto projektu.",
emptySubtitle: "Spusťte úlohu nyní a zkontrolujte její výsledky.",
runNow: "Začněte hned",
table: {
status: "Stav",
started: "Začal",
duration: "Délka",
error: "Chyba",
},
stopJob: "Zastavit práci",
},
runDetail: {
loading: "Načítám podrobnosti o běhu...",
notFound: "Nemožná nalezení běhu.",
back: "Zpět",
unknownJob: "Neznámá pracovní pozice",
runHeading: "{{name}} — Spustit #{{id}}",
duration: "Doba trvání: {{value}}",
continueInThread: "Pokračovat v tématu",
creating: "Vytváření...",
threadFailed: "Nedaří se vytvořit vlákno.",
sections: {
prompt: "Návod",
error: "Chyba",
thinking: "Myšlenky ({{count}})",
toolCalls: "Volání nástrojů ({{count}})",
files: "Soubory ({{count}})",
response: "Reakce",
metrics: "Metriky",
},
metrics: {
promptTokens: "Klíčová slova:",
completionTokens: "Tokeny pro dokončení:",
},
stopJob: "Zastavení práce",
killing: "Zastavte...",
},
toolCall: {
arguments: "Argumenty:",
showResult: "Zobrazit výsledek",
hideResult: "Skryt výsledek",
},
file: {
unknown: "Neznámý soubor",
download: "Stáhnout",
downloadFailed: "Nepodařilo se stáhnout soubor",
types: {
powerpoint: "Prezentace v programu PowerPoint",
pdf: "Dokument ve formátu PDF",
word: "Dokument ve formátu Word",
spreadsheet: "Tabulka (v programu)",
generic: "Soubor",
},
},
status: {
completed: "Dokončeno",
failed: "Neúspěšné",
timed_out: "Časový limit dosáhl",
running: "Běh",
queued: "Na čekací listině",
},
},
};
export default TRANSLATIONS;

View File

@ -110,6 +110,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Planlagte opgaver",
},
login: {
"multi-user": {
@ -1510,6 +1511,166 @@ const TRANSLATIONS = {
unknown: "Ukendt",
},
},
scheduledJobs: {
title: "Planlagte opgaver",
enableNotifications:
"Aktiver notifikationer i browseren for at modtage resultater af jobsøgning",
description:
"Opret gentagne AI-opgaver, der kører efter en plan. Hver opgave udfører en forespørgsel med eventuelle tilgængelige værktøjer og gemmer resultatet til senere gennemgang.",
newJob: "Ny still",
loading: "Indlæses...",
emptyTitle: "Ingen planlagte opgaver endnu",
emptySubtitle: "Opret et for at komme i gang.",
table: {
name: "Navn",
schedule: "Tidsplan",
status: "Status",
lastRun: "Sidste tur",
nextRun: "Næste tur",
actions: "Handlinger",
},
confirmDelete:
"Er du sikker på, at du ønsker at slette denne planlagte opgave?",
toast: {
deleted: "Job slettet",
triggered: "Job blev korrekt initieret.",
triggerFailed: "Mislykkedes med at starte jobbet",
triggerSkipped: "Arbejdet er allerede i gang.",
killed: "Arbejdet blev afbrudt med succes.",
killFailed: "Mislykkedes med at stoppe arbejdet",
},
row: {
neverRun: "Aldrig køre",
viewRuns: "Visning af løb",
runNow: "Gå nu",
enable: "Aktiver",
disable: "Deaktiver",
edit: "Rediger",
delete: "Slet",
},
modal: {
titleEdit: "Rediger planlagt opgave",
titleNew: "Ny planlagt opgave",
nameLabel: "Navn",
namePlaceholder: "f.eks. Daglig nyhedsindsamling",
promptLabel: "Anmodning",
promptPlaceholder: "Instruktionen om at køre på hver eksekvering...",
scheduleLabel: "Tidsplan",
modeBuilder: "Bygger",
modeCustom: "Tilpasset",
cronPlaceholder: "Udtryk for tidsplan (f.eks. 0 9 * * *)",
currentSchedule: "Nuværende tidsplan:",
toolsLabel: "Værktøjer (valgfrit)",
toolsDescription:
"Vælg hvilke agentværktøjer denne opgave kan bruge. Hvis ingen værktøjer er valgt, vil opgaven køre uden nogen værktøjer.",
toolsSearch: "Søg",
toolsNoResults: "Ingen værktøjer matcher",
required: "Nødvendigt",
requiredFieldsBanner:
"Venligst udfyld alle de obligatoriske felter for at oprette en stilling.",
cancel: "Annullér",
saving: "Spar...",
updateJob: "Opdater stillingen",
createJob: "Opret stilling",
jobUpdated: "Job er opdateret",
jobCreated: "Job blev skabt",
},
builder: {
fallbackWarning:
'Denne tekstfelt kan ikke redigeres visuelt. Vælg "Tilpas" for at bevare den, eller ændr noget nedenfor for at overskrive den.',
run: "Løb",
frequency: {
minute: "hvert minut",
hour: "per time",
day: "dagligt",
week: "hver uge",
month: "månedligt",
},
every: "Hver",
minuteOne: "1 minut",
minuteOther: "{{count}} minutter",
atMinute: "I minutter",
pastEveryHour: "hvert time",
at: "Her",
on: "Om",
onDay: "På en dag",
ofEveryMonth: "af hver måned",
weekdays: {
sun: "Sol",
mon: "Mandag",
tue: "Tirsdag",
wed: "Onsdag",
thu: "Torsdag",
fri: "Fri",
sat: "Lørdag",
},
},
runHistory: {
back: "Tilbage til stillingsopslag",
title: "Historik: {{name}}",
schedule: "Tidsplan:",
emptyTitle: "Ingen resultater endnu for denne opgave.",
emptySubtitle: "Kør jobbet nu og se resultaterne.",
runNow: "Start nu",
table: {
status: "Status",
started: "Startede",
duration: "Varighed",
error: "Fejl",
},
stopJob: "Afbryd ansættelsen",
},
runDetail: {
loading: "Indlæsning af detaljer om kørslen...",
notFound: "Fejl: Kørsel ikke fundet.",
back: "Tilbage",
unknownJob: "Ukendt stilling",
runHeading: "{{name}} — Kør #{{id}}",
duration: "Varighed: {{value}}",
continueInThread: "Fortsæt i tråden",
creating: "Oprettelse...",
threadFailed: "Kunne ikke oprette tråd",
sections: {
prompt: "Opfordring",
error: "Fejl",
thinking: "Tanker ({{count}})",
toolCalls: "Opkald til værktøjer ({{count}})",
files: "Filer ({{count}})",
response: "Svar",
metrics: "Målinger",
},
metrics: {
promptTokens: "Prompt-ord:",
completionTokens: "Afslutningsmarkører:",
},
stopJob: "Afslut stillingen",
killing: "Afbryde...",
},
toolCall: {
arguments: "Argumenter:",
showResult: "Vis resultat",
hideResult: "Skjul resultat",
},
file: {
unknown: "Ukendt fil",
download: "Download",
downloadFailed: "Kunne ikke hente filen",
types: {
powerpoint: "PowerPoint",
pdf: "PDF-dokument",
word: "Ord-dokument",
spreadsheet: "Regneark",
generic: "Fil",
},
},
status: {
completed: "Afsluttet",
failed: "Mislykket",
timed_out: "Tidsudløb",
running: "Løb",
queued: "I venter",
},
},
};
export default TRANSLATIONS;

View File

@ -109,6 +109,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Geplante Aufgaben",
},
login: {
"multi-user": {
@ -1543,6 +1544,168 @@ const TRANSLATIONS = {
unknown: "Unbekannt",
},
},
scheduledJobs: {
title: "Geplante Aufgaben",
enableNotifications:
"Aktivieren Sie Benachrichtigungen im Browser für Stellenangebote",
description:
"Erstellen Sie wiederkehrende KI-Aufgaben, die zu einem bestimmten Zeitpunkt ausgeführt werden. Jede Aufgabe führt eine Anfrage aus, optional mit zusätzlichen Werkzeugen, und speichert das Ergebnis zur Überprüfung.",
newJob: "Neue Arbeitsstelle",
loading: "Laden...",
emptyTitle: "Noch keine geplante Aufgaben",
emptySubtitle: "Erstellen Sie eines, um anzufangen.",
table: {
name: "Name",
schedule: "Zeitplan",
status: "Status",
lastRun: "Letzter Lauf",
nextRun: "Nächster Lauf",
actions: "Aktionen",
},
confirmDelete:
"Sind Sie sicher, dass Sie diesen geplanten Job löschen möchten?",
toast: {
deleted: "Stellenanzeige gelöscht",
triggered: "Die Aufgabe wurde erfolgreich gestartet.",
triggerFailed: "Fehlgeschlagenes Auslösen der Aufgabe",
triggerSkipped: "Die Arbeiten für dieses Projekt sind bereits in Gang",
killed: "Die Arbeit wurde erfolgreich beendet.",
killFailed: "Nicht in der Lage, die Arbeit zu beenden",
},
row: {
neverRun: "Bitte niemals laufen",
viewRuns: "Laufstrecken",
runNow: "Beginnen Sie jetzt",
enable: "Aktivieren",
disable: "Deaktivieren",
edit: "Bearbeiten",
delete: "Löschen",
},
modal: {
titleEdit: "Geplante Aufgabe bearbeiten",
titleNew: "Neuer geplanter Job",
nameLabel: "Name",
namePlaceholder: "z.B. Tages-Nachrichten-Zusammenfassung",
promptLabel: "Anweisung",
promptPlaceholder:
"Die Anweisung, dass es bei jeder Ausführung erfolgen soll…",
scheduleLabel: "Zeitplan",
modeBuilder: "Bauunternehmer",
modeCustom: "Maßgeschneidert",
cronPlaceholder: "Ausdruck für die Ausführungszeit (z. B. 0 9 * * *)",
currentSchedule: "Aktueller Zeitplan:",
toolsLabel: "Werkzeuge (optional)",
toolsDescription:
"Wählen Sie, welche Agenten-Tools für diese Aufgabe verwendet werden können. Wenn keine Tools ausgewählt sind, wird die Aufgabe ohne Verwendung von Tools ausgeführt.",
toolsSearch: "Suche",
toolsNoResults: "Keine der verfügbaren Werkzeuge passen",
required: "Erforderlich",
requiredFieldsBanner:
"Bitte füllen Sie alle erforderlichen Felder aus, um die Stellenanzeige zu erstellen.",
cancel: "Abbrechen",
saving: "Sparen...",
updateJob: "Stellenanzeige aktualisieren",
createJob: "Stellenanzeige erstellen",
jobUpdated: "Stellenanzeige aktualisiert",
jobCreated: "Arbeitsstelle geschaffen",
},
builder: {
fallbackWarning:
'Dieser Text kann nicht visuell bearbeitet werden. Verwenden Sie die Option "Benutzerdefiniert", um ihn beizubehalten, oder ändern Sie die entsprechenden Felder unten, um ihn zu überschreiben.',
run: "Laufen",
frequency: {
minute: "jede Minute",
hour: "pro Stunde",
day: "täglich",
week: "wöchentlich",
month: "monatlich",
},
every: "Jeder",
minuteOne: "1 Minute",
minuteOther: "{{count}} Minuten",
atMinute: "In der Minute",
pastEveryHour: "in jeder Stunde",
at: "Bei",
on: "Über",
onDay: "An einem Tag",
ofEveryMonth: "für jeden Monat",
weekdays: {
sun: "Sonne",
mon: "Montag",
tue: "Dienstag",
wed: "Mittwoch",
thu: "Donnerstag",
fri: "Freitag",
sat: "Samstag",
},
},
runHistory: {
back: "Zurück zu Stellen",
title: "Verlauf: {{name}}",
schedule: "Zeitplan:",
emptyTitle: "Noch keine Fortschritte bei dieser Aufgabe.",
emptySubtitle:
"Führen Sie die Aufgabe jetzt aus und überprüfen Sie die Ergebnisse.",
runNow: "Jetzt los!",
table: {
status: "Status",
started: "Angefangen",
duration: "Dauer",
error: "Fehler",
},
stopJob: "Arbeitsplatz verlassen",
},
runDetail: {
loading: "Details zum Ladevorgang werden geladen...",
notFound: "Fehler: Befehl nicht gefunden.",
back: "Zurück",
unknownJob: "Unbekannte Stellenbezeichnung",
runHeading: "{{name}} Ausführung #{{id}}",
duration: "Dauer: {{value}}",
continueInThread: "Weiter in diesem Thread",
creating: "Erstellen...",
threadFailed: "Fehlgeschlagen beim Erstellen des Threads",
sections: {
prompt: "Anfrage",
error: "Fehler",
thinking: "Gedanken ({{count}})",
toolCalls: "Funktionsaufrufe ({{count}})",
files: "Dateien ({{count}})",
response: "Antwort",
metrics: "Kennzahlen",
},
metrics: {
promptTokens: "Auslöse-Token:",
completionTokens: "Abschluss-Token:",
},
stopJob: "Arbeitsplatz verlassen",
killing: "Anhalten...",
},
toolCall: {
arguments: "Argumente:",
showResult: "Ergebnis anzeigen",
hideResult: "Ergebnis ausblenden",
},
file: {
unknown: "Unbekannte Datei",
download: "Herunterladen",
downloadFailed: "Datei konnte nicht heruntergeladen werden",
types: {
powerpoint: "PowerPoint",
pdf: "PDF-Dokument",
word: "Word-Dokument",
spreadsheet: "Tabellenkalkulation",
generic: "Datei",
},
},
status: {
completed: "Abgeschlossen",
failed: "Fehlgeschlagen",
timed_out: "Zeitüberschreitung",
running: "Laufen",
queued: "Warteschlange",
},
},
};
export default TRANSLATIONS;

View File

@ -94,6 +94,7 @@ const TRANSLATIONS = {
embeds: "Chat Embed",
security: "Security",
"event-logs": "Event Logs",
"scheduled-jobs": "Scheduled Jobs",
privacy: "Privacy & Data",
"ai-providers": "AI Providers",
"agent-skills": "Agent Skills",
@ -1489,6 +1490,164 @@ const TRANSLATIONS = {
},
},
},
scheduledJobs: {
title: "Scheduled Jobs",
enableNotifications: "Enable browser notifications for job results",
description:
"Create recurring AI tasks that run on a schedule. Each job runs a prompt with optional tools and saves the result for review.",
newJob: "New Job",
loading: "Loading...",
emptyTitle: "No Scheduled Jobs yet",
emptySubtitle: "Create one to get started.",
table: {
name: "Name",
schedule: "Schedule",
status: "Status",
lastRun: "Last Run",
nextRun: "Next Run",
actions: "Actions",
},
confirmDelete: "Are you sure you want to delete this scheduled job?",
status: {
completed: "Completed",
failed: "Failed",
timed_out: "Timed out",
running: "Running",
queued: "Queued",
},
toast: {
deleted: "Job deleted",
triggered: "Job triggered successfully",
triggerFailed: "Failed to trigger job",
triggerSkipped: "A run is already in progress for this job",
killed: "Job stopped successfully",
killFailed: "Failed to stop job",
},
row: {
neverRun: "Never run",
viewRuns: "View runs",
runNow: "Run now",
enable: "Enable",
disable: "Disable",
edit: "Edit",
delete: "Delete",
},
modal: {
titleEdit: "Edit Scheduled Job",
titleNew: "New Scheduled Job",
nameLabel: "Name",
namePlaceholder: "e.g. Daily News Digest",
promptLabel: "Prompt",
promptPlaceholder: "The instruction to run on each execution...",
scheduleLabel: "Schedule",
modeBuilder: "Builder",
modeCustom: "Custom",
cronPlaceholder: "Cron expression (e.g. 0 9 * * *)",
currentSchedule: "Current schedule:",
toolsLabel: "Tools (Optional)",
toolsDescription:
"Select which agent tools this job can use. If none are selected, the job runs without any tools.",
toolsSearch: "Search",
toolsNoResults: "No tools match",
required: "Required",
requiredFieldsBanner:
"Please fill out all required fields in order to create job.",
cancel: "Cancel",
saving: "Saving...",
updateJob: "Update Job",
createJob: "Create Job",
jobUpdated: "Job updated",
jobCreated: "Job created",
},
builder: {
fallbackWarning:
"This expression can't be edited visually. Switch to Custom to keep it, or change anything below to overwrite it.",
run: "Run",
frequency: {
minute: "every minute",
hour: "hourly",
day: "daily",
week: "weekly",
month: "monthly",
},
every: "Every",
minuteOne: "1 minute",
minuteOther: "{{count}} minutes",
atMinute: "At minute",
pastEveryHour: "past every hour",
at: "At",
on: "On",
onDay: "On day",
ofEveryMonth: "of every month",
weekdays: {
sun: "Sun",
mon: "Mon",
tue: "Tue",
wed: "Wed",
thu: "Thu",
fri: "Fri",
sat: "Sat",
},
},
runHistory: {
back: "Back to jobs",
title: "Run History: {{name}}",
schedule: "Schedule:",
emptyTitle: "No runs yet for this job",
emptySubtitle: "Run the job now and view its results.",
runNow: "Run Now",
stopJob: "Stop job",
table: {
status: "Status",
started: "Started",
duration: "Duration",
error: "Error",
},
},
runDetail: {
loading: "Loading run details...",
notFound: "Run not found.",
back: "Back",
unknownJob: "Unknown Job",
runHeading: "{{name}} — Run #{{id}}",
duration: "Duration: {{value}}",
continueInThread: "Continue in Thread",
creating: "Creating...",
threadFailed: "Failed to create thread",
stopJob: "Stop Job",
killing: "Stopping...",
sections: {
prompt: "Prompt",
error: "Error",
thinking: "Thoughts ({{count}})",
toolCalls: "Tool Calls ({{count}})",
files: "Files ({{count}})",
response: "Response",
metrics: "Metrics",
},
metrics: {
promptTokens: "Prompt tokens:",
completionTokens: "Completion tokens:",
},
},
toolCall: {
arguments: "Arguments:",
showResult: "Show result",
hideResult: "Hide result",
},
file: {
unknown: "Unknown file",
download: "Download",
downloadFailed: "Failed to download file",
types: {
powerpoint: "PowerPoint",
pdf: "PDF Document",
word: "Word Document",
spreadsheet: "Spreadsheet",
generic: "File",
},
},
},
};
export default TRANSLATIONS;

View File

@ -109,6 +109,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Tareas programadas",
},
login: {
"multi-user": {
@ -1554,6 +1555,165 @@ const TRANSLATIONS = {
unknown: "Desconocido",
},
},
scheduledJobs: {
title: "Tareas programadas",
enableNotifications:
"Activar las notificaciones del navegador para los resultados de búsqueda de empleo.",
description:
"Cree tareas de IA recurrentes que se ejecuten según un horario. Cada tarea ejecuta una instrucción con herramientas opcionales y guarda el resultado para su revisión.",
newJob: "Nuevo trabajo",
loading: "Cargando...",
emptyTitle: "Aún no hay tareas programadas.",
emptySubtitle: "Cree uno para empezar.",
table: {
name: "Nombre",
schedule: "Horario",
status: "Estado",
lastRun: "Última carrera",
nextRun: "Próxima carrera",
actions: "Acciones",
},
confirmDelete: "¿Está seguro de que desea eliminar esta tarea programada?",
toast: {
deleted: "Trabajo eliminado",
triggered: "La tarea se ha completado con éxito.",
triggerFailed: "No se pudo iniciar la tarea.",
triggerSkipped: "Ya se ha iniciado el trabajo.",
killed: "La tarea se completó con éxito.",
killFailed: "No logró detener el trabajo.",
},
row: {
neverRun: "Nunca corras",
viewRuns: "Ejecución de pruebas",
runNow: "¡Corre ahora!",
enable: "Habilitar",
disable: "Desactivar",
edit: "Editar",
delete: "Eliminar",
},
modal: {
titleEdit: "Modificar tarea programada",
titleNew: "Nueva tarea programada",
nameLabel: "Nombre",
namePlaceholder: "p. ej., Resumen diario de noticias",
promptLabel: "Solicitud",
promptPlaceholder: "La instrucción para ejecutarlo en cada ejecución...",
scheduleLabel: "Horario",
modeBuilder: "Constructor",
modeCustom: "Personalizado",
cronPlaceholder: "Expresión de cron (por ejemplo, 0 9 * * *)",
currentSchedule: "Horario actual:",
toolsLabel: "Herramientas (opcional)",
toolsDescription:
"Seleccione las herramientas disponibles para esta tarea. Si ninguna herramienta está seleccionada, la tarea se ejecutará sin utilizar ninguna herramienta.",
toolsSearch: "Buscar",
toolsNoResults: "No se encontraron herramientas que coincidan.",
required: "Requerido",
requiredFieldsBanner:
"Por favor, complete todos los campos obligatorios para crear el anuncio de empleo.",
cancel: "Cancelar",
saving: "Ahorrando...",
updateJob: "Actualizar puesto de trabajo",
createJob: "Crear empleo",
jobUpdated: "Puesto actualizado",
jobCreated: "Puesto creado",
},
builder: {
fallbackWarning:
'Esta expresión no se puede modificar visualmente. Cambie a "Personalizado" para mantenerla, o modifique cualquier cosa debajo para reemplazarla.',
run: "Correr",
frequency: {
minute: "cada minuto",
hour: "por hora",
day: "diario",
week: "semanal",
month: "mensual",
},
every: "Cada",
minuteOne: "1 minuto",
minuteOther: "{{count}} minutos",
atMinute: "En el minuto",
pastEveryHour: "cada hora",
at: "A partir de",
on: "Sobre",
onDay: "En un día",
ofEveryMonth: "de cada mes",
weekdays: {
sun: "Sol",
mon: "Una",
tue: "Martes",
wed: "Miércoles",
thu: "Jueves",
fri: "Viernes",
sat: "Sábado",
},
},
runHistory: {
back: "Volver a las ofertas de empleo",
title: "Historial de ejecuciones: {{name}}",
schedule: "Horario:",
emptyTitle: "Aún no hay candidatos para este puesto.",
emptySubtitle: "Ejecute la tarea ahora y vea los resultados.",
runNow: "¡Corre ahora!",
table: {
status: "Estado",
started: "Comenzó",
duration: "Duración",
error: "Error",
},
stopJob: "Suspender el empleo",
},
runDetail: {
loading: "Cargando detalles de la ejecución...",
notFound: "No se encontró la ejecución.",
back: "Regreso; Atrás",
unknownJob: "Puesto sin especificar",
runHeading: "{{name}} — Ejecutar la prueba #{{id}}",
duration: "Duración: {{value}}",
continueInThread: "Continuar en esta conversación",
creating: "Creando...",
threadFailed: "No se pudo crear el hilo.",
sections: {
prompt: "Indicación",
error: "Error",
thinking: "Ideas ({{count}})",
toolCalls: "Llamadas a herramientas ({{count}})",
files: "Archivos ({{count}})",
response: "Respuesta",
metrics: "Indicadores",
},
metrics: {
promptTokens: "Palabras clave:",
completionTokens: "Tokens de finalización:",
},
stopJob: "Suspender el empleo",
killing: "Detener...",
},
toolCall: {
arguments: "Argumentos:",
showResult: "Mostrar resultado",
hideResult: "Ocultar resultado",
},
file: {
unknown: "Archivo desconocido",
download: "Descargar",
downloadFailed: "No se pudo descargar el archivo.",
types: {
powerpoint: "Presentación de diapositivas",
pdf: "Documento en formato PDF",
word: "Documento de Word",
spreadsheet: "Hoja de cálculo",
generic: "Archivo",
},
},
status: {
completed: "Completado",
failed: "Fracasado",
timed_out: "Tiempo agotado",
running: "Correr",
queued: "En cola",
},
},
};
export default TRANSLATIONS;

View File

@ -108,6 +108,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Planeeritud tööd",
},
login: {
"multi-user": {
@ -1464,6 +1465,166 @@ const TRANSLATIONS = {
unknown: "Tuntud pole",
},
},
scheduledJobs: {
title: "Planeeritud tööd",
enableNotifications: "Aktiveeri braiseri teavitused tööväljundite kohta",
description:
"Loo korduvad AI-ülesanded, mis töötavad eeldatud ajakavaga. Iga ülesanne käitab promp, kasutades valikuvõimalusega tööriistu, ja salvestab tulemuse kontrollimiseks.",
newJob: "Uus töö",
loading: "Laadimine...",
emptyTitle: "Hetkel pole planeeritud tööde nimekirja.",
emptySubtitle: "Loo üks, et alustada.",
table: {
name: "Nimi",
schedule: "Ajavälja",
status: "Статус",
lastRun: "Viimne sõit",
nextRun: "Järgmine üritus",
actions: "Meetmed",
},
confirmDelete:
"Kas olete kindel, et soovite seda planeeritud tööd kustutada?",
toast: {
deleted: "Töö kustutatud",
triggered: "Töö on edukalt käivitunud.",
triggerFailed: "Ei õnnestunud töö käivitada",
triggerSkipped: "Töö on juba alguses.",
killed: "Töö lõpetati edukalt",
killFailed: "Edasi töötamist ei suutnud peatada",
},
row: {
neverRun: "Ära kunagi kiirusta",
viewRuns: "Vaatamise marsrid",
runNow: "Alustage kohe",
enable: "Aktiveerida",
disable: "Välja lülitada",
edit: "Redigeerimine",
delete: "Hüvida",
},
modal: {
titleEdit: "Muuda planeeritud tööd",
titleNew: "Uus planeeritud töö",
nameLabel: "Nimi",
namePlaceholder: "nt. Päevase uudiste kokkuvõte",
promptLabel: "Järgmis",
promptPlaceholder:
"Juhend, mis käsitleb programmi käivitamist iga kord, kui seda kasutatakse...",
scheduleLabel: "Ajavälja",
modeBuilder: "Ehitaj, ehitaja",
modeCustom: "Kohandatud",
cronPlaceholder: "Cron väljendus (näiteks 0 9 * * *)",
currentSchedule: "Praegune ajakava:",
toolsLabel: "Vahendid (valikuline)",
toolsDescription:
"Valige välja need agenti vahendid, mida see töö saab kasutada. Kui ühtki vahendit ei ole valitud, siis töö toimub ilma vahenditeta.",
toolsSearch: "otsing",
toolsNoResults: "Midagi sellist ei ole",
required: "Nõutav",
requiredFieldsBanner:
"Palun täitke kõik vajalikud väljad, et töö avaldamine oleks võimalik.",
cancel: "Katkuda",
saving: "Säästmine...",
updateJob: "Töö avaldamise uuendamine",
createJob: "Loo töökoht",
jobUpdated: "Töö on uuendatud",
jobCreated: "Töö loodud",
},
builder: {
fallbackWarning:
'See väljend ei ole võimalik muuta visuaalselt. Valige "Custom" režiim, et seda säilitada, või muutke allolevaid elemente, et seda asendada.',
run: "Jooksa",
frequency: {
minute: "iga minut",
hour: "iga tunn",
day: "iga päev",
week: "iga nädal",
month: "kuukohane",
},
every: "Igal",
minuteOne: "1 minut",
minuteOther: "{{count}} minut",
atMinute: "Minutil",
pastEveryHour: "iga tunni järel",
at: "Samal ajal",
on: "On",
onDay: "Ühel päeval",
ofEveryMonth: "iga kuu",
weekdays: {
sun: "Päev",
mon: "Päev",
tue: "teisipäev",
wed: "Keskpäev",
thu: "Reede",
fri: "Reede",
sat: "Laud",
},
},
runHistory: {
back: "Tagasi töökohtadele",
title: "Täitmise ajalugu: {{name}}",
schedule: "Ajavõrdlus:",
emptyTitle: "Hetkel pole selle töös midagi saavutatud.",
emptySubtitle: "Alustage tööd kohe ja vaadake selle tulemisi.",
runNow: "Alustage kohe",
table: {
status: "Статус",
started: "Algas",
duration: "Kestvus",
error: "Viga",
},
stopJob: "Töö peatamine",
},
runDetail: {
loading: "Laadimise ajal saadaval on sõidu üksikasjad...",
notFound: "Programm ei leitud.",
back: "Tagasi",
unknownJob: "Tuntmatu amet",
runHeading: "{{name}} — Üritus #{{id}}",
duration: "Kestvus: {{value}}",
continueInThread: "Jätka teemas",
creating: "Loomine...",
threadFailed: "Epäõnnes teema loomist",
sections: {
prompt: "Järgmis",
error: "Viga",
thinking: "Mõtisklused ({{count}})",
toolCalls: "Vahendite kutsumised ({{count}})",
files: "Failid ({{count}})",
response: "Vastus",
metrics: "Mõõdised",
},
metrics: {
promptTokens: "Algatusmärgid:",
completionTokens: "Lõpetamisandmed:",
},
stopJob: "Töö peatamine",
killing: "Peatumine...",
},
toolCall: {
arguments: "Argumentid:",
showResult: "Näita tulemust",
hideResult: "Peida tulemus",
},
file: {
unknown: "Tuntmatu fail",
download: "Laadige alla",
downloadFailed: "Faili ei õnnestunud alla laadida",
types: {
powerpoint: "PowerPoint",
pdf: "PDF-dokumend",
word: "Dokumend",
spreadsheet: "Lehtaraken",
generic: "Fail",
},
},
status: {
completed: "Lõpitatud",
failed: "Epäõnnestunud",
timed_out: "Aja täitunud",
running: "Jooksmine",
queued: "Ootel",
},
},
};
export default TRANSLATIONS;

View File

@ -110,6 +110,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "تلگرام",
},
"scheduled-jobs": "وظایف برنامه‌ریزی شده",
},
login: {
"multi-user": {
@ -1498,6 +1499,166 @@ const TRANSLATIONS = {
unknown: "نامشخص",
},
},
scheduledJobs: {
title: "وظایف برنامه‌ریزی‌شده",
enableNotifications: "فعال کردن اعلان‌های مرورگر برای نتایج جستجوی شغل",
description:
"ایجاد وظایف هوش مصنوعی تکراری که در یک برنامه زمانی اجرا می‌شوند. هر وظیفه یک دستورالعمل را با استفاده از ابزارهای اختیاری اجرا کرده و نتیجه را برای بررسی ذخیره می‌کند.",
newJob: "یک موقعیت شغلی جدید",
loading: "در حال بارگذاری...",
emptyTitle: "هیچ کار برنامه‌ریزی‌شده‌ای در حال حاضر وجود ندارد.",
emptySubtitle: "برای شروع، یک نمونه ایجاد کنید.",
table: {
name: "نام",
schedule: "برنامه زمانی",
status: "وضعیت",
lastRun: "آخرین اجرا",
nextRun: "مسیر بعدی",
actions: "اقدامات",
},
confirmDelete:
"آیا مطمئن هستید که می‌خواهید این وظیفه برنامه‌ریزی شده را حذف کنید؟",
toast: {
deleted: "حذف شده",
triggered: "وظیفه با موفقیت انجام شد.",
triggerFailed: "عدم اجرای وظیفه",
triggerSkipped: "کار مربوط به این پروژه از قبل آغاز شده است.",
killed: "کار با موفقیت به پایان رسید.",
killFailed: "عدم توانایی در متوقف کردن کار",
},
row: {
neverRun: "هرگز سرعت خود را افزایش ندهید.",
viewRuns: "نمایش‌ها",
runNow: "فوری، همین حالا",
enable: "فعال کردن",
disable: "غیرفعال کردن",
edit: "ویرایش کردن",
delete: "حذف",
},
modal: {
titleEdit: "ویرایش وظیفه برنامه‌ریزی شده",
titleNew: "وظیفه جدید برنامه ریزی شده",
nameLabel: "نام",
namePlaceholder: "به عنوان مثال، خلاصه‌ای از اخبار روز",
promptLabel: "دستورالعمل",
promptPlaceholder: "دستورالعمل برای اجرای یک فعالیت در هر بار اجرا...",
scheduleLabel: "برنامه زمانی",
modeBuilder: "مهندس، سازنده",
modeCustom: "سفارشی",
cronPlaceholder: "عبارت زمانی (به عنوان مثال، 0 9 * * *)",
currentSchedule: "برنامه فعلی:",
toolsLabel: "ابزارها (اختیاری)",
toolsDescription:
"انتخاب ابزارهای مورد استفاده برای این وظیفه. اگر هیچ ابزایی انتخاب نشده باشد، وظیفه بدون استفاده از ابزار اجرا می‌شود.",
toolsSearch: "جستجو",
toolsNoResults: "هیچ ابزاری وجود ندارد",
required: "الزامی",
requiredFieldsBanner:
"لطفاً تمام فیلدهای مورد نیاز را پر کنید تا بتوانید شغل را ایجاد کنید.",
cancel: "لغو کردن",
saving: "ذخیره...",
updateJob: "به‌روزرسانی درخواست",
createJob: "ایجاد شغل",
jobUpdated: "وضعیت شغلی به‌روز شده است",
jobCreated: "یک شغل ایجاد شد",
},
builder: {
fallbackWarning:
'این عبارت نمی‌تواند به صورت بصری ویرایش شود. برای حفظ آن، از گزینه "کامل" استفاده کنید، یا هر چیزی زیر آن را تغییر دهید تا جایگزین شود.',
run: "دویدن",
frequency: {
minute: "هر دقیقه",
hour: "به ازای هر ساعت",
day: "روزانه",
week: "هفتگی",
month: "ماهانه",
},
every: "هر",
minuteOne: "۱ دقیقه",
minuteOther: "{{count}} دقیقه",
atMinute: "در دقیقه",
pastEveryHour: "هر ساعت از گذشته",
at: "در",
on: "در",
onDay: "در روز",
ofEveryMonth: "از هر ماه",
weekdays: {
sun: "خورشید",
mon: "یک",
tue: "دوشنبه",
wed: "روز سه شنبه",
thu: "روز چهارشنبه",
fri: "جمعه",
sat: "جمعه",
},
},
runHistory: {
back: "بازگشت به بخش فرصت‌های شغلی",
title: "تاریخ اجرای: {{name}}",
schedule: "برنامه زمانی:",
emptyTitle: "هنوز هیچ پیشرفتی در این پروژه حاصل نشده است.",
emptySubtitle:
"اجرای وظیفه را اکنون انجام دهید و نتایج آن را مشاهده کنید.",
runNow: "فوری",
table: {
status: "وضعیت",
started: "شروع",
duration: "مدت زمان",
error: "خطا",
},
stopJob: "متوقف کردن کار",
},
runDetail: {
loading: "بارگذاری جزئیات اجرای تمرین...",
notFound: "دستورالعمل یافت نشد.",
back: "بازگشت",
unknownJob: "عنوان شغلی نامشخص",
runHeading: "{{name}} — اجرای شماره {{id}}",
duration: "مدت زمان: {{value}}",
continueInThread: "ادامه در این موضوع",
creating: "ایجاد...",
threadFailed: "امکان ایجاد نخ (thread) وجود نداشت.",
sections: {
prompt: "دستورالعمل",
error: "خطا",
thinking: "اندیشه‌ها ({{count}})",
toolCalls: "فراخوانی ابزار ({{count}})",
files: "فایل‌ها ({{count}})",
response: "پاسخ",
metrics: "معیارها",
},
metrics: {
promptTokens: "توکن‌های آغازین:",
completionTokens: "توکن‌های تکمیل:",
},
stopJob: "متوقف کردن کار",
killing: "توقف...",
},
toolCall: {
arguments: "استدلال‌ها:",
showResult: "نتایج را نشان دهید",
hideResult: "پنهان کردن نتیجه",
},
file: {
unknown: "فایلی ناشناخته",
download: "دانلود",
downloadFailed: "عدم امکان دانلود فایل",
types: {
powerpoint: "پاورپوینت",
pdf: "فایل به فرمت PDF",
word: "فایل ورد",
spreadsheet: "جدول داده‌ها",
generic: "فایل",
},
},
status: {
completed: "تکمیل شده",
failed: "شکست",
timed_out: "زمان به پایان رسید",
running: "دویدن",
queued: "در صف انتظار",
},
},
};
export default TRANSLATIONS;

View File

@ -108,6 +108,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Tâches planifiées",
},
login: {
"multi-user": {
@ -1531,6 +1532,167 @@ const TRANSLATIONS = {
unknown: "Inconnu",
},
},
scheduledJobs: {
title: "Tâches planifiées",
enableNotifications:
"Activer les notifications du navigateur pour les résultats de recherche d'emploi.",
description:
"Créez des tâches d'IA récurrentes qui s'exécutent selon un calendrier. Chaque tâche exécute une requête avec des outils optionnels et enregistre le résultat pour examen ultérieur.",
newJob: "Nouvelle offre d'emploi",
loading: "Chargement...",
emptyTitle: "Aucune tâche planifiée pour le moment.",
emptySubtitle: "Créez-en un pour commencer.",
table: {
name: "Nom",
schedule: "Calendrier",
status: "État",
lastRun: "Dernière course",
nextRun: "Prochaine course",
actions: "Actions",
},
confirmDelete:
"Êtes-vous certain de vouloir supprimer cette tâche planifiée ?",
toast: {
deleted: "Emploi supprimé",
triggered: "La tâche a été exécutée avec succès.",
triggerFailed: "N'a pas réussi à lancer la tâche",
triggerSkipped: "Le projet est déjà en cours.",
killed: "La tâche a été exécutée avec succès.",
killFailed: "Impossible d'arrêter le travail.",
},
row: {
neverRun: "Ne jamais courir",
viewRuns: "Afficher les résultats",
runNow: "Partons maintenant",
enable: "Activer",
disable: "Désactiver",
edit: "Modifier",
delete: "Supprimer",
},
modal: {
titleEdit: "Modifier la tâche planifiée",
titleNew: "Nouvelle tâche planifiée",
nameLabel: "Nom",
namePlaceholder: 'par exemple, "Résumé quotidien des actualités"',
promptLabel: "Demande",
promptPlaceholder: "L'instruction de s'exécuter à chaque exécution...",
scheduleLabel: "Calendrier",
modeBuilder: "Constructeur",
modeCustom: "Personnalisé",
cronPlaceholder: "Expression de temps (par exemple, 0 9 * * *)",
currentSchedule: "Planning actuel :",
toolsLabel: "Outils (facultatifs)",
toolsDescription:
"Sélectionnez les outils d'agent que cette tâche peut utiliser. Si aucun outil n'est sélectionné, la tâche s'exécutera sans aucun outil.",
toolsSearch: "Rechercher",
toolsNoResults: "Aucun outil ne correspond",
required: "Nécessaire",
requiredFieldsBanner:
"Veuillez remplir tous les champs obligatoires afin de créer l'annonce.",
cancel: "Annuler",
saving: "Économiser...",
updateJob: "Mettre à jour l'emploi",
createJob: "Créer un emploi",
jobUpdated: "Poste mis à jour",
jobCreated: "Emploi créé",
},
builder: {
fallbackWarning:
'Cette expression ne peut pas être modifiée visuellement. Pour la conserver, passez en mode "Personnalisé". Sinon, modifiez tout ce qui se trouve en dessous pour la remplacer.',
run: "Courir",
frequency: {
minute: "chaque minute",
hour: "par heure",
day: "quotidien",
week: "hebdomadaire",
month: "mensuel",
},
every: "Chaque",
minuteOne: "1 minute",
minuteOther: "{{count}} minutes",
atMinute: "À la minute",
pastEveryHour: "chaque heure",
at: "À",
on: "Sur",
onDay: "Un jour",
ofEveryMonth: "chaque mois",
weekdays: {
sun: "Soleil",
mon: "Moi",
tue: "Mardi",
wed: "Mercredi",
thu: "Jeudi",
fri: "Vendredi",
sat: "Samedi",
},
},
runHistory: {
back: "Retour à la liste des offres d'emploi",
title: "Historique des exécutions : {{name}}",
schedule: "Calendrier :",
emptyTitle: "Aucun résultat pour cette tâche.",
emptySubtitle:
"Exécutez la tâche immédiatement et consultez les résultats.",
runNow: "Partir maintenant",
table: {
status: "Statut",
started: "Débuté",
duration: "Durée",
error: "Erreur",
},
stopJob: "Arrêter le travail",
},
runDetail: {
loading: "Affichage des détails de la course...",
notFound: "La commande n'a pas été trouvée.",
back: "Retour",
unknownJob: "Poste non spécifié",
runHeading: "{{name}} — Exécution n°{{id}}",
duration: "Durée : {{value}}",
continueInThread: "Continuer la discussion",
creating: "Créer...",
threadFailed: "Impossible de créer le thread.",
sections: {
prompt: "Demande",
error: "Erreur",
thinking: "Pensées ({{count}})",
toolCalls: "Appels aux outils ({{count}})",
files: "Fichiers ({{count}})",
response: "Réponse",
metrics: "Indicateurs",
},
metrics: {
promptTokens: "Mots-clés de requête:",
completionTokens: "Jetons de complétion :",
},
stopJob: "Arrêter le travail",
killing: "Arrêt...",
},
toolCall: {
arguments: "Arguments:",
showResult: "Afficher le résultat",
hideResult: "Masquer le résultat",
},
file: {
unknown: "Fichier inconnu",
download: "Télécharger",
downloadFailed: "Échec du téléchargement du fichier",
types: {
powerpoint: "PowerPoint",
pdf: "Document au format PDF",
word: "Document Word",
spreadsheet: "Tableur",
generic: "Fichier",
},
},
status: {
completed: "Terminé",
failed: "Échoué",
timed_out: "Temps écoulé",
running: "Course à pied",
queued: "En attente",
},
},
};
export default TRANSLATIONS;

View File

@ -106,6 +106,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "טלגרם",
},
"scheduled-jobs": "משימות מתוכננות",
},
login: {
"multi-user": {
@ -1452,6 +1453,163 @@ const TRANSLATIONS = {
unknown: "לא ידוע",
},
},
scheduledJobs: {
title: "משימות מתוכננות",
enableNotifications: "אפשר להפעיל התראות בדפדפן עבור תוצאות חיפוש עבודה.",
description:
"צור משימות AI חוזרות שיופעלו על פי לוח זמנים. כל משימה תפעיל שאילתה עם כלים אופציונליים ותשמור על התוצאה לבדיקה.",
newJob: "תפקיד חדש",
loading: "טעינה...",
emptyTitle: "אין משימות מתוכננות עדיין",
emptySubtitle: "צרו אחד כדי להתחיל.",
table: {
name: "שם",
schedule: "לוח זמנים",
status: "סטטוס",
lastRun: "הפעולה האחרונה",
nextRun: "הפעולה הבאה",
actions: "פעולות",
},
confirmDelete: "האם אתה בטוח שאתה רוצה למחוק משימה זו שתוכננה?",
toast: {
deleted: "המשרה נמחקת",
triggered: "התעסוקה בוצעה בהצלחה.",
triggerFailed: "לא הצליח להפעיל את העבודה",
triggerSkipped: "העבודה כבר נמצאת בשלבי ביצוע.",
killed: "העבודה הסתיימה בהצלחה.",
killFailed: "לא הצלחתי לעצור את העבודה",
},
row: {
neverRun: "לעולם אל תרוץ",
viewRuns: "מסלולים",
runNow: "תתחילו עכשיו",
enable: "אפשר",
disable: "לכבות",
edit: "עריכה",
delete: "מחיקה",
},
modal: {
titleEdit: "עריכת משימה מתוכננת",
titleNew: "משימה מתוזמנת חדשה",
nameLabel: "שם",
namePlaceholder: "לדוגמה: תקציר חדשות יומי",
promptLabel: "הוראה",
promptPlaceholder: "ההוראה להפעיל בכל ביצוע...",
scheduleLabel: "לוח זמנים",
modeBuilder: "מבנה",
modeCustom: "מותאם אישית",
cronPlaceholder: "ביטוי לזמני ביצוע (למשל, 0 9 * * *)",
currentSchedule: "לוח הזמנים הנוכחי:",
toolsLabel: "כלים (אופציונלי)",
toolsDescription:
"בחר את כלי העבודה שניתן להשתמש בהם עבור משימה זו. אם לא נבחרו כלים, המשימה תפעל ללא שימוש בכלים.",
toolsSearch: "חיפוש",
toolsNoResults: "אין כלים המתאימים",
required: "נדרש",
requiredFieldsBanner: "אנא מלאו את כל השדות הנדרשים כדי ליצור משרה.",
cancel: "בטל",
saving: "חיסכון...",
updateJob: "עדכון משרה",
createJob: "יצירת משרה",
jobUpdated: "פרסום מעודכן",
jobCreated: "תפקיד נוצר",
},
builder: {
fallbackWarning:
'הביטוי הזה אינו ניתן לעריכה באופן ויזואלי. עברו למצב "מותאם אישית" כדי לשמור עליו, או שנה את כל מה שמתחת כדי להחליף אותו.',
run: "לרוץ",
frequency: {
minute: "כל דקה",
hour: "לכל שעה",
day: "יומי",
week: "שבועי",
month: "חודשי",
},
every: "כל",
minuteOne: "1 דקה",
minuteOther: "{{count}} דקות",
atMinute: "במהלך",
pastEveryHour: "כל שעה",
at: "במהלך",
on: "על",
onDay: "ביום",
ofEveryMonth: "של כל חודש",
weekdays: {
sun: "שמש",
mon: "יום שני",
tue: "יום שני",
wed: "יום רביעי",
thu: "יום רביעי",
fri: "יום שישי",
sat: "שבת",
},
},
runHistory: {
back: "חזרה למחיפוש עבודה",
title: "היסטוריית ריצות: {{name}}",
schedule: "לוח זמנים:",
emptyTitle: "עדיין לא בוצעו עבודות עבור פרויקט זה.",
emptySubtitle: "הפעל את העבודה עכשיו וראה את התוצאות שלה.",
runNow: "התחילו עכשיו",
table: {
status: "סטטוס",
started: "התחיל",
duration: "משך זמן",
error: "שגיאה",
},
stopJob: "להפסיק עבודה",
},
runDetail: {
loading: "טעינת פרטי הריצה...",
notFound: "לא נמצאה פעולה.",
back: "חזרה",
unknownJob: "תפקיד לא ידוע",
runHeading: "{{name}} — הפעל את מספר {{id}}",
duration: "משך: {{value}}",
continueInThread: "להמשיך בדיון",
creating: "יצירה...",
threadFailed: "לא הצליח ליצור דיון.",
sections: {
prompt: "הוראה",
error: "שגיאה",
thinking: "מחשבות ({{count}})",
toolCalls: "קריאות לכלי ({{count}})",
files: "קבצים ({{count}})",
response: "תגובה",
metrics: "מדדים",
},
metrics: {
promptTokens: "מילות מפתח:",
completionTokens: "טוקנים לסיום:",
},
stopJob: "הפסק עבודה",
killing: "עצירה...",
},
toolCall: {
arguments: "טיעונים:",
showResult: "הצג תוצאה",
hideResult: "הסתר תוצאה",
},
file: {
unknown: "קובץ לא מזוהה",
download: "הורדה",
downloadFailed: "לא הצליח להוריד את הקובץ",
types: {
powerpoint: "פאוורPoint",
pdf: "מסמך PDF",
word: "מסמך מילה",
spreadsheet: "טבלה",
generic: "קובץ",
},
},
status: {
completed: "הושלם",
failed: "נכשל",
timed_out: "הזמן פג",
running: "ריצה",
queued: "באי תור",
},
},
};
export default TRANSLATIONS;

View File

@ -110,6 +110,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Lavori pianificati",
},
login: {
"multi-user": {
@ -1552,6 +1553,168 @@ const TRANSLATIONS = {
unknown: "Sconosciuto",
},
},
scheduledJobs: {
title: "Lavori pianificati",
enableNotifications:
"Abilitare le notifiche del browser per i risultati delle ricerche di lavoro",
description:
"Definisci attività di intelligenza artificiale ricorrenti che vengono eseguite su un programma prestabilito. Ogni attività esegue un prompt con strumenti opzionali e salva il risultato per la revisione.",
newJob: "Nuovo lavoro",
loading: "Caricamento...",
emptyTitle: "Al momento non ci sono attività pianificate.",
emptySubtitle: "Crea uno per iniziare.",
table: {
name: "Nome",
schedule: "Programma",
status: "Stato",
lastRun: "Ultima corsa",
nextRun: "Prossima corsa",
actions: "Azioni",
},
confirmDelete:
"È sicuro che desideri eliminare questa attività programmata?",
toast: {
deleted: "Lavoro eliminato",
triggered: "L'attività è stata avviata correttamente.",
triggerFailed: "Non è stato possibile avviare il lavoro.",
triggerSkipped: "Sono già in corso i lavori per questo progetto.",
killed: "L'attività è stata interrotta con successo.",
killFailed: "Non è stato possibile interrompere l'attività lavorativa.",
},
row: {
neverRun: "Non correre mai",
viewRuns: "Orari",
runNow: "Inizia ora",
enable: "Abilitare",
disable: "Disattivare",
edit: "Modifica",
delete: "Elimina",
},
modal: {
titleEdit: "Modifica attività programmata",
titleNew: "Nuovo lavoro programmato",
nameLabel: "Nome",
namePlaceholder: 'ad esempio, "Rassegna giornalistica quotidiana"',
promptLabel: "Suggerimento",
promptPlaceholder:
"L'istruzione per eseguire l'operazione ad ogni esecuzione...",
scheduleLabel: "Programma",
modeBuilder: "Costruttore",
modeCustom: "Personalizzato",
cronPlaceholder: "Espressione della cron (ad esempio, 0 9 * * *)",
currentSchedule: "Orario attuale:",
toolsLabel: "Strumenti (opzionali)",
toolsDescription:
"Seleziona quali strumenti di automazione possono essere utilizzati per questo lavoro. Se nessuno strumento è selezionato, il lavoro verrà eseguito senza l'utilizzo di alcun strumento.",
toolsSearch: "Ricerca",
toolsNoResults: "Non sono state trovate corrispondenze.",
required: "Necessario",
requiredFieldsBanner:
"Si prega di compilare tutti i campi obbligatori per creare l'annuncio di lavoro.",
cancel: "Annulla",
saving: "Risparmio...",
updateJob: "Aggiorna l'annuncio di lavoro",
createJob: "Creare un'offerta di lavoro",
jobUpdated: "Posizione aggiornata",
jobCreated: "Posizione creata",
},
builder: {
fallbackWarning:
'Questa espressione non può essere modificata visivamente. Seleziona "Personalizzato" per mantenerla, oppure modifica qualsiasi elemento sottostante per sovrascriverla.',
run: "Correre",
frequency: {
minute: "ogni minuto",
hour: "a ore",
day: "quotidiano",
week: "settimanale",
month: "mensile",
},
every: "Ogni",
minuteOne: "1 minuto",
minuteOther: "{{count}} minuti",
atMinute: "A ogni minuto",
pastEveryHour: "ogni ora",
at: "A",
on: "Su",
onDay: "In un giorno",
ofEveryMonth: "di ogni mese",
weekdays: {
sun: "Sole",
mon: "Mon",
tue: "Domenica",
wed: "Mercoledì",
thu: "Giovedì",
fri: "Venerdì",
sat: "Sabato",
},
},
runHistory: {
back: "Ritorna alle offerte di lavoro",
title: "Cronologia: {{name}}",
schedule: "Programma:",
emptyTitle:
"Al momento, non ci sono stati risultati positivi per questa posizione.",
emptySubtitle: "Avvia l'operazione e visualizza i risultati.",
runNow: "Inizia subito",
table: {
status: "Stato",
started: "Iniziato",
duration: "Durata",
error: "Errore",
},
stopJob: "Interrompere l'attività lavorativa",
},
runDetail: {
loading: "Caricamento dei dettagli dell'esecuzione...",
notFound: "Comando non trovato.",
back: "Indietro",
unknownJob: "Posizione non specificata",
runHeading: "{{name}} — Esecuzione #{{id}}",
duration: "Durata: {{value}}",
continueInThread: "Continua nella discussione",
creating: "Creazione...",
threadFailed: "Impossibile creare il thread.",
sections: {
prompt: "Richiesta",
error: "Errore",
thinking: "Pensieri ({{count}})",
toolCalls: "Chiamate a strumenti ({{count}})",
files: "File ({{count}})",
response: "Risposta",
metrics: "Metriche",
},
metrics: {
promptTokens: "Parole chiave:",
completionTokens: "Token di completamento:",
},
stopJob: "Interruzione del lavoro",
killing: "Fermare...",
},
toolCall: {
arguments: "Argomentazioni:",
showResult: "Mostra risultato",
hideResult: "Nascondi risultato",
},
file: {
unknown: "File sconosciuto",
download: "Scarica",
downloadFailed: "Impossibile scaricare il file.",
types: {
powerpoint: "PowerPoint",
pdf: "Documento in formato PDF",
word: "Documento Word",
spreadsheet: "Foglio di calcolo",
generic: "File",
},
},
status: {
completed: "Completato",
failed: "Fallito",
timed_out: "Tempo scaduto",
running: "Corsa",
queued: "In attesa",
},
},
};
export default TRANSLATIONS;

View File

@ -108,6 +108,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "テレグラム",
},
"scheduled-jobs": "計画された作業",
},
login: {
"multi-user": {
@ -1496,6 +1497,164 @@ const TRANSLATIONS = {
unknown: "不明",
},
},
scheduledJobs: {
title: "予定されている作業",
enableNotifications: "求人情報の通知をブラウザで許可する",
description:
"定期的に実行されるAIタスクを作成します。これらのタスクは、指定されたスケジュールに従って実行され、オプションのツールを使用してプロンプトを実行し、結果を保存してレビューします。",
newJob: "新しい仕事",
loading: "読み込み中...",
emptyTitle: "現時点で予定されている作業はありません。",
emptySubtitle: "まずは、簡単なものから始めてみましょう。",
table: {
name: "名前",
schedule: "スケジュール",
status: "ステータス",
lastRun: "最後の走行",
nextRun: "次回の開催",
actions: "行動",
},
confirmDelete: "本当にこの予定された作業を削除してもよろしいですか?",
toast: {
deleted: "求人情報が削除されました",
triggered: "ジョブが正常に実行されました",
triggerFailed: "ジョブの実行が失敗しました",
triggerSkipped: "この仕事については、すでに作業が進んでいます。",
killed: "作業は正常に終了しました",
killFailed: "仕事をやめることができなかった",
},
row: {
neverRun: "絶対に走らない",
viewRuns: "実行例",
runNow: "今すぐ行動を",
enable: "有効にする",
disable: "無効化",
edit: "編集",
delete: "削除",
},
modal: {
titleEdit: "予定されたタスクの編集",
titleNew: "新規スケジュールされた作業",
nameLabel: "名前",
namePlaceholder: "例:デイリーニュースダイジェスト",
promptLabel: "指示",
promptPlaceholder: "「各実行時に実行する」という指示...",
scheduleLabel: "スケジュール",
modeBuilder: "建設業者",
modeCustom: "オーダーメイド",
cronPlaceholder: "Cron 形式の指定 (例: 0 9 * * *)",
currentSchedule: "現在のスケジュール:",
toolsLabel: "道具(任意)",
toolsDescription:
"このタスクで使用できるエージェントツールを選択してください。 ツールが選択されていない場合、タスクはツールなしで実行されます。",
toolsSearch: "検索",
toolsNoResults: "該当するツールは見つかりませんでした。",
required: "必要",
requiredFieldsBanner:
"求人を作成するには、必要なすべての項目を記入してください。",
cancel: "キャンセル",
saving: "保存中...",
updateJob: "求人情報の更新",
createJob: "求人を作成する",
jobUpdated: "求人情報が更新されました",
jobCreated: "雇用が創出された",
},
builder: {
fallbackWarning:
"このテキストは、視覚的に編集することはできません。元のテキストを維持するには、「カスタム」モードに切り替えてください。または、以下の項目を変更することで、このテキストを上書きできます。",
run: "走る",
frequency: {
minute: "1分ごとに",
hour: "時間ごと",
day: "毎日",
week: "毎週",
month: "毎月",
},
every: "すべて",
minuteOne: "1分",
minuteOther: "{{count}} 分",
atMinute: "分単位で",
pastEveryHour: "過去の、1時間ごとに",
at: "~に",
on: "~について",
onDay: "ある日",
ofEveryMonth: "毎月",
weekdays: {
sun: "太陽",
mon: "月",
tue: "火曜日",
wed: "水曜日",
thu: "木曜日",
fri: "金曜日",
sat: "土曜日",
},
},
runHistory: {
back: "求人情報に戻る",
title: "実行履歴: {{name}}",
schedule: "スケジュール:",
emptyTitle: "現時点では、この仕事に対してまだ成果は出ていません。",
emptySubtitle: "現在ジョブを実行し、その結果を確認してください。",
runNow: "今すぐ実行",
table: {
status: "ステータス",
started: "開始",
duration: "期間",
error: "エラー",
},
stopJob: "仕事の停止",
},
runDetail: {
loading: "ロード実行の詳細を読み込んでいます...",
notFound: "指定されたプログラムが見つかりませんでした。",
back: "背面",
unknownJob: "不明な職種",
runHeading: "{{name}} — 実行: #{{id}}",
duration: "期間: {{value}}",
continueInThread: "スレッドへの書き込みを続ける",
creating: "作成中...",
threadFailed: "スレッドの作成に失敗しました",
sections: {
prompt: "指示",
error: "エラー",
thinking: "考え ({{count}})",
toolCalls: "ツール呼び出し ({{count}})",
files: "ファイル ({{count}})",
response: "返答",
metrics: "指標",
},
metrics: {
promptTokens: "プロンプトトークン:",
completionTokens: "完了トークン:",
},
stopJob: "求人停止",
killing: "停止…",
},
toolCall: {
arguments: "主張:",
showResult: "結果を表示",
hideResult: "結果を非表示にする",
},
file: {
unknown: "不明なファイル",
download: "ダウンロード",
downloadFailed: "ファイルのダウンロードに失敗しました",
types: {
powerpoint: "パワーポイント",
pdf: "PDFドキュメント",
word: "Wordドキュメント",
spreadsheet: "スプレッドシート",
generic: "ファイル",
},
},
status: {
completed: "完了",
failed: "失敗",
timed_out: "時間切れ",
running: "ランニング",
queued: "待ち列",
},
},
};
export default TRANSLATIONS;

View File

@ -107,6 +107,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "텔레그램",
},
"scheduled-jobs": "예정된 작업",
},
login: {
"multi-user": {
@ -1470,6 +1471,164 @@ const TRANSLATIONS = {
unknown: "알 수 없음",
},
},
scheduledJobs: {
title: "예정된 작업",
enableNotifications: "채용 결과에 대한 브라우저 알림 활성화",
description:
"정기적으로 실행되는 AI 작업을 생성하고, 일정을 설정합니다. 각 작업은 선택적으로 도구를 사용하여 프롬프트를 실행하고, 결과를 저장하여 검토합니다.",
newJob: "새로운 직업",
loading: "로딩 중...",
emptyTitle: "현재 예약된 작업은 없습니다.",
emptySubtitle: "시작하기 위해 하나를 만들어 보세요.",
table: {
name: "이름",
schedule: "일정",
status: "상태",
lastRun: "마지막 기록",
nextRun: "다음 경주",
actions: "행동",
},
confirmDelete: "정말로 이 예약된 작업을 삭제하시겠습니까?",
toast: {
deleted: "직책 삭제",
triggered: "직업이 성공적으로 시작되었습니다.",
triggerFailed: "작업 실행에 실패",
triggerSkipped: "이 프로젝트는 이미 진행 중입니다.",
killed: "직업이 성공적으로 종료되었습니다.",
killFailed: "일자리를 유지하지 못함",
},
row: {
neverRun: "절대 질주하지 마세요",
viewRuns: "실행 횟수",
runNow: "지금 실행하세요",
enable: "활성화",
disable: "비활성화",
edit: "편집",
delete: "삭제",
},
modal: {
titleEdit: "예정된 작업 수정",
titleNew: "새로 예약된 작업",
nameLabel: "이름",
namePlaceholder: "예: 매일 뉴스 요약",
promptLabel: "요청",
promptPlaceholder: "각 실행 시 실행 지시",
scheduleLabel: "일정",
modeBuilder: "건축업자",
modeCustom: "맞춤형",
cronPlaceholder: "Cron 표현 (예: 0 9 * * *)",
currentSchedule: "현재 일정:",
toolsLabel: "(선택 사항) 도구",
toolsDescription:
"이 작업에서 사용할 수 있는 에이전트 도구를 선택합니다. 선택 사항이 없으면 작업은 어떤 도구 없이 실행됩니다.",
toolsSearch: "검색",
toolsNoResults: "어떤 도구도 해당되지 않습니다",
required: "필수",
requiredFieldsBanner:
"직업을 생성하려면 모든 필수 항목을 정확하게 작성해 주세요.",
cancel: "취소",
saving: "저축 중...",
updateJob: "업데이트",
createJob: "일 만들기",
jobUpdated: "직책 변경",
jobCreated: "새로운 직책 생성",
},
builder: {
fallbackWarning:
'이 표현은 시각적으로 수정할 수 없습니다. 원본을 유지하려면 "사용자 지정" 모드로 변경하거나, 아래의 내용을 변경하여 덮어쓰십시오.',
run: "달리기",
frequency: {
minute: "매 분마다",
hour: "시간당",
day: "매일",
week: "매주",
month: "매월",
},
every: "모든",
minuteOne: "1분",
minuteOther: "{{count}} 분",
atMinute: "분",
pastEveryHour: "과거, 매 시간",
at: "~에서",
on: "~에 대해",
onDay: "특정 날",
ofEveryMonth: "매달",
weekdays: {
sun: "태양",
mon: "월요일",
tue: "화요일",
wed: "수요일",
thu: "목요일",
fri: "금요일",
sat: "토",
},
},
runHistory: {
back: "이전으로",
title: "실행 기록: {{name}}",
schedule: "일정:",
emptyTitle: "이 업무에 아직 성과가 없습니다.",
emptySubtitle: "현재 작업을 실행하고 결과를 확인하세요.",
runNow: "지금 실행",
table: {
status: "상태",
started: "시작",
duration: "기간",
error: "오류",
},
stopJob: "직업 중단",
},
runDetail: {
loading: "로딩 중: 실행 세부 정보...",
notFound: "실행 명령을 찾을 수 없습니다.",
back: "뒤",
unknownJob: "알 수 없는 직업",
runHeading: "{{name}} — 실행: #{{id}}",
duration: "기간: {{value}}",
continueInThread: "스레드에 계속 참여",
creating: "만들기...",
threadFailed: "스레드를 생성하는 데 실패했습니다.",
sections: {
prompt: "요청",
error: "오류",
thinking: "생각 ({{count}})",
toolCalls: "도구 호출 ({{count}})",
files: "파일 ({{count}})",
response: "응답",
metrics: "지표",
},
metrics: {
promptTokens: "프롬프트 토큰:",
completionTokens: "완료 토큰:",
},
stopJob: "직업 중단",
killing: "멈추다...",
},
toolCall: {
arguments: "논거",
showResult: "결과 표시",
hideResult: "결과 숨기기",
},
file: {
unknown: "알 수 없는 파일",
download: "다운로드",
downloadFailed: "파일 다운로드 실패",
types: {
powerpoint: "파워포인트",
pdf: "PDF 문서",
word: "워드 문서",
spreadsheet: "스프레드시트",
generic: "파일",
},
},
status: {
completed: "완료",
failed: "실패",
timed_out: "시간 초과",
running: "달리기",
queued: "대기 중",
},
},
};
export default TRANSLATIONS;

View File

@ -115,6 +115,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "„Telegram“",
},
"scheduled-jobs": "Planuojami darbai",
},
login: {
"multi-user": {
@ -1513,6 +1514,166 @@ const TRANSLATIONS = {
unknown: "Nenurodytas",
},
},
scheduledJobs: {
title: "Planuotos užduotys",
enableNotifications:
"Įgalinkite naršyklės pranešimus dėl darbo paieškos rezultatų",
description:
"Sukurkite nuolatines AI užduotis, kurios vyks pagal nustatytą grafiką. Kiekviena užduotis atliks užduotį su galimais įrankiais ir išsaugos rezultatą, kad galėtų būti peržiūrėta.",
newJob: "Nauja darbo pozicija",
loading: "Įkėlimas...",
emptyTitle: "Nėra nurodytų uždučių.",
emptySubtitle: "Sukurkite vieną, kad pradėtumėte.",
table: {
name: "Pavadinimas",
schedule: "Programinė tvarka",
status: "Statusas",
lastRun: "Paskutinė kelionė",
nextRun: "Kitas maršrutas",
actions: "Veikmai",
},
confirmDelete:
"Ar esate tikri, kad norite ištrinti šią užduotį, kurią įtraukėte į planą?",
toast: {
deleted: "Darbas ištrintas",
triggered: "Darbas buvo sėkmingai inicijuotas.",
triggerFailed: "Nepavyko inicijuoti užduoties",
triggerSkipped: "Šis projektas jau pradėtas vykdyti.",
killed: "Darbas sėkmingai baigtas",
killFailed: "Nepavyko sustabdyti darbą",
},
row: {
neverRun: "Niekada nesnydžkite",
viewRuns: "Vaizdo įrašai",
runNow: "Pradėkite dabar",
enable: "Aaktyvinti",
disable: "Išjungti",
edit: "Redaguo",
delete: "Ištrinkti",
},
modal: {
titleEdit: "Redaguoti užplanuotą užduotį",
titleNew: "Naujas planuojamas darbas",
nameLabel: "Pavadinimas",
namePlaceholder: "Pavyzdžiui, kasdieninė naujienų apžvalga",
promptLabel: "Instrukcija",
promptPlaceholder: "Instrukcija, kad reikia vykdyti kiekvieną kartą...",
scheduleLabel: "Programėlė",
modeBuilder: "Statybininkas",
modeCustom: "Individualus",
cronPlaceholder: "Laiko išraiška (pvz., 0 9 * * *)",
currentSchedule: "Dabartinė tvarka:",
toolsLabel: "Įrankės (neprivalomi)",
toolsDescription:
"Pasirinkite, kokius agento įrankius šiandiena gali naudoti. Jei nė vienas įrankis nėra pasirinktas, šiandiena veiks be jokių įrankių.",
toolsSearch: "Paieška",
toolsNoResults: "Nėra įrankių, kurie atitinka",
required: "Reikalingas",
requiredFieldsBanner:
"Prašome užpildyti visus reikalingus laukelius, kad būtų galima sukurti darbo skelbimą.",
cancel: "Anuliu",
saving: "Taupymas...",
updateJob: "Atnaujinti darbą",
createJob: "Sukurti darbo poziciją",
jobUpdated: "Darbas atnaujintas",
jobCreated: "Sukurtas darbas",
},
builder: {
fallbackWarning:
'Šią frazę negalima redaguoti vizualiai. Norėdami ją išsaugoti, pasirinkite "Individualus" režimą, arba pakeiskite žemiau esančias vertimes, kad ją pakeistumėte.',
run: "Bėgti",
frequency: {
minute: "būdami kiekvieną minutę",
hour: "valandinės",
day: "kasdieninis",
week: "kas savaitę",
month: "kas mėnesį",
},
every: "Kiekvienas",
minuteOne: "1 minutė",
minuteOther: "{{count}} minučių",
atMinute: "Minutė po minutės",
pastEveryHour: "būdami kiekvieną valandą",
at: "At",
on: "Apie",
onDay: "Jau",
ofEveryMonth: "iš kiekvieno mėnesio",
weekdays: {
sun: "Saulė",
mon: "Moneta",
tue: "Trečiadienis",
wed: "Trečiadienis",
thu: "Ketvirtadienis",
fri: "Penktadienis",
sat: "Sekmadienis",
},
},
runHistory: {
back: "Grįžti į paieškas",
title: "Paleidimo istorija: {{name}}",
schedule: "Programinė tvarka:",
emptyTitle: "Kol šis darbas nėra baigtas",
emptySubtitle: "Atlikite šią užduotį dabar ir peržiūrėkite rezultatus.",
runNow: "Pradėkite dabar",
table: {
status: "Statusas",
started: "Pradėtas",
duration: "Tr উপক",
error: "Klaida",
},
stopJob: "Pamesti darbas",
},
runDetail: {
loading: "Įkraudami važiavimo duomenis...",
notFound: "Nepavyko rasti.",
back: "Atgal",
unknownJob: "Nenurodytas darbas",
runHeading: "{{name}} Treniruotė #{{id}}",
duration: "Trūkumas: {{value}}",
continueInThread: "Toliau diskusijoje",
creating: "Kurimas...",
threadFailed: "Nepavyko sukurti temą",
sections: {
prompt: "Įspūdis",
error: "Klaida",
thinking: "Mintys ({{count}})",
toolCalls: "Naudojamų įrankių kvietimai ({{count}})",
files: "Failai ({{count}})",
response: "Atgarsas",
metrics: "Matmenys",
},
metrics: {
promptTokens: "Įspūdingos žymės:",
completionTokens: "Baigimo žymekliai:",
},
stopJob: "Nutraukite darbą",
killing: "Sustabdyti...",
},
toolCall: {
arguments: "Argumentai:",
showResult: "Parodyti rezultatą",
hideResult: "Slėpti rezultatą",
},
file: {
unknown: "Nežinomas failas",
download: "Atsisiųsti",
downloadFailed: "Nepavyko atsisiųsti failą",
types: {
powerpoint: "PowerPoint",
pdf: "PDF dokumentas",
word: "Dokumentas, kurį galima redaguoti Microsoft Word programoje",
spreadsheet: "Spalvotas lapas (tabelis)",
generic: "Failas",
},
},
status: {
completed: "Baigtas",
failed: "Nepavyko",
timed_out: "Laikas baigėsi",
running: "Bėgimas",
queued: "Apsisukęs",
},
},
};
export default TRANSLATIONS;

View File

@ -108,6 +108,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Plānotas darba uzdevumi",
},
login: {
"multi-user": {
@ -1523,6 +1524,166 @@ const TRANSLATIONS = {
unknown: "Nezināms",
},
},
scheduledJobs: {
title: "Plānotas darba uzdevumi",
enableNotifications:
"Ievērojiet pārlūkprogrammā atgādinājumus par darbā pieņemšanas rezultātiem",
description:
"Izveidot atkārtotas AI uzdevumus, kas tiek veiktas saskaņā ar noteiktu grafiku. Katrs uzdevums izmanto norādīto, kā arī var izmantot papildu rīkus, un rezultāts tiek saglabāts, lai to varētu pārskatīt.",
newJob: "Jauna darba pozīcija",
loading: "Ielāde...",
emptyTitle: "Vēl nav plānotu darbu",
emptySubtitle: "Izveidot vienu, lai sāktu.",
table: {
name: "Vārds",
schedule: "Kalendārs",
status: "Statuss",
lastRun: "Pēdējā brauciens",
nextRun: "Nākamā trase",
actions: "Rīcības",
},
confirmDelete: "Vai jūs noteikti vēlaties dzēst šo plānoto darbu?",
toast: {
deleted: "Darbs izdzēsts",
triggered: "Darbs veiksmīgi izpildīts.",
triggerFailed: "Neizdevās aktivizēt darbu",
triggerSkipped: "Šī darba izpilde jau ir sākusies.",
killed: "Darbs veiksmīgi pārtraukts",
killFailed: "Neizdevās pārtraukt darbu",
},
row: {
neverRun: "Nekad neiet sprintā",
viewRuns: "Skatīšanās reģīni",
runNow: "Runāt tagad",
enable: "Aktivizēt",
disable: "Atspējot",
edit: "Rediģēt",
delete: "Dzēst",
},
modal: {
titleEdit: "Ieraksta plānoto darbu",
titleNew: "Jauna plānota darba",
nameLabel: "Vārds",
namePlaceholder: "piemēram, ikdienas ziņu apkopojums",
promptLabel: "Iekšēja aicinājums",
promptPlaceholder:
"Instrukcija, kas norāda, ka programmu jāizpilda katrā darbībā...",
scheduleLabel: "Kalendārs",
modeBuilder: "Celtniejs",
modeCustom: "Pielāgotais",
cronPlaceholder: "Laika izteiksme (piemēram, 0 9 * * *)",
currentSchedule: "Pašreizējais grafiks:",
toolsLabel: "Rīki (pēc izvēles)",
toolsDescription:
"Izvēlieties, kādas programmatūras rīkus šis darbs var izmantot. Ja neviens rīks nav izvēlēts, darbs tiks veikts bez jebkurām programmatūras rīkiem.",
toolsSearch: "Meklēšana",
toolsNoResults: "Nav atrasts neviens atbilstošs rīks.",
required: "Nepieciešams",
requiredFieldsBanner:
"Lūdzu, aizpildiet visus obligātās laukus, lai izveidotu darbā.",
cancel: "Atcelt",
saving: "Ietaupīt...",
updateJob: "Atjaunināt darbu",
createJob: "Izveidot darbu",
jobUpdated: "Darba statuss atjaunināts",
jobCreated: "Izveidots darbs",
},
builder: {
fallbackWarning:
'Šo izteikumu nevar redzami rediģēt. Izmantojiet "Custom" režīmu, lai to saglabātu, vai mainiet jebko zemāk, lai to pārvietotu.',
run: "Runāt",
frequency: {
minute: "katru minūti",
hour: "laika periodā",
day: "katru dienu",
week: "katru nedēļu",
month: "mēnesī",
},
every: "Katrs",
minuteOne: "1 minūte",
minuteOther: "{{count}} minūtes",
atMinute: "Katrā minūtē",
pastEveryHour: "katru stundu",
at: "At",
on: "Par",
onDay: "Vienā dienā",
ofEveryMonth: "katram mēnešam",
weekdays: {
sun: "Suņa",
mon: "Ikdiena",
tue: "Otrdiena",
wed: "Trešdiena",
thu: "Ceturtdien",
fri: "Svētdiena",
sat: "Sastāvējums",
},
},
runHistory: {
back: "Atgriezties uz darba sludinājumiem",
title: "Runas vēsture: {{name}}",
schedule: "Kalendārs:",
emptyTitle: "Vēl nav veikti nekādi darbi",
emptySubtitle: "Veiciet darbu šajā laikā un apskatiet tā rezultātus.",
runNow: "Runā tagad",
table: {
status: "Statuss",
started: "Sācies",
duration: "Laiks",
error: "Kļūda",
},
stopJob: "Aizstāt darbu",
},
runDetail: {
loading: "Ievadīšanas darbību apraksts...",
notFound: "Nav atrasta.",
back: "Atpakaļ",
unknownJob: "Nevērtēts darbs",
runHeading: "{{name}} Runa Nr. {{id}}",
duration: "Laiks: {{value}}",
continueInThread: "Turpināt diskusiju",
creating: "Izveidot...",
threadFailed: "Izdevās izveidot tēmu",
sections: {
prompt: "Iekšējais stimuls",
error: "Kļūda",
thinking: "Domas ({{count}})",
toolCalls: "Rīku izmantošana ({{count}})",
files: "Faili ({{count}})",
response: "Atbilde",
metrics: "Mērījumi",
},
metrics: {
promptTokens: "Ievade:",
completionTokens: "Pilnībā aprakstīti elementi:",
},
stopJob: "Aizstāt darbu",
killing: "Apstādam...",
},
toolCall: {
arguments: "Argumenti:",
showResult: "Rādīt rezultātu",
hideResult: "Noslēgt rezultātu",
},
file: {
unknown: "Nezināms failss",
download: "Lejupielādēt",
downloadFailed: "Neizdevās lejupielādēt failu",
types: {
powerpoint: "PowerPoint",
pdf: "PDF dokumenta",
word: "Vārdu dokumenta faila",
spreadsheet: "Tabulas veidols",
generic: "Faila",
},
},
status: {
completed: "Pilnots",
failed: "Neizdevies",
timed_out: "Laiks esot beidzies",
running: "Skriešana",
queued: "Iekļauts rindā",
},
},
};
export default TRANSLATIONS;

View File

@ -109,6 +109,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Geplande taken",
},
login: {
"multi-user": {
@ -1524,6 +1525,167 @@ const TRANSLATIONS = {
unknown: "Onbekend",
},
},
scheduledJobs: {
title: "Geplande taken",
enableNotifications:
"Activeer browser notificaties voor resultaten van vacatures.",
description:
"Maak herhaalde AI-taken die volgens een schema worden uitgevoerd. Elke taak voert een prompt uit met optionele tools en slaat het resultaat op voor beoordeling.",
newJob: "Nieuwe baan",
loading: "Laad...",
emptyTitle: "Er zijn nog geen geplande taken.",
emptySubtitle: "Maak er één om aan de slag te gaan.",
table: {
name: "Naam",
schedule: "Planning/Tijdschema",
status: "Status",
lastRun: "Laatste rit",
nextRun: "Volgende keer",
actions: "Acties",
},
confirmDelete:
"Bent u er zeker van dat u deze geplande taak wilt verwijderen?",
toast: {
deleted: "Vacature verwijderd",
triggered: "De werkzaamheden zijn succesvol afgerond.",
triggerFailed: "Niet mogelijk om de taak uit te voeren",
triggerSkipped: "Er is al begonnen met het uitvoeren van dit project.",
killed: "De werkzaamheden zijn succesvol beëindigd.",
killFailed: "Niet in staat geweest om het werk te stoppen.",
},
row: {
neverRun: "Nooit versnellen",
viewRuns: "Bekijk de resultaten",
runNow: "Begin nu",
enable: "Aan zetten/Activeren",
disable: "Uitschakelen",
edit: "Bewerk",
delete: "Verwijderen",
},
modal: {
titleEdit: "Wijzig geplande taak",
titleNew: "Nieuwe geplande taak",
nameLabel: "Naam",
namePlaceholder: "bijvoorbeeld: Dagelijkse nieuwsbrief",
promptLabel: "Aanvraag",
promptPlaceholder:
"De instructie om uit te voeren bij elke uitvoering...",
scheduleLabel: "Planning/Tijdschema",
modeBuilder: "Bouwer",
modeCustom: "Op maat gemaakt",
cronPlaceholder: "Cron-expressie (bijvoorbeeld 0 9 * * *)",
currentSchedule: "Huidelijk schema:",
toolsLabel: "Benodigde hulpmiddelen (optioneel)",
toolsDescription:
"Selecteer welke agent-tools deze taak kan gebruiken. Als er geen tools zijn geselecteerd, voert de taak uit zonder enige tools.",
toolsSearch: "Zoeken",
toolsNoResults: "Geen van de beschikbare gereedschappen komt overeen.",
required: "Vereist",
requiredFieldsBanner:
"Vul al de vereiste velden in om een vacature aan te maken.",
cancel: "Annuleren",
saving: "Opslaan...",
updateJob: "Werk bijwerken",
createJob: "Vacature aanmaken",
jobUpdated: "Functie bijgewerkt",
jobCreated: "Werk gecreëerd",
},
builder: {
fallbackWarning:
'Deze tekst kan niet visueel worden bewerkt. Kies voor "Aanpassen" om deze te behouden, of wijzig hieronder om deze te vervangen.',
run: "Lopen",
frequency: {
minute: "per minuut",
hour: "per uur",
day: "dagelijks",
week: "wekelijks",
month: "maandelijks",
},
every: "Elke",
minuteOne: "1 minuut",
minuteOther: "{{count}} minuten",
atMinute: "Bij het begin van",
pastEveryHour: "elke uur",
at: "Bij",
on: "Op",
onDay: "Op een dag",
ofEveryMonth: "of per maand",
weekdays: {
sun: "Zon",
mon: "Maandag",
tue: "Maandag",
wed: "Wedstrijd",
thu: "Donderdag",
fri: "Vrijdag",
sat: "Zaterdag",
},
},
runHistory: {
back: "Terug naar vacatures",
title: "Historie: {{name}}",
schedule: "Planning:",
emptyTitle: "Er zijn nog geen resultaten behaald voor deze opdracht.",
emptySubtitle: "Voer de taak nu uit en bekijk de resultaten.",
runNow: "Start nu",
table: {
status: "Status",
started: "Begonnen",
duration: "Duur",
error: "Fout",
},
stopJob: "Werkonderbreking",
},
runDetail: {
loading: "Laad details van de uitvoering in...",
notFound: "Geen uitvoering gevonden.",
back: "Terug",
unknownJob: "Onbekende functie",
runHeading: "{{name}} — Uitvoering #{{id}}",
duration: "Duur: {{value}}",
continueInThread: "Blijf reageren in dit gesprek",
creating: "Creëren...",
threadFailed: "Niet in staat om een nieuwe thread te creëren.",
sections: {
prompt: "Aanvraag",
error: "Fout",
thinking: "Denken ({{count}})",
toolCalls: "Aanroepen van tools ({{count}})",
files: "Bestanden ({{count}})",
response: "Antwoord",
metrics: "Meetwaarden",
},
metrics: {
promptTokens: "Aanwijstokens:",
completionTokens: "Voltooiingstokens:",
},
stopJob: "Werkonderbreking",
killing: "Stoppen...",
},
toolCall: {
arguments: "Argumenten:",
showResult: "Toon resultaat",
hideResult: "Resultaat verbergen",
},
file: {
unknown: "Onbekend bestand",
download: "Downloaden",
downloadFailed: "Fout bij het downloaden van het bestand",
types: {
powerpoint: "PowerPoint",
pdf: "PDF-document",
word: "Word-document",
spreadsheet: "Spreadsheet (tabellenblad)",
generic: "Bestand",
},
},
status: {
completed: "Afgerond",
failed: "Mislukt",
timed_out: "Tijdslimiet bereikt",
running: "Hardlopen",
queued: "In de wachtrij",
},
},
};
export default TRANSLATIONS;

View File

@ -109,6 +109,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Zaplanowane zadania",
},
login: {
"multi-user": {
@ -1527,6 +1528,166 @@ const TRANSLATIONS = {
unknown: "Nieznany",
},
},
scheduledJobs: {
title: "Zaplanowane zadania",
enableNotifications:
"Włącz powiadomienia w przeglądarce dotyczące wyników rekrutacji",
description:
"Stwórz powtarzalne zadania oparte na sztucznej inteligencji, które będą wykonywane według zadanego harmonogramu. Każde zadanie będzie zawierało zapytanie oraz opcjonalne narzędzia, a także zapisze wynik, który będzie można później przejrzeć.",
newJob: "Nowa praca",
loading: "Ładowanie...",
emptyTitle: "Na razie nie ma zaplanowanych zadań.",
emptySubtitle: "Stwórz jeden, aby zacząć.",
table: {
name: "Imię",
schedule: "Harmonogram",
status: "Stan",
lastRun: "Ostatnia przejażdżka",
nextRun: "Następna rozgrywka",
actions: "Działania",
},
confirmDelete: "Czy na pewno chcesz usunąć tę zaplanowaną czynność?",
toast: {
deleted: "Usunięto pracę",
triggered: "Zlecenie zostało pomyślnie uruchomione.",
triggerFailed: "Nie udało się uruchomić zadania.",
triggerSkipped: "Prace nad tym projektem są już w toku.",
killed: "Praca została pomyślnie zakończona.",
killFailed: "Nie udało się zatrzymać pracy",
},
row: {
neverRun: "Nigdy nie należy jechać zbyt szybko.",
viewRuns: "Wyświetlanie/Odtwarzanie",
runNow: "Zacznij od razu",
enable: "Włącz",
disable: "Wyłączyć",
edit: "Edytuj",
delete: "Usuń",
},
modal: {
titleEdit: "Edytuj zaplanowaną pracę",
titleNew: "Nowe zaplanowane zadanie",
nameLabel: "Imię",
namePlaceholder: "np. Codzienne podsumowanie wiadomości",
promptLabel: "Instrukcja",
promptPlaceholder:
"Instrukcja dotycząca uruchamiania w każdym przypadku...",
scheduleLabel: "Harmonogram",
modeBuilder: "Budowniczy",
modeCustom: "Dostosowane",
cronPlaceholder: "Wyrażenie crona (np. 0 9 * * *)",
currentSchedule: "Obecny harmonogram:",
toolsLabel: "Narzędzia (opcjonalne)",
toolsDescription:
"Wybierz, które narzędzia dla agentów mogą być używane w tym przypadku. Jeśli żadne narzędzia nie zostaną wybrane, praca będzie wykonywana bez żadnych narzędzi.",
toolsSearch: "Wyszukaj",
toolsNoResults: "Żaden z dostępnych narzędzi nie pasuje.",
required: "Wymagane",
requiredFieldsBanner:
"Prosimy o wypełnienie wszystkich wymaganych pól, aby utworzyć ogłoszenie o pracę.",
cancel: "Anuluj",
saving: "Oszczędzanie...",
updateJob: "Aktualizacja oferty pracy",
createJob: "Utwórz ofertę pracy",
jobUpdated: "Informacja o aktualizacji oferty pracy",
jobCreated: "Utworzono stanowisko",
},
builder: {
fallbackWarning:
'To wyrażenie nie można edytować graficznie. Jeśli chcesz je zachować, przejdź do opcji "Custom". W przeciwnym razie możesz zmienić dowolne elementy poniżej, aby je zastąpić.',
run: "Bieg",
frequency: {
minute: "co minutę",
hour: "godzinne",
day: "codzienny",
week: "tygodniowy",
month: "miesięczny",
},
every: "Każdy",
minuteOne: "1 minuta",
minuteOther: "{{count}} minut",
atMinute: "W określonym momencie",
pastEveryHour: "przeszłe, co godzinę",
at: "W",
on: "Na",
onDay: "W dniu",
ofEveryMonth: "każdego miesiąca",
weekdays: {
sun: "Słońce",
mon: "Monety",
tue: "Wtorek",
wed: "Środa",
thu: "Czwartek",
fri: "Piątek",
sat: "Sobota",
},
},
runHistory: {
back: "Powrót do ofert pracy",
title: "Historia uruchomień: {{name}}",
schedule: "Harmonogram:",
emptyTitle: "Na razie nie udało się wykonać żadnych zadań.",
emptySubtitle: "Uruchom teraz zadanie i sprawdź jego wyniki.",
runNow: "Rozpocznij teraz",
table: {
status: "Stan",
started: "Zaczęto",
duration: "Czas trwania",
error: "Błąd",
},
stopJob: "Zakończ pracę",
},
runDetail: {
loading: "Wczytywanie szczegółów przebiegu...",
notFound: "Nie znaleziono.",
back: "Wstecz",
unknownJob: "Nieznane stanowisko",
runHeading: "{{name}} Uruchom {{id}}",
duration: "Czas trwania: {{value}}",
continueInThread: "Kontynuuj dyskusję",
creating: "Tworzenie...",
threadFailed: "Nie udało się utworzyć wątku.",
sections: {
prompt: "Instrukcja",
error: "Błąd",
thinking: "Przemyślenia ({{count}})",
toolCalls: "Wywoływanie narzędzi ({{count}})",
files: "Pliki ({{count}})",
response: "Odpowiedź",
metrics: "Wskaźniki",
},
metrics: {
promptTokens: "Słowa kluczowe:",
completionTokens: "Tokeny zakończenia:",
},
stopJob: "Zakończ pracę",
killing: "Przestań...",
},
toolCall: {
arguments: "Argumenty:",
showResult: "Wyświetl wynik",
hideResult: "Ukryj wynik",
},
file: {
unknown: "Nieznany plik",
download: "Pobierz",
downloadFailed: "Nie udało się pobrać pliku",
types: {
powerpoint: "Prezentacja w programie PowerPoint",
pdf: "Dokument w formacie PDF",
word: "Dokument w formacie Word",
spreadsheet: "Arkusz kalkulacyjny",
generic: "Plik",
},
},
status: {
completed: "Zakończone",
failed: "Nie udało się",
timed_out: "Czas wymarł",
running: "Bieganie",
queued: "W kolejce",
},
},
};
export default TRANSLATIONS;

View File

@ -108,6 +108,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Tarefas Agendadas",
},
login: {
"multi-user": {
@ -1501,6 +1502,165 @@ const TRANSLATIONS = {
unknown: "Desconhecido",
},
},
scheduledJobs: {
title: "Tarefas Agendadas",
enableNotifications:
"Ative as notificações do navegador para resultados de emprego.",
description:
"Crie tarefas de IA recorrentes que sejam executadas em um determinado horário. Cada tarefa executa um prompt com ferramentas opcionais e salva o resultado para revisão.",
newJob: "Novo emprego",
loading: "Carregando...",
emptyTitle: "Ainda não há tarefas agendadas.",
emptySubtitle: "Crie um para começar.",
table: {
name: "Nome",
schedule: "Horário",
status: "Estado",
lastRun: "Última corrida",
nextRun: "Próxima corrida",
actions: "Ações",
},
confirmDelete: "Tem certeza de que deseja excluir esta tarefa agendada?",
toast: {
deleted: "Emprego excluído",
triggered: "A tarefa foi executada com sucesso.",
triggerFailed: "Não foi possível iniciar a tarefa.",
triggerSkipped: "O projeto já está em andamento.",
killed: "A tarefa foi concluída com sucesso.",
killFailed: "Não conseguiu impedir a demissão.",
},
row: {
neverRun: "Nunca corri",
viewRuns: "Visualizações/Reproduções",
runNow: "Corra agora",
enable: "Ativar",
disable: "Desativar",
edit: "Editar",
delete: "Excluir",
},
modal: {
titleEdit: "Editar tarefa agendada",
titleNew: "Novo Trabalho Agendado",
nameLabel: "Nome",
namePlaceholder: "ex: Resumo diário de notícias",
promptLabel: "Solicitação",
promptPlaceholder: "A instrução para executar em cada execução...",
scheduleLabel: "Cronograma",
modeBuilder: "Construtor",
modeCustom: "Personalizado",
cronPlaceholder: "Expressão de cron (por exemplo, 0 9 * * *)",
currentSchedule: "Agenda atual:",
toolsLabel: "Ferramentas (Opcional)",
toolsDescription:
"Selecione quais ferramentas do agente podem ser utilizadas nesta tarefa. Se nenhuma ferramenta for selecionada, a tarefa será executada sem o uso de nenhuma ferramenta.",
toolsSearch: "Pesquisar",
toolsNoResults: "Não foram encontradas ferramentas correspondentes.",
required: "Requerido",
requiredFieldsBanner:
"Por favor, preencha todos os campos obrigatórios para criar o anúncio de emprego.",
cancel: "Cancelar",
saving: "Economizando...",
updateJob: "Atualizar Vaga",
createJob: "Criar Vaga",
jobUpdated: "Emprego atualizado",
jobCreated: "Emprego criado",
},
builder: {
fallbackWarning:
'Esta expressão não pode ser editada visualmente. Se desejar mantê-la, selecione "Personalizado". Caso contrário, altere qualquer um dos campos abaixo para substituí-la.',
run: "Correr",
frequency: {
minute: "a cada minuto",
hour: "por hora",
day: "diário",
week: "semanal",
month: "mensal",
},
every: "Cada",
minuteOne: "1 minuto",
minuteOther: "{{count}} minutos",
atMinute: "Em minuto",
pastEveryHour: "a cada hora",
at: "Em",
on: "Sobre",
onDay: "Em um dia",
ofEveryMonth: "de cada mês",
weekdays: {
sun: "Sol",
mon: "Individual",
tue: "Terça-feira",
wed: "Quarta-feira",
thu: "Quinta-feira",
fri: "Dia de sexta-feira",
sat: "Sábado",
},
},
runHistory: {
back: "Voltar para as vagas",
title: "Histórico de Execuções: {{name}}",
schedule: "Horário:",
emptyTitle: "Ainda não houve progresso nesta tarefa.",
emptySubtitle: "Execute a tarefa agora e visualize os resultados.",
runNow: "Comece agora",
table: {
status: "Estado",
started: "Começou",
duration: "Duração",
error: "Erro",
},
stopJob: "Interromper o emprego",
},
runDetail: {
loading: "Carregando detalhes da execução...",
notFound: "Não foi encontrado nenhum resultado.",
back: "Retorno",
unknownJob: "Cargo não especificado",
runHeading: "{{name}} — Executar a tarefa #{{id}}",
duration: "Duração: {{value}}",
continueInThread: "Continue na discussão",
creating: "Criando...",
threadFailed: "Falhou ao criar a thread.",
sections: {
prompt: "Solicitação",
error: "Erro",
thinking: "Pensamentos ({{count}})",
toolCalls: "Chamadas de ferramentas ({{count}})",
files: "Arquivos ({{count}})",
response: "Resposta",
metrics: "Métricas",
},
metrics: {
promptTokens: "Palavras-chave de gatilho:",
completionTokens: "Tokens de conclusão:",
},
stopJob: "Interromper o emprego",
killing: "Parar...",
},
toolCall: {
arguments: "Argumentos:",
showResult: "Exibir resultado",
hideResult: "Esconder resultado",
},
file: {
unknown: "Arquivo desconhecido",
download: "Baixar",
downloadFailed: "Falha ao baixar o arquivo",
types: {
powerpoint: "PowerPoint",
pdf: "Documento em formato PDF",
word: "Documento em formato Word",
spreadsheet: "Planilha",
generic: "Arquivo",
},
},
status: {
completed: "Concluído",
failed: "Falhou",
timed_out: "Tempo esgotado",
running: "Corrida",
queued: "Em espera",
},
},
};
export default TRANSLATIONS;

View File

@ -109,6 +109,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Sarcini programate",
},
login: {
"multi-user": {
@ -1530,6 +1531,167 @@ const TRANSLATIONS = {
unknown: "Necunoscut",
},
},
scheduledJobs: {
title: "Sarcini programate",
enableNotifications:
"Activați notificările din browser pentru rezultatele căutării de locuri de muncă.",
description:
"Creați sarcini AI repetitive, care rulează conform unui program. Fiecare sarcină execută un prompt, folosind opțional anumite instrumente, și salvează rezultatul pentru a fi revizuit ulterior.",
newJob: "Loc de muncă nou",
loading: "Se încarcă...",
emptyTitle: "Momentan, nu există sarcini programate.",
emptySubtitle: "Creați unul pentru a începe.",
table: {
name: "Nume",
schedule: "Program",
status: "Stare",
lastRun: "Ultima cursă",
nextRun: "Următoarea cursă",
actions: "Acțiuni",
},
confirmDelete:
"Sunteți sigur că doriți să eliminați această sarcină programată?",
toast: {
deleted: "Locul de muncă a fost șters",
triggered: "Job-ul a fost executat cu succes.",
triggerFailed: "Nu a reușit să declanșeze execuția.",
triggerSkipped:
"Procesul de licitație pentru acest proiect este deja în desfășurare.",
killed: "Procesul de angajare s-a încheiat cu succes.",
killFailed: "Nu am reușit să opresc activitatea.",
},
row: {
neverRun: "Nu alerga",
viewRuns: "Rute disponibile",
runNow: "Începeți acum",
enable: "Activează",
disable: "Dezactivează",
edit: "Editează",
delete: "Șterge",
},
modal: {
titleEdit: "Modifică programarea unei sarcini",
titleNew: "Job nou programat",
nameLabel: "Nume",
namePlaceholder: "de exemplu, „Rezumatul zilnic de știri”",
promptLabel: "Solicitare",
promptPlaceholder: "Instrucțiunea de a rula la fiecare execuție...",
scheduleLabel: "Program",
modeBuilder: "Constructor",
modeCustom: "Personalizat",
cronPlaceholder: "Expresia cron (de exemplu, 0 9 * * *)",
currentSchedule: "Programul actual:",
toolsLabel: "Unelte (opționale)",
toolsDescription:
"Selectați instrumentele disponibile pentru acest job. Dacă niciun instrument nu este selectat, job-ul va rula fără a utiliza niciun instrument.",
toolsSearch: "Caută",
toolsNoResults: "Nu există unelte potrivite.",
required: "Necesare",
requiredFieldsBanner:
"Vă rugăm să completați toate câmpurile obligatorii pentru a crea o ofertă de loc de muncă.",
cancel: "Anula",
saving: "Economisire...",
updateJob: "Actualizare post",
createJob: "Creați o nouă poziție",
jobUpdated: "Postul a fost actualizat",
jobCreated: "Loc de muncă creat",
},
builder: {
fallbackWarning:
'Această expresie nu poate fi modificată vizual. Selectați "Personalizat" pentru a o păstra, sau modificați orice element de mai jos pentru a o suprascrie.',
run: "Alătura",
frequency: {
minute: "în fiecare minut",
hour: "pe oră",
day: "zilnic",
week: "săptămânal",
month: "lunar",
},
every: "Fiecare",
minuteOne: "1 minut",
minuteOther: "{{count}} minute(s)",
atMinute: "La minut",
pastEveryHour: "în fiecare oră",
at: "La",
on: "În",
onDay: "Într-o zi",
ofEveryMonth: "pentru fiecare lună",
weekdays: {
sun: "Soare",
mon: "O singură",
tue: "Marți",
wed: "Miercuri",
thu: "Joi",
fri: "Ziua de vineri",
sat: "Sat",
},
},
runHistory: {
back: "Înapoi la anunțuri de angajare",
title: "Istoric de rulare: {{name}}",
schedule: "Program:",
emptyTitle: "Nu am obținut încă rezultate pentru acest proiect.",
emptySubtitle: "Executați sarcina acum și verificați rezultatele.",
runNow: "Începeți acum",
table: {
status: "Stare",
started: "A început",
duration: "Durată",
error: "Eroare",
},
stopJob: "Întrerupeți activitatea",
},
runDetail: {
loading: "Încărcare detalii despre rulare...",
notFound: "Nu s-a găsit.",
back: "Înapoi",
unknownJob: "Loc de muncă necunoscut",
runHeading: "{{name}} — Executarea #{{id}}",
duration: "Durată: {{value}}",
continueInThread: "Continuă în acest thread",
creating: "Crearea...",
threadFailed: "Nu a reușit să creeze thread-ul.",
sections: {
prompt: "Solicitare",
error: "Eroare",
thinking: "Gânduri ({{count}})",
toolCalls: "Apeluri către instrumente ({{count}})",
files: "Fișiere ({{count}})",
response: "Răspuns",
metrics: "Indicatori",
},
metrics: {
promptTokens: "Cuvinte-cheie:",
completionTokens: "Token-uri de finalizare:",
},
stopJob: "Încetarea activității",
killing: "Oprire...",
},
toolCall: {
arguments: "Argumente:",
showResult: "Afișează rezultatul",
hideResult: "Ascunde rezultatul",
},
file: {
unknown: "Fișier necunoscut",
download: "Descarcă",
downloadFailed: "Nu a reușit să descarce fișierul",
types: {
powerpoint: "PowerPoint",
pdf: "Fișier PDF",
word: "Fișier Word",
spreadsheet: "Fișă de calcul",
generic: "Fișier",
},
},
status: {
completed: "Finalizat",
failed: "Eșuat",
timed_out: "Timpul a expirat",
running: "Cursa",
queued: "În așteptare",
},
},
};
export default TRANSLATIONS;

View File

@ -108,6 +108,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Телеграм",
},
"scheduled-jobs": "Запланированные задачи",
},
login: {
"multi-user": {
@ -1536,6 +1537,166 @@ const TRANSLATIONS = {
unknown: "Неизвестно",
},
},
scheduledJobs: {
title: "Запланированные задачи",
enableNotifications:
"Включите уведомления в браузере о результатах поиска работы",
description:
"Создавайте повторяющиеся задачи на основе искусственного интеллекта, которые будут выполняться по расписанию. Каждая задача включает в себя запрос, а также необязательные инструменты, и сохраняет результат для последующего просмотра.",
newJob: "Новая работа",
loading: "Загрузка...",
emptyTitle: "В настоящее время нет запланированных задач.",
emptySubtitle: "Создайте один, чтобы начать.",
table: {
name: "Имя",
schedule: "График",
status: "Статус",
lastRun: "Последний запуск",
nextRun: "Следующая тренировка",
actions: "Действия",
},
confirmDelete: "Вы уверены, что хотите удалить эту запланированную задачу?",
toast: {
deleted: "Вакансия удалена",
triggered: "Задача успешно выполнена.",
triggerFailed: "Не удалось запустить задачу.",
triggerSkipped: "Работа уже начата.",
killed: "Работа была успешно завершена.",
killFailed: "Не удалось остановить работу",
},
row: {
neverRun: "Никогда не бегите",
viewRuns: "Просмотры",
runNow: "Начните сейчас",
enable: "Включить",
disable: "Отключить",
edit: "Редактировать",
delete: "Удалить",
},
modal: {
titleEdit: "Изменить запланированную задачу",
titleNew: "Новая запланированная задача",
nameLabel: "Имя",
namePlaceholder: "Например, ежедневный дайджест новостей",
promptLabel: "Запрос",
promptPlaceholder: "Инструкция о выполнении каждой операции...",
scheduleLabel: "График",
modeBuilder: "Строитель",
modeCustom: "Индивидуальный",
cronPlaceholder: "Выражение Cron (например, 0 9 * * *)",
currentSchedule: "Текущий график:",
toolsLabel: "Инструменты (необязательно)",
toolsDescription:
"Выберите, какие инструменты могут использоваться для выполнения этой задачи. Если ни один инструмент не выбран, задача будет выполнена без использования каких-либо инструментов.",
toolsSearch: "Поиск",
toolsNoResults: "Не найдено подходящих инструментов.",
required: "Необходимо",
requiredFieldsBanner:
"Пожалуйста, заполните все обязательные поля, чтобы создать объявление о работе.",
cancel: "Отменить",
saving: "Сохранение...",
updateJob: "Обновить информацию о работе",
createJob: "Создать вакансию",
jobUpdated: "Информация о работе обновлена",
jobCreated: "Создана работа",
},
builder: {
fallbackWarning:
'Эта опция не может быть изменена визуально. Чтобы сохранить её, перейдите в режим "Настройка". В противном случае, вы можете изменить что-либо ниже, чтобы заменить её.',
run: "Бег",
frequency: {
minute: "каждую минуту",
hour: "почасовая",
day: "ежедневно",
week: "еженедельный",
month: "ежемесячный",
},
every: "Каждый",
minuteOne: "1 минута",
minuteOther: "{{count}} минут",
atMinute: "В определенный момент",
pastEveryHour: "каждый час",
at: "В",
on: "О",
onDay: "В один день",
ofEveryMonth: "каждого месяца",
weekdays: {
sun: "Солнце",
mon: "Понедельник",
tue: "Вторник",
wed: "Среда",
thu: "Четверг",
fri: "Пятница",
sat: "Суббота",
},
},
runHistory: {
back: "Вернуться к вакансиям",
title: "История выполнения: {{name}}",
schedule: "График:",
emptyTitle:
"На данный момент никаких результатов или успехов в этом проекте.",
emptySubtitle: "Запустите задание сейчас и просмотрите его результаты.",
runNow: "Начните сейчас",
table: {
status: "Статус",
started: "Началось",
duration: "Продолжительность",
error: "Ошибка",
},
stopJob: "Прекратить работу",
},
runDetail: {
loading: "Загрузка информации о ходе выполнения...",
notFound: "Не найдено.",
back: "Назад",
unknownJob: "Неизвестная должность",
runHeading: "{{name}} — Запуск №{{id}}",
duration: "Продолжительность: {{value}}",
continueInThread: "Продолжить обсуждение в этой теме",
creating: "Создание...",
threadFailed: "Не удалось создать нить.",
sections: {
prompt: "Инструкция",
error: "Ошибка",
thinking: "Мысли ({{count}})",
toolCalls: "Вызовы инструментов ({{count}})",
files: "Файлы ({{count}})",
response: "Ответ",
metrics: "Показатели",
},
metrics: {
promptTokens: "Ключевые слова:",
completionTokens: "Токены завершения:",
},
stopJob: "Прекратить работу",
killing: "Остановка...",
},
toolCall: {
arguments: "Аргументы:",
showResult: "Отобразить результат",
hideResult: "Скрыть результат",
},
file: {
unknown: "Неизвестный файл",
download: "Скачать",
downloadFailed: "Не удалось скачать файл",
types: {
powerpoint: "Презентация PowerPoint",
pdf: "Документ в формате PDF",
word: "Текстовый документ",
spreadsheet: "Таблица (в электронных таблицах)",
generic: "Файл",
},
},
status: {
completed: "Завершено",
failed: "Неудачный",
timed_out: "Время вышло",
running: "Бег",
queued: "В очереди",
},
},
};
export default TRANSLATIONS;

View File

@ -109,6 +109,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Planlanan İşler",
},
login: {
"multi-user": {
@ -1520,6 +1521,165 @@ const TRANSLATIONS = {
unknown: "Bilinmiyor",
},
},
scheduledJobs: {
title: "Planlanan İşler",
enableNotifications:
"İş ilanları sonuçları için tarayıcı bildirimlerini etkinleştirin.",
description:
"Tekrarlayan yapay zeka görevlerini, belirli bir zaman çizelgesine göre otomatik olarak çalıştırın. Her görev, isteğe bağlı araçlarla birlikte bir sorguyu çalıştırır ve sonuçları inceleme için kaydeder.",
newJob: "Yeni iş",
loading: "Yükleniyor...",
emptyTitle: "Şu anda planlanan herhangi bir iş yok.",
emptySubtitle: "Başlamak için bir tane oluşturun.",
table: {
name: "Ad",
schedule: "Program",
status: "Durum",
lastRun: "Son Çalışma",
nextRun: "Sonuç",
actions: "Eylemler",
},
confirmDelete: "Bu planlanan görevi silmekten emin misiniz?",
toast: {
deleted: "İş kayboldu",
triggered: "İş başarıyla başlatıldı.",
triggerFailed: "İşin başlatılması başarısız oldu.",
triggerSkipped: "Bu iş için zaten bir çalışma süreci başlamıştır.",
killed: "İş, başarıyla tamamlandı.",
killFailed: "İşten ayrılmayı başaramadı",
},
row: {
neverRun: "Asla hızlanmayın.",
viewRuns: "Çalışma seansları",
runNow: "Hemen harekete geçin",
enable: "Etkinleştir",
disable: "Devre dışı bırak",
edit: "Düzenle",
delete: "Sil",
},
modal: {
titleEdit: "Planlanan Görevi Düzenle",
titleNew: "Yeni Planlanan İş",
nameLabel: "Ad",
namePlaceholder: "Örneğin, Günlük Haber Özeti",
promptLabel: "Talep",
promptPlaceholder: "Her çalışmada çalıştırılma talimatı...",
scheduleLabel: "Program",
modeBuilder: "İnşaatçı",
modeCustom: "Özel",
cronPlaceholder: "Cron ifadesi (örneğin, 0 9 * * *)",
currentSchedule: "Mevcut program:",
toolsLabel: "Araçlar (İsteğe Bağlı)",
toolsDescription:
"Bu iş için kullanılabilen ajan araçlarını seçin. Eğer hiçbir araç seçilmezse, iş herhangi bir araç olmadan çalışacaktır.",
toolsSearch: "Arama",
toolsNoResults: "Hiçbir araç bulunamadı",
required: "Gereklidir",
requiredFieldsBanner:
"Lütfen iş ilanını oluşturmak için gerekli tüm alanları doldurunuz.",
cancel: "İptal et",
saving: "Kaydet...",
updateJob: "İş Tanımını Güncelle",
createJob: "İş Tanımı Oluştur",
jobUpdated: "İş pozisyonu güncellendi",
jobCreated: "İş pozisyonu oluşturuldu",
},
builder: {
fallbackWarning:
'Bu ifade görsel olarak düzenlenemez. Mevcut haliyle bırakmak için "Özel" seçeneğine geçin veya aşağıdaki herhangi bir alanı değiştirerek üzerine yazın.',
run: "Koş",
frequency: {
minute: "her dakika",
hour: "saatlik",
day: "günlük",
week: "haftalık",
month: "aylık",
},
every: "Her",
minuteOne: "1 dakika",
minuteOther: "{{count}} dakika",
atMinute: "Dakikada",
pastEveryHour: "geçmişte, her saat",
at: "Saat",
on: "On",
onDay: "Bir gün",
ofEveryMonth: "her ayın",
weekdays: {
sun: "Güneş",
mon: "Pazartesi",
tue: "Salı",
wed: "Salı",
thu: "Perşembe",
fri: "Cuma",
sat: "Satmak",
},
},
runHistory: {
back: "İş ilanlarına dön",
title: "Geçmiş Çalışmalar: {{name}}",
schedule: "Program:",
emptyTitle: "Bu iş için henüz herhangi bir ilerleme kaydedilmedi.",
emptySubtitle: "Şimdi işlemi başlatın ve sonuçlarını görüntüleyin.",
runNow: "Şimdi koşun",
table: {
status: "Durum",
started: "Başlangıç",
duration: "Süre",
error: "Hata",
},
stopJob: "İşten ayrıl",
},
runDetail: {
loading: "Yükleme işleminin ayrıntıları yükleniyor...",
notFound: "İstenen komut bulunamadı.",
back: "Geri",
unknownJob: "Bilinmeyen İş",
runHeading: "{{name}} — Çalışma #{{id}}",
duration: "Süre: {{value}}",
continueInThread: "İlgili başlıkta devam et",
creating: "Yaratmak...",
threadFailed: "İşlem başlatma başarısız oldu.",
sections: {
prompt: "Uyarı",
error: "Hata",
thinking: "Düşünceler ({{count}})",
toolCalls: "Araç Çağrıları ({{count}})",
files: "Dosyalar ({{count}})",
response: "Cevap",
metrics: "Ölçüm değerleri",
},
metrics: {
promptTokens: "Başlangıç belirteçleri:",
completionTokens: "Tamamlanmış token'lar:",
},
stopJob: "İşten Çık",
killing: "Dur...",
},
toolCall: {
arguments: "Tartışmalar:",
showResult: "Sonuçları göster",
hideResult: "Sonucu gizle",
},
file: {
unknown: "Bilinmeyen dosya",
download: "İndir",
downloadFailed: "Dosya indirme işlemi başarısız oldu",
types: {
powerpoint: "PowerPoint",
pdf: "PDF belgesi",
word: "Kelime Belgesi",
spreadsheet: "Tablo",
generic: "Dosya",
},
},
status: {
completed: "Tamamlandı",
failed: "Başarısız",
timed_out: "Zaman aşımı",
running: "Koşmak",
queued: "Bekleme halinde",
},
},
};
export default TRANSLATIONS;

View File

@ -109,6 +109,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "Telegram",
},
"scheduled-jobs": "Công việc theo lịch trình",
},
login: {
"multi-user": {
@ -1506,6 +1507,165 @@ const TRANSLATIONS = {
unknown: "Không xác định",
},
},
scheduledJobs: {
title: "Công việc theo lịch trình",
enableNotifications:
"Kích hoạt thông báo trình duyệt để nhận kết quả tìm kiếm việc làm",
description:
"Tạo các tác vụ AI lặp đi lặp lại, chạy theo lịch trình. Mỗi tác vụ sẽ thực hiện một yêu cầu với các công cụ tùy chọn và lưu kết quả để xem xét.",
newJob: "Vị trí công việc mới",
loading: "Đang tải...",
emptyTitle: "Hiện chưa có công việc nào được lên lịch.",
emptySubtitle: "Tạo một cái để bắt đầu.",
table: {
name: "Tên",
schedule: "Lịch trình",
status: "Trạng thái",
lastRun: "Lần chạy cuối",
nextRun: "Chuyến đi tiếp theo",
actions: "Hành động",
},
confirmDelete: "Bạn có chắc chắn muốn xóa công việc này?",
toast: {
deleted: "Việc xóa công việc",
triggered: "Việc tìm kiếm việc làm đã thành công.",
triggerFailed: "Không thể kích hoạt công việc",
triggerSkipped: "Công việc này đã bắt đầu triển khai.",
killed: "Việc làm đã được hoàn thành thành công.",
killFailed: "Không thể ngăn chặn việc chấm dứt hợp đồng",
},
row: {
neverRun: "Không bao giờ chạy",
viewRuns: "Xem theo các lượt",
runNow: "Hãy bắt đầu ngay bây giờ.",
enable: "Kích hoạt",
disable: "Tắt",
edit: "Chỉnh sửa",
delete: "Xóa",
},
modal: {
titleEdit: "Chỉnh sửa công việc theo lịch",
titleNew: "Công việc mới theo lịch trình",
nameLabel: "Tên",
namePlaceholder: "Ví dụ: Tóm tắt tin tức hàng ngày",
promptLabel: "Yêu cầu",
promptPlaceholder: "Hướng dẫn để chạy trong mỗi lần thực hiện...",
scheduleLabel: "Lịch trình",
modeBuilder: "Nhà xây dựng",
modeCustom: "Tùy chỉnh",
cronPlaceholder: "Biểu thức Cron (ví dụ: 0 9 * * *)",
currentSchedule: "Lịch trình hiện tại:",
toolsLabel: "Dụng cụ (Tùy chọn)",
toolsDescription:
"Chọn các công cụ hỗ trợ mà công việc này có thể sử dụng. Nếu không chọn công cụ nào, công việc sẽ chạy mà không sử dụng bất kỳ công cụ nào.",
toolsSearch: "Tìm kiếm",
toolsNoResults: "Không có công cụ nào phù hợp",
required: "Yêu cầu",
requiredFieldsBanner:
"Vui lòng điền đầy đủ các trường thông tin bắt buộc để tạo công việc.",
cancel: "Hủy",
saving: "Tiết kiệm...",
updateJob: "Cập nhật công việc",
createJob: "Tạo công việc",
jobUpdated: "Thông tin công việc đã được cập nhật",
jobCreated: "Vị trí được tạo",
},
builder: {
fallbackWarning:
'Biểu thức này không thể chỉnh sửa trực quan. Để giữ nguyên, hãy chuyển sang chế độ "Tùy chỉnh". Hoặc, bạn có thể thay đổi bất kỳ nội dung nào bên dưới để ghi đè lên biểu thức này.',
run: "Chạy",
frequency: {
minute: "mỗi phút",
hour: "theo giờ",
day: "hàng ngày",
week: "hàng tuần",
month: "hàng tháng",
},
every: "Mỗi",
minuteOne: "1 phút",
minuteOther: "{{count}} phút",
atMinute: "Tại phút",
pastEveryHour: "hàng giờ",
at: "Tại",
on: "Về",
onDay: "Trong ngày",
ofEveryMonth: "của mỗi tháng",
weekdays: {
sun: "Mặt trời",
mon: "Thứ hai",
tue: "Thứ hai",
wed: "Thứ Ba",
thu: "Tháng",
fri: "Thứ Sáu",
sat: "Thứ Sáu",
},
},
runHistory: {
back: "Quay lại tìm việc",
title: "Lịch sử hoạt động: {{name}}",
schedule: "Lịch trình:",
emptyTitle: "Hiện tại, công việc này chưa có kết quả cụ thể.",
emptySubtitle: "Thực hiện công việc ngay bây giờ và xem kết quả.",
runNow: "Hãy bắt đầu ngay bây giờ.",
table: {
status: "Trạng thái",
started: "Bắt đầu",
duration: "Thời gian",
error: "Lỗi",
},
stopJob: "Ngừng làm",
},
runDetail: {
loading: "Hiển thị chi tiết chạy...",
notFound: "Không tìm thấy lệnh.",
back: "Quay lại",
unknownJob: "Vị trí công việc chưa được xác định",
runHeading: "{{name}} — Chạy lệnh #{{id}}",
duration: "Thời gian: {{value}}",
continueInThread: "Tiếp tục thảo luận trong chủ đề này",
creating: "Tạo ra...",
threadFailed: "Không thể tạo ra luồng (thread).",
sections: {
prompt: "Yêu cầu",
error: "Lỗi",
thinking: "Ý kiến ({{count}})",
toolCalls: "Gọi công cụ ({{count}})",
files: "Tệp tin ({{count}})",
response: "Phản hồi",
metrics: "Các chỉ số",
},
metrics: {
promptTokens: "Từ gợi ý:",
completionTokens: "Các token hoàn thành:",
},
stopJob: "Dừng việc",
killing: "Dừng lại...",
},
toolCall: {
arguments: "Các lập luận:",
showResult: "Hiển thị kết quả",
hideResult: "Ẩn kết quả",
},
file: {
unknown: "Tệp không xác định",
download: "Tải xuống",
downloadFailed: "Không thể tải xuống tệp",
types: {
powerpoint: "Trình chiếu PowerPoint",
pdf: "Tài liệu PDF",
word: "Tệp Word",
spreadsheet: "Bảng tính",
generic: "Tệp",
},
},
status: {
completed: "Hoàn thành",
failed: "Thất bại",
timed_out: "Thời gian đã hết",
running: "Chạy bộ",
queued: "Đang chờ",
},
},
};
export default TRANSLATIONS;

View File

@ -105,6 +105,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "电报",
},
"scheduled-jobs": "计划好的任务",
},
login: {
"multi-user": {
@ -1409,6 +1410,163 @@ const TRANSLATIONS = {
unknown: "未知",
},
},
scheduledJobs: {
title: "计划好的任务",
enableNotifications: "启用浏览器通知,以便及时获取招聘结果",
description:
"创建可重复执行的 AI 任务,并设置执行时间表。每个任务会执行一个提示,并可以选择使用辅助工具,然后保存结果供后续审查。",
newJob: "新工作",
loading: "正在加载...",
emptyTitle: "目前没有计划好的任务。",
emptySubtitle: "创建一个,开始吧。",
table: {
name: "姓名",
schedule: "时间表",
status: "状态",
lastRun: "最后一次",
nextRun: "下一次尝试",
actions: "行动",
},
confirmDelete: "您确定要删除这个已计划的任务吗?",
toast: {
deleted: "已删除工作",
triggered: "工作已成功启动",
triggerFailed: "未能启动任务",
triggerSkipped: "目前,这项工作已经开始进行中。",
killed: "工作已成功停止。",
killFailed: "未能阻止工作",
},
row: {
neverRun: "切勿奔跑",
viewRuns: "观看记录",
runNow: "现在就行动",
enable: "启用",
disable: "禁用",
edit: "编辑",
delete: "删除",
},
modal: {
titleEdit: "编辑计划任务",
titleNew: "新建任务",
nameLabel: "姓名",
namePlaceholder: "例如:每日新闻摘要",
promptLabel: "提示",
promptPlaceholder: "“在每次执行时执行以下指令…”",
scheduleLabel: "时间表",
modeBuilder: "建筑师",
modeCustom: "定制",
cronPlaceholder: "Cron 表达式例如0 9 * * *",
currentSchedule: "当前时间表:",
toolsLabel: "工具(可选)",
toolsDescription:
"选择此任务可以使用的任何代理工具。如果未选择任何工具,则任务将不会使用任何工具。",
toolsSearch: "搜索",
toolsNoResults: "没有合适的工具",
required: "必需",
requiredFieldsBanner: "请务必填写所有必填字段,以便创建职位。",
cancel: "取消",
saving: "节省...",
updateJob: "更新职位",
createJob: "创建工作",
jobUpdated: "工作信息已更新",
jobCreated: "创造了工作",
},
builder: {
fallbackWarning:
"这个表达式无法通过图形界面进行编辑。请选择“自定义”选项来保留它,或者修改下面的内容来覆盖它。",
run: "跑步",
frequency: {
minute: "每分钟",
hour: "每小时",
day: "每日",
week: "每周",
month: "每月",
},
every: "每一个",
minuteOne: "1 分钟",
minuteOther: "{{count}} 分钟",
atMinute: "在…分",
pastEveryHour: "过去每个小时",
at: "在",
on: "关于",
onDay: "在某一天",
ofEveryMonth: "每个月",
weekdays: {
sun: "太阳",
mon: "周一",
tue: "周二",
wed: "周三",
thu: "星期四",
fri: "周五",
sat: "星期六",
},
},
runHistory: {
back: "返回工作列表",
title: "运行历史:{{name}}",
schedule: "时间表:",
emptyTitle: "目前为止,这项工作还没有取得任何成果。",
emptySubtitle: "立即运行任务,并查看其结果。",
runNow: "立即行动",
table: {
status: "状态",
started: "开始",
duration: "时长",
error: "错误",
},
stopJob: "停止工作",
},
runDetail: {
loading: "正在加载运行详情...",
notFound: "未找到。",
back: "返回",
unknownJob: "未知的职位",
runHeading: "{{name}} — 运行 #{{id}}",
duration: "时长:{{value}}",
continueInThread: "继续参与该主题讨论",
creating: "创作...",
threadFailed: "未能创建线程",
sections: {
prompt: "提示",
error: "错误",
thinking: "想法 ({{count}})",
toolCalls: "工具调用 ({{count}})",
files: "文件 ({{count}})",
response: "回应",
metrics: "指标",
},
metrics: {
promptTokens: "提示词:",
completionTokens: "完成标记:",
},
stopJob: "停止工作",
killing: "停止...",
},
toolCall: {
arguments: "论点:",
showResult: "显示结果",
hideResult: "隐藏结果",
},
file: {
unknown: "未知的文件",
download: "下载",
downloadFailed: "未能下载文件",
types: {
powerpoint: "幻灯片",
pdf: "PDF 格式文档",
word: "文档",
spreadsheet: "电子表格",
generic: "文件",
},
},
status: {
completed: "已完成",
failed: "失败",
timed_out: "超时",
running: "跑步",
queued: "排队",
},
},
};
export default TRANSLATIONS;

View File

@ -105,6 +105,7 @@ const TRANSLATIONS = {
"available-channels": {
telegram: "電訊",
},
"scheduled-jobs": "預約排定的工作",
},
login: {
"multi-user": {
@ -1405,6 +1406,163 @@ const TRANSLATIONS = {
unknown: "未知的",
},
},
scheduledJobs: {
title: "預約排定的工作",
enableNotifications: "啟用瀏覽器通知,以便收到工作結果",
description:
"建立可重複執行的 AI 任務,並設定執行時間表。每個任務會執行一個指令,並可選地使用工具,然後將結果儲存以供後續檢閱。",
newJob: "新的工作",
loading: "載入中...",
emptyTitle: "目前沒有預約的任務",
emptySubtitle: "先從一個開始。",
table: {
name: "姓名",
schedule: "時間表",
status: "狀態",
lastRun: "最後一次",
nextRun: "下一次活動",
actions: "行動",
},
confirmDelete: "您確定要刪除這個預約的任務嗎?",
toast: {
deleted: "已刪除",
triggered: "工作已成功啟動",
triggerFailed: "未能觸發工作",
triggerSkipped: "目前,這個項目已經啟動。",
killed: "工作已成功停止",
killFailed: "未能成功終止工作",
},
row: {
neverRun: "請勿急於完成",
viewRuns: "觀看次數",
runNow: "現在就開始行動",
enable: "啟用",
disable: "停用",
edit: "編輯",
delete: "刪除",
},
modal: {
titleEdit: "編輯排程工作",
titleNew: "新的排程工作",
nameLabel: "姓名",
namePlaceholder: "例如:每日新聞摘要",
promptLabel: "提示",
promptPlaceholder: "指示內容為:在每次執行時執行...",
scheduleLabel: "時間表",
modeBuilder: "建築師",
modeCustom: "客製化",
cronPlaceholder: "Cron 運算式例如0 9 * * *",
currentSchedule: "目前時間表:",
toolsLabel: "工具(可選)",
toolsDescription:
"請選擇此工作可以使用的工具。如果沒有選擇任何工具,則工作將不使用任何工具。",
toolsSearch: "搜尋",
toolsNoResults: "沒有任何工具符合",
required: "必需",
requiredFieldsBanner: "請務必填寫所有必要欄位,以便建立工作。",
cancel: "取消",
saving: "儲存...",
updateJob: "更新工作",
createJob: "建立工作",
jobUpdated: "工作資訊已更新",
jobCreated: "已創建工作",
},
builder: {
fallbackWarning:
"這個表達方式無法透過視覺編輯來修改。請選擇「自訂」選項來保留它,或修改以下內容以覆蓋它。",
run: "跑步",
frequency: {
minute: "每分鐘",
hour: "每小時",
day: "每天",
week: "每週",
month: "每月",
},
every: "每一個",
minuteOne: "1 分鐘",
minuteOther: "{{count}} 分鐘",
atMinute: "每分鐘",
pastEveryHour: "過去每小時",
at: "在",
on: "關於",
onDay: "在某一天",
ofEveryMonth: "每個月",
weekdays: {
sun: "太陽",
mon: "星期一",
tue: "週二",
wed: "星期三",
thu: "星期四",
fri: "週五",
sat: "星期六",
},
},
runHistory: {
back: "返回工作機會",
title: "運行歷史:{{name}}",
schedule: "時間表:",
emptyTitle: "目前這個工作尚未有任何進展。",
emptySubtitle: "現在執行此任務,並查看結果。",
runNow: "立即行動",
table: {
status: "狀態",
started: "開始",
duration: "時間",
error: "錯誤",
},
stopJob: "停止工作",
},
runDetail: {
loading: "載入執行記錄詳細...",
notFound: "未找到程式。",
back: "返回",
unknownJob: "未知的職位",
runHeading: "{{name}} — 執行 #{{id}}",
duration: "時間:{{value}}",
continueInThread: "繼續追蹤此主題",
creating: "創作...",
threadFailed: "未能建立執行緒",
sections: {
prompt: "提示",
error: "錯誤",
thinking: "想法 ({{count}})",
toolCalls: "工具呼叫 ({{count}})",
files: "檔案 ({{count}})",
response: "回覆",
metrics: "指標",
},
metrics: {
promptTokens: "提示詞:",
completionTokens: "完成標記:",
},
stopJob: "停止工作",
killing: "停止...",
},
toolCall: {
arguments: "論點:",
showResult: "顯示結果",
hideResult: "隱藏結果",
},
file: {
unknown: "未知的檔案",
download: "下載",
downloadFailed: "無法下載檔案",
types: {
powerpoint: "PowerPoint",
pdf: "PDF 文件",
word: "Word 檔案",
spreadsheet: "電子表格",
generic: "檔案",
},
},
status: {
completed: "已完成",
failed: "失敗",
timed_out: "時間到",
running: "跑步",
queued: "排隊",
},
},
};
export default TRANSLATIONS;

View File

@ -5,6 +5,7 @@ import App from "@/App.jsx";
import PrivateRoute, {
AdminRoute,
ManagerRoute,
SingleUserRoute,
} from "@/components/PrivateRoute";
import Login from "@/pages/Login";
import SimpleSSOPassthrough from "@/pages/Login/SSO/simple";
@ -381,6 +382,35 @@ const router = createBrowserRouter([
return { element: <AdminRoute Component={TelegramBotSettings} /> };
},
},
{
path: "/settings/scheduled-jobs",
lazy: async () => {
const { default: ScheduledJobs } = await import(
"@/pages/GeneralSettings/ScheduledJobs"
);
return { element: <SingleUserRoute Component={ScheduledJobs} /> };
},
},
{
path: "/settings/scheduled-jobs/:id/runs",
lazy: async () => {
const { default: ScheduledJobRuns } = await import(
"@/pages/GeneralSettings/ScheduledJobs/RunHistoryPage"
);
return { element: <SingleUserRoute Component={ScheduledJobRuns} /> };
},
},
{
path: "/settings/scheduled-jobs/:id/runs/:runId",
lazy: async () => {
const { default: ScheduledJobRunDetail } = await import(
"@/pages/GeneralSettings/ScheduledJobs/RunDetailPage"
);
return {
element: <SingleUserRoute Component={ScheduledJobRunDetail} />,
};
},
},
// Catch-all route for 404s
{
path: "*",

View File

@ -0,0 +1,127 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";
const ScheduledJobs = {
list: async function () {
return await fetch(`${API_BASE}/scheduled-jobs`, {
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ jobs: [] }));
},
create: async function (data) {
return await fetch(`${API_BASE}/scheduled-jobs/new`, {
method: "POST",
headers: baseHeaders(),
body: JSON.stringify(data),
})
.then((res) => res.json())
.catch(() => ({ job: null, error: "Failed to create scheduled job" }));
},
get: async function (id) {
return await fetch(`${API_BASE}/scheduled-jobs/${id}`, {
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ job: null }));
},
update: async function (id, data) {
return await fetch(`${API_BASE}/scheduled-jobs/${id}`, {
method: "PUT",
headers: baseHeaders(),
body: JSON.stringify(data),
})
.then((res) => res.json())
.catch((e) => ({
job: null,
error: e.message,
}));
},
delete: async function (id) {
return await fetch(`${API_BASE}/scheduled-jobs/${id}`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ success: false }));
},
toggle: async function (id) {
return await fetch(`${API_BASE}/scheduled-jobs/${id}/toggle`, {
method: "POST",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ job: null }));
},
trigger: async function (id) {
return await fetch(`${API_BASE}/scheduled-jobs/${id}/trigger`, {
method: "POST",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => ({ success: false, error: e.message }));
},
runs: async function (id) {
return await fetch(`${API_BASE}/scheduled-jobs/${id}/runs`, {
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ runs: [] }));
},
getRun: async function (runId) {
return await fetch(`${API_BASE}/scheduled-jobs/runs/${runId}`, {
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ run: null, job: null }));
},
markRunRead: async function (runId) {
return await fetch(`${API_BASE}/scheduled-jobs/runs/${runId}/read`, {
method: "POST",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ success: false }));
},
continueInThread: async function (runId) {
return await fetch(`${API_BASE}/scheduled-jobs/runs/${runId}/continue`, {
method: "POST",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => ({
workspaceSlug: null,
threadSlug: null,
error: e.message,
}));
},
availableTools: async function () {
return await fetch(`${API_BASE}/scheduled-jobs/available-tools`, {
headers: baseHeaders(),
})
.then((res) => res.json())
.catch(() => ({ tools: [] }));
},
killRun: async function (runId) {
return await fetch(`${API_BASE}/scheduled-jobs/runs/${runId}/kill`, {
method: "POST",
headers: baseHeaders(),
})
.then((res) => res.json())
.catch((e) => ({ success: false, error: e.message }));
},
};
export default ScheduledJobs;

View File

@ -0,0 +1,196 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
parseCronToBuilderState,
buildCronFromBuilderState,
} from "../utils/cron";
const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30];
const MINUTES = Array.from({ length: 60 }, (_, i) => i);
const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => i + 1);
const pad2 = (n) => String(n).padStart(2, "0");
const inputClass =
"border-none bg-theme-settings-input-bg text-theme-text-primary text-sm rounded-lg focus:outline-primary-button outline-none p-2.5";
const labelClass = "text-sm text-theme-text-secondary";
// Visual cron builder. Maintains its own state derived from the incoming
// `value` on mount, and emits a fresh 5-field cron string via `onChange`
// whenever the user changes any sub-field.
export default function CronBuilder({ value, onChange }) {
const { t } = useTranslation();
const WEEKDAYS = [
{ value: 0, label: t("scheduledJobs.builder.weekdays.sun") },
{ value: 1, label: t("scheduledJobs.builder.weekdays.mon") },
{ value: 2, label: t("scheduledJobs.builder.weekdays.tue") },
{ value: 3, label: t("scheduledJobs.builder.weekdays.wed") },
{ value: 4, label: t("scheduledJobs.builder.weekdays.thu") },
{ value: 5, label: t("scheduledJobs.builder.weekdays.fri") },
{ value: 6, label: t("scheduledJobs.builder.weekdays.sat") },
];
const initial = parseCronToBuilderState(value);
const [state, setState] = useState(initial.state);
const [wasFallback, setWasFallback] = useState(initial.wasFallback);
const update = (patch) => {
const next = { ...state, ...patch };
setState(next);
if (wasFallback) setWasFallback(false);
const cron = buildCronFromBuilderState(next);
if (cron !== value) onChange(cron);
};
return (
<div className="flex gap-3 p-3 bg-theme-settings-input-bg/40 rounded-lg">
{wasFallback && (
<p className="text-xs text-yellow-400">
{t("scheduledJobs.builder.fallbackWarning")}
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
<span className={labelClass}>{t("scheduledJobs.builder.run")}</span>
<select
value={state.frequency}
onChange={(e) => update({ frequency: e.target.value })}
className={inputClass}
>
<option value="minute">
{t("scheduledJobs.builder.frequency.minute")}
</option>
<option value="hour">
{t("scheduledJobs.builder.frequency.hour")}
</option>
<option value="day">
{t("scheduledJobs.builder.frequency.day")}
</option>
<option value="week">
{t("scheduledJobs.builder.frequency.week")}
</option>
<option value="month">
{t("scheduledJobs.builder.frequency.month")}
</option>
</select>
</div>
{state.frequency === "minute" && (
<div className="flex items-center gap-2 flex-wrap">
<span className={labelClass}>{t("scheduledJobs.builder.every")}</span>
<select
value={state.minuteInterval}
onChange={(e) =>
update({ minuteInterval: parseInt(e.target.value, 10) })
}
className={inputClass}
>
{MINUTE_INTERVALS.map((n) => (
<option key={n} value={n}>
{n === 1
? t("scheduledJobs.builder.minuteOne")
: t("scheduledJobs.builder.minuteOther", { count: n })}
</option>
))}
</select>
</div>
)}
{state.frequency === "hour" && (
<div className="flex items-center gap-2 flex-wrap">
<span className={labelClass}>
{t("scheduledJobs.builder.atMinute")}
</span>
<select
value={state.hourMinuteOffset}
onChange={(e) =>
update({ hourMinuteOffset: parseInt(e.target.value, 10) })
}
className={inputClass}
>
{MINUTES.map((n) => (
<option key={n} value={n}>
{pad2(n)}
</option>
))}
</select>
<span className={labelClass}>
{t("scheduledJobs.builder.pastEveryHour")}
</span>
</div>
)}
{(state.frequency === "day" ||
state.frequency === "week" ||
state.frequency === "month") && (
<div className="flex items-center gap-2 flex-wrap">
<span className={labelClass}>{t("scheduledJobs.builder.at")}</span>
<input
type="time"
value={`${pad2(state.hour)}:${pad2(state.minute)}`}
onChange={(e) => {
const [h, m] = e.target.value.split(":");
update({
hour: parseInt(h, 10) || 0,
minute: parseInt(m, 10) || 0,
});
}}
className={`${inputClass} [color-scheme:dark] light:[color-scheme:light]`}
/>
</div>
)}
{state.frequency === "week" && (
<div className="flex items-center gap-2 flex-wrap">
<span className={labelClass}>{t("scheduledJobs.builder.on")}</span>
<div className="flex gap-1 flex-wrap">
{WEEKDAYS.map((day) => {
const selected = state.weekdays.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => {
const next = selected
? state.weekdays.filter((d) => d !== day.value)
: [...state.weekdays, day.value];
update({ weekdays: next.length ? next : [day.value] });
}}
className={`border-none px-3 py-1 text-xs rounded-full transition-colors ${
selected
? "bg-zinc-50 text-zinc-950 light:bg-zinc-950 light:text-white"
: "bg-white/5 text-theme-text-secondary hover:bg-white/10 hover:text-theme-text-primary light:bg-slate-200 light:hover:bg-slate-300"
}`}
>
{day.label}
</button>
);
})}
</div>
</div>
)}
{state.frequency === "month" && (
<div className="flex items-center gap-2 flex-wrap">
<span className={labelClass}>{t("scheduledJobs.builder.onDay")}</span>
<select
value={state.dayOfMonth}
onChange={(e) =>
update({ dayOfMonth: parseInt(e.target.value, 10) })
}
className={inputClass}
>
{DAYS_OF_MONTH.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<span className={labelClass}>
{t("scheduledJobs.builder.ofEveryMonth")}
</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
export default function FormActions({ isEditing, saving, onClose }) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between pt-4">
<button
type="button"
onClick={onClose}
className="border-none h-[34px] px-3.5 text-sm font-medium text-zinc-50 light:text-slate-900 border border-zinc-700 light:border-slate-600 rounded-lg hover:bg-zinc-800 light:hover:bg-slate-100 transition-colors"
>
{t("scheduledJobs.modal.cancel")}
</button>
<button
type="submit"
disabled={saving}
className="border-none h-[34px] px-3.5 text-sm font-medium text-zinc-950 light:text-white bg-zinc-50 light:bg-slate-900 hover:bg-zinc-200 light:hover:bg-slate-800 rounded-lg transition-colors disabled:opacity-50"
>
{saving
? t("scheduledJobs.modal.saving")
: isEditing
? t("scheduledJobs.modal.updateJob")
: t("scheduledJobs.modal.createJob")}
</button>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
export default function JobDescription({ form, errors, onChange }) {
const { t } = useTranslation();
return (
<>
<div>
<label className="flex items-baseline gap-1.5 mb-2 text-sm font-medium text-theme-text-primary">
<span>
{t("scheduledJobs.modal.nameLabel")}{" "}
<span className="text-red-400">*</span>
</span>
{errors.name && (
<span className="text-red-400 italic font-normal">
{t("scheduledJobs.modal.required", "Required")}
</span>
)}
</label>
<input
type="text"
name="name"
value={form.name}
onChange={onChange}
placeholder={t("scheduledJobs.modal.namePlaceholder")}
className={`bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5 border ${
errors.name ? "border-red-300" : "border-transparent"
}`}
/>
</div>
<div>
<label className="flex items-baseline gap-1.5 mb-2 text-sm font-medium text-theme-text-primary">
<span>
{t("scheduledJobs.modal.promptLabel")}{" "}
<span className="text-red-400">*</span>
</span>
{errors.prompt && (
<span className="text-red-400 italic font-normal">
{t("scheduledJobs.modal.required", "Required")}
</span>
)}
</label>
<textarea
name="prompt"
value={form.prompt}
onChange={onChange}
placeholder={t("scheduledJobs.modal.promptPlaceholder")}
rows={4}
className={`bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5 resize-y border ${
errors.prompt ? "border-red-300" : "border-transparent"
}`}
/>
</div>
</>
);
}

View File

@ -0,0 +1,89 @@
import { useTranslation } from "react-i18next";
import CronBuilder from "./CronBuilder";
import { humanizeCron } from "../utils/cron";
export default function JobSchedule({
schedule,
scheduleMode,
error,
onScheduleChange,
onModeChange,
}) {
const { t, i18n } = useTranslation();
const handleInputChange = (e) => {
onScheduleChange(e.target.value);
};
return (
<div>
<label className="flex items-baseline gap-1.5 mb-2 text-sm font-medium text-theme-text-primary">
<span>
{t("scheduledJobs.modal.scheduleLabel")}{" "}
<span className="text-red-400">*</span>
</span>
{error && (
<span className="text-red-400 italic font-normal">
{t("scheduledJobs.modal.required", "Required")}
</span>
)}
</label>
<div className="flex gap-1 mb-2 p-1 bg-theme-settings-input-bg rounded-lg w-fit">
{[
{
value: "builder",
label: t("scheduledJobs.modal.modeBuilder"),
},
{ value: "custom", label: t("scheduledJobs.modal.modeCustom") },
].map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => onModeChange(tab.value)}
className={`border-none px-3 py-1 text-xs rounded-md transition-colors ${
scheduleMode === tab.value
? "bg-zinc-50 text-zinc-950 light:bg-zinc-950 light:text-white"
: "text-theme-text-secondary hover:text-theme-text-primary"
}`}
>
{tab.label}
</button>
))}
</div>
{scheduleMode === "builder" && (
<div
className={`rounded-lg border ${
error ? "border-red-300" : "border-transparent"
}`}
>
<CronBuilder value={schedule} onChange={onScheduleChange} />
</div>
)}
{scheduleMode === "custom" && (
<input
type="text"
name="schedule"
value={schedule}
onChange={handleInputChange}
placeholder={t("scheduledJobs.modal.cronPlaceholder")}
className={`bg-theme-settings-input-bg text-theme-text-primary placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button outline-none block w-full p-2.5 border ${
error ? "border-red-300" : "border-transparent"
}`}
/>
)}
<p className="text-xs text-theme-text-secondary mt-2">
{t("scheduledJobs.modal.currentSchedule")}{" "}
<code className="text-theme-text-primary">{schedule}</code>
{schedule && (
<span className="ml-2">
{humanizeCron(schedule, i18n.language)}
</span>
)}
</p>
</div>
);
}

View File

@ -0,0 +1,352 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
CaretDown,
CaretRight,
Check,
MagnifyingGlass,
Minus,
Warning,
X,
} from "@phosphor-icons/react";
import { useTranslation } from "react-i18next";
import paths from "@/utils/paths";
/**
* Build a flat lookup map from categorized tools for quick label resolution.
* @param {Array} categories - Categorized tools from backend
* @returns {Map<string, { name: string, active: boolean, description?: string }>}
*/
function buildToolLookup(categories) {
const lookup = new Map();
for (const cat of categories) {
for (const item of cat.items || []) {
lookup.set(item.id, item);
}
}
return lookup;
}
function Checkbox({ state, disabled = false }) {
const filled = state === "checked" || state === "indeterminate";
return (
<span
aria-hidden="true"
className={`flex items-center justify-center size-4 rounded border shrink-0 transition-colors ${
disabled
? "bg-zinc-700 border-zinc-600 opacity-50"
: filled
? "bg-sky-500 border-sky-700 light:bg-sky-600 light:border-sky-800"
: "bg-zinc-800 border-zinc-600 light:bg-white light:border-zinc-600"
}`}
>
{state === "checked" && (
<Check
size={12}
weight="bold"
className="text-white light:text-white"
/>
)}
{state === "indeterminate" && (
<Minus
size={12}
weight="bold"
className="text-white light:text-white"
/>
)}
</span>
);
}
export default function ToolsSelector({
availableTools,
selectedTools,
onChange,
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState("bottom");
const [expandedCategories, setExpandedCategories] = useState({});
const containerRef = useRef(null);
useEffect(() => {
if (!open) return;
const onClick = (e) => {
if (!containerRef.current?.contains(e.target)) setOpen(false);
};
const onKey = (e) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
useEffect(() => {
if (!open) return;
const POPOVER_MAX_HEIGHT = 320;
const updatePlacement = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
setPlacement(
spaceBelow < POPOVER_MAX_HEIGHT && spaceAbove > spaceBelow
? "top"
: "bottom"
);
};
updatePlacement();
window.addEventListener("resize", updatePlacement);
window.addEventListener("scroll", updatePlacement, true);
return () => {
window.removeEventListener("resize", updatePlacement);
window.removeEventListener("scroll", updatePlacement, true);
};
}, [open]);
const toolLookup = useMemo(
() => buildToolLookup(availableTools),
[availableTools]
);
const filteredCategories = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return availableTools;
return availableTools
.map((cat) => {
if (cat.name.toLowerCase().includes(q)) return cat;
const items = (cat.items || []).filter(
(item) =>
item.name.toLowerCase().includes(q) ||
item.description?.toLowerCase().includes(q)
);
return items.length ? { ...cat, items } : null;
})
.filter(Boolean);
}, [availableTools, search]);
const isCategoryExpanded = (catId) =>
search.trim() || expandedCategories[catId];
const toggleCategory = (catId) => {
setExpandedCategories((prev) => ({ ...prev, [catId]: !prev[catId] }));
};
const categoryState = (cat) => {
const ids = (cat.items || []).map((i) => i.id);
const count = ids.filter((id) => selectedTools.includes(id)).length;
if (count === 0) return "unchecked";
if (count === ids.length) return "checked";
return "indeterminate";
};
const toggleCategorySelection = (cat, e) => {
e.stopPropagation();
const ids = (cat.items || []).map((i) => i.id);
if (categoryState(cat) === "checked") {
onChange(selectedTools.filter((id) => !ids.includes(id)));
} else {
onChange([...new Set([...selectedTools, ...ids])]);
}
};
const toggleTool = (id) => {
onChange(
selectedTools.includes(id)
? selectedTools.filter((x) => x !== id)
: [...selectedTools, id]
);
};
const removeTool = (id) => onChange(selectedTools.filter((x) => x !== id));
const labelFor = (id) => toolLookup.get(id)?.name || id;
return (
<div>
<label className="block text-sm font-medium text-zinc-50 light:text-slate-700">
{t("scheduledJobs.modal.toolsLabel", "Tools (Optional)")}
</label>
<p className="text-xs text-zinc-400 light:text-slate-600 mt-1 mb-3">
{t(
"scheduledJobs.modal.toolsDescription",
"Select which agent tools this job can use. If none are selected, the job runs without any tools."
)}
</p>
<div className="relative" ref={containerRef}>
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
placeholder={t("scheduledJobs.modal.toolsSearch", "Search tools")}
className="border border-transparent light:border-slate-300 bg-zinc-800 light:bg-white text-zinc-300 light:text-slate-700 placeholder:text-zinc-400 light:placeholder:text-slate-500 text-sm rounded-lg focus:outline-sky-500 outline-none block w-full px-3.5 py-2.5 pr-9"
/>
<MagnifyingGlass
size={16}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 light:text-slate-500 pointer-events-none"
/>
</div>
{open && (
<div
className={`absolute left-0 right-0 z-20 max-h-80 overflow-y-auto p-1.5 bg-zinc-800 light:bg-white rounded-lg flex flex-col shadow-[0px_4px_12px_0px_rgba(0,0,0,0.35)] border border-zinc-700 light:border-slate-300 ${
placement === "top" ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
{filteredCategories.length === 0 ? (
<p className="text-xs text-zinc-400 light:text-slate-500 px-2 py-3 text-center">
{t("scheduledJobs.modal.toolsNoResults", "No tools match")}
</p>
) : (
filteredCategories.map((cat) => {
const expanded = isCategoryExpanded(cat.category);
const state = categoryState(cat);
const enabledCount = (cat.items || []).filter((i) =>
selectedTools.includes(i.id)
).length;
return (
<div key={cat.category} className="mb-0.5">
<button
type="button"
onClick={() => toggleCategory(cat.category)}
className="border-none flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium text-zinc-50 light:text-slate-700 bg-zinc-700/50 light:bg-slate-100 hover:bg-zinc-700 light:hover:bg-slate-200 transition-colors"
>
{expanded ? (
<CaretDown size={12} weight="bold" />
) : (
<CaretRight size={12} weight="bold" />
)}
<span className="flex-1 text-left truncate flex items-center gap-1.5">
{cat.name}
{cat.requiresSetup && (
<a
href={paths.settings.agentSkills()}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500/20 text-amber-400 light:bg-amber-100 light:text-amber-700 hover:bg-amber-500/30 light:hover:bg-amber-200 transition-colors"
title={t(
"scheduledJobs.modal.needsSetup",
"This skill requires configuration before use"
)}
>
<Warning size={10} weight="fill" />
{t(
"scheduledJobs.modal.needsSetupLabel",
"Needs Setup"
)}
</a>
)}
</span>
<span className="text-xs text-zinc-400 light:text-slate-500 mr-1">
{enabledCount}/{(cat.items || []).length}
</span>
<span
onClick={(e) =>
!cat.requiresSetup && toggleCategorySelection(cat, e)
}
className={
cat.requiresSetup
? "cursor-not-allowed"
: "cursor-pointer"
}
>
<Checkbox state={state} disabled={cat.requiresSetup} />
</span>
</button>
{expanded && (
<div className="ml-2 border-l border-zinc-700 light:border-slate-200 pl-2 mt-0.5">
{(cat.items || []).map((item) => {
const isSelected = selectedTools.includes(item.id);
const itemNeedsSetup =
item.requiresSetup || cat.requiresSetup;
return (
<button
key={item.id}
type="button"
onClick={() =>
!itemNeedsSetup && toggleTool(item.id)
}
disabled={itemNeedsSetup}
title={item.description || undefined}
className={`border-none flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm text-zinc-300 light:text-slate-600 transition-colors ${
itemNeedsSetup
? "opacity-60 cursor-not-allowed"
: "hover:bg-zinc-700/60 light:hover:bg-slate-100"
}`}
>
<div className="flex-1 text-left">
<span className="flex items-center gap-1.5 truncate">
{item.name}
{item.requiresSetup && !cat.requiresSetup && (
<a
href={paths.settings.agentSkills()}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-500/20 text-amber-400 light:bg-amber-100 light:text-amber-700 hover:bg-amber-500/30 light:hover:bg-amber-200 transition-colors"
title={t(
"scheduledJobs.modal.needsSetup",
"This skill requires configuration before use"
)}
>
<Warning size={10} weight="fill" />
{t(
"scheduledJobs.modal.needsSetupLabel",
"Needs Setup"
)}
</a>
)}
</span>
{item.description && (
<span className="block text-xs text-zinc-500 light:text-slate-400 truncate">
{item.description}
</span>
)}
</div>
<Checkbox
state={isSelected ? "checked" : "unchecked"}
disabled={itemNeedsSetup}
/>
</button>
);
})}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
{selectedTools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedTools.map((id) => (
<div
key={id}
className="bg-zinc-800 light:bg-slate-200 flex gap-1.5 h-[26px] items-center justify-center px-3.5 py-0.5 rounded-full text-sm text-zinc-300 light:text-slate-700"
>
<span className="whitespace-nowrap">{labelFor(id)}</span>
<button
type="button"
onClick={() => removeTool(id)}
aria-label={`Remove ${labelFor(id)}`}
className="border-none text-zinc-400 light:text-slate-500 hover:text-zinc-50 light:hover:text-slate-900 transition-colors"
>
<X size={12} weight="bold" />
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,163 @@
import { useState, useEffect } from "react";
import { X, WarningCircle } from "@phosphor-icons/react";
import ScheduledJobs from "@/models/scheduledJobs";
import showToast from "@/utils/toast";
import { safeJsonParse } from "@/utils/request";
import { useTranslation } from "react-i18next";
import JobDescription from "./JobDescription";
import JobSchedule from "./JobSchedule";
import ToolsSelector from "./ToolsSelector";
import FormActions from "./FormActions";
function setDefaultFormState(job) {
return {
name: job?.name || "",
prompt: job?.prompt || "",
schedule: job?.schedule || "0 9 * * *",
scheduleMode: "builder",
selectedTools: job?.tools ? safeJsonParse(job.tools, []) : [],
};
}
export default function JobFormModal({ job = null, onClose, onSaved }) {
const { t } = useTranslation();
const isEditing = !!job;
const [form, setForm] = useState(setDefaultFormState(job));
const [availableTools, setAvailableTools] = useState([]);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState({
name: false,
prompt: false,
schedule: false,
});
const hasErrors = () => Object.values(errors).some(Boolean);
const clearError = (field) => {
setErrors((prev) => (prev[field] ? { ...prev, [field]: false } : prev));
};
useEffect(() => {
ScheduledJobs.availableTools().then(({ tools }) => {
setAvailableTools(tools || []);
});
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
clearError(name);
};
const handleScheduleChange = (cron) => {
setForm((prev) => ({ ...prev, schedule: cron }));
clearError("schedule");
};
const handleModeChange = (mode) => {
setForm((prev) => ({ ...prev, scheduleMode: mode }));
};
const setSelectedTools = (selectedTools) => {
setForm((prev) => ({ ...prev, selectedTools }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const nextErrors = {
name: !form.name.trim(),
prompt: !form.prompt.trim(),
schedule: !form.schedule.trim(),
};
if (nextErrors.name || nextErrors.prompt || nextErrors.schedule) {
setErrors(nextErrors);
return;
}
setSaving(true);
const data = {
name: form.name.trim(),
prompt: form.prompt.trim(),
schedule: form.schedule.trim(),
tools: form.selectedTools,
};
const result = isEditing
? await ScheduledJobs.update(job.id, data)
: await ScheduledJobs.create(data);
setSaving(false);
if (result.error) {
showToast(result.error, "error");
return;
}
showToast(
isEditing
? t("scheduledJobs.modal.jobUpdated")
: t("scheduledJobs.modal.jobCreated"),
"success"
);
onSaved();
};
return (
<div className="relative w-full max-w-2xl max-h-full">
<div className="relative bg-theme-bg-secondary rounded-lg shadow border border-theme-modal-border">
<div className="flex flex-col gap-1 p-4 border-b rounded-t border-theme-modal-border">
<div className="flex items-start justify-between">
<h3 className="text-xl font-semibold text-theme-text-primary">
{isEditing
? t("scheduledJobs.modal.titleEdit")
: t("scheduledJobs.modal.titleNew")}
</h3>
<button
onClick={onClose}
type="button"
className="border-none transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center"
>
<X className="text-gray-300 text-lg" />
</button>
</div>
{hasErrors() && (
<div className="flex gap-1 items-center">
<WarningCircle size={16} className="text-red-400 shrink-0" />
<p className="text-sm text-red-400">
{t(
"scheduledJobs.modal.requiredFieldsBanner",
"Please fill out all required fields in order to create job."
)}
</p>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<JobDescription form={form} errors={errors} onChange={handleChange} />
<JobSchedule
schedule={form.schedule}
scheduleMode={form.scheduleMode}
error={errors.schedule}
onScheduleChange={handleScheduleChange}
onModeChange={handleModeChange}
/>
{availableTools.length > 0 && (
<ToolsSelector
availableTools={availableTools}
selectedTools={form.selectedTools}
onChange={setSelectedTools}
/>
)}
<FormActions
isEditing={isEditing}
saving={saving}
onClose={onClose}
/>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,434 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import {
ArrowLeft,
ChatText,
Brain,
Wrench,
File,
Stop,
} from "@phosphor-icons/react";
import ScheduledJobs from "@/models/scheduledJobs";
import usePolling from "@/hooks/usePolling";
import showToast from "@/utils/toast";
import paths from "@/utils/paths";
import renderMarkdown from "@/utils/chat/markdown";
import CollapsibleSection from "./components/CollapsibleSection";
import ToolCallCard from "./components/ToolCallCard";
import GeneratedFileCard from "./components/GeneratedFileCard";
import moment from "moment";
import { formatDuration, numberWithCommas } from "@/utils/numbers";
import DOMPurify from "@/utils/chat/purify";
import {
THOUGHT_REGEX_COMPLETE,
THOUGHT_REGEX_OPEN,
THOUGHT_REGEX_CLOSE,
} from "@/components/WorkspaceChat/ChatContainer/ChatHistory/ThoughtContainer";
export default function RunDetailPage() {
const { t } = useTranslation();
const { id, runId } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [run, setRun] = useState(null);
const [job, setJob] = useState(null);
const [continuing, setContinuing] = useState(false);
const [killing, setKilling] = useState(false);
useEffect(() => {
fetchRun();
}, [runId]);
const fetchRun = async () => {
const data = await ScheduledJobs.getRun(runId);
setRun(data.run);
setJob(data.job);
setLoading(false);
if (data.run && !data.run.readAt) {
ScheduledJobs.markRunRead(runId);
}
};
const isNonTerminal = run?.status === "running" || run?.status === "queued";
// Poll every 3s while a run is in progress so the trace/status updates live.
// Stops automatically once the run reaches a terminal state.
usePolling(fetchRun, 3000, isNonTerminal);
const handleContinueInThread = async () => {
setContinuing(true);
const { workspaceSlug, threadSlug, error } =
await ScheduledJobs.continueInThread(runId);
if (error || !workspaceSlug || !threadSlug) {
showToast(error || t("scheduledJobs.runDetail.threadFailed"), "error");
setContinuing(false);
return;
}
navigate(paths.workspace.thread(workspaceSlug, threadSlug));
};
const handleKillRun = async () => {
setKilling(true);
const { success, error } = await ScheduledJobs.killRun(runId);
setKilling(false);
if (!success) {
showToast(error || t("scheduledJobs.toast.killFailed"), "error");
return;
}
showToast(t("scheduledJobs.toast.killed"), "success");
fetchRun();
};
if (loading) {
return (
<RunDetailLayout>
<p className="text-zinc-400 light:text-slate-600 text-sm">
{t("scheduledJobs.runDetail.loading")}
</p>
</RunDetailLayout>
);
}
if (!run) {
return (
<RunDetailLayout>
<p className="text-zinc-400 light:text-slate-600 text-sm">
{t("scheduledJobs.runDetail.notFound")}
</p>
</RunDetailLayout>
);
}
const result = run.result || {};
return (
<RunDetailLayout>
<RunHeader
t={t}
job={job}
run={run}
result={result}
continuing={continuing}
killing={killing}
onBack={() => navigate(paths.settings.scheduledJobRuns(id))}
onContinueInThread={handleContinueInThread}
onKillRun={handleKillRun}
/>
<div className="mt-6 space-y-4">
<PromptSection t={t} prompt={job?.prompt} />
<ErrorSection t={t} error={run?.error} />
<AgentThoughtsSection t={t} result={result} />
<ToolCallsSection t={t} result={result} />
<GeneratedFilesSection t={t} result={result} />
<FinalResponseSection t={t} result={result} />
<MetricsSection t={t} metrics={result?.metrics} />
</div>
</RunDetailLayout>
);
}
function RunDetailLayout({ children }) {
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll p-4 md:p-0"
>
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16">
{children}
</div>
</div>
</div>
);
}
function RunHeader({
t,
job,
run,
result,
continuing,
killing,
onBack,
onContinueInThread,
onKillRun,
}) {
function getStatusInfo() {
return {
completed: {
text: t("scheduledJobs.status.completed"),
style: "text-green-400 light:text-green-600",
},
failed: {
text: t("scheduledJobs.status.failed"),
style: "text-red-400 light:text-red-600",
},
timed_out: {
text: t("scheduledJobs.status.timed_out"),
style: "text-orange-400 light:text-orange-600",
},
running: {
text: t("scheduledJobs.status.running"),
style: "text-yellow-400 light:text-yellow-600",
},
queued: {
text: t("scheduledJobs.status.queued"),
style: "text-blue-400 light:text-blue-600",
},
default: {
text: "—",
style: "text-zinc-400 light:text-slate-600",
},
};
}
const statusInfo = getStatusInfo();
const { text, style } = statusInfo[run.status] || statusInfo.default;
const isKillable = ["running", "queued"].includes(run.status);
return (
<div className="w-full flex items-end justify-between gap-x-4 pb-6 border-white/10 light:border-zinc-300 border-b-2">
<div className="flex flex-col gap-y-2">
<button
type="button"
onClick={onBack}
className="border-none flex items-center gap-2 text-zinc-400 light:text-slate-600 hover:text-zinc-50 light:hover:text-slate-950 text-sm transition-colors w-fit"
>
<ArrowLeft className="h-4 w-4" />
{t("scheduledJobs.runDetail.back")}
</button>
<p className="text-lg leading-7 font-semibold text-zinc-50 light:text-slate-950">
{t("scheduledJobs.runDetail.runHeading", {
name: job?.name || t("scheduledJobs.runDetail.unknownJob"),
id: run.id,
})}
</p>
<div className="flex items-center gap-2 text-xs">
<span className={style}>{text}</span>
<span className="text-zinc-400 light:text-slate-600">
{moment(run.startedAt).format("LTS")}
</span>
{result.duration && (
<span className="text-zinc-400 light:text-slate-600">
{t("scheduledJobs.runDetail.duration", {
value: formatDuration(result.duration / 1000),
})}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isKillable && (
<button
type="button"
onClick={onKillRun}
disabled={killing}
title={t("scheduledJobs.runDetail.stopJob")}
className="border-none h-9 px-5 rounded-lg bg-red-500/20 text-red-400 light:bg-red-100 light:text-red-600 text-sm font-medium hover:bg-red-500/30 light:hover:bg-red-200 transition-colors disabled:opacity-50 shrink-0 flex items-center gap-2"
>
<Stop className="h-4 w-4" weight="bold" />
{killing
? t("scheduledJobs.runDetail.killing")
: t("scheduledJobs.runDetail.stopJob")}
</button>
)}
{run.status === "completed" && (
<button
type="button"
onClick={onContinueInThread}
disabled={continuing}
className="border-none h-9 px-5 rounded-lg bg-zinc-50 text-zinc-950 light:bg-slate-900 light:text-white text-sm font-medium hover:bg-zinc-200 light:hover:bg-slate-800 transition-colors disabled:opacity-50 shrink-0"
>
{continuing
? t("scheduledJobs.runDetail.creating")
: t("scheduledJobs.runDetail.continueInThread")}
</button>
)}
</div>
</div>
);
}
function PromptSection({ t, prompt }) {
return (
<div className="border border-zinc-700 light:border-slate-400 rounded-lg p-[18px]">
<p className="text-sm font-medium text-white light:text-slate-950 uppercase tracking-[1.4px] mb-1">
{t("scheduledJobs.runDetail.sections.prompt")}
</p>
<p className="text-sm text-zinc-400 light:text-slate-600 whitespace-pre-wrap">
{prompt || "—"}
</p>
</div>
);
}
function ErrorSection({ t, error }) {
if (!error) return null;
return (
<div className="border border-red-500/20 light:border-red-300 rounded-lg p-[18px] bg-red-500/5 light:bg-red-50">
<p className="text-sm font-medium text-red-400 light:text-red-600 uppercase tracking-[1.4px] mb-1">
{t("scheduledJobs.runDetail.sections.error")}
</p>
<p className="text-sm text-red-300 light:text-red-700">{error}</p>
</div>
);
}
function AgentThoughtsSection({ t, result }) {
if (!result.thoughts || result.thoughts.length === 0) return null;
return (
<CollapsibleSection
title={t("scheduledJobs.runDetail.sections.thinking", {
count: result.thoughts.length,
})}
icon={Brain}
>
<div className="space-y-2">
{result.thoughts.map((thought, i) => (
<div
key={i}
className="flex items-start gap-2 text-sm text-theme-text-secondary"
>
<span className="text-xs text-theme-text-secondary/50 mt-0.5 min-w-[20px]">
{i + 1}.
</span>
<span>{thought}</span>
</div>
))}
</div>
</CollapsibleSection>
);
}
function ToolCallsSection({ t, result }) {
if (!result.toolCalls || result.toolCalls.length === 0) return null;
return (
<CollapsibleSection
title={t("scheduledJobs.runDetail.sections.toolCalls", {
count: result.toolCalls.length,
})}
icon={Wrench}
>
<div className="space-y-3">
{result.toolCalls.map((toolCall, i) => (
<ToolCallCard key={i} toolCall={toolCall} />
))}
</div>
</CollapsibleSection>
);
}
function GeneratedFilesSection({ t, result }) {
// outputs contains {type, payload} objects from agent plugins
const outputs = result.outputs || [];
if (outputs.length === 0) return null;
return (
<CollapsibleSection
title={t("scheduledJobs.runDetail.sections.files", {
count: outputs.length,
})}
icon={File}
defaultOpen={true}
>
<div className="space-y-2">
{outputs.map((output, i) => (
<GeneratedFileCard key={i} file={output.payload} type={output.type} />
))}
</div>
</CollapsibleSection>
);
}
function FinalResponseSection({ t, result }) {
if (!result.text) return null;
let reasoning = null;
let msgToRender = result.text;
if (result.text.match(THOUGHT_REGEX_COMPLETE)) {
reasoning = result.text.match(THOUGHT_REGEX_COMPLETE)?.[0];
if (reasoning)
reasoning = reasoning
.replace(THOUGHT_REGEX_OPEN, "")
.replace(THOUGHT_REGEX_CLOSE, "")
.trim();
msgToRender = result.text.replace(THOUGHT_REGEX_COMPLETE, "");
}
return (
<CollapsibleSection
title={t("scheduledJobs.runDetail.sections.response")}
icon={ChatText}
defaultOpen={true}
copyableContent={msgToRender}
>
{reasoning && (
<div className="text-sm text-zinc-50/50 light:text-slate-950/50 markdown border-l-2 border-zinc-700 light:border-slate-400 pl-2 mb-4">
<span
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(renderMarkdown(reasoning)),
}}
/>
</div>
)}
<div
className="text-sm text-zinc-50 light:text-slate-950 markdown"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(renderMarkdown(msgToRender)),
}}
/>
</CollapsibleSection>
);
}
function MetricsSection({ t, metrics }) {
if (!metrics || Object.keys(metrics).length === 0) return null;
// Todo: there is a bug where if you create a job that has no tools, we wont get any metrics
// To avoid confusion, we should not show the metrics section if there are no metrics.
if (!metrics.prompt_tokens || !metrics.completion_tokens) return null;
function renderModel(metrics) {
if (!metrics.model) return null;
return (
<span className="font-mono text-xs font-normal text-zinc-50/50 light:text-slate-950/50">
({metrics.model})
</span>
);
}
return (
<div className="border border-zinc-700 light:border-slate-400 rounded-lg p-[18px]">
<p className="text-sm font-semibold text-zinc-400 light:text-slate-600 uppercase tracking-[1.4px] mb-1">
{t("scheduledJobs.runDetail.sections.metrics")} {renderModel(metrics)}
</p>
<div className="flex gap-6 text-sm text-zinc-400 light:text-slate-600">
{metrics.prompt_tokens != null && (
<span>
{t("scheduledJobs.runDetail.metrics.promptTokens")}{" "}
<span className="text-zinc-50 light:text-slate-950">
{numberWithCommas(metrics.prompt_tokens)}
</span>
</span>
)}
{metrics.completion_tokens != null && (
<span>
{t("scheduledJobs.runDetail.metrics.completionTokens")}{" "}
<span className="text-zinc-50 light:text-slate-950">
{numberWithCommas(metrics.completion_tokens)}
</span>
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import { ArrowLeft } from "@phosphor-icons/react";
import ScheduledJobs from "@/models/scheduledJobs";
import usePolling from "@/hooks/usePolling";
import showToast from "@/utils/toast";
import paths from "@/utils/paths";
import RunRow from "./components/RunRow";
import { humanizeCron } from "./utils/cron";
export default function RunHistoryPage() {
const { t } = useTranslation();
const { id } = useParams();
const [job, setJob] = useState(null);
const [runs, setRuns] = useState([]);
const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false);
const hasInFlightRun = runs.some(
(r) => r.status === "queued" || r.status === "running"
);
const fetchRuns = async () => {
const { runs: foundRuns } = await ScheduledJobs.runs(id);
setRuns(foundRuns || []);
setLoading(false);
};
useEffect(() => {
ScheduledJobs.get(id).then(({ job }) => setJob(job));
fetchRuns();
}, [id]);
// Poll every 5s while visible so new runs appear and running statuses update.
usePolling(fetchRuns, 5000);
const handleRunNow = async () => {
setTriggering(true);
const { success, skipped, error } = await ScheduledJobs.trigger(id);
setTriggering(false);
if (!success) {
showToast(error || t("scheduledJobs.toast.triggerFailed"), "error");
return;
}
if (skipped) {
showToast(
t(
"scheduledJobs.toast.triggerSkipped",
"A run is already in progress for this job"
),
"info"
);
} else {
showToast(t("scheduledJobs.toast.triggered"), "success");
}
fetchRuns();
};
if (loading) {
return (
<RunHistoryLayout job={job}>
<div className="flex flex-col items-center justify-center gap-8 py-24 text-center">
<p className="text-zinc-400 light:text-slate-600 text-sm">
{t("scheduledJobs.loading")}
</p>
</div>
</RunHistoryLayout>
);
}
return (
<RunHistoryLayout job={job}>
<div className="pt-8">
<div className="flex items-center px-4 pb-[18px] text-xs font-semibold uppercase tracking-[1.4px] text-zinc-400 light:text-slate-600">
<span className="w-[200px]">
{t("scheduledJobs.runHistory.table.status")}
</span>
<span className="w-[260px]">
{t("scheduledJobs.runHistory.table.started")}
</span>
<span className="w-[160px]">
{t("scheduledJobs.runHistory.table.duration")}
</span>
<span className="flex-1">
{t("scheduledJobs.runHistory.table.error")}
</span>
</div>
<div className="h-px w-full bg-white/10 light:bg-slate-300" />
{runs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-8 py-24 text-center">
<div className="flex flex-col gap-1.5">
<p className="text-base font-semibold text-zinc-50 light:text-slate-950">
{t("scheduledJobs.runHistory.emptyTitle")}
</p>
<p className="text-sm font-medium text-zinc-400 light:text-slate-600">
{t("scheduledJobs.runHistory.emptySubtitle")}
</p>
</div>
<button
type="button"
onClick={handleRunNow}
disabled={triggering || hasInFlightRun}
className="border-none h-9 px-5 rounded-lg bg-zinc-50 text-zinc-950 light:bg-slate-900 light:text-white text-sm font-medium hover:bg-zinc-200 light:hover:bg-slate-800 transition-colors disabled:opacity-50"
>
{t("scheduledJobs.runHistory.runNow")}
</button>
</div>
) : (
<div className="flex flex-col divide-y divide-white/5 light:divide-slate-200">
{runs.map((run) => (
<RunRow
key={run.id}
run={run}
jobId={job?.id}
onKilled={fetchRuns}
/>
))}
</div>
)}
</div>
</RunHistoryLayout>
);
}
function RunHistoryLayout({ job, children }) {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll p-4 md:p-0"
>
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16">
<div className="w-full flex flex-col gap-y-2 pb-6 border-white/10 light:border-slate-300 border-b-2">
<button
type="button"
onClick={() => navigate(paths.settings.scheduledJobs())}
className="border-none flex items-center gap-2 text-zinc-400 light:text-slate-600 hover:text-zinc-50 light:hover:text-slate-950 text-sm transition-colors w-fit"
>
<ArrowLeft className="h-4 w-4" />
{t("scheduledJobs.runHistory.back")}
</button>
<p className="text-lg leading-7 font-semibold text-zinc-50 light:text-slate-950">
{t("scheduledJobs.runHistory.title", {
name: job?.name || "...",
})}
</p>
<p className="text-xs text-zinc-400 light:text-slate-600">
{t("scheduledJobs.runHistory.schedule")}{" "}
<code>{humanizeCron(job?.schedule, i18n.language) || "—"}</code>
</p>
</div>
{children}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { useState } from "react";
import {
CaretDown,
CaretRight,
CheckCircle,
Copy,
} from "@phosphor-icons/react";
import { copyMarkdownAsRichText } from "@/utils/clipboard";
// Generic expand/collapse panel used by the run-detail page to wrap each
// trace section (thinking, tool calls, response, files).
export default function CollapsibleSection({
title,
icon: Icon,
children,
defaultOpen = false,
copyableContent = null,
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-zinc-700 light:border-slate-400 rounded-lg overflow-hidden">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setOpen(!open)}
className="border-none flex-1 h-12 flex items-center gap-2 px-[18px] hover:bg-white/5 light:hover:bg-slate-100 transition-colors text-left"
>
{open ? (
<CaretDown className="h-4 w-4 text-zinc-50 light:text-slate-950" />
) : (
<CaretRight className="h-4 w-4 text-zinc-50 light:text-slate-950" />
)}
<Icon className="h-4 w-4 text-zinc-50 light:text-slate-950" />
<span className="text-sm font-medium text-zinc-50 light:text-slate-950">
{title}
</span>
</button>
{copyableContent && <CopyButton content={copyableContent} />}
</div>
{open && (
<div className="px-[18px] py-3 border-t border-zinc-700 light:border-slate-400">
{children}
</div>
)}
</div>
);
}
function CopyButton({ content }) {
const [copied, setCopied] = useState(false);
const copyToClipboard = async () => {
await copyMarkdownAsRichText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={copyToClipboard}
disabled={copied}
className="h-12 px-4 flex items-center hover:bg-white/5 light:hover:bg-slate-100 transition-colors"
>
{copied ? (
<CheckCircle size={20} className="text-green-500" />
) : (
<Copy size={20} className="text-zinc-50 light:text-slate-950" />
)}
</button>
);
}

View File

@ -0,0 +1,134 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CircleNotch, DownloadSimple } from "@phosphor-icons/react";
import StorageFiles from "@/models/files";
import showToast from "@/utils/toast";
import { humanFileSize } from "@/utils/numbers";
// File extension display badge/colors. Lifted out of RunDetailPage with
// the file card itself; nothing else uses this map so it lives here.
const FILE_DISPLAY_MAP = {
pptx: {
badge: "PPT",
bg: "bg-orange-100",
text: "text-orange-800",
label: (t) => t("scheduledJobs.file.types.powerpoint"),
},
ppt: {
badge: "PPT",
bg: "bg-orange-100",
text: "text-orange-800",
label: (t) => t("scheduledJobs.file.types.powerpoint"),
},
pdf: {
badge: "PDF",
bg: "bg-red-100",
text: "text-red-800",
label: (t) => t("scheduledJobs.file.types.pdf"),
},
doc: {
badge: "DOC",
bg: "bg-blue-100",
text: "text-blue-800",
label: (t) => t("scheduledJobs.file.types.word"),
},
docx: {
badge: "DOC",
bg: "bg-blue-100",
text: "text-blue-800",
label: (t) => t("scheduledJobs.file.types.word"),
},
xls: {
badge: "XLS",
bg: "bg-green-100",
text: "text-green-800",
label: (t) => t("scheduledJobs.file.types.spreadsheet"),
},
xlsx: {
badge: "XLS",
bg: "bg-green-100",
text: "text-green-800",
label: (t) => t("scheduledJobs.file.types.spreadsheet"),
},
csv: {
badge: "CSV",
bg: "bg-green-100",
text: "text-green-800",
label: (t) => t("scheduledJobs.file.types.spreadsheet"),
},
default: {
badge: "FILE",
bg: "bg-zinc-200",
text: "text-zinc-800",
label: (t) => t("scheduledJobs.file.types.generic"),
},
};
function getFileDisplayInfo(filename, t) {
const extension = filename?.split(".")?.pop()?.toLowerCase() ?? "txt";
const fileDisplayInfo =
FILE_DISPLAY_MAP[extension] || FILE_DISPLAY_MAP.default;
return {
...fileDisplayInfo,
type: fileDisplayInfo.label(t),
};
}
// Card representing a file generated by a job run. Streams the file from
// the storage API on download via dynamic import of file-saver.
export default function GeneratedFileCard({ file }) {
const { t } = useTranslation();
const [downloading, setDownloading] = useState(false);
const { badge, bg, text, type } = getFileDisplayInfo(file.filename, t);
const handleDownload = async () => {
if (downloading || !file.storageFilename) return;
setDownloading(true);
try {
const blob = await StorageFiles.download(file.storageFilename);
if (!blob) throw new Error("Failed to download file");
const { saveAs } = await import("file-saver");
saveAs(blob, file.filename || file.storageFilename);
} catch {
showToast(t("scheduledJobs.file.downloadFailed"), "error");
} finally {
setDownloading(false);
}
};
return (
<div className="flex items-center justify-between bg-zinc-800 light:bg-slate-100 rounded-lg p-3">
<div className="flex items-center gap-3 min-w-0">
<div
className={`${bg} ${text} rounded-lg flex items-center justify-center shrink-0 h-10 w-10 text-xs font-semibold`}
>
{badge}
</div>
<div className="flex flex-col min-w-0">
<p className="text-sm text-white light:text-slate-950 truncate">
{file.filename || t("scheduledJobs.file.unknown")}
</p>
<p className="text-sm text-zinc-400 light:text-slate-600">
{humanFileSize(file.fileSize, true, 1)}
{file.fileSize && type ? " · " : ""}
{type}
</p>
</div>
</div>
<button
type="button"
onClick={handleDownload}
disabled={downloading}
title={t("scheduledJobs.file.download")}
aria-label={t("scheduledJobs.file.download")}
className="border-none text-zinc-400 light:text-slate-600 hover:text-zinc-50 light:hover:text-slate-950 transition-colors shrink-0 ml-4 disabled:opacity-50"
>
{downloading ? (
<CircleNotch size={16} weight="bold" className="animate-spin" />
) : (
<DownloadSimple size={16} weight="bold" />
)}
</button>
</div>
);
}

View File

@ -0,0 +1,107 @@
import { useNavigate } from "react-router-dom";
import { Play, PencilSimple, X } from "@phosphor-icons/react";
import paths from "@/utils/paths";
import { humanizeCron } from "../utils/cron";
import { useTranslation } from "react-i18next";
// One row of the scheduled-jobs list. Clicking the name navigates to the
// run history; CRUD callbacks come from the parent.
export default function JobRow({ job, onTrigger, onToggle, onEdit, onDelete }) {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
// A job has at most one in-flight run; disable "Run now" while it's queued
// or running so users get visible feedback that their click registered and
// so the backend dedup never has to drop a manual trigger silently.
const inFlight =
job.latestRun?.status === "running" || job.latestRun?.status === "queued";
const statusText = job.latestRun
? t(`scheduledJobs.status.${job.latestRun.status}`, job.latestRun.status)
: t("scheduledJobs.row.neverRun");
const stop = (handler) => (e) => {
e.stopPropagation();
handler();
};
return (
<div
role="button"
tabIndex={0}
onClick={() => navigate(paths.settings.scheduledJobRuns(job.id))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate(paths.settings.scheduledJobRuns(job.id));
}
}}
className="flex items-center justify-between px-4 h-14 hover:bg-white/5 light:hover:bg-slate-200 transition-colors cursor-pointer"
title={t("scheduledJobs.row.viewRuns")}
>
<span className="w-[150px] text-sm font-medium text-white light:text-slate-950 truncate">
{job.name}
</span>
<span className="w-[180px] text-sm text-zinc-400 light:text-slate-600 truncate">
{humanizeCron(job.schedule, i18n.language)}
</span>
<span className="w-[120px] text-sm text-zinc-400 light:text-slate-600 truncate">
{statusText}
</span>
<span className="w-[180px] text-sm text-zinc-400 light:text-slate-600 truncate">
{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : "—"}
</span>
<span className="w-[180px] text-sm text-zinc-400 light:text-slate-600 truncate">
{job.enabled && job.nextRunAt
? new Date(job.nextRunAt).toLocaleString()
: "—"}
</span>
<div className="w-[140px] flex items-center justify-end gap-1">
<button
type="button"
onClick={stop(() => onDelete(job.id))}
className="border-none p-2 rounded-full text-zinc-400 light:text-slate-950 hover:text-red-400 light:hover:text-red-600 hover:bg-white/10 light:hover:bg-slate-300/50 transition-colors"
title={t("scheduledJobs.row.delete")}
>
<X className="h-4 w-4 shrink-0" />
</button>
<button
type="button"
onClick={stop(() => onEdit(job))}
className="border-none p-2 rounded-full text-zinc-400 light:text-slate-950 hover:text-white light:hover:text-slate-700 hover:bg-white/10 light:hover:bg-slate-300/50 transition-colors"
title={t("scheduledJobs.row.edit")}
>
<PencilSimple className="h-4 w-4 shrink-0" />
</button>
<button
type="button"
onClick={stop(() => onTrigger(job.id))}
disabled={inFlight}
className="border-none p-2 rounded-full text-zinc-400 light:text-slate-950 hover:text-white light:hover:text-slate-700 hover:bg-white/10 light:hover:bg-slate-300/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title={t("scheduledJobs.row.runNow")}
>
<Play className="h-4 w-4 shrink-0" />
</button>
<button
type="button"
role="switch"
aria-checked={job.enabled}
onClick={stop(() => onToggle(job.id))}
title={
job.enabled
? t("scheduledJobs.row.disable")
: t("scheduledJobs.row.enable")
}
className={`border-none relative h-[15px] w-7 rounded-full p-0.5 transition-colors ${
job.enabled ? "bg-green-400" : "bg-zinc-600 light:bg-slate-300"
}`}
>
<span
className={`block h-3 w-3 rounded-full bg-white shadow transition-transform ${
job.enabled ? "translate-x-[13px]" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,101 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Circle, Stop } from "@phosphor-icons/react";
import moment from "moment";
import paths from "@/utils/paths";
import StatusBadge from "./StatusBadge";
import ScheduledJobs from "@/models/scheduledJobs";
import showToast from "@/utils/toast";
import { formatDuration } from "@/utils/numbers";
/**
* Format a run's elapsed time as ms / s / m.
* @param {Object} run - The run object.
* @returns {string} The formatted duration.
*/
function formatRunDuration(run) {
if (!run.completedAt || !run.startedAt) return "—";
const duration = moment.duration(
moment(run.completedAt).diff(moment(run.startedAt))
);
return formatDuration(duration.asSeconds());
}
/**
* One row of the run history table. The whole row is clickable and
* navigates to the run detail page.
* @param {Object} run - The run object.
* @param {string} jobId - The ID of the job.
* @param {function} onKilled - Callback when a run is killed (to refresh the list).
* @returns {React.ReactNode} The rendered row.
*/
export default function RunRow({ run, jobId, onKilled }) {
const { t } = useTranslation();
const navigate = useNavigate();
const [killing, setKilling] = useState(false);
const unreadAndTerminal =
!run.readAt && !["running", "queued"].includes(run.status);
const isKillable = ["running", "queued"].includes(run.status);
const handleKill = async (e) => {
e.stopPropagation();
setKilling(true);
const { success, error } = await ScheduledJobs.killRun(run.id);
setKilling(false);
if (!success) {
showToast(error || t("scheduledJobs.toast.killFailed"), "error");
return;
}
showToast(t("scheduledJobs.toast.killed"), "success");
onKilled?.();
};
return (
<button
type="button"
onClick={() =>
navigate(paths.settings.scheduledJobRunDetail(jobId, run.id))
}
className="border-none flex items-center px-4 h-14 hover:bg-white/5 light:hover:bg-slate-200 transition-colors text-left w-full"
>
<div className="w-[200px] flex items-center gap-2 relative">
{unreadAndTerminal && (
<Circle
weight="fill"
className="h-2 w-2 text-blue-400 light:text-blue-600 absolute -left-4"
/>
)}
{isKillable && (
<button
type="button"
onClick={handleKill}
disabled={killing}
title={t("scheduledJobs.runHistory.stopJob")}
className="border-none ml-2 p-1.5 rounded bg-red-500/20 text-red-400 light:bg-red-100 light:text-red-600 hover:bg-red-500/30 light:hover:bg-red-200 transition-colors disabled:opacity-50"
>
<Stop className="h-3.5 w-3.5" weight="bold" />
</button>
)}
<StatusBadge status={run.status} />
</div>
<span className="w-[260px] text-sm font-medium text-white light:text-slate-950 truncate">
{new Date(run.startedAt).toLocaleString()}
</span>
<span className="w-[160px] text-sm font-medium text-white light:text-slate-950 truncate">
{formatRunDuration(run)}
</span>
<span
className={`flex-1 text-sm truncate pr-4 ${
run.error
? "text-red-400 light:text-red-600 italic"
: "font-medium text-white light:text-slate-950"
}`}
>
{run.error || "—"}
</span>
</button>
);
}

View File

@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next";
/**
* Per-status text styling per the run history figma:
* - non-terminal (queued, running) italic zinc-400
* - completed white medium
* - failed / timed_out red-400
* @param {string} status - The status of the run.
* @returns {string} The styled status text.
*/
function getStatusesMap(t) {
return {
completed: {
text: t("scheduledJobs.status.completed"),
style: "font-medium text-white light:text-slate-950",
},
failed: {
text: t("scheduledJobs.status.failed"),
style: "text-red-400 light:text-red-600",
},
timed_out: {
text: t("scheduledJobs.status.timed_out"),
style: "text-red-400 light:text-red-600",
},
running: {
text: t("scheduledJobs.status.running"),
style: "italic text-zinc-400 light:text-slate-600",
},
queued: {
text: t("scheduledJobs.status.queued"),
style: "italic text-zinc-400 light:text-slate-600",
},
default: {
text: "—",
style: "text-zinc-400 light:text-slate-600",
},
};
}
/**
* Status text shown in the run history table. Plain text no pill to
* match the run history figma.
* @param {string} status - The status of the run.
* @returns {string} The status text.
*/
export default function StatusBadge({ status }) {
const { t } = useTranslation();
const statusesMap = getStatusesMap(t);
const { text, style } = statusesMap[status] || statusesMap.default;
return <span className={`text-sm ${style}`}>{text}</span>;
}

View File

@ -0,0 +1,144 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Wrench } from "@phosphor-icons/react";
import hljs from "highlight.js";
import { safeJsonParse } from "@/utils/request";
import { useTheme } from "@/hooks/useTheme";
import DOMPurify from "@/utils/chat/purify";
import truncate from "truncate";
import moment from "moment";
const MAX_RESULT_LENGTH = 5000;
/**
* Get the appropriate highlight.js theme based on the theme.
* @param {boolean} isLight - Whether the theme is light.
* @returns {string} The highlight.js theme.
*/
function getHljsTheme(isLight) {
return isLight ? "github" : "github-dark";
}
/**
* Try to render `value` as syntax-highlighted JSON. Returns a `dangerouslySetInnerHTML`
* payload, or null if the value isn't an object (caller should fall back to plain text).
* @param {string} value - The value to format and highlight.
* @returns {Object} The formatted and highlighted value.
*/
function formatAndHighlight(value) {
const parsed =
typeof value === "string" ? safeJsonParse(value, value) : value;
if (typeof parsed !== "object" || parsed === null) return null;
const formatted = JSON.stringify(parsed, null, 2);
const truncatedFormatted = truncate(formatted, MAX_RESULT_LENGTH);
const highlighted = hljs.highlight(truncatedFormatted, {
language: "json",
}).value;
return { __html: DOMPurify.sanitize(highlighted) };
}
/**
* Single tool call inside the run trace. Shows the tool name, arguments, and
* (on demand) the tool's result. JSON arguments and results are pretty-printed
* and syntax-highlighted; non-JSON values fall back to plain text.
* @param {Object} toolCall - The tool call object.
* @returns {React.ReactNode} The rendered tool call card.
*/
export default function ToolCallCard({ toolCall }) {
const [showResult, setShowResult] = useState(false);
return (
<div className="border border-white/5 rounded-lg p-3 bg-theme-bg-primary/30">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Wrench className="h-3.5 w-3.5 text-blue-400" />
<span className="text-sm font-medium text-theme-text-primary">
{toolCall.toolName}
</span>
</div>
<ToolCallTimestamp toolCall={toolCall} />
</div>
<ToolCallArguments toolCall={toolCall} />
<ToolCallResult
toolCall={toolCall}
showResult={showResult}
setShowResult={setShowResult}
/>
</div>
);
}
function ToolCallTimestamp({ toolCall }) {
if (!toolCall.timestamp) return null;
return (
<span className="text-xs text-theme-text-secondary">
{moment(toolCall.timestamp).format("LTS")}
</span>
);
}
function ToolCallArguments({ toolCall }) {
const { t } = useTranslation();
const { isLight } = useTheme();
if (!toolCall.arguments) return null;
const highlightedArgs = formatAndHighlight(toolCall.arguments);
return (
<div className="mb-2">
<span className="text-xs text-theme-text-secondary">
{t("scheduledJobs.toolCall.arguments")}
</span>
{highlightedArgs ? (
<pre
className={`text-xs rounded-lg p-2 mt-1 overflow-x-auto white-scrollbar tool-call-scrollbar hljs ${getHljsTheme(isLight)}`}
dangerouslySetInnerHTML={highlightedArgs}
/>
) : (
<pre className="text-xs text-theme-text-primary bg-theme-bg-primary/50 rounded p-2 mt-1 overflow-x-auto white-scrollbar tool-call-scrollbar">
{typeof toolCall.arguments === "string"
? toolCall.arguments
: JSON.stringify(toolCall.arguments, null, 2)}
</pre>
)}
</div>
);
}
function ToolCallResult({ toolCall, showResult, setShowResult }) {
const { t } = useTranslation();
const { isLight } = useTheme();
if (!toolCall.result) return null;
const resultText =
typeof toolCall.result === "string"
? toolCall.result
: JSON.stringify(toolCall.result, null, 2);
const truncatedResult = truncate(resultText, MAX_RESULT_LENGTH);
const highlightedResult = formatAndHighlight(resultText);
if (!resultText) return null;
return (
<div>
<button
type="button"
onClick={() => setShowResult(!showResult)}
className="border-none text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
{showResult
? t("scheduledJobs.toolCall.hideResult")
: t("scheduledJobs.toolCall.showResult")}
</button>
{showResult &&
(highlightedResult ? (
<pre
className={`text-xs rounded-lg p-2 mt-1 overflow-x-auto max-h-64 overflow-y-auto whitespace-pre-wrap white-scrollbar tool-call-scrollbar hljs ${getHljsTheme(isLight)}`}
dangerouslySetInnerHTML={highlightedResult}
/>
) : (
<pre className="text-xs text-theme-text-primary bg-theme-bg-primary/50 rounded p-2 mt-1 overflow-x-auto max-h-64 overflow-y-auto whitespace-pre-wrap white-scrollbar tool-call-scrollbar">
{truncatedResult}
</pre>
))}
</div>
);
}

View File

@ -0,0 +1,242 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Sidebar from "@/components/SettingsSidebar";
import { isMobile } from "react-device-detect";
import ScheduledJobs from "@/models/scheduledJobs";
import { subscribeToPushNotifications } from "@/hooks/useWebPushNotifications";
import useWebPushNotifications from "@/hooks/useWebPushNotifications";
import usePolling from "@/hooks/usePolling";
import JobFormModal from "./JobFormModal";
import ModalWrapper from "@/components/ModalWrapper";
import { useModal } from "@/hooks/useModal";
import showToast from "@/utils/toast";
import JobRow from "./components/JobRow";
import { Bell } from "@phosphor-icons/react";
import { Tooltip } from "react-tooltip";
export default function ScheduledJobsPage() {
const { t } = useTranslation();
useWebPushNotifications(false);
const { isOpen, openModal, closeModal } = useModal();
const [loading, setLoading] = useState(true);
const [jobs, setJobs] = useState([]);
const [editingJob, setEditingJob] = useState(null);
const fetchJobs = async () => {
const { jobs: foundJobs } = await ScheduledJobs.list();
setJobs(foundJobs || []);
setLoading(false);
};
useEffect(() => {
fetchJobs();
}, []);
// Poll every 5s while tab is visible so status badges and run timestamps stay in sync.
usePolling(fetchJobs, 5000);
const handleDelete = async (id) => {
if (!window.confirm(t("scheduledJobs.confirmDelete"))) return;
await ScheduledJobs.delete(id);
showToast(t("scheduledJobs.toast.deleted"), "success", { clear: true });
fetchJobs();
};
const handleToggle = async (id) => {
const result = await ScheduledJobs.toggle(id);
if (result?.error) showToast(result.error, "error", { clear: true });
fetchJobs();
};
const handleTrigger = async (id) => {
const { success, skipped, error } = await ScheduledJobs.trigger(id);
if (!success) {
showToast(error || t("scheduledJobs.toast.triggerFailed"), "error", {
clear: true,
});
} else if (skipped) {
showToast(
t(
"scheduledJobs.toast.triggerSkipped",
"A run is already in progress for this job"
),
"info",
{ clear: true }
);
} else {
showToast(t("scheduledJobs.toast.triggered"), "success", { clear: true });
}
fetchJobs();
};
const handleEdit = (job) => {
setEditingJob(job);
openModal();
};
const handleCreate = () => {
setEditingJob(null);
openModal();
};
if (loading) {
return (
<BaseLayout showNewJobButton={false} handleCreate={handleCreate}>
<div className="w-full flex items-center justify-center text-zinc-400 light:text-slate-600 text-sm pt-8">
{t("scheduledJobs.loading")}
</div>
</BaseLayout>
);
}
return (
<BaseLayout
showNewJobButton={jobs.length !== 0}
handleCreate={handleCreate}
>
<div className="pt-8">
<div className="flex items-center justify-between px-4 pb-[18px] text-xs font-semibold uppercase tracking-[1.4px] text-zinc-400 light:text-slate-600">
<span className="w-[150px]">{t("scheduledJobs.table.name")}</span>
<span className="w-[180px]">{t("scheduledJobs.table.schedule")}</span>
<span className="w-[120px]">{t("scheduledJobs.table.status")}</span>
<span className="w-[180px]">{t("scheduledJobs.table.lastRun")}</span>
<span className="w-[180px]">{t("scheduledJobs.table.nextRun")}</span>
<span className="w-[140px] text-right">
{t("scheduledJobs.table.actions")}
</span>
</div>
<div className="h-px w-full bg-white/10 light:bg-slate-300" />
{jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-8 py-24 text-center">
<div className="flex flex-col gap-1.5">
<p className="text-base font-semibold text-zinc-50 light:text-slate-950">
{t("scheduledJobs.emptyTitle")}
</p>
<p className="text-sm font-medium text-zinc-400 light:text-slate-600">
{t("scheduledJobs.emptySubtitle")}
</p>
</div>
<button
type="button"
onClick={handleCreate}
className="border-none h-9 px-5 rounded-lg bg-zinc-50 text-zinc-950 light:bg-slate-900 light:text-white text-sm font-medium hover:bg-zinc-200 light:hover:bg-slate-800 transition-colors"
>
{t("scheduledJobs.newJob")}
</button>
</div>
) : (
<div className="flex flex-col divide-y divide-white/5 light:divide-slate-300">
{jobs.map((job) => (
<JobRow
key={job.id}
job={job}
onTrigger={handleTrigger}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
<ModalWrapper isOpen={isOpen}>
<JobFormModal
job={editingJob}
onClose={closeModal}
onSaved={() => {
closeModal();
fetchJobs();
}}
/>
</ModalWrapper>
</BaseLayout>
);
}
function BaseLayout({
showNewJobButton = false,
handleCreate = () => {},
children,
}) {
const { t } = useTranslation();
return (
<div className="w-screen h-screen overflow-hidden bg-theme-bg-container flex">
<Sidebar />
<div
style={{ height: isMobile ? "100%" : "calc(100% - 32px)" }}
className="relative md:ml-[2px] md:mr-[16px] md:my-[16px] md:rounded-[16px] bg-theme-bg-secondary w-full h-full overflow-y-scroll p-4 md:p-0"
>
<div className="flex flex-col w-full px-1 md:pl-6 md:pr-[50px] md:py-6 py-16">
<div className="w-full flex items-end justify-between gap-x-4 pb-6 border-white/10 light:border-slate-300 border-b-2">
<div className="flex flex-col gap-y-2">
<p className="text-lg leading-7 font-semibold text-zinc-50 light:text-slate-950">
{t("scheduledJobs.title")}
</p>
<p className="text-xs leading-4 text-zinc-400 light:text-slate-600 max-w-[700px]">
{t("scheduledJobs.description")}
</p>
</div>
<div className="flex items-center gap-x-2 shrink-0">
<NotificationBellButton />
{showNewJobButton && (
<button
type="button"
onClick={handleCreate}
className="border-none h-9 px-5 rounded-lg bg-zinc-50 text-zinc-950 light:bg-slate-900 light:text-white text-sm font-medium hover:bg-zinc-200 light:hover:bg-slate-800 transition-colors"
>
{t("scheduledJobs.newJob")}
</button>
)}
</div>
</div>
{children}
</div>
</div>
</div>
);
}
function NotificationBellButton() {
const { t } = useTranslation();
const [permissionState, setPermissionState] = useState(
typeof Notification !== "undefined" ? Notification.permission : "denied"
);
if (
!("serviceWorker" in navigator) ||
!("PushManager" in window) ||
permissionState === "granted"
) {
return null;
}
const handleClick = async () => {
await subscribeToPushNotifications();
setPermissionState(Notification.permission);
};
return (
<>
<button
type="button"
onClick={handleClick}
data-tooltip-id="notification-bell-tooltip"
data-tooltip-content={t(
"scheduledJobs.enableNotifications",
"Enable browser notifications for job results"
)}
className="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-white/10 light:hover:bg-slate-200 transition-colors"
>
<Bell size={20} className="text-orange-400" />
</button>
<Tooltip
id="notification-bell-tooltip"
place="bottom"
className="tooltip !text-xs"
/>
</>
);
}

View File

@ -0,0 +1,277 @@
import cronstrue from "cronstrue/i18n";
import moment from "moment";
/**
* Convert a local hour and minute to UTC using moment.js.
* Handles DST and timezone edge cases properly.
* @param {number} localHour - Hour in local time (0-23).
* @param {number} localMinute - Minute (0-59).
* @returns {{ hour: number, minute: number }} Hour and minute in UTC.
*/
export function localTimeToUTC(localHour, localMinute = 0) {
const local = moment().hour(localHour).minute(localMinute).second(0);
const utc = local.clone().utc();
return { hour: utc.hour(), minute: utc.minute() };
}
/**
* Convert a UTC hour and minute to local time using moment.js.
* Handles DST and timezone edge cases properly.
* @param {number} utcHour - Hour in UTC (0-23).
* @param {number} utcMinute - Minute (0-59).
* @returns {{ hour: number, minute: number }} Hour and minute in local time.
*/
export function utcTimeToLocal(utcHour, utcMinute = 0) {
const utc = moment.utc().hour(utcHour).minute(utcMinute).second(0);
const local = utc.clone().local();
return { hour: local.hour(), minute: local.minute() };
}
/**
* Get the user's timezone abbreviation for display.
* @returns {string} Timezone abbreviation (e.g., "PST", "EST", "UTC").
*/
export function getTimezoneAbbreviation() {
try {
const formatter = new Intl.DateTimeFormat(undefined, {
timeZoneName: "short",
});
const parts = formatter.formatToParts(new Date());
const tzPart = parts.find((p) => p.type === "timeZoneName");
return tzPart?.value || "local time";
} catch {
return "local time";
}
}
/**
* Humanize a cron expression for display in the user's local timezone.
* The cron is stored in UTC, so we convert it to local time for display.
* @param {string} cron - The cron expression (in UTC).
* @param {string} locale - The locale.
* @returns {string} The humanized cron expression with timezone indicator.
*/
export function humanizeCron(cron, locale) {
if (!cron) return "";
try {
const localCron = convertCronToLocalTime(cron);
const humanized = cronstrue.toString(localCron, {
throwExceptionOnParseError: false,
locale: toCronstrueLocale(locale),
});
return `${humanized} ${getTimezoneAbbreviation()}`;
} catch {
return cron;
}
}
/**
* Convert a UTC cron expression to local time for display purposes.
* Only converts the hour field for patterns that have a specific hour.
* @param {string} cron - The cron expression in UTC.
* @returns {string} The cron expression adjusted to local time.
*/
function convertCronToLocalTime(cron) {
if (!cron || typeof cron !== "string") return cron;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return cron;
const [minute, hour, dom, mon, dow] = parts;
// Only convert if hour is a specific number (not * or */n)
if (/^\d+$/.test(hour) && /^\d+$/.test(minute)) {
const local = utcTimeToLocal(parseInt(hour, 10), parseInt(minute, 10));
return `${local.minute} ${local.hour} ${dom} ${mon} ${dow}`;
}
return cron;
}
/**
* Convert a locale code to a cronstrue locale code.
* i18next uses BCP-47-ish codes like "zh-tw"; cronstrue's i18n bundle
* uses "zh_TW". Convert "xx-yy" "xx_YY" so region-tagged locales resolve
* instead of silently falling back to English.
* @param {string} locale - The locale code.
* @returns {string} The cronstrue locale code.
*/
function toCronstrueLocale(locale) {
if (!locale) return undefined;
const [lang, region] = locale.split("-");
return region ? `${lang}_${region.toUpperCase()}` : lang;
}
/**
* Default state for the visual builder. Used when an incoming cron expression
* cannot be reverse-parsed into one of the builder's supported shapes.
* @returns {Object} The default builder state.
*/
export const DEFAULT_BUILDER_STATE = {
frequency: "day",
minuteInterval: 1,
hourMinuteOffset: 0,
hour: 9,
minute: 0,
weekdays: [1],
dayOfMonth: 1,
};
/**
* Parse a 5-field cron expression into the visual builder's state shape.
* Only recognizes the patterns the builder itself can produce; anything else
* (ranges, step values in non-minute fields, multiple months, etc.) is
* reported with `wasFallback: true` so the UI can warn the user that the
* stored expression cannot be edited visually.
*
* IMPORTANT: The cron expression is stored in UTC on the backend. This function
* converts the hour to local time so the builder shows the user's local time.
*
* Returns: { state, wasFallback }
* @param {string} cron - The cron expression (in UTC).
* @returns {Object} The builder state (with hours in local time).
*/
export function parseCronToBuilderState(cron) {
const fallback = { state: { ...DEFAULT_BUILDER_STATE }, wasFallback: true };
if (!cron || typeof cron !== "string") return fallback;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return fallback;
const [m, h, dom, mon, dow] = parts;
if (mon !== "*") return fallback;
// Every minute
if (m === "*" && h === "*" && dom === "*" && dow === "*") {
return {
state: {
...DEFAULT_BUILDER_STATE,
frequency: "minute",
minuteInterval: 1,
},
wasFallback: false,
};
}
// Every N minutes
const stepMatch = m.match(/^\*\/(\d+)$/);
if (stepMatch && h === "*" && dom === "*" && dow === "*") {
return {
state: {
...DEFAULT_BUILDER_STATE,
frequency: "minute",
minuteInterval: parseInt(stepMatch[1], 10),
},
wasFallback: false,
};
}
// Hourly at minute X
if (/^\d+$/.test(m) && h === "*" && dom === "*" && dow === "*") {
return {
state: {
...DEFAULT_BUILDER_STATE,
frequency: "hour",
hourMinuteOffset: parseInt(m, 10),
},
wasFallback: false,
};
}
// Daily at H:M - convert UTC to local
if (/^\d+$/.test(m) && /^\d+$/.test(h) && dom === "*" && dow === "*") {
const local = utcTimeToLocal(parseInt(h, 10), parseInt(m, 10));
return {
state: {
...DEFAULT_BUILDER_STATE,
frequency: "day",
hour: local.hour,
minute: local.minute,
},
wasFallback: false,
};
}
// Weekly on one or more weekdays at H:M - convert UTC to local
if (
/^\d+$/.test(m) &&
/^\d+$/.test(h) &&
dom === "*" &&
/^\d+(,\d+)*$/.test(dow)
) {
const days = [
...new Set(
dow
.split(",")
.map((d) => parseInt(d, 10) % 7)
.filter((d) => d >= 0 && d <= 6)
),
];
const local = utcTimeToLocal(parseInt(h, 10), parseInt(m, 10));
return {
state: {
...DEFAULT_BUILDER_STATE,
frequency: "week",
hour: local.hour,
minute: local.minute,
weekdays: days.length ? days : [1],
},
wasFallback: false,
};
}
// Monthly on day D at H:M - convert UTC to local
if (/^\d+$/.test(m) && /^\d+$/.test(h) && /^\d+$/.test(dom) && dow === "*") {
const local = utcTimeToLocal(parseInt(h, 10), parseInt(m, 10));
return {
state: {
...DEFAULT_BUILDER_STATE,
frequency: "month",
hour: local.hour,
minute: local.minute,
dayOfMonth: parseInt(dom, 10),
},
wasFallback: false,
};
}
return fallback;
}
/**
* Build a 5-field cron expression from the visual builder's state.
*
* IMPORTANT: The builder state has hours in local time. This function
* converts the time to UTC before building the cron expression, since
* the backend stores and executes cron expressions in UTC.
*
* @param {Object} state - The builder state (with time in local timezone).
* @returns {string} The cron expression (in UTC).
*/
export function buildCronFromBuilderState(state) {
switch (state.frequency) {
case "minute": {
const n = state.minuteInterval || 1;
return n === 1 ? "* * * * *" : `*/${n} * * * *`;
}
case "hour":
return `${state.hourMinuteOffset} * * * *`;
case "day": {
const utc = localTimeToUTC(state.hour, state.minute);
return `${utc.minute} ${utc.hour} * * *`;
}
case "week": {
const utc = localTimeToUTC(state.hour, state.minute);
const days = (state.weekdays?.length ? state.weekdays : [1])
.slice()
.sort((a, b) => a - b)
.join(",");
return `${utc.minute} ${utc.hour} * * ${days}`;
}
case "month": {
const utc = localTimeToUTC(state.hour, state.minute);
return `${utc.minute} ${utc.hour} ${state.dayOfMonth} * *`;
}
default: {
const utc = localTimeToUTC(9, 0);
return `${utc.minute} ${utc.hour} * * *`;
}
}
}

View File

@ -0,0 +1,25 @@
import renderMarkdown from "./chat/markdown";
/**
* Copies the given markdown string as rich text to the clipboard.
* @param {string} markdownString - The markdown string to copy.
* @returns {Promise<void>}
*/
export async function copyMarkdownAsRichText(markdownString) {
try {
const htmlContent = renderMarkdown(markdownString);
const blobHTML = new Blob([htmlContent], { type: "text/html" });
const blobText = new Blob([markdownString], { type: "text/plain" });
const data = [
new ClipboardItem({
"text/html": blobHTML,
"text/plain": blobText,
}),
];
await navigator.clipboard.write(data);
} catch (error) {
console.error("Failed to copy markdown as rich text: ", error);
}
}

View File

@ -58,3 +58,32 @@ export function milliToHms(milli = 0) {
var sDisplay = s >= 0.01 ? s.toFixed(2) + "s" : "";
return hDisplay + mDisplay + sDisplay;
}
/**
* Format a duration in milliseconds to a human readable string
* - Less than 1 second - show milliseconds (50ms)
* - Less than 60 seconds - show seconds (5s)
* - Less than 1 hour - show min:sec (1m 30s)
* - 1 hour or more - show h:min:sec (1h 30m 5s)
* @param {number} duration - duration in milliseconds
* @returns {string}
*/
export function formatDuration(duration) {
try {
if (duration < 0) return "";
if (duration < 1) return `${(duration * 1000).toFixed(0)}ms`;
if (duration < 60) return `${duration.toFixed(1)}s`;
if (duration < 3600) {
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
return `${minutes}m ${seconds}s`;
}
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
return `${hours}h ${minutes}m ${seconds}s`;
} catch {
return "";
}
}

View File

@ -173,6 +173,15 @@ export default {
telegram: () => {
return `/settings/external-connections/telegram`;
},
scheduledJobs: () => {
return `/settings/scheduled-jobs`;
},
scheduledJobRuns: (jobId) => {
return `/settings/scheduled-jobs/${jobId}/runs`;
},
scheduledJobRunDetail: (jobId, runId) => {
return `/settings/scheduled-jobs/${jobId}/runs/${runId}`;
},
},
agents: {
builder: () => {

View File

@ -1711,6 +1711,11 @@ cosmiconfig@^7.0.0:
path-type "^4.0.0"
yaml "^1.10.0"
cronstrue@^2.50.0:
version "2.61.0"
resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.61.0.tgz#97c79c77045c052afb44cb9f5f8eaf54398094f2"
integrity sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA==
cross-env@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"

View File

@ -458,6 +458,14 @@ TTS_PROVIDER="native"
# AGENT_SKILL_RERANKER_ENABLED="true"
# AGENT_SKILL_RERANKER_TOP_N=15 # (optional) Number of top tools to keep after reranking (default: 15)
# (optional) Maximum number of scheduled jobs that can run concurrently.
# Default is 1. Increase if using a cloud LLM provider with high rate limits.
# SCHEDULED_JOB_MAX_CONCURRENT=1
# (optional) Maximum time in milliseconds a scheduled job can run before being terminated.
# Default is 5 minutes (300000ms).
# SCHEDULED_JOB_TIMEOUT_MS=300000
# (optional) Comma-separated list of skills that are auto-approved.
# This will allow the skill to be invoked without user interaction.
# AGENT_AUTO_APPROVED_SKILLS=create-pdf-file,create-word-file

View File

@ -10,7 +10,9 @@ const {
} = require("../utils/middleware/multiUserProtected");
const { WorkspaceChats } = require("../models/workspaceChats");
const { Workspace } = require("../models/workspace");
const { ScheduledJobRun } = require("../models/scheduledJobRun");
const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib");
const { Telemetry } = require("../models/telemetry");
/**
* Endpoints for serving agent-generated files (PPTX, etc.) with authentication
@ -42,14 +44,13 @@ function agentFileServerEndpoints(app) {
.json({ error: "Invalid filename format" });
}
// Find a chat record that references this file and that the user can access
const validChat = await findValidChatForFile(
filename,
// Find a chat or scheduled job run that references this file
const fileSource = await findFileSource(filename, {
user,
multiUserMode(response)
);
isMultiUser: multiUserMode(response),
});
if (!validChat) {
if (!fileSource) {
return response.status(404).json({
error: "File not found or access denied",
});
@ -66,7 +67,7 @@ function agentFileServerEndpoints(app) {
// Get mime type and set headers for download
const mimeType = createFilesLib.getMimeType(`.${parsed.extension}`);
const safeFilename = createFilesLib.sanitizeFilenameForHeader(
validChat.displayFilename || filename
fileSource.displayFilename || filename
);
response.setHeader("Content-Type", mimeType);
response.setHeader(
@ -74,7 +75,11 @@ function agentFileServerEndpoints(app) {
`attachment; filename="${safeFilename}"`
);
response.setHeader("Content-Length", fileData.buffer.length);
return response.send(fileData.buffer);
response.send(fileData.buffer);
Telemetry.sendTelemetry("agent_generated_file_downloaded", {
type: mimeType,
}).catch(() => {});
return;
} catch (error) {
console.error("[agentFileServer] Download error:", error.message);
return response.status(500).json({ error: "Failed to download file" });
@ -84,59 +89,90 @@ function agentFileServerEndpoints(app) {
}
/**
* Finds a valid chat record that references the given storage filename
* and that the user has access to.
* @param {string} storageFilename - The storage filename to search for
* @param {object|null} user - The user object (null in single-user mode)
* @param {boolean} isMultiUser - Whether multi-user mode is enabled
* @returns {Promise<{workspaceId: number, displayFilename: string}|null>}
* Locates the source record (a workspace chat or a scheduled job run) that
* references the given storage filename, and confirms the requester has access.
*
* Search order:
* 1. Workspace chats the user can access (per multi-user permissions).
* 2. Scheduled job runs single-user only, so no per-user access check.
*
* @param {string} storageFilename
* @param {{ user: object|null, isMultiUser: boolean }} ctx
* @returns {Promise<{workspaceId: number|null, displayFilename: string}|null>}
*/
async function findValidChatForFile(storageFilename, user, isMultiUser) {
async function findFileSource(storageFilename, { user, isMultiUser }) {
try {
// Get all workspaces the user has access to.
// In single-user mode, all workspaces are accessible.
// In multi-user mode, only workspaces assigned to the user are accessible.
let workspaceIds;
if (isMultiUser && user) {
const workspaces = await Workspace.whereWithUser(user);
workspaceIds = workspaces.map((w) => w.id);
} else {
const workspaces = await Workspace.where();
workspaceIds = workspaces.map((w) => w.id);
}
if (workspaceIds.length === 0) return null;
// Use database-level filtering to only fetch chats that contain the filename
// This avoids loading all chats into memory
const chats = await WorkspaceChats.where({
workspaceId: { in: workspaceIds },
include: true,
response: { contains: storageFilename },
const fromChat = await findInWorkspaceChats(storageFilename, {
user,
isMultiUser,
});
if (fromChat) return fromChat;
for (const chat of chats) {
try {
const response = safeJsonParse(chat.response, { outputs: [] });
const output = response.outputs.find(
(o) => o?.payload?.storageFilename === storageFilename
);
if (!output) continue;
return {
workspaceId: chat.workspaceId,
displayFilename:
output.payload.filename || output.payload.displayFilename,
};
} catch {
continue;
}
}
if (isMultiUser) return null;
return null;
return await findInScheduledJobRuns(storageFilename);
} catch (error) {
console.error("[findValidChatForFile] Error:", error.message);
console.error("[findFileSource] Error:", error.message);
return null;
}
}
// Search workspace chats the user has access to. In single-user mode all
// workspaces are accessible; in multi-user mode only workspaces assigned to
// the user are. Returns the matching chat's workspace + display filename.
async function findInWorkspaceChats(storageFilename, { user, isMultiUser }) {
const workspaces =
isMultiUser && user
? await Workspace.whereWithUser(user)
: await Workspace.where();
const workspaceIds = workspaces.map((w) => w.id);
if (workspaceIds.length === 0) return null;
// DB-level filter so we don't load every chat into memory.
const chats = await WorkspaceChats.where({
workspaceId: { in: workspaceIds },
include: true,
response: { contains: storageFilename },
});
for (const chat of chats) {
const { outputs = [] } = safeJsonParse(chat.response, { outputs: [] });
const output = outputs.find(
(o) => o?.payload?.storageFilename === storageFilename
);
if (!output) continue;
return {
workspaceId: chat.workspaceId,
displayFilename:
output.payload.filename || output.payload.displayFilename,
};
}
return null;
}
// Search completed scheduled job runs. Scheduled jobs are single-user only,
// so this skips access control. Returns the matching run's display filename.
async function findInScheduledJobRuns(storageFilename) {
const runs = await ScheduledJobRun.where({
status: "completed",
result: { contains: storageFilename },
});
for (const run of runs) {
const { outputs = [] } = safeJsonParse(run.result, { outputs: [] });
const output = outputs.find(
(o) => o?.payload?.storageFilename === storageFilename
);
if (!output) continue;
return {
workspaceId: null,
displayFilename: output.payload.filename || storageFilename,
};
}
return null;
}
module.exports = { agentFileServerEndpoints };

View File

@ -0,0 +1,376 @@
const { ScheduledJob } = require("../models/scheduledJob");
const { ScheduledJobRun } = require("../models/scheduledJobRun");
const { validatedRequest } = require("../utils/middleware/validatedRequest");
const { isSingleUserMode } = require("../utils/middleware/multiUserProtected");
const { reqBody, safeJsonParse } = require("../utils/http");
const { BackgroundService } = require("../utils/BackgroundWorkers");
const { Telemetry } = require("../models/telemetry");
// BackgroundService is a singleton, so `new BackgroundService()` anywhere in
// the codebase returns the same instance that `server/index.js` booted. We
// grab that reference once and reuse it across handlers.
const backgroundService = new BackgroundService();
function scheduledJobEndpoints(app) {
if (!app) return;
// List available tools for job configuration
app.get(
"/scheduled-jobs/available-tools",
[validatedRequest, isSingleUserMode],
async (_request, response) => {
try {
const tools = await ScheduledJob.availableTools();
return response.status(200).json({ tools });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500).json({ tools: [] });
}
}
);
// Get a single run detail
app.get(
"/scheduled-jobs/runs/:runId",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const run = await ScheduledJobRun.get({
id: Number(request.params.runId),
});
if (!run) {
return response
.status(404)
.json({ run: null, error: "Run not found" });
}
const job = await ScheduledJob.get({ id: run.jobId });
return response.status(200).json({
run: {
...run,
result: safeJsonParse(run.result, null),
},
job,
});
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// Mark a run as read or continue in thread, or kill a running or queued job run
app.post(
"/scheduled-jobs/runs/:runId/:action",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { action } = request.params;
if (!["read", "continue", "kill"].includes(action))
throw new Error("Invalid action");
if (action === "read") {
await ScheduledJobRun.markRead(Number(request.params.runId));
return response.status(200).json({ success: true });
}
if (action === "continue") {
const { workspace, thread, error } =
await ScheduledJobRun.continueInThread(
Number(request.params.runId)
);
if (error) return response.status(500).json({ error });
return response.status(200).json({
workspaceSlug: workspace.slug,
threadSlug: thread.slug,
});
}
if (action === "kill") {
const run = await ScheduledJobRun.get({
id: Number(request.params.runId),
});
if (!run)
return response.status(404).json({ error: "Run not found" });
if (!["queued", "running"].includes(run.status)) {
return response.status(400).json({
error: "Only running or queued jobs can be killed",
});
}
const killed = backgroundService.killRun(run.jobId, run.id);
if (!killed) await ScheduledJobRun.kill(run.id);
return response.status(200).json({ success: true });
}
} catch {
response.sendStatus(500);
}
}
);
// List all scheduled jobs
app.get(
"/scheduled-jobs",
[validatedRequest, isSingleUserMode],
async (_request, response) => {
try {
const jobs = await ScheduledJob.where({}, null, null, {
runs: {
take: 1,
orderBy: { startedAt: "desc" },
},
});
const jobsWithStatus = jobs.map(({ runs, ...job }) => ({
...job,
latestRun: runs[0] || null,
}));
return response.status(200).json({ jobs: jobsWithStatus });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// Create a new scheduled job
app.post(
"/scheduled-jobs/new",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { name, prompt, tools, schedule } = reqBody(request);
let errorMessage = null;
if (!name?.trim()) {
errorMessage = "Name is required";
} else if (!prompt?.trim()) {
errorMessage = "Prompt is required";
} else if (!schedule?.trim()) {
errorMessage = "Schedule is required";
} else if (!ScheduledJob.isValidCron(schedule)) {
errorMessage = "Invalid cron expression";
} else if (tools?.length > 0 && !Array.isArray(tools)) {
errorMessage = "Tools must be an array";
}
if (errorMessage)
return response.status(400).json({
job: null,
error: errorMessage,
});
// New jobs default to enabled, so creating one always counts as an
// activation. Reject if it would push us past the configured cap.
const activation = await ScheduledJob.canActivate();
if (!activation.allowed) {
return response.status(400).json({
job: null,
error: `Cannot create: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`,
});
}
const { job, error } = await ScheduledJob.create({
name: name.trim(),
prompt: prompt.trim(),
tools: tools || null,
schedule: schedule.trim(),
});
if (error) {
return response.status(400).json({ job: null, error });
}
backgroundService.addScheduledJob(job);
Telemetry.sendTelemetry("scheduled_job_created").catch(() => {});
return response.status(201).json({ job, error: null });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// Get a single scheduled job
app.get(
"/scheduled-jobs/:id",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const job = await ScheduledJob.get({
id: Number(request.params.id),
});
if (!job) {
return response
.status(404)
.json({ job: null, error: "Job not found" });
}
return response.status(200).json({ job });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// Update a scheduled job
app.put(
"/scheduled-jobs/:id",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const { name, prompt, tools, schedule, enabled } = reqBody(request);
const updates = {};
if (name !== undefined) updates.name = String(name).trim();
if (prompt !== undefined) updates.prompt = String(prompt).trim();
if (tools !== undefined) updates.tools = tools;
if (enabled !== undefined) updates.enabled = Boolean(enabled);
if (schedule !== undefined) {
if (!ScheduledJob.isValidCron(schedule)) {
return response
.status(400)
.json({ job: null, error: "Invalid cron expression" });
}
updates.schedule = String(schedule).trim();
}
// If this update would activate the job, enforce the active-jobs cap.
// We pass excludeId so a re-save of an already-enabled job is not
// double-counted against the limit.
if (updates.enabled === true) {
const activation = await ScheduledJob.canActivate({
excludeId: Number(request.params.id),
});
if (!activation.allowed) {
return response.status(400).json({
job: null,
error: `Cannot enable: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`,
});
}
}
const { job, error } = await ScheduledJob.update(
Number(request.params.id),
updates
);
if (error) {
return response.status(400).json({ job: null, error });
}
await backgroundService.syncScheduledJob(job.id);
return response.status(200).json({ job, error: null });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// Delete a scheduled job
app.delete(
"/scheduled-jobs/:id",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
backgroundService.removeScheduledJob(Number(request.params.id));
const success = await ScheduledJob.delete(Number(request.params.id));
return response.status(200).json({ success });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// Toggle enable/disable
app.post(
"/scheduled-jobs/:id/toggle",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const job = await ScheduledJob.get({
id: Number(request.params.id),
});
if (!job) {
return response.status(404).json({ error: "Job not found" });
}
// Toggling a disabled job to enabled is an activation — enforce the cap.
// Disabling never needs a check.
if (!job.enabled) {
const activation = await ScheduledJob.canActivate({
excludeId: job.id,
});
if (!activation.allowed) {
return response.status(400).json({
job: null,
error: `Cannot enable: maximum of ${activation.limit} active scheduled jobs reached. Disable another job first.`,
});
}
}
const { job: updated } = await ScheduledJob.update(job.id, {
enabled: !job.enabled,
});
await backgroundService.syncScheduledJob(job.id);
return response.status(200).json({ job: updated });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// Manual trigger — runs the job immediately
app.post(
"/scheduled-jobs/:id/trigger",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const job = await ScheduledJob.get({
id: Number(request.params.id),
});
if (!job) {
return response.status(404).json({ error: "Job not found" });
}
const run = await backgroundService.enqueueScheduledJob(job.id);
return response
.status(200)
.json({ success: true, skipped: !run, error: null });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
// List runs for a job
app.get(
"/scheduled-jobs/:id/runs",
[validatedRequest, isSingleUserMode],
async (request, response) => {
try {
const runs = await ScheduledJobRun.where(
{ jobId: Number(request.params.id) },
50,
{ startedAt: "desc" }
);
return response.status(200).json({ runs });
} catch (e) {
console.error(e.message, e);
response.sendStatus(500);
}
}
);
}
module.exports = { scheduledJobEndpoints };

View File

@ -35,6 +35,7 @@ const { mcpServersEndpoints } = require("./endpoints/mcpServers");
const { mobileEndpoints } = require("./endpoints/mobile");
const { webPushEndpoints } = require("./endpoints/webPush");
const { telegramEndpoints } = require("./endpoints/telegram");
const { scheduledJobEndpoints } = require("./endpoints/scheduledJobs");
const {
outlookAgentEndpoints,
} = require("./endpoints/utils/outlookAgentUtils");
@ -95,6 +96,7 @@ mcpServersEndpoints(apiRouter);
mobileEndpoints(apiRouter);
webPushEndpoints(apiRouter);
telegramEndpoints(apiRouter);
scheduledJobEndpoints(apiRouter);
outlookAgentEndpoints(apiRouter);
googleAgentSkillEndpoints(apiRouter);
// Externally facing embedder endpoints

View File

@ -1,5 +1,6 @@
const { log, conclude } = require("./helpers/index.js");
const { WorkspaceChats } = require("../models/workspaceChats.js");
const { ScheduledJobRun } = require("../models/scheduledJobRun.js");
const createFilesLib = require("../utils/agents/aibitat/plugins/create-files/lib.js");
const { safeJsonParse } = require("../utils/http/index.js");
@ -66,8 +67,16 @@ const { safeJsonParse } = require("../utils/http/index.js");
* @returns {Promise<Set<string>>}
*/
async function getActiveStorageFilenames(batchSize = 50) {
const storageFilenames = new Set();
const [workspaceChats, scheduledJobRuns] = await Promise.all([
workspaceChatGeneratedFilenames(batchSize),
scheduledJobRunGeneratedFilenames(batchSize),
]);
return new Set([...workspaceChats, ...scheduledJobRuns]);
}
async function workspaceChatGeneratedFilenames(batchSize = 50) {
const storageFilenames = new Set();
try {
let offset = 0;
let hasMore = true;
@ -89,8 +98,9 @@ async function getActiveStorageFilenames(batchSize = 50) {
try {
const response = safeJsonParse(chat.response, { outputs: [] });
for (const output of response.outputs) {
if (output?.payload?.storageFilename)
storageFilenames.add(output.payload.storageFilename);
if (!output || !output.payload || !output.payload.storageFilename)
continue;
storageFilenames.add(output.payload.storageFilename);
}
} catch {
continue;
@ -101,7 +111,49 @@ async function getActiveStorageFilenames(batchSize = 50) {
hasMore = chats.length === batchSize;
}
} catch (error) {
console.error("[getActiveStorageFilenames] Error:", error.message);
console.error("[workspaceChatGeneratedFilenames] Error:", error.message);
}
return storageFilenames;
}
async function scheduledJobRunGeneratedFilenames(batchSize = 50) {
const storageFilenames = new Set();
try {
let offset = 0;
let hasMore = true;
while (hasMore) {
const runs = await ScheduledJobRun.where(
{ status: "completed" },
batchSize,
{ id: "asc" },
{},
offset
);
if (runs.length === 0) {
hasMore = false;
break;
}
for (const run of runs) {
try {
const response = safeJsonParse(run.result, { outputs: [] });
for (const output of response.outputs) {
if (!output?.payload?.storageFilename) continue;
storageFilenames.add(output.payload.storageFilename);
}
} catch {
continue;
}
}
offset += runs.length;
hasMore = runs.length === batchSize;
}
} catch (error) {
console.error("[scheduledJobRunGeneratedFilenames] Error:", error.message);
}
return storageFilenames;

View File

@ -27,8 +27,28 @@ function updateSourceDocument(docPath = null, jsonContent = {}) {
});
}
/**
* Strips thought/thinking tags from text (e.g., <thinking>...</thinking>)
* Useful for cleaning LLM responses before sending notifications.
* @param {string} text - The text to strip thoughts from.
* @returns {string} - The text with thought tags and their content removed.
*/
const THOUGHT_KEYWORDS = ["thought", "thinking", "think", "thought_chain"];
const THOUGHT_REGEX_COMPLETE = new RegExp(
THOUGHT_KEYWORDS.map(
(keyword) =>
`<${keyword}\\s*(?:[^>]*?)?\\s*>[\\s\\S]*?<\\/${keyword}\\s*(?:[^>]*?)?>`
).join("|"),
"gi"
);
function stripThinkingFromText(text = "") {
return text.replace(THOUGHT_REGEX_COMPLETE, "").trim();
}
module.exports = {
log,
conclude,
updateSourceDocument,
stripThinkingFromText,
};

View File

@ -0,0 +1,111 @@
const { safeJsonParse } = require("../../utils/http");
const { stripThinkingFromText } = require("./index.js");
/**
* Maximum time in milliseconds a scheduled job can run before being terminated.
* @type {number}
*/
const SCHEDULED_JOB_TIMEOUT_MS =
Number(process.env.SCHEDULED_JOB_TIMEOUT_MS) || 5 * 60 * 1000;
/**
* Create a callback function for the agent action.
* This is for intercepting messages from the agent and storing the results or thoughts, executions, traces, etc.
* @returns {object}
*/
function agentActionCb() {
const thoughts = [];
const toolCalls = [];
// Use a container object so the reference is preserved when values are updated
const state = {
textResponse: "",
metrics: {},
};
const handler = {
send(jsonStr) {
const data = safeJsonParse(jsonStr, null);
if (!data) return;
if (data.type === "statusResponse" && data.content) {
thoughts.push(data.content);
return;
}
if (data.type === "reportStreamEvent" && data.content) {
const inner = data.content;
if (inner.type === "textResponseChunk" && inner.content)
state.textResponse += inner.content;
if (inner.type === "fullTextResponse" && inner.content)
state.textResponse = inner.content;
if (inner.type === "usageMetrics" && inner.metrics)
state.metrics = inner.metrics;
return;
}
// Final message from agent (onMessage event)
if (data.content && data.from && data.from !== "USER") {
if (!state.textResponse) state.textResponse = data.content;
}
},
close() {},
};
return {
/** Handler to intercept messages from the agent */
handler,
/** Thoughts from the agent @type {string[]} */
thoughts,
/** Tool calls from the agent @type {object[]} */
toolCalls,
/** State container for textResponse and metrics - access via state.textResponse and state.metrics */
state,
};
}
function truncateNotificationBody(bodyText = "") {
if (!bodyText) return "Job completed";
if (bodyText.length <= 100) return bodyText;
return bodyText.slice(0, 100) + (bodyText.length > 100 ? "..." : "");
}
/**
* Send a web push notification to the primary user.
* @param {object} job - The scheduled job object.
* @param {string} runId - The ID of the scheduled job run.
* @param {string} textResponse - The text response from the agent.
* @param {function} logFn - The function to log the error.
* @returns {Promise<void>}
*/
async function sendWebPushNotification(job, runId, textResponse, logFn) {
try {
const {
pushNotificationService,
} = require("../../utils/PushNotifications/index.js");
await pushNotificationService.loadSubscriptions();
// Strip thinking tags from the text response and then truncate to 100 characters
// if the response is longer than 100 characters.
let notificationBody = stripThinkingFromText(textResponse);
notificationBody = truncateNotificationBody(notificationBody);
await pushNotificationService.sendNotification({
to: "primary",
payload: {
title: `${job.name} completed`,
body: notificationBody,
data: {
onClickUrl: `/settings/scheduled-jobs/${job.id}/runs/${runId}`,
},
},
});
} catch (pushError) {
logFn(`Failed to send push notification: ${pushError.message}`);
}
}
module.exports = {
sendWebPushNotification,
SCHEDULED_JOB_TIMEOUT_MS,
agentActionCb,
};

View File

@ -0,0 +1,157 @@
const { log, conclude } = require("./helpers/index.js");
const { v4: uuidv4 } = require("uuid");
const { safeJsonParse } = require("../utils/http");
const {
agentActionCb,
SCHEDULED_JOB_TIMEOUT_MS,
sendWebPushNotification,
} = require("./helpers/scheduled-job-helper.js");
const { ScheduledJob } = require("../models/scheduledJob.js");
const { ScheduledJobRun } = require("../models/scheduledJobRun.js");
/** Status of the scheduled job run @type {'success' | 'failed' | 'timed_out' | 'not_found' | 'killed' | undefined} */
let status;
let runId = null;
process.on("SIGTERM", async () => {
status = "killed";
log("Received SIGTERM, marking job as killed by user");
if (runId) await ScheduledJobRun.kill(runId);
conclude();
});
process.on("message", async (payload) => {
const { jobId, runId: payloadRunId } = payload;
runId = payloadRunId;
let timeoutId = null;
let errorMessage = null;
// The run row was created by the parent process (BackgroundService) in
// status `queued` (it may have been waiting in p-queue). The worker
// transitions it to `running` here so `startedAt` reflects actual execution
// start, then runs to a terminal state. If the job has been deleted between
// enqueue and now, fail the row.
try {
if (!jobId || !runId) return;
const job = await ScheduledJob.get({ id: Number(jobId) });
if (!job) {
log(`Scheduled job ${jobId} not found`);
status = "not_found";
return;
}
// Transition queued -> running. If this returns false, the row was
// already moved to a terminal state (e.g. parent failed it because it
// thought the worker had died). Bail out without touching it further.
const transitioned = await ScheduledJobRun.markRunning(runId);
if (!transitioned) {
log(
`Scheduled job "${job.name}" (id=${job.id}) is no longer queued, skipping`
);
return;
}
log(
`Starting scheduled job: "${job.name}" (id=${job.id}) with timeout ${SCHEDULED_JOB_TIMEOUT_MS}ms`
);
await ScheduledJob.updateRunTimestamps(job.id);
const { handler, thoughts, toolCalls, state } = agentActionCb();
const { EphemeralAgentHandler } = require("../utils/agents/ephemeral.js");
const agentHandler = await new EphemeralAgentHandler({
uuid: uuidv4(),
prompt: job.prompt,
}).init();
// Tool overrides control which tools the agent can use:
// - Array with items: only those specific tools are loaded
// - Empty array: no tools are loaded
const toolOverrides = safeJsonParse(job.tools, []);
await agentHandler.createAIbitat({
handler,
toolOverrides,
});
// Auto-approve all tool invocations when running a scheduled job
agentHandler.aibitat.requestToolApproval = async () => {
log("Tool approval requested for scheduled job, auto-approving");
return {
approved: true,
message: "Auto-approved by scheduled job runner.",
};
};
// Capture tool results for the execution trace
agentHandler.aibitat.onToolCallResult(
({ toolName, arguments: args, result }) => {
toolCalls.push({
toolName,
arguments: args,
result,
timestamp: Date.now(),
});
}
);
const startTime = Date.now();
await Promise.race([
agentHandler.startAgentCluster(),
new Promise((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error("SCHEDULED_JOB_TIMEOUT")),
SCHEDULED_JOB_TIMEOUT_MS
);
}),
]).finally(() => {
if (!timeoutId) return;
clearTimeout(timeoutId);
timeoutId = null;
});
const duration = Date.now() - startTime;
// Get outputs from aibitat which include proper type info (e.g., PptxFileDownload, ExcelFileDownload)
// for correct re-rendering when porting to workspace chat
const outputs = agentHandler.getPendingOutputs();
status = "success";
await ScheduledJobRun.complete(runId, {
result: {
text: state.textResponse,
thoughts,
toolCalls,
outputs,
metrics: state.metrics,
duration,
},
});
log(`Scheduled job "${job.name}" completed in ${duration}ms)`);
await sendWebPushNotification(job, runId, state.textResponse, log);
} catch (error) {
if (error.message === "SCHEDULED_JOB_TIMEOUT") {
status = "timed_out";
log("Scheduled job timed out");
} else {
status = "failed";
log(`Scheduled job error: ${error.message}`);
errorMessage = error.message;
}
} finally {
switch (status) {
case "not_found":
await ScheduledJobRun.failIfNotTerminal(runId, "Job no longer exists");
break;
case "timed_out":
await ScheduledJobRun.timeout(runId);
break;
case "failed":
await ScheduledJobRun.fail(runId, { error: errorMessage });
break;
default: // Do nothing by default (success, killed, other)
break;
}
if (timeoutId) clearTimeout(timeoutId);
conclude();
}
});

View File

@ -0,0 +1,499 @@
const prisma = require("../utils/prisma");
const later = require("@breejs/later");
const cronValidate = require("cron-validate").default;
// Use UTC time for cron interpretation. This ensures consistent behavior
// regardless of server timezone (e.g., when running in containers).
// The frontend is responsible for converting user's local time to UTC
// when creating/editing schedules, and converting UTC back to local time
// when displaying.
later.date.UTC();
const ScheduledJob = {
writable: ["name", "prompt", "tools", "schedule", "enabled"],
/**
* Maximum number of scheduled jobs that can be enabled at once.
* null = no limit. Set to a positive integer to cap concurrent active jobs;
* attempting to enable a job past the cap will be rejected at the API layer.
* @todo: add a configuration option for this
* @type {number|null}
*/
MAX_ACTIVE: null,
/**
* Compute the next run time from a cron expression.
* Uses @breejs/later which is already available via Bree.
* @param {string} cronExpression
* @returns {Date|null}
*/
computeNextRunAt: function (cronExpression) {
try {
const sched = later.parse.cron(cronExpression);
const next = later.schedule(sched).next(1);
return next || null;
} catch (error) {
console.error(
"Failed to compute next run time from cron:",
error.message
);
return null;
}
},
/**
* Validate a cron expression.
* Uses cron-validate which is already available via Bree.
* @param {string} cronExpression
* @returns {boolean}
*/
isValidCron: function (cronExpression) {
try {
return cronValidate(cronExpression).isValid();
} catch {
return false;
}
},
create: async function ({ name, prompt, tools = null, schedule } = {}) {
try {
const nextRunAt = this.computeNextRunAt(schedule);
const job = await prisma.scheduled_jobs.create({
data: {
name: String(name),
prompt: String(prompt),
tools: tools ? JSON.stringify(tools) : null,
schedule: String(schedule),
nextRunAt,
},
});
return { job, error: null };
} catch (error) {
console.error("Failed to create scheduled job:", error.message);
return { job: null, error: error.message };
}
},
update: async function (id, data = {}) {
try {
const updates = {};
for (const key of this.writable) {
if (data.hasOwnProperty(key)) {
if (key === "tools") {
updates[key] = data[key] ? JSON.stringify(data[key]) : null;
} else {
updates[key] = data[key];
}
}
}
// Recompute nextRunAt if schedule changed
if (updates.schedule) {
updates.nextRunAt = this.computeNextRunAt(updates.schedule);
}
updates.updatedAt = new Date();
const job = await prisma.scheduled_jobs.update({
where: { id: Number(id) },
data: updates,
});
return { job, error: null };
} catch (error) {
console.error("Failed to update scheduled job:", error.message);
return { job: null, error: error.message };
}
},
get: async function (clause = {}) {
try {
const job = await prisma.scheduled_jobs.findFirst({ where: clause });
return job || null;
} catch (error) {
console.error("Failed to get scheduled job:", error.message);
return null;
}
},
where: async function (
clause = {},
limit = null,
orderBy = null,
include = {}
) {
try {
const results = await prisma.scheduled_jobs.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null
? { orderBy }
: { orderBy: { createdAt: "desc" } }),
...(Object.keys(include).length > 0 ? { include } : {}),
});
return results;
} catch (error) {
console.error("Failed to query scheduled jobs:", error.message);
return [];
}
},
delete: async function (id) {
try {
await prisma.scheduled_jobs.delete({ where: { id: Number(id) } });
return true;
} catch (error) {
console.error("Failed to delete scheduled job:", error.message);
return false;
}
},
allEnabled: async function () {
try {
return await prisma.scheduled_jobs.findMany({
where: { enabled: true },
});
} catch (error) {
console.error("Failed to get enabled scheduled jobs:", error.message);
return [];
}
},
/**
* Count enabled scheduled jobs, optionally excluding a single job by id.
* `excludeId` is used by canActivate so that re-saving an already-enabled job
* is not double-counted against the limit.
* @param {number|null} excludeId
* @returns {Promise<number>}
*/
countActive: async function (excludeId = null) {
try {
return await prisma.scheduled_jobs.count({
where: {
enabled: true,
...(excludeId != null ? { NOT: { id: Number(excludeId) } } : {}),
},
});
} catch (error) {
console.error("Failed to count active scheduled jobs:", error.message);
return 0;
}
},
/**
* Check whether a job can be activated without exceeding MAX_ACTIVE.
* Pass `excludeId` when re-saving an existing job to avoid counting it twice.
* @param {{ excludeId?: number|null }} [opts]
* @returns {Promise<{ allowed: boolean, limit: number|null, current: number }>}
*/
canActivate: async function ({ excludeId = null } = {}) {
const limit = this.MAX_ACTIVE;
if (limit == null) {
return { allowed: true, limit: null, current: 0 };
}
const current = await this.countActive(excludeId);
return { allowed: current < limit, limit, current };
},
/**
* Recompute nextRunAt from the current time.
* Used on cold startup to correct stale nextRunAt values.
* @param {number} id
*/
recomputeNextRunAt: async function (id) {
try {
const job = await this.get({ id: Number(id) });
if (!job) return;
const nextRunAt = this.computeNextRunAt(job.schedule);
if (!nextRunAt) return;
await prisma.scheduled_jobs.update({
where: { id: Number(id) },
data: { nextRunAt, updatedAt: new Date() },
});
} catch (error) {
console.error("Failed to recompute nextRunAt:", error.message);
}
},
/**
* Update lastRunAt and nextRunAt after a job run.
* @param {number} id
*/
updateRunTimestamps: async function (id) {
try {
const job = await this.get({ id: Number(id) });
if (!job) return;
const nextRunAt = this.computeNextRunAt(job.schedule);
await prisma.scheduled_jobs.update({
where: { id: Number(id) },
data: {
lastRunAt: new Date(),
nextRunAt,
updatedAt: new Date(),
},
});
} catch (error) {
console.error("Failed to update run timestamps:", error.message);
}
},
/**
* Get ALL available tools for scheduled jobs to choose from.
* Unlike the global agent settings, each scheduled job can have its own tool configuration.
* This returns all possible tools so users can enable different tools for different scheduled tasks.
*
* @returns {Promise<{
* category: string,
* name: string,
* items: Array<{ id: string, name: string, description?: string, requiresSetup?: boolean }>
* }[]>}
*/
availableTools: async function () {
const AgentPlugins = require("../utils/agents/aibitat/plugins");
const ImportedPlugin = require("../utils/agents/imported");
const { AgentFlows } = require("../utils/agentFlows");
const MCPCompatibilityLayer = require("../utils/MCP");
const {
listSQLConnections,
} = require("../utils/agents/aibitat/plugins/sql-agent/SQLConnectors");
const {
GmailBridge,
} = require("../utils/agents/aibitat/plugins/gmail/lib");
const {
GoogleCalendarBridge,
} = require("../utils/agents/aibitat/plugins/google-calendar/lib");
const {
OutlookBridge,
} = require("../utils/agents/aibitat/plugins/outlook/lib");
const categories = [];
// Check which skills need setup
const sqlConnections = await listSQLConnections();
const sqlNeedsSetup = sqlConnections.length === 0;
const gmailConfig = await GmailBridge.getConfig();
const gmailNeedsSetup = !gmailConfig.deploymentId || !gmailConfig.apiKey;
const gcalConfig = await GoogleCalendarBridge.getConfig();
const gcalNeedsSetup = !gcalConfig.deploymentId || !gcalConfig.apiKey;
const outlookConfig = await OutlookBridge.getConfig();
const outlookNeedsSetup =
!outlookConfig.clientId ||
!outlookConfig.clientSecret ||
!outlookConfig.accessToken;
// Default skills (always available)
const DEFAULT_SKILLS = [
{
id: "rag-memory",
name: "RAG Memory",
description: "Recall and cite information from embedded documents",
},
{
id: "document-summarizer",
name: "Document Summarizer",
description: "Summarize documents in the workspace",
},
{
id: "web-scraping",
name: "Web Scraping",
description: "Scrape content from web pages",
},
];
// Configurable skills without sub-skills
const SIMPLE_CONFIGURABLE_SKILLS = [
{
id: "create-chart",
name: "Create Charts",
description: "Generate data visualization charts",
},
{
id: "web-browsing",
name: "Web Browsing",
description: "Search and browse the web",
},
{
id: "sql-agent",
name: "SQL Agent",
description: "Query connected SQL databases",
requiresSetup: sqlNeedsSetup,
},
];
// Build agent skills category
const agentSkillItems = [...DEFAULT_SKILLS, ...SIMPLE_CONFIGURABLE_SKILLS];
if (agentSkillItems.length > 0) {
categories.push({
category: "agent-skills",
name: "Agent Skills",
items: agentSkillItems,
});
}
// Helper to prettify a sub-skill name (e.g., "gmail-get-inbox" -> "Get Inbox")
const prettifySubSkillName = (name, prefix) => {
let cleaned = name;
const prefixes = [prefix, "gcal", "filesystem", "create"];
for (const p of prefixes) {
if (cleaned.startsWith(`${p}-`)) {
cleaned = cleaned.slice(p.length + 1);
break;
}
}
return cleaned
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
};
// Helper function to build sub-skill items from AgentPlugins
const buildSubSkillItems = (pluginKey, namePrefix) => {
const plugin = AgentPlugins[pluginKey];
if (!plugin || !Array.isArray(plugin.plugin)) return [];
return plugin.plugin.map((subPlugin) => ({
id: `${plugin.name}#${subPlugin.name}`,
name: prettifySubSkillName(subPlugin.name, namePrefix),
description: subPlugin.description || null,
}));
};
// Filesystem Agent (has sub-skills)
const filesystemItems = buildSubSkillItems("filesystemAgent", "filesystem");
if (filesystemItems.length > 0) {
categories.push({
category: "filesystem-agent",
name: "File System",
items: filesystemItems,
});
}
// Create Files Agent (has sub-skills)
const createFilesItems = buildSubSkillItems("createFilesAgent", "create");
if (createFilesItems.length > 0) {
categories.push({
category: "create-files-agent",
name: "Create Files",
items: createFilesItems,
});
}
// Gmail Agent (has sub-skills)
const gmailItems = buildSubSkillItems("gmailAgent", "gmail");
if (gmailItems.length > 0) {
categories.push({
category: "gmail-agent",
name: "Gmail",
items: gmailItems.map((item) => ({
...item,
requiresSetup: gmailNeedsSetup,
})),
requiresSetup: gmailNeedsSetup,
});
}
// Google Calendar Agent (has sub-skills)
const googleCalendarItems = buildSubSkillItems(
"googleCalendarAgent",
"gcal"
);
if (googleCalendarItems.length > 0) {
categories.push({
category: "google-calendar-agent",
name: "Google Calendar",
items: googleCalendarItems.map((item) => ({
...item,
requiresSetup: gcalNeedsSetup,
})),
requiresSetup: gcalNeedsSetup,
});
}
// Outlook Agent (has sub-skills)
const outlookItems = buildSubSkillItems("outlookAgent", "outlook");
if (outlookItems.length > 0) {
categories.push({
category: "outlook-agent",
name: "Outlook",
items: outlookItems.map((item) => ({
...item,
requiresSetup: outlookNeedsSetup,
})),
requiresSetup: outlookNeedsSetup,
});
}
// Custom/imported skills category
const importedPlugins = ImportedPlugin.listImportedPlugins();
if (importedPlugins.length > 0) {
const customSkillItems = importedPlugins.map((plugin) => ({
id: `@@${plugin.hubId}`,
name: plugin.name || plugin.hubId,
description: plugin.description || null,
}));
categories.push({
category: "custom-skills",
name: "Custom Skills",
items: customSkillItems,
});
}
// Agent flows category
const allFlows = AgentFlows.listFlows();
if (allFlows.length > 0) {
const flowItems = allFlows.map((flow) => ({
id: `@@flow_${flow.uuid}`,
name: flow.name,
description: flow.description || null,
}));
categories.push({
category: "agent-flows",
name: "Agent Flows",
items: flowItems,
});
}
// MCP servers category - get all servers
// MCP servers are selected as a whole (@@mcp_serverName), not individual tools.
// The agent loader expands the server into its individual tools at runtime.
try {
const mcpLayer = new MCPCompatibilityLayer();
const servers = await mcpLayer.servers();
const mcpItems = [];
for (const server of servers) {
const toolCount = server.tools?.length || 0;
mcpItems.push({
id: `@@mcp_${server.name}`,
name: server.name,
description:
toolCount > 0
? `${toolCount} tools available`
: "No tools available",
});
}
if (mcpItems.length > 0) {
categories.push({
category: "mcp-servers",
name: "MCP Servers",
items: mcpItems,
});
}
} catch (error) {
console.error("Failed to load MCP servers for available tools:", error);
}
return categories;
},
};
module.exports = { ScheduledJob };

View File

@ -0,0 +1,362 @@
const prisma = require("../utils/prisma");
const ScheduledJobRun = {
statuses: {
queued: "queued",
running: "running",
completed: "completed",
failed: "failed",
timed_out: "timed_out",
},
// Non-terminal statuses — a row in any of these states is considered
// "in flight" for dedup purposes. The parent claims a row as `queued`
// when enqueuing; the worker transitions it to `running` once it
// actually begins executing (it may sit in p-queue first).
nonTerminalStatuses: ["queued", "running"],
/**
* Claim a new run for a job. At most one in-flight run per job is allowed
* if a `queued` or `running` row already exists, this returns null and the
* caller should drop the request. The check + insert run inside an
* interactive transaction so two concurrent callers cannot both pass;
* SQLite serializes writes.
*
* The row is created in `queued` status; the worker transitions it to
* `running` via markRunning() once it actually begins executing.
*
* @param {number} jobId
* @returns {Promise<object|null>} The created run row, or null if a run is
* already in progress for this job (or on failure).
*/
start: async function (jobId) {
try {
return await prisma.$transaction(async (tx) => {
const existing = await tx.scheduled_job_runs.findFirst({
where: {
jobId: Number(jobId),
status: { in: this.nonTerminalStatuses },
},
select: { id: true },
});
if (existing) return null;
return tx.scheduled_job_runs.create({
data: {
jobId: Number(jobId),
status: this.statuses.queued,
},
});
});
} catch (error) {
console.error("Failed to enqueue scheduled job run:", error.message);
return null;
}
},
/**
* Transition a queued run into the running state. Called by the worker as
* its first DB write, so `startedAt` reflects actual execution start rather
* than queue-claim time. Filtered updateMany makes it a no-op if the row
* has already been transitioned to a terminal state (e.g. parent failed it
* because the worker failed to spawn, then a stale child somehow boots).
*
* @param {number} id - scheduled_job_runs.id
* @returns {Promise<boolean>} true if the row transitioned, false otherwise
*/
markRunning: async function (id) {
try {
const result = await prisma.scheduled_job_runs.updateMany({
where: { id: Number(id), status: this.statuses.queued },
data: {
status: this.statuses.running,
startedAt: new Date(),
},
});
return result.count > 0;
} catch (error) {
console.error(
"Failed to transition scheduled job run to running:",
error.message
);
return false;
}
},
/**
* Mark a run as failed only if it has not already reached a terminal state.
* Used by the parent process when a worker exits unexpectedly atomic
* filtered update prevents clobbering a row the worker already transitioned
* to `completed` (the rare race where the worker succeeded but exited
* non-zero during cleanup).
* @param {number} id - scheduled_job_runs.id
* @param {string} errorMsg
*/
failIfNotTerminal: async function (id, errorMsg) {
try {
const result = await prisma.scheduled_job_runs.updateMany({
where: {
id: Number(id),
status: { in: this.nonTerminalStatuses },
},
data: {
status: this.statuses.failed,
error: String(errorMsg || "Worker exited unexpectedly"),
completedAt: new Date(),
},
});
return result.count > 0;
} catch (error) {
console.error(
"Failed to conditionally fail scheduled job run:",
error.message
);
return false;
}
},
complete: async function (id, { result } = {}) {
try {
const run = await prisma.scheduled_job_runs.update({
where: { id: Number(id) },
data: {
status: this.statuses.completed,
result: typeof result === "string" ? result : JSON.stringify(result),
completedAt: new Date(),
},
});
return run;
} catch (error) {
console.error("Failed to complete scheduled job run:", error.message);
return null;
}
},
fail: async function (id, { error: errorMsg } = {}) {
try {
// Use updateMany with a filter to avoid overwriting a run that was
// already moved to a terminal state (e.g., killed by user).
const result = await prisma.scheduled_job_runs.updateMany({
where: {
id: Number(id),
status: { in: this.nonTerminalStatuses },
},
data: {
status: this.statuses.failed,
error: String(errorMsg || "Unknown error"),
completedAt: new Date(),
},
});
if (result.count === 0) return null;
return await this.get({ id: Number(id) });
} catch (error) {
console.error(
"Failed to mark scheduled job run as failed:",
error.message
);
return null;
}
},
timeout: async function (id) {
try {
// Use updateMany with a filter to avoid overwriting a run that was
// already moved to a terminal state (e.g., killed by user).
const result = await prisma.scheduled_job_runs.updateMany({
where: {
id: Number(id),
status: { in: this.nonTerminalStatuses },
},
data: {
status: this.statuses.timed_out,
error: "Job execution timed out",
completedAt: new Date(),
},
});
if (result.count === 0) return null;
return await this.get({ id: Number(id) });
} catch (error) {
console.error(
"Failed to mark scheduled job run as timed out:",
error.message
);
return null;
}
},
/**
* Kill a running or queued job run. This marks the run as failed with a
* user-initiated kill message. The actual worker process termination is
* handled by BackgroundService.killRun().
* - Killing a run will also mark it as read (user killed it, so dont bother with unread status)
* @param {number} id - scheduled_job_runs.id
* @returns {Promise<object|null>} The updated run row, or null if not killable
*/
kill: async function (id) {
try {
const result = await prisma.scheduled_job_runs.updateMany({
where: {
id: Number(id),
status: { in: this.nonTerminalStatuses },
},
data: {
status: this.statuses.failed,
error: "Job killed by user",
completedAt: new Date(),
readAt: new Date(),
},
});
if (result.count === 0) return null;
return await this.get({ id: Number(id) });
} catch (error) {
console.error("Failed to kill scheduled job run:", error.message);
return null;
}
},
get: async function (clause = {}, include = {}) {
try {
const run = await prisma.scheduled_job_runs.findFirst({
where: clause,
...(Object.keys(include).length > 0 ? { include } : {}),
});
return run || null;
} catch (error) {
console.error("Failed to get scheduled job run:", error.message);
return null;
}
},
where: async function (
clause = {},
limit = null,
orderBy = null,
include = {},
offset = 0
) {
try {
const results = await prisma.scheduled_job_runs.findMany({
where: clause,
...(limit !== null ? { take: limit } : {}),
...(orderBy !== null
? { orderBy }
: { orderBy: { startedAt: "desc" } }),
...(Object.keys(include).length > 0 ? { include } : {}),
...(offset !== null ? { skip: offset } : {}),
});
return results;
} catch (error) {
console.error("Failed to query scheduled job runs:", error.message);
return [];
}
},
markRead: async function (id) {
try {
await prisma.scheduled_job_runs.update({
where: { id: Number(id) },
data: { readAt: new Date() },
});
return true;
} catch (error) {
console.error("Failed to mark run as read:", error.message);
return false;
}
},
delete: async function (clause = {}) {
try {
await prisma.scheduled_job_runs.deleteMany({ where: clause });
return true;
} catch (error) {
console.error("Failed to delete scheduled job runs:", error.message);
return false;
}
},
/**
* Mark all orphaned in-flight runs (queued or running) as failed used on
* cold startup to recover rows whose owning worker died with the server.
*/
failOrphanedRuns: async function () {
try {
const result = await prisma.scheduled_job_runs.updateMany({
where: { status: { in: this.nonTerminalStatuses } },
data: {
status: this.statuses.failed,
error: "Server restarted during execution",
completedAt: new Date(),
},
});
return result.count;
} catch (error) {
console.error("Failed to fail orphaned runs:", error.message);
return 0;
}
},
/**
* Continue a run in a workspace thread.
* This will create a new workspace and thread specific for the run if they do not exist, and add the run's response to the thread.
* @param {number} runId - The ID of the run to continue.
* @returns {Promise<{workspace: import("@prisma/client").workspaces | null, thread: import("@prisma/client").workspace_threads | null, error: string | null}>} A promise that resolves to an object containing the workspace, thread, and an error message if applicable.
*/
continueInThread: async function (runId) {
try {
const { Workspace } = require("./workspace");
const { WorkspaceThread } = require("./workspaceThread");
const { WorkspaceChats } = require("./workspaceChats");
const { safeJsonParse } = require("../utils/http");
const run = await this.get({ id: Number(runId) }, { job: true });
if (!run) throw new Error("Run not found");
const result = safeJsonParse(run.result, {});
const responseText = result?.text || "No response was generated.";
// Get or create the "Scheduled Jobs" workspace
const { workspace, error: workspaceError } = await Workspace.upsert(
{ slug: "scheduled-jobs" },
{
name: "Scheduled Jobs",
slug: "scheduled-jobs",
chatMode: "automatic",
}
);
if (workspaceError)
throw new Error(workspaceError || "Failed to create workspace");
const { thread, message: threadError } =
await WorkspaceThread.new(workspace);
if (threadError)
throw new Error(threadError || "Failed to create thread");
await WorkspaceChats.new({
workspaceId: workspace.id,
prompt: run.job.prompt,
response: {
text: responseText,
sources: result.sources || [],
outputs: result.outputs || [],
type: "chat",
},
threadId: thread.id,
include: true,
});
return {
workspace,
thread,
error: null,
};
} catch (error) {
return {
workspace: null,
thread: null,
error: error.message ?? "Unknown error",
};
}
},
};
module.exports = { ScheduledJobRun };

View File

@ -28,6 +28,7 @@ const Telemetry = {
link_uploaded: 30,
raw_document_uploaded: 30,
document_parsed: 30,
agent_generated_file_downloaded: 30,
},
id: async function () {

View File

@ -566,6 +566,29 @@ const Workspace = {
}
},
/**
* Upsert a workspace.
* If the workspace does not exist, it will be created.
* If the workspace exists, it will be updated (if data is provided).
* @param {Object} clause - The clause to upsert the workspace by.
* @param {Object} createData - The data to create the workspace with.
* @param {Object} updateData - The data to update the workspace with if it already exists.
* @returns {Promise<{workspace: import("@prisma/client").workspaces | null, error: string | null}>} A promise that resolves to an object containing the upserted workspace and an error message if applicable.
*/
upsert: async function (clause = {}, createData = {}, updateData = {}) {
try {
const workspace = await prisma.workspaces.upsert({
where: clause,
update: updateData,
create: createData,
});
return { workspace, error: null };
} catch (error) {
console.error(error.message);
return { workspace: null, error: error.message };
}
},
/**
* Get the prompt history for a workspace.
* @param {Object} options - The options to get prompt history for.

View File

@ -22,6 +22,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@aws-sdk/client-bedrock-runtime": "^3.775.0",
"@breejs/later": "4.2.0",
"@datastax/astra-db-ts": "^0.1.3",
"@ladjs/graceful": "^3.2.2",
"@lancedb/lancedb": "0.15.0",
@ -54,6 +55,7 @@
"chromadb": "^2.0.1",
"cohere-ai": "^7.19.0",
"cors": "^2.8.5",
"cron-validate": "1.4.5",
"diff": "7.0.0",
"docx": "9.6.1",
"dompurify": "3.3.3",
@ -82,6 +84,7 @@
"node-telegram-bot-api": "^0.67.0",
"ollama": "^0.6.3",
"openai": "4.95.1",
"p-queue": "6.6.2",
"pdf-lib": "1.17.1",
"pg": "^8.11.5",
"pinecone-client": "^1.1.0",

View File

@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "scheduled_jobs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"prompt" TEXT NOT NULL,
"tools" TEXT,
"schedule" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"lastRunAt" DATETIME,
"nextRunAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "scheduled_job_runs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"jobId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'queued',
"result" TEXT,
"error" TEXT,
"startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" DATETIME,
"readAt" DATETIME,
CONSTRAINT "scheduled_job_runs_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "scheduled_jobs" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "scheduled_job_runs_jobId_idx" ON "scheduled_job_runs"("jobId");

View File

@ -395,3 +395,31 @@ model external_communication_connectors {
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
}
model scheduled_jobs {
id Int @id @default(autoincrement())
name String
prompt String
tools String? // JSON array of tool identifiers (null = use all enabled agent skills)
schedule String // Cron expression
enabled Boolean @default(true)
lastRunAt DateTime?
nextRunAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
runs scheduled_job_runs[]
}
model scheduled_job_runs {
id Int @id @default(autoincrement())
jobId Int
status String @default("queued") // queued | running | completed | failed | timed_out — model always sets explicitly
result String? // JSON execution trace
error String?
startedAt DateTime @default(now())
completedAt DateTime?
readAt DateTime? // null = unread
job scheduled_jobs @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([jobId])
}

View File

@ -1,13 +1,28 @@
const path = require("path");
const Graceful = require("@ladjs/graceful");
const Bree = require("@mintplex-labs/bree");
const later = require("@breejs/later");
const PQueue = require("p-queue").default;
const setLogger = require("../logger");
// Use UTC time for cron interpretation. This ensures consistent behavior
// regardless of server timezone (e.g., when running in containers).
later.date.UTC();
class BackgroundService {
name = "BackgroundWorkerService";
static _instance = null;
documentSyncEnabled = false;
#root = path.resolve(__dirname, "../../jobs");
#scheduledJobTimers = new Map();
#scheduledJobQueue = new PQueue({
concurrency: Number(process.env.SCHEDULED_JOB_MAX_CONCURRENT) || 1,
});
// Tracks in-flight worker processes per scheduled jobId so we can kill any
// active runs when the job is deleted. Without this, a running worker
// outlives the cascade-delete of its scheduled_job_runs row and throws when
// it tries to write the result back (prisma.update on a missing row).
#scheduledJobWorkers = new Map();
#alwaysRunJobs = [
{
@ -76,7 +91,18 @@ class BackgroundService {
async boot() {
const { DocumentSyncQueue } = require("../../models/documentSyncQueue");
const { ScheduledJobRun } = require("../../models/scheduledJobRun");
this.documentSyncEnabled = await DocumentSyncQueue.enabled();
// Mark any orphaned scheduled job runs as failed (server crashed mid-execution)
const orphanedCount = await ScheduledJobRun.failOrphanedRuns();
if (orphanedCount > 0) {
this.#log(
`Marked ${orphanedCount} orphaned scheduled job run(s) as failed`
);
}
const jobsToRun = this.jobs();
this.#log("Starting...");
@ -90,15 +116,30 @@ class BackgroundService {
});
this.graceful = new Graceful({ brees: [this.bree], logger: this.logger });
this.graceful.listen();
this.bree.start();
this.#log(
`Service started with ${jobsToRun.length} jobs`,
jobsToRun.map((j) => j.name)
);
await this.#bootScheduledJobs();
}
/**
* Cleanup scheduled jobs (in-process cron timers + p-queue)
*/
#cleanupScheduledJobs() {
for (const [id, timer] of this.#scheduledJobTimers) {
timer.clear();
this.#scheduledJobTimers.delete(id);
}
this.#scheduledJobQueue.clear();
}
async stop() {
this.#log("Stopping...");
this.#cleanupScheduledJobs();
if (!!this.graceful && !!this.bree) this.graceful.stopBree(this.bree, 0);
this.bree = null;
this.graceful = null;
@ -163,6 +204,192 @@ class BackgroundService {
/* Job may already be removed */
}
}
// ---------------------------------------------------------------
// Scheduled Jobs — in-process cron timers + p-queue
//
// Bree tightly couples scheduling with worker spawning — when a
// Bree cron fires, it directly calls run() which immediately
// spawns a child process with no way to intercept it. We manage
// our own cron timers (via later.setInterval) to decouple
// scheduling from execution so we can route jobs through p-queue
// for global concurrency control before spawning workers.
//
// Per-job dedup lives in the database, not in process memory: any
// non-terminal row (`queued` or `running`) in scheduled_job_runs means
// the job has a run in flight. ScheduledJobRun.start() does the check +
// insert atomically and creates the row in `queued` status. The worker
// transitions it to `running` once it actually begins executing, so
// `startedAt` reflects execution start rather than queue-claim time.
// Cron-fired and manually-triggered enqueues use the same rule —
// at most one in-flight run per job, regardless of source.
// ---------------------------------------------------------------
/**
* Register cron timers for all enabled scheduled jobs on startup.
*/
async #bootScheduledJobs() {
const { ScheduledJob } = require("../../models/scheduledJob");
const enabledJobs = await ScheduledJob.allEnabled();
for (const job of enabledJobs) {
await ScheduledJob.recomputeNextRunAt(job.id);
this.addScheduledJob(job);
}
if (enabledJobs.length > 0) {
this.#log(
`Registered ${enabledJobs.length} scheduled job(s) (max concurrent: ${this.#scheduledJobQueue.concurrency})`,
enabledJobs.map((j) => `${j.name} (${j.schedule})`)
);
}
}
/**
* Register an in-process cron timer for a scheduled job.
* When the cron fires, the jobId is enqueued for execution.
* @param {object} job - scheduled_jobs DB record
*/
addScheduledJob(job) {
this.removeScheduledJob(job.id);
const sched = later.parse.cron(job.schedule);
const timer = later.setInterval(() => {
this.enqueueScheduledJob(job.id);
}, sched);
this.#scheduledJobTimers.set(job.id, timer);
}
/**
* Remove an in-process cron timer for a scheduled job and kill any in-flight
* worker processes for it. Killing in-flight workers prevents them from
* writing results back to a scheduled_job_runs row that the FK cascade (from
* a subsequent ScheduledJob.delete) is about to remove.
* @param {number} jobId - scheduled_jobs.id
*/
removeScheduledJob(jobId) {
const timer = this.#scheduledJobTimers.get(jobId);
if (timer) timer.clear();
this.#scheduledJobTimers.delete(jobId);
const workers = this.#scheduledJobWorkers.get(jobId);
if (workers) {
for (const worker of workers) {
try {
worker.kill("SIGTERM");
} catch {
/* worker may have already exited */
}
}
this.#scheduledJobWorkers.delete(jobId);
}
}
/**
* Re-sync a scheduled job's cron timer after an update.
* Removes the old timer and re-adds if still enabled.
* @param {number} jobId - scheduled_jobs.id
*/
async syncScheduledJob(jobId) {
const { ScheduledJob } = require("../../models/scheduledJob");
this.removeScheduledJob(jobId);
const job = await ScheduledJob.get({ id: Number(jobId) });
if (job && job.enabled) {
this.addScheduledJob(job);
}
}
/**
* Kill a specific run's worker process. This terminates the worker but does
* not update the database the caller should use ScheduledJobRun.kill()
* before or after calling this to mark the run as failed.
*
* @param {number} jobId - scheduled_jobs.id (parent job)
* @param {number} runId - scheduled_job_runs.id (not directly used, but for
* future multi-run support; currently we kill all workers for the jobId)
* @returns {boolean} true if a worker was found and killed, false otherwise
*/
killRun(jobId, _runId) {
const workers = this.#scheduledJobWorkers.get(Number(jobId));
if (!workers || workers.size === 0) return false;
let killed = false;
for (const worker of workers) {
try {
worker.kill("SIGTERM");
killed = true;
} catch {
/* worker may have already exited */
}
}
return killed;
}
/**
* Enqueue a scheduled job for execution. Called by both the cron timer
* (in addScheduledJob) and the manual trigger endpoint. ScheduledJobRun.start()
* atomically rejects the call if the job already has a run in flight.
*
* @param {number} jobId - scheduled_jobs.id
* @returns {Promise<object|null>} the created run row, or null if skipped
* because a run is already in flight for this job.
*/
async enqueueScheduledJob(jobId) {
const { ScheduledJobRun } = require("../../models/scheduledJobRun");
const run = await ScheduledJobRun.start(jobId);
// if start returns null, skip enqueuing, schueduled job already has a run in flight
if (!run) return null;
this.#scheduledJobQueue.add(() =>
this.#runScheduledJobWorker(jobId, run.id).catch(async (err) => {
this.#log(`Scheduled job ${jobId} failed: ${err.message}`);
await ScheduledJobRun.failIfNotTerminal(run.id, err.message);
})
);
return run;
}
/**
* Spawn the run-scheduled-job worker for a given run and resolve when it
* exits so p-queue can advance. The worker reads its payload from an IPC
* `process.on("message", ...)` handler.
*
* @param {number} jobId
* @param {number} runId
* @returns {Promise<void>}
*/
async #runScheduledJobWorker(jobId, runId) {
const scriptPath = path.resolve(this.jobsRoot, "run-scheduled-job.js");
const { worker, jobId: workerId } = await this.spawnWorker(scriptPath);
if (!this.#scheduledJobWorkers.has(jobId)) {
this.#scheduledJobWorkers.set(jobId, new Set());
}
this.#scheduledJobWorkers.get(jobId).add(worker);
try {
worker.send({ jobId, runId });
await new Promise((resolve, reject) => {
worker.on("exit", (code, signal) => {
// SIGTERM is sent by removeScheduledJob when the job is deleted
// mid-run; treat that as a normal exit rather than a worker failure.
if (code === 0 || code == null || signal === "SIGTERM") {
resolve();
} else {
reject(new Error(`Worker exited with code ${code}`));
}
});
worker.on("error", reject);
});
} finally {
const workers = this.#scheduledJobWorkers.get(jobId);
if (workers) {
workers.delete(worker);
if (workers.size === 0) this.#scheduledJobWorkers.delete(jobId);
}
await this.removeJob(workerId).catch(() => {});
}
}
}
module.exports.BackgroundService = BackgroundService;

View File

@ -170,7 +170,7 @@ class PushNotifications {
* @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 {void}
* @returns {Promise<void>}
*/
sendNotification({ to = "primary", payload = {} } = {}) {
if (this.#subscriptions.size === 0)
@ -180,10 +180,16 @@ class PushNotifications {
`.sendNotification() - Subscription for user ${to} not found`
);
this.#log(`.sendNotification() - Sending notification to user ${to}`);
this.pushService.sendNotification(
this.#subscriptions.get(to),
JSON.stringify(payload)
);
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}`);
});
}
/**

View File

@ -417,6 +417,18 @@ class AIbitat {
return this;
}
/**
* Triggered when a tool call completes and returns a result.
* Used by scheduled jobs to capture tool results for the execution trace.
*
* @param listener
* @returns
*/
onToolCallResult(listener = () => null) {
this.emitter.on("toolCallResult", listener);
return this;
}
/**
* Register an error in the chat history.
* This will trigger the `onError` event.
@ -926,6 +938,11 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection
const result = await fn.handler(args);
Telemetry.sendTelemetry("agent_tool_call", { tool: name }, null, true);
this.emitter.emit("toolCallResult", {
toolName: name,
arguments: args,
result,
});
/**
* If the tool call has direct output enabled, return the result directly to the chat
@ -1079,6 +1096,11 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection
const result = await fn.handler(args);
Telemetry.sendTelemetry("agent_tool_call", { tool: name }, null, true);
this.emitter.emit("toolCallResult", {
toolName: name,
arguments: args,
result,
});
if (this.skipHandleExecution) {
this.skipHandleExecution = false;

View File

@ -55,7 +55,7 @@ class EphemeralAgentHandler extends AgentHandler {
/**
* @param {{
* uuid: string,
* workspace: import("@prisma/client").workspaces,
* workspace: import("@prisma/client").workspaces|null,
* prompt: string,
* userId: import("@prisma/client").users["id"]|null,
* threadId: import("@prisma/client").workspace_threads["id"]|null,
@ -65,7 +65,7 @@ class EphemeralAgentHandler extends AgentHandler {
*/
constructor({
uuid,
workspace,
workspace = null,
prompt,
userId = null,
threadId = null,
@ -95,6 +95,8 @@ class EphemeralAgentHandler extends AgentHandler {
}
async #chatHistory(limit = 10) {
if (!this.#workspace) return [];
try {
const rawHistory = (
await WorkspaceChats.where(
@ -144,7 +146,7 @@ class EphemeralAgentHandler extends AgentHandler {
*/
#getFallbackProvider() {
// First, fallback to the workspace chat provider and model if they exist
if (this.#workspace.chatProvider && this.#workspace.chatModel) {
if (this.#workspace?.chatProvider && this.#workspace?.chatModel) {
return {
provider: this.#workspace.chatProvider,
model: this.#workspace.chatModel,
@ -183,7 +185,7 @@ class EphemeralAgentHandler extends AgentHandler {
}
// The provider was explicitly set, so check if the workspace has an agent model set.
if (this.#workspace.agentModel) return this.#workspace.agentModel;
if (this.#workspace?.agentModel) return this.#workspace.agentModel;
// Otherwise, we have no model to use - so guess a default model to use via the provider
// and it's system ENV params and if that fails - we return either a base model or null.
@ -191,7 +193,7 @@ class EphemeralAgentHandler extends AgentHandler {
}
#providerSetupAndCheck() {
this.provider = this.#workspace.agentProvider ?? null;
this.provider = this.#workspace?.agentProvider ?? null;
this.model = this.#fetchModel();
if (!this.provider)
@ -367,6 +369,8 @@ class EphemeralAgentHandler extends AgentHandler {
* @returns {Promise<string>} Formatted context string to append to user message
*/
async #fetchParsedFileContext() {
if (!this.#workspace) return "";
const user = this.#userId ? { id: this.#userId } : null;
const thread = this.#threadId ? { id: this.#threadId } : null;
const documentManager = new DocumentManager({
@ -437,6 +441,7 @@ class EphemeralAgentHandler extends AgentHandler {
args = {
handler: null,
telegramChatId: null,
toolOverrides: null,
}
) {
this.aibitat = new AIbitat({
@ -446,7 +451,7 @@ class EphemeralAgentHandler extends AgentHandler {
handlerProps: {
invocation: {
workspace: this.#workspace,
workspace_id: this.#workspace.id,
workspace_id: this.#workspace?.id ?? null,
},
log: this.log,
},
@ -471,6 +476,13 @@ class EphemeralAgentHandler extends AgentHandler {
// Load required agents (Default + custom)
await this.#loadAgents();
// Override tools if specified (e.g., for scheduled jobs with per-job tool selection)
if (args.toolOverrides) {
this.#funcsToLoad = args.toolOverrides;
const agentDef = this.aibitat.agents.get("@agent");
if (agentDef) agentDef.functions = args.toolOverrides;
}
// Attach all required plugins for functions to operate.
await this.#attachPlugins(args);
}
@ -484,6 +496,15 @@ class EphemeralAgentHandler extends AgentHandler {
});
}
/**
* Gets pending outputs registered by plugins (e.g., file downloads).
* These outputs include the proper type for re-rendering in chat history.
* @returns {Array<{type: string, payload: object}>}
*/
getPendingOutputs() {
return this.aibitat?._pendingOutputs ?? [];
}
/**
* Determine if the message should invoke the agent handler.
* This is true when the user explicitly invokes an agent (via @agent prefix)

View File

@ -1595,7 +1595,7 @@
dependencies:
regenerator-runtime "^0.14.0"
"@breejs/later@^4.2.0":
"@breejs/later@4.2.0", "@breejs/later@^4.2.0":
version "4.2.0"
resolved "https://registry.npmjs.org/@breejs/later/-/later-4.2.0.tgz"
integrity sha512-EVMD0SgJtOuFeg0lAVbCwa+qeTKILb87jqvLyUtQswGD9+ce2nB52Y5zbTF1Hc0MDFfbydcMcxb47jSdhikVHA==
@ -4989,7 +4989,7 @@ crc32-stream@^4.0.2:
crc-32 "^1.2.0"
readable-stream "^3.4.0"
cron-validate@^1.4.5:
cron-validate@1.4.5, cron-validate@^1.4.5:
version "1.4.5"
resolved "https://registry.npmjs.org/cron-validate/-/cron-validate-1.4.5.tgz"
integrity sha512-nKlOJEnYKudMn/aNyNH8xxWczlfpaazfWV32Pcx/2St51r2bxWbGhZD7uwzMcRhunA/ZNL+Htm/i0792Z59UMQ==
@ -8467,7 +8467,7 @@ p-locate@^5.0.0:
dependencies:
p-limit "^3.0.2"
p-queue@^6.6.2:
p-queue@6.6.2, p-queue@^6.6.2:
version "6.6.2"
resolved "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz"
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==