From ff92a37345e9ef6986adcd0e30f0f73f6ee5551c Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Fri, 22 May 2026 17:57:49 +0800 Subject: [PATCH 1/3] feat(scheduled-tasks): add timing reminders & prompts (#1567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new settings page (Tools → 定时任务) for creating one-shot, daily, and weekly scheduled tasks. Each task can either raise a system notification or send a preset prompt to a chosen agent — with an opt-in autoSend toggle that bypasses the deeplink draft path via sessionService.createSession. A small in-process scheduler arms one setTimeout per task with a 12h chained-timeout cap; missed one-shot tasks are backfilled on next launch, recurring tasks are not. Persisted via ConfigPresenter, normalized on read to drop any malformed records. --- docs/features/scheduled-tasks/plan.md | 63 ++ docs/features/scheduled-tasks/spec.md | 59 ++ docs/features/scheduled-tasks/tasks.md | 14 + src/main/presenter/configPresenter/index.ts | 24 +- src/main/presenter/index.ts | 69 +- .../after-start/scheduledTasksStartHook.ts | 38 + .../beforeQuit/scheduledTasksStopHook.ts | 21 + .../lifecyclePresenter/hooks/index.ts | 2 + src/main/presenter/scheduledTasks/index.ts | 341 +++++++++ .../presenter/scheduledTasks/normalize.ts | 208 ++++++ src/main/routes/index.ts | 78 +- src/renderer/api/ScheduledTasksClient.ts | 85 +++ .../components/ScheduledTasksSettings.vue | 681 ++++++++++++++++++ src/renderer/settings/main.ts | 1 + src/renderer/src/i18n/da-DK/routes.json | 1 + src/renderer/src/i18n/da-DK/settings.json | 50 ++ src/renderer/src/i18n/en-US/routes.json | 1 + src/renderer/src/i18n/en-US/settings.json | 50 ++ src/renderer/src/i18n/fa-IR/routes.json | 1 + src/renderer/src/i18n/fa-IR/settings.json | 50 ++ src/renderer/src/i18n/fr-FR/routes.json | 1 + src/renderer/src/i18n/fr-FR/settings.json | 50 ++ src/renderer/src/i18n/he-IL/routes.json | 1 + src/renderer/src/i18n/he-IL/settings.json | 50 ++ src/renderer/src/i18n/ja-JP/routes.json | 1 + src/renderer/src/i18n/ja-JP/settings.json | 50 ++ src/renderer/src/i18n/ko-KR/routes.json | 1 + src/renderer/src/i18n/ko-KR/settings.json | 50 ++ src/renderer/src/i18n/pt-BR/routes.json | 1 + src/renderer/src/i18n/pt-BR/settings.json | 50 ++ src/renderer/src/i18n/ru-RU/routes.json | 1 + src/renderer/src/i18n/ru-RU/settings.json | 50 ++ src/renderer/src/i18n/zh-CN/routes.json | 1 + src/renderer/src/i18n/zh-CN/settings.json | 50 ++ src/renderer/src/i18n/zh-HK/routes.json | 1 + src/renderer/src/i18n/zh-HK/settings.json | 50 ++ src/renderer/src/i18n/zh-TW/routes.json | 1 + src/renderer/src/i18n/zh-TW/settings.json | 50 ++ src/shared/contracts/routes.ts | 13 + .../contracts/routes/scheduledTasks.routes.ts | 121 ++++ src/shared/contracts/routes/system.routes.ts | 1 + src/shared/scheduledTasks.ts | 56 ++ src/shared/settingsNavigation.ts | 20 + .../types/presenters/legacy.presenters.d.ts | 3 + test/main/presenter/scheduledTasks.test.ts | 227 ++++++ 45 files changed, 2704 insertions(+), 33 deletions(-) create mode 100644 docs/features/scheduled-tasks/plan.md create mode 100644 docs/features/scheduled-tasks/spec.md create mode 100644 docs/features/scheduled-tasks/tasks.md create mode 100644 src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts create mode 100644 src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts create mode 100644 src/main/presenter/scheduledTasks/index.ts create mode 100644 src/main/presenter/scheduledTasks/normalize.ts create mode 100644 src/renderer/api/ScheduledTasksClient.ts create mode 100644 src/renderer/settings/components/ScheduledTasksSettings.vue create mode 100644 src/shared/contracts/routes/scheduledTasks.routes.ts create mode 100644 src/shared/scheduledTasks.ts create mode 100644 test/main/presenter/scheduledTasks.test.ts diff --git a/docs/features/scheduled-tasks/plan.md b/docs/features/scheduled-tasks/plan.md new file mode 100644 index 000000000..bc8084c7f --- /dev/null +++ b/docs/features/scheduled-tasks/plan.md @@ -0,0 +1,63 @@ +# Implementation Plan + +## Architecture + +- **Shared types** (`src/shared/scheduledTasks.ts`) define the `Trigger`, + `Action`, `ScheduledTask`, and `ScheduledTasksSettings` shapes. +- **Route contracts** (`src/shared/contracts/routes/scheduledTasks.routes.ts`) + expose `scheduledTasks.{list,upsert,delete,toggle,fireNow}` via Zod + schemas, mirroring `onboarding.routes.ts`. +- **Persistence** is handled in `ConfigPresenter` (`get/setScheduledTasks`) + with a `normalizeScheduledTasksConfig` pass identical in pattern to + `normalizeHooksNotificationsConfig`. +- **Scheduling** lives in a new `ScheduledTasksService` + (`src/main/presenter/scheduledTasks/index.ts`). One `setTimeout` per + armed task, chained at most 12h at a time. Public surface: + - `start()` — read tasks, run startup pass (one-shot backfill, arm next + slot for recurring), called from the existing lifecycle init flow. + - `stop()` — clear all armed timers (called on app shutdown). + - `list()` / `upsert(task)` / `delete(id)` / `toggle(id, enabled)` / + `fireNow(id)` — back the IPC routes and rearm timers on mutation. + - `computeNextFireAt(task, after)` — pure function, exported for tests. +- **Action dispatch** is a small helper inside the service: switch on + `task.action.kind`, then call `notificationPresenter` and/or + `eventBus.sendToRenderer(DEEPLINK_EVENTS.START, ...)` and/or + `sessionService.createSession(...)`. + +## Wiring + +- `Presenter` constructor (`src/main/presenter/index.ts`) instantiates + `ScheduledTasksService` next to `hooksNotifications`, passing it + `configPresenter`, `notificationPresenter`, `windowPresenter`, and a + thunk that resolves `sessionService` lazily (the route runtime owns + sessionService, so the service exposes a setter the route runtime calls + during bootstrap). +- `src/main/routes/index.ts` wires the five new route cases against + `runtime.scheduledTasksService` and, in the same place that constructs + the runtime, sets the service's session-service reference so auto-send + has somewhere to call. +- Lifecycle `after-start` hook invokes `scheduledTasksService.start()` + after the other presenters have come up; the existing `beforeQuit` hook + calls `stop()`. + +## UI + +- Settings navigation adds `settings-scheduled-tasks` (group `tools`, + position 5.6, icon `lucide:clock-9`). +- `ScheduledTasksSettings.vue` mirrors `NotificationsHooksSettings.vue`: + ScrollArea + header + "新建任务" button + bordered cards per task. +- A renderer client `ScheduledTasksClient.ts` matches the + `OnboardingClient` shape. +- i18n keys go in every locale under `routes.settings-scheduled-tasks` and + `settings.scheduledTasks.*` so `pnpm run i18n` stays green. + +## Validation + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm run typecheck` +- Unit tests in `test/main/scheduledTasks/` cover `computeNextFireAt` + (daily wrap, weekly across the week, one-shot past with/without + `lastFiredAt`) and `normalizeScheduledTasksConfig` (drops malformed + entries, preserves valid ones). diff --git a/docs/features/scheduled-tasks/spec.md b/docs/features/scheduled-tasks/spec.md new file mode 100644 index 000000000..725c20bb2 --- /dev/null +++ b/docs/features/scheduled-tasks/spec.md @@ -0,0 +1,59 @@ +# Scheduled Tasks + +## Problem + +Closes [#1567](https://github.com/ThinkInAIXYZ/deepchat/issues/1567). + +Users want to schedule "reminders" and "planned tasks" inside DeepChat — +either a plain notification ("drink water at 4pm every day") or a scheduled +chat prompt ("every morning at 9 ask the deepchat agent for today's plan"). +No equivalent feature exists today; the only time-aware paths are +`hooksNotifications` (event-driven, not time-driven) and the deeplink +"start" flow (which has `autoSend` security-disabled, so it can only +prefill a chat draft). + +## User Story + +As a DeepChat user, I want to create a scheduled task with a trigger time +(once at a specific datetime, daily, or weekly on a chosen day) and an +action (raise a system notification, prefill a new chat thread with a +preset prompt, or auto-send a preset prompt to a chosen agent/model). I +want my tasks to persist across app restarts; one-shot tasks that I missed +because the app was closed should still fire on next launch. + +## Acceptance Criteria + +- A "定时任务" entry exists in Settings → Tools (between Notifications & + Hooks and Plugins). It lists, creates, edits, toggles, deletes, and + manually fires user-defined scheduled tasks. +- Each task has: + - A name and an enabled toggle. + - A trigger of one of three kinds: `once` (a specific datetime), + `daily` (hour + minute), or `weekly` (day-of-week + hour + minute). + - An action of one of two kinds: `notify` (title + body for the system + notification) or `prompt` (notification title + chat message + optional + agent / provider / model / system prompt + `autoSend` toggle). +- When a task fires: + - `notify`: a system notification appears via `notificationPresenter`, + subject to the existing `notificationsEnabled` config. + - `prompt` with `autoSend = false`: a system notification appears and the + main window's new-thread page receives the deeplink-start payload, + prefilling the chat input. + - `prompt` with `autoSend = true`: `sessionService.createSession` is + invoked directly using the configured agent/provider/model, so the LLM + actually responds without user interaction. A notification is raised + when the session is created. +- One-shot tasks whose `firesAt` was in the past at launch and that have no + `lastFiredAt` recorded are fired once on startup (backfill). Recurring + tasks are not backfilled — they simply jump to the next slot. +- Task records survive app restart (persisted through `ConfigPresenter`'s + ElectronStore as the `scheduledTasks` key). + +## Non-goals + +- Cron-expression input. Daily / weekly / once is sufficient for the + feature ask; a future iteration may add `cron` and `interval` trigger + kinds. +- Per-task timezone handling. Triggers use the OS's local time. +- Letting the LLM schedule tasks via an MCP tool. Possible follow-up. +- Calendar / iCal export. diff --git a/docs/features/scheduled-tasks/tasks.md b/docs/features/scheduled-tasks/tasks.md new file mode 100644 index 000000000..6ef8d0c94 --- /dev/null +++ b/docs/features/scheduled-tasks/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +- [x] Add SDD artifacts. +- [ ] Define shared types (`src/shared/scheduledTasks.ts`) and route contracts. +- [ ] Implement `ScheduledTasksService` (presenter + `computeNextFireAt` + action dispatch). +- [ ] Wire `ConfigPresenter` persistence (`scheduledTasks` key) with normalize-on-read. +- [ ] Register routes in `src/main/routes/index.ts` and instantiate in `Presenter` constructor. +- [ ] Hook lifecycle: start on `after-start`, stop on `beforeQuit`. +- [ ] Add renderer client `src/renderer/api/ScheduledTasksClient.ts`. +- [ ] Add settings navigation entry and dynamic route component. +- [ ] Implement `ScheduledTasksSettings.vue` (CRUD, mirror NotificationsHooks layout). +- [ ] Add i18n keys across all locales. +- [ ] Unit tests for `computeNextFireAt` and `normalizeScheduledTasksConfig`. +- [ ] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck`. diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 34b0d6ffb..fc79b50df 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -89,6 +89,11 @@ import { createDefaultHooksNotificationsConfig, normalizeHooksNotificationsConfig } from '../hooksNotifications/config' +import { normalizeScheduledTasksConfig } from '../scheduledTasks/normalize' +import { + createDefaultScheduledTasksSettings, + type ScheduledTasksSettings +} from '@shared/scheduledTasks' import { AcpDbStore, AppSettingsDbBackedStore, @@ -131,6 +136,7 @@ interface IAppSettings { enableSkills?: boolean // Skills system global toggle skillDraftSuggestionsEnabled?: boolean // Whether agent may propose skill drafts after tasks hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings + scheduledTasks?: ScheduledTasksSettings // User-defined scheduled tasks defaultModel?: { providerId: string; modelId: string } // Default model for new conversations defaultVisionModel?: { providerId: string; modelId: string } // Legacy vision model setting for migration only defaultProjectPath?: string | null @@ -434,7 +440,8 @@ export class ConfigPresenter implements IConfigPresenter { skillDraftSuggestionsEnabled: false, updateChannel: 'stable', // Default to stable version appVersion: this.currentAppVersion, - hooksNotifications: createDefaultHooksNotificationsConfig() + hooksNotifications: createDefaultHooksNotificationsConfig(), + scheduledTasks: createDefaultScheduledTasksSettings() } }) @@ -3191,6 +3198,21 @@ export class ConfigPresenter implements IConfigPresenter { return normalized } + getScheduledTasksConfig(): ScheduledTasksSettings { + const raw = this.store.get('scheduledTasks') + const normalized = normalizeScheduledTasksConfig(raw) + if (!raw || JSON.stringify(raw) !== JSON.stringify(normalized)) { + this.store.set('scheduledTasks', normalized) + } + return normalized + } + + setScheduledTasksConfig(config: ScheduledTasksSettings): ScheduledTasksSettings { + const normalized = normalizeScheduledTasksConfig(config) + this.store.set('scheduledTasks', normalized) + return normalized + } + async testHookCommand(hookId: string): Promise { return await presenter.hooksNotifications.testHookCommand(hookId) } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 7729f1372..3825633b7 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -64,6 +64,7 @@ import type { SkillSessionStatePort } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' +import { ScheduledTasksService } from './scheduledTasks' import { AgentSessionPresenter } from './agentSessionPresenter' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { ProjectPresenter } from './projectPresenter' @@ -191,6 +192,7 @@ export class Presenter implements IPresenter { pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter hooksNotifications: HooksNotificationsService + scheduledTasks: ScheduledTasksService commandPermissionService: CommandPermissionService filePermissionService: FilePermissionService settingsPermissionService: SettingsPermissionService @@ -486,6 +488,11 @@ export class Presenter implements IPresenter { getSession: async () => null, getMessage: async () => null }) + this.scheduledTasks = new ScheduledTasksService({ + configPresenter: this.configPresenter, + notificationPresenter: this.notificationPresenter, + windowPresenter: this.windowPresenter + }) const newSessionHooksBridge = new NewSessionHooksBridge(this.hooksNotifications) const providerCatalogPort: ProviderCatalogPort = { getProviderModels: (providerId) => this.configPresenter.getProviderModels?.(providerId) ?? [], @@ -944,6 +951,41 @@ export class Presenter implements IPresenter { export let presenter: Presenter let cachedMainKernelRouteRuntime: ReturnType | undefined +const buildMainKernelRouteRuntime = () => + createMainKernelRouteRuntime({ + configPresenter: presenter.configPresenter, + llmProviderPresenter: presenter.llmproviderPresenter, + agentSessionPresenter: presenter.agentSessionPresenter, + skillPresenter: presenter.skillPresenter, + mcpPresenter: presenter.mcpPresenter, + syncPresenter: presenter.syncPresenter, + upgradePresenter: presenter.upgradePresenter, + dialogPresenter: presenter.dialogPresenter, + toolPresenter: presenter.toolPresenter, + sqlitePresenter: presenter.sqlitePresenter, + windowPresenter: presenter.windowPresenter, + devicePresenter: presenter.devicePresenter, + projectPresenter: presenter.projectPresenter, + filePresenter: presenter.filePresenter, + workspacePresenter: presenter.workspacePresenter, + yoBrowserPresenter: presenter.yoBrowserPresenter, + tabPresenter: presenter.tabPresenter, + startupWorkloadCoordinator: presenter.startupWorkloadCoordinator, + pluginPresenter: presenter.pluginPresenter, + databaseSecurityPresenter: presenter.databaseSecurityPresenter, + scheduledTasks: presenter.scheduledTasks + }) + +export function getMainKernelRouteRuntime(): ReturnType { + if (!presenter) { + throw new Error('Presenter must be initialized before accessing the kernel route runtime') + } + if (!cachedMainKernelRouteRuntime) { + cachedMainKernelRouteRuntime = buildMainKernelRouteRuntime() + } + return cachedMainKernelRouteRuntime +} + // Initialize presenter with database instance and optional lifecycle manager export function getInstance(lifecycleManager: ILifecycleManager): Presenter { // only allow initialize once @@ -955,32 +997,7 @@ export function getInstance(lifecycleManager: ILifecycleManager): Presenter { return presenter } -registerMainKernelRoutes(ipcMain, () => - presenter - ? (cachedMainKernelRouteRuntime ??= createMainKernelRouteRuntime({ - configPresenter: presenter.configPresenter, - llmProviderPresenter: presenter.llmproviderPresenter, - agentSessionPresenter: presenter.agentSessionPresenter, - skillPresenter: presenter.skillPresenter, - mcpPresenter: presenter.mcpPresenter, - syncPresenter: presenter.syncPresenter, - upgradePresenter: presenter.upgradePresenter, - dialogPresenter: presenter.dialogPresenter, - toolPresenter: presenter.toolPresenter, - sqlitePresenter: presenter.sqlitePresenter, - windowPresenter: presenter.windowPresenter, - devicePresenter: presenter.devicePresenter, - projectPresenter: presenter.projectPresenter, - filePresenter: presenter.filePresenter, - workspacePresenter: presenter.workspacePresenter, - yoBrowserPresenter: presenter.yoBrowserPresenter, - tabPresenter: presenter.tabPresenter, - startupWorkloadCoordinator: presenter.startupWorkloadCoordinator, - pluginPresenter: presenter.pluginPresenter, - databaseSecurityPresenter: presenter.databaseSecurityPresenter - })) - : undefined -) +registerMainKernelRoutes(ipcMain, () => (presenter ? getMainKernelRouteRuntime() : undefined)) // 检查对象属性是否为函数 (用于动态调用) // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts new file mode 100644 index 000000000..8fb15ec0e --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts @@ -0,0 +1,38 @@ +/** + * Scheduled tasks start hook for after-start phase + * + * The route runtime owns the wiring between the scheduled tasks service and + * the session service (for auto-send actions), so we force its construction + * by reading any route runtime via getRuntime, then call `start()` so the + * scheduler arms timers and backfills missed one-shot tasks. + */ + +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { presenter, getMainKernelRouteRuntime } from '@/presenter' +import { LifecyclePhase } from '@shared/lifecycle' + +export const scheduledTasksStartHook: LifecycleHook = { + name: 'scheduled-tasks-start', + phase: LifecyclePhase.AFTER_START, + priority: 20, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + throw new Error('scheduledTasksStartHook: Presenter not initialized') + } + + // Touch the route runtime so the session creator gets wired up before + // the scheduler fires anything. + try { + getMainKernelRouteRuntime() + } catch (error) { + console.warn( + '[scheduledTasksStartHook] Failed to prime route runtime; auto-send may degrade to draft mode:', + error + ) + } + + presenter.scheduledTasks.start() + console.log('scheduledTasksStartHook: Scheduler started') + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts new file mode 100644 index 000000000..6012c92a7 --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts @@ -0,0 +1,21 @@ +/** + * Scheduled tasks stop hook for beforeQuit phase + * Cancels all armed timers so the scheduler does not fire during shutdown. + */ + +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { presenter } from '@/presenter' +import { LifecyclePhase } from '@shared/lifecycle' + +export const scheduledTasksStopHook: LifecycleHook = { + name: 'scheduled-tasks-stop', + phase: LifecyclePhase.BEFORE_QUIT, + priority: 30, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + return + } + presenter.scheduledTasks.stop() + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/index.ts b/src/main/presenter/lifecyclePresenter/hooks/index.ts index 85493bd8f..59a419324 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/index.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/index.ts @@ -15,9 +15,11 @@ export { legacyImportHook } from './after-start/legacyImportHook' export { rtkHealthCheckHook } from './after-start/rtkHealthCheckHook' export { usageStatsBackfillHook } from './after-start/usageStatsBackfillHook' export { sqliteMainlineNormalizationHook } from './after-start/sqliteMainlineNormalizationHook' +export { scheduledTasksStartHook } from './after-start/scheduledTasksStartHook' export { trayDestroyHook } from './beforeQuit/trayDestroyHook' export { floatingDestroyHook } from './beforeQuit/floatingDestroyHook' export { presenterDestroyHook } from './beforeQuit/presenterDestroyHook' export { builtinKnowledgeDestroyHook } from './beforeQuit/builtinKnowledgeDestroyHook' export { windowQuittingHook } from './beforeQuit/windowQuittingHook' export { acpCleanupHook } from './beforeQuit/acpCleanupHook' +export { scheduledTasksStopHook } from './beforeQuit/scheduledTasksStopHook' diff --git a/src/main/presenter/scheduledTasks/index.ts b/src/main/presenter/scheduledTasks/index.ts new file mode 100644 index 000000000..97cc811ca --- /dev/null +++ b/src/main/presenter/scheduledTasks/index.ts @@ -0,0 +1,341 @@ +import { randomUUID } from 'node:crypto' +import log from 'electron-log' +import type { IConfigPresenter, INotificationPresenter, IWindowPresenter } from '@shared/presenter' +import { DEEPLINK_EVENTS } from '@/events' +import { + SCHEDULED_TASKS_VERSION, + SCHEDULED_TASK_DEFAULT_AGENT_ID, + type ScheduledTask, + type ScheduledTaskAction, + type ScheduledTasksSettings +} from '@shared/scheduledTasks' +import type { z } from 'zod' +import { + scheduledTaskActionSchema, + scheduledTaskTriggerSchema, + type scheduledTasksUpsertInputSchema +} from '@shared/contracts/routes/scheduledTasks.routes' +import { computeNextFireAt, shouldBackfillOneShot } from './normalize' + +const MAX_TIMEOUT_MS = 12 * 60 * 60 * 1000 // 12h chained-timeout cap +const RECENT_DRIFT_TOLERANCE_MS = 60 * 1000 // forgive up to 1m clock drift + +export type ScheduledTasksUpsertInput = z.input + +interface SessionCreator { + createSessionForTask(input: { + agentId: string + message: string + providerId?: string + modelId?: string + systemPrompt?: string + }): Promise<{ sessionId: string | null }> +} + +export interface ScheduledTasksServiceDeps { + configPresenter: Pick< + IConfigPresenter, + 'getScheduledTasksConfig' | 'setScheduledTasksConfig' | 'getNotificationsEnabled' + > + notificationPresenter: Pick + windowPresenter: Pick & { + mainWindow: IWindowPresenter['mainWindow'] + } + sessionCreator?: SessionCreator +} + +export class ScheduledTasksService { + private readonly configPresenter: ScheduledTasksServiceDeps['configPresenter'] + private readonly notificationPresenter: ScheduledTasksServiceDeps['notificationPresenter'] + private readonly windowPresenter: ScheduledTasksServiceDeps['windowPresenter'] + private sessionCreator: SessionCreator | null + private readonly timers = new Map() + private started = false + + constructor(deps: ScheduledTasksServiceDeps) { + this.configPresenter = deps.configPresenter + this.notificationPresenter = deps.notificationPresenter + this.windowPresenter = deps.windowPresenter + this.sessionCreator = deps.sessionCreator ?? null + } + + setSessionCreator(creator: SessionCreator | null): void { + this.sessionCreator = creator + } + + start(): void { + if (this.started) { + return + } + this.started = true + this.runStartupPass() + } + + stop(): void { + this.started = false + for (const timer of this.timers.values()) { + clearTimeout(timer) + } + this.timers.clear() + } + + list(): ScheduledTasksSettings { + return this.configPresenter.getScheduledTasksConfig() + } + + upsert(input: ScheduledTasksUpsertInput): { + task: ScheduledTask + settings: ScheduledTasksSettings + } { + const now = Date.now() + const current = this.list() + const existingIndex = input.id ? current.tasks.findIndex((task) => task.id === input.id) : -1 + const existing = existingIndex >= 0 ? current.tasks[existingIndex] : null + + const trigger = scheduledTaskTriggerSchema.parse(input.trigger) + const action = scheduledTaskActionSchema.parse(input.action) + + const triggerChanged = !existing || JSON.stringify(existing.trigger) !== JSON.stringify(trigger) + + const task: ScheduledTask = { + id: existing?.id ?? input.id ?? randomUUID(), + name: input.name, + enabled: input.enabled, + trigger, + action, + createdAt: existing?.createdAt ?? now, + // Reset lastFiredAt when the trigger changes so a rescheduled one-shot + // doesn't get skipped on the assumption it has already run. + lastFiredAt: triggerChanged ? null : (existing?.lastFiredAt ?? null) + } + + const tasks = + existingIndex >= 0 + ? current.tasks.map((value, index) => (index === existingIndex ? task : value)) + : [...current.tasks, task] + + const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) + + this.cancel(task.id) + if (task.enabled) { + this.armTask(task, Date.now()) + } + + return { task, settings } + } + + delete(id: string): ScheduledTasksSettings { + const current = this.list() + const next = current.tasks.filter((task) => task.id !== id) + const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks: next }) + this.cancel(id) + return settings + } + + toggle(id: string, enabled: boolean): { task: ScheduledTask; settings: ScheduledTasksSettings } { + const current = this.list() + const existing = current.tasks.find((task) => task.id === id) + if (!existing) { + throw new Error(`Unknown scheduled task: ${id}`) + } + const updated: ScheduledTask = { ...existing, enabled } + const tasks = current.tasks.map((task) => (task.id === id ? updated : task)) + const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) + + this.cancel(id) + if (enabled) { + this.armTask(updated, Date.now()) + } + + return { task: updated, settings } + } + + async fireNow(id: string): Promise<{ task: ScheduledTask; settings: ScheduledTasksSettings }> { + const current = this.list() + const existing = current.tasks.find((task) => task.id === id) + if (!existing) { + throw new Error(`Unknown scheduled task: ${id}`) + } + await this.dispatch(existing) + const settings = this.markFired(existing) + const refreshed = settings.tasks.find((task) => task.id === id) ?? existing + return { task: refreshed, settings } + } + + private runStartupPass(): void { + const now = Date.now() + const settings = this.list() + for (const task of settings.tasks) { + if (!task.enabled) { + continue + } + if (shouldBackfillOneShot(task, now)) { + void this.fireAndPersist(task) + continue + } + this.armTask(task, now) + } + } + + private armTask(task: ScheduledTask, now: number): void { + const nextFireAt = computeNextFireAt(task, now - RECENT_DRIFT_TOLERANCE_MS) + if (!nextFireAt) { + return + } + + const delay = Math.max(0, nextFireAt - now) + if (delay > MAX_TIMEOUT_MS) { + const timer = setTimeout(() => { + this.timers.delete(task.id) + const refreshed = this.list().tasks.find((entry) => entry.id === task.id) + if (refreshed?.enabled) { + this.armTask(refreshed, Date.now()) + } + }, MAX_TIMEOUT_MS) + this.timers.set(task.id, timer) + return + } + + const timer = setTimeout(() => { + this.timers.delete(task.id) + const refreshed = this.list().tasks.find((entry) => entry.id === task.id) + if (!refreshed || !refreshed.enabled) { + return + } + void this.fireAndPersist(refreshed) + }, delay) + this.timers.set(task.id, timer) + } + + private cancel(id: string): void { + const timer = this.timers.get(id) + if (timer) { + clearTimeout(timer) + this.timers.delete(id) + } + } + + private persist(settings: ScheduledTasksSettings): ScheduledTasksSettings { + return this.configPresenter.setScheduledTasksConfig(settings) + } + + private markFired(task: ScheduledTask): ScheduledTasksSettings { + const current = this.list() + const tasks = current.tasks.map((entry) => { + if (entry.id !== task.id) { + return entry + } + // One-shot tasks auto-disable on fire so the user notices and can + // either delete or reschedule. + const disable = entry.trigger.kind === 'once' + return { + ...entry, + lastFiredAt: Date.now(), + enabled: disable ? false : entry.enabled + } + }) + return this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) + } + + private async fireAndPersist(task: ScheduledTask): Promise { + try { + await this.dispatch(task) + } catch (error) { + log.error('[ScheduledTasks] Dispatch failed:', error) + } finally { + this.markFired(task) + if (task.trigger.kind !== 'once') { + // Re-arm for the next recurring slot using the just-persisted state. + const refreshed = this.list().tasks.find((entry) => entry.id === task.id) + if (refreshed?.enabled) { + this.armTask(refreshed, Date.now()) + } + } + } + } + + private async dispatch(task: ScheduledTask): Promise { + await this.runAction(task.id, task.action) + } + + private async runAction(taskId: string, action: ScheduledTaskAction): Promise { + if (action.kind === 'notify') { + await this.notificationPresenter.showNotification({ + id: `scheduled:${taskId}`, + title: action.title, + body: action.body + }) + return + } + + if (action.kind === 'prompt') { + if (action.autoSend) { + await this.runPromptAutoSend(taskId, action) + return + } + await this.runPromptDraft(taskId, action) + } + } + + private async runPromptDraft( + taskId: string, + action: Extract + ): Promise { + const target = this.windowPresenter.mainWindow + if (target && !target.isDestroyed()) { + this.windowPresenter.sendToWindow(target.id, DEEPLINK_EVENTS.START, { + msg: action.message, + modelId: action.modelId ?? null, + systemPrompt: action.systemPrompt ?? '', + mentions: [], + autoSend: false + }) + this.windowPresenter.focusMainWindow() + } else { + log.warn('[ScheduledTasks] No main window available for prompt draft action') + } + + await this.notificationPresenter.showNotification({ + id: `scheduled:${taskId}`, + title: action.title, + body: action.message.slice(0, 200) + }) + } + + private async runPromptAutoSend( + taskId: string, + action: Extract + ): Promise { + if (!this.sessionCreator) { + log.warn('[ScheduledTasks] sessionCreator is not wired; falling back to draft mode') + await this.runPromptDraft(taskId, action) + return + } + + try { + await this.sessionCreator.createSessionForTask({ + agentId: action.agentId ?? SCHEDULED_TASK_DEFAULT_AGENT_ID, + message: action.message, + providerId: action.providerId, + modelId: action.modelId, + systemPrompt: action.systemPrompt + }) + + await this.notificationPresenter.showNotification({ + id: `scheduled:${taskId}`, + title: action.title, + body: action.message.slice(0, 200) + }) + } catch (error) { + log.error('[ScheduledTasks] Failed to create session for task:', error) + // Fall back so the user still sees something happened. + await this.runPromptDraft(taskId, action) + } + } +} + +export { + computeNextFireAt, + normalizeScheduledTasksConfig, + shouldBackfillOneShot +} from './normalize' diff --git a/src/main/presenter/scheduledTasks/normalize.ts b/src/main/presenter/scheduledTasks/normalize.ts new file mode 100644 index 000000000..4fc88067d --- /dev/null +++ b/src/main/presenter/scheduledTasks/normalize.ts @@ -0,0 +1,208 @@ +import { randomUUID } from 'node:crypto' +import log from 'electron-log' +import { z } from 'zod' +import { + SCHEDULED_TASKS_VERSION, + type ScheduledTask, + type ScheduledTaskAction, + type ScheduledTaskTrigger, + type ScheduledTasksSettings, + createDefaultScheduledTasksSettings +} from '@shared/scheduledTasks' + +const TriggerSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('once'), firesAt: z.number().int().nonnegative() }), + z.object({ + kind: z.literal('daily'), + hour: z.number().int().min(0).max(23), + minute: z.number().int().min(0).max(59) + }), + z.object({ + kind: z.literal('weekly'), + dayOfWeek: z.number().int().min(0).max(6), + hour: z.number().int().min(0).max(23), + minute: z.number().int().min(0).max(59) + }) +]) + +const ActionSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('notify'), + title: z.string().max(200), + body: z.string().max(2000) + }), + z.object({ + kind: z.literal('prompt'), + title: z.string().max(200), + message: z.string().max(20000), + autoSend: z.boolean(), + agentId: z.string().optional(), + providerId: z.string().optional(), + modelId: z.string().optional(), + systemPrompt: z.string().max(20000).optional() + }) +]) + +const ScheduledTaskSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(200), + enabled: z.boolean(), + trigger: TriggerSchema, + action: ActionSchema, + createdAt: z.number().int().nonnegative(), + lastFiredAt: z.number().int().nonnegative().nullable() +}) + +const LooseSchedulerSettingsSchema = z + .object({ + version: z.unknown().optional(), + tasks: z.array(z.unknown()).optional() + }) + .strip() + +const sanitizeTrigger = (input: unknown): ScheduledTaskTrigger | null => { + const parsed = TriggerSchema.safeParse(input) + return parsed.success ? parsed.data : null +} + +const sanitizeAction = (input: unknown): ScheduledTaskAction | null => { + const parsed = ActionSchema.safeParse(input) + return parsed.success ? parsed.data : null +} + +const sanitizeTask = (input: unknown, fallbackIndex: number, now: number): ScheduledTask | null => { + if (!input || typeof input !== 'object') { + return null + } + const record = input as Record + const trigger = sanitizeTrigger(record.trigger) + const action = sanitizeAction(record.action) + if (!trigger || !action) { + return null + } + + const id = + typeof record.id === 'string' && record.id.trim().length > 0 ? record.id.trim() : randomUUID() + const name = + typeof record.name === 'string' && record.name.trim().length > 0 + ? record.name.trim().slice(0, 200) + : `Task ${fallbackIndex + 1}` + const enabled = record.enabled === true + const createdAt = + typeof record.createdAt === 'number' && + Number.isFinite(record.createdAt) && + record.createdAt > 0 + ? record.createdAt + : now + const lastFiredAt = + typeof record.lastFiredAt === 'number' && + Number.isFinite(record.lastFiredAt) && + record.lastFiredAt > 0 + ? record.lastFiredAt + : null + + const candidate = { id, name, enabled, trigger, action, createdAt, lastFiredAt } + const parsed = ScheduledTaskSchema.safeParse(candidate) + return parsed.success ? parsed.data : null +} + +export const normalizeScheduledTasksConfig = ( + input: unknown, + now: number = Date.now() +): ScheduledTasksSettings => { + const defaults = createDefaultScheduledTasksSettings() + const parsed = LooseSchedulerSettingsSchema.safeParse(input) + if (!parsed.success) { + log.warn('[ScheduledTasks] Invalid config, using defaults:', parsed.error?.message) + return defaults + } + + const rawTasks = Array.isArray(parsed.data.tasks) ? parsed.data.tasks : [] + const tasks = rawTasks.reduce((acc, candidate, index) => { + const sanitized = sanitizeTask(candidate, index, now) + if (sanitized) { + acc.push(sanitized) + } else { + log.warn(`[ScheduledTasks] Dropping malformed task at index ${index}`) + } + return acc + }, []) + + return { + version: SCHEDULED_TASKS_VERSION, + tasks + } +} + +const startOfMinute = (timestamp: number): number => { + const date = new Date(timestamp) + date.setSeconds(0, 0) + return date.getTime() +} + +const buildWallClockToday = ( + reference: number, + hour: number, + minute: number, + dayOffset = 0 +): number => { + const date = new Date(reference) + date.setDate(date.getDate() + dayOffset) + date.setHours(hour, minute, 0, 0) + return date.getTime() +} + +/** + * Compute the next absolute timestamp at which `task` should fire, strictly + * after `after`. Returns `null` if the task can no longer fire (one-shot + * already fired or one-shot whose `firesAt` is in the past with respect to + * `after` — backfill handling is up to the caller via `lastFiredAt`). + */ +export const computeNextFireAt = (task: ScheduledTask, after: number): number | null => { + const trigger = task.trigger + switch (trigger.kind) { + case 'once': { + if (task.lastFiredAt) { + return null + } + return trigger.firesAt > after ? trigger.firesAt : null + } + case 'daily': { + let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 0) + if (candidate <= after) { + candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 1) + } + return candidate + } + case 'weekly': { + const reference = new Date(after) + const currentDay = reference.getDay() + let dayOffset = (trigger.dayOfWeek - currentDay + 7) % 7 + let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset) + if (candidate <= after) { + dayOffset += 7 + candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset) + } + return candidate + } + default: + return null + } +} + +/** + * Returns true when a one-shot task should be backfilled (fired immediately + * on startup) because its `firesAt` is in the past and it has never been + * fired. Recurring tasks are never backfilled. + */ +export const shouldBackfillOneShot = (task: ScheduledTask, now: number): boolean => { + if (task.trigger.kind !== 'once') { + return false + } + if (task.lastFiredAt) { + return false + } + return task.trigger.firesAt <= now +} + +export const startOfMinuteForTests = startOfMinute diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 4518f0be1..39fb60d36 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -243,6 +243,14 @@ import type { StartupWorkloadCoordinator } from '@/presenter/startupWorkloadCoor import type { PluginPresenter } from '@/presenter/pluginPresenter' import type { DatabaseSecurityPresenter } from '@/presenter/databaseSecurityPresenter' import type { SQLitePresenter } from '@/presenter/sqlitePresenter' +import type { ScheduledTasksService } from '@/presenter/scheduledTasks' +import { + scheduledTasksDeleteRoute, + scheduledTasksFireNowRoute, + scheduledTasksListRoute, + scheduledTasksToggleRoute, + scheduledTasksUpsertRoute +} from '@shared/contracts/routes/scheduledTasks.routes' export type MainKernelRouteRuntime = { configPresenter: IConfigPresenter @@ -270,6 +278,7 @@ export type MainKernelRouteRuntime = { startupWorkloadCoordinator: StartupWorkloadCoordinator pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter + scheduledTasks: ScheduledTasksService } export function createMainKernelRouteRuntime(deps: { @@ -293,6 +302,7 @@ export function createMainKernelRouteRuntime(deps: { startupWorkloadCoordinator: StartupWorkloadCoordinator pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter + scheduledTasks: ScheduledTasksService }): MainKernelRouteRuntime { const scheduler = createNodeScheduler() const hotPathPorts = createPresenterHotPathPorts({ @@ -303,6 +313,35 @@ export function createMainKernelRouteRuntime(deps: { llmProviderPresenter: deps.llmProviderPresenter }) + const sessionService = new SessionService({ + sessionRepository: hotPathPorts.sessionRepository, + messageRepository: hotPathPorts.messageRepository, + scheduler + }) + + // Wire scheduled tasks → sessions for the auto-send action. + const mainWindowWebContentsId = deps.windowPresenter.mainWindow?.webContents?.id ?? -1 + deps.scheduledTasks.setSessionCreator({ + async createSessionForTask(input) { + const session = await sessionService.createSession( + { + agentId: input.agentId, + message: input.message, + providerId: input.providerId, + modelId: input.modelId, + ...(input.systemPrompt + ? { generationSettings: { systemPrompt: input.systemPrompt } } + : {}) + }, + { + webContentsId: mainWindowWebContentsId, + windowId: deps.windowPresenter.mainWindow?.id ?? null + } + ) + return { sessionId: session?.id ?? null } + } + }) + return { configPresenter: deps.configPresenter, llmProviderPresenter: deps.llmProviderPresenter, @@ -332,11 +371,7 @@ export function createMainKernelRouteRuntime(deps: { }), listSettingsActivity: async () => [] } as unknown as ISQLitePresenter), - sessionService: new SessionService({ - sessionRepository: hotPathPorts.sessionRepository, - messageRepository: hotPathPorts.messageRepository, - scheduler - }), + sessionService, chatService: new ChatService({ sessionRepository: hotPathPorts.sessionRepository, messageRepository: hotPathPorts.messageRepository, @@ -360,7 +395,8 @@ export function createMainKernelRouteRuntime(deps: { tabPresenter: deps.tabPresenter, startupWorkloadCoordinator: deps.startupWorkloadCoordinator, pluginPresenter: deps.pluginPresenter, - databaseSecurityPresenter: deps.databaseSecurityPresenter + databaseSecurityPresenter: deps.databaseSecurityPresenter, + scheduledTasks: deps.scheduledTasks } } @@ -1557,6 +1593,36 @@ export async function dispatchDeepchatRoute( return onboardingResetRoute.output.parse({ state }) } + case scheduledTasksListRoute.name: { + scheduledTasksListRoute.input.parse(rawInput) + const settings = runtime.scheduledTasks.list() + return scheduledTasksListRoute.output.parse({ settings }) + } + + case scheduledTasksUpsertRoute.name: { + const input = scheduledTasksUpsertRoute.input.parse(rawInput) + const { task, settings } = runtime.scheduledTasks.upsert(input) + return scheduledTasksUpsertRoute.output.parse({ task, settings }) + } + + case scheduledTasksDeleteRoute.name: { + const input = scheduledTasksDeleteRoute.input.parse(rawInput) + const settings = runtime.scheduledTasks.delete(input.id) + return scheduledTasksDeleteRoute.output.parse({ settings }) + } + + case scheduledTasksToggleRoute.name: { + const input = scheduledTasksToggleRoute.input.parse(rawInput) + const { task, settings } = runtime.scheduledTasks.toggle(input.id, input.enabled) + return scheduledTasksToggleRoute.output.parse({ task, settings }) + } + + case scheduledTasksFireNowRoute.name: { + const input = scheduledTasksFireNowRoute.input.parse(rawInput) + const { task, settings } = await runtime.scheduledTasks.fireNow(input.id) + return scheduledTasksFireNowRoute.output.parse({ task, settings }) + } + case startupGetBootstrapRoute.name: { startupGetBootstrapRoute.input.parse(rawInput) const coordinator = (runtime as Partial).startupWorkloadCoordinator diff --git a/src/renderer/api/ScheduledTasksClient.ts b/src/renderer/api/ScheduledTasksClient.ts new file mode 100644 index 000000000..e41a88b26 --- /dev/null +++ b/src/renderer/api/ScheduledTasksClient.ts @@ -0,0 +1,85 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + scheduledTasksDeleteRoute, + scheduledTasksFireNowRoute, + scheduledTasksListRoute, + scheduledTasksToggleRoute, + scheduledTasksUpsertRoute, + scheduledTasksSettingsSchema, + scheduledTaskSchema, + type scheduledTasksUpsertInputSchema +} from '@shared/contracts/routes/scheduledTasks.routes' +import type { z } from 'zod' +import { getDeepchatBridge } from './core' + +export type ScheduledTasksUpsertInput = z.input + +const parseSettingsResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[ScheduledTasksClient] Invalid response shape from ${routeName}`) + } + const maybe = (result as { settings?: unknown }).settings + const parsed = scheduledTasksSettingsSchema.safeParse(maybe) + if (!parsed.success) { + throw new Error(`[ScheduledTasksClient] Invalid settings response from ${routeName}`) + } + return parsed.data +} + +const parseTaskResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[ScheduledTasksClient] Invalid response shape from ${routeName}`) + } + const maybeTask = (result as { task?: unknown }).task + const parsedTask = scheduledTaskSchema.safeParse(maybeTask) + if (!parsedTask.success) { + throw new Error(`[ScheduledTasksClient] Invalid task response from ${routeName}`) + } + return parsedTask.data +} + +export function createScheduledTasksClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function list() { + const result = await bridge.invoke(scheduledTasksListRoute.name, {}) + return parseSettingsResponse(scheduledTasksListRoute.name, result) + } + + async function upsert(input: ScheduledTasksUpsertInput) { + const result = await bridge.invoke(scheduledTasksUpsertRoute.name, input) + return { + task: parseTaskResponse(scheduledTasksUpsertRoute.name, result), + settings: parseSettingsResponse(scheduledTasksUpsertRoute.name, result) + } + } + + async function remove(id: string) { + const result = await bridge.invoke(scheduledTasksDeleteRoute.name, { id }) + return parseSettingsResponse(scheduledTasksDeleteRoute.name, result) + } + + async function toggle(id: string, enabled: boolean) { + const result = await bridge.invoke(scheduledTasksToggleRoute.name, { id, enabled }) + return { + task: parseTaskResponse(scheduledTasksToggleRoute.name, result), + settings: parseSettingsResponse(scheduledTasksToggleRoute.name, result) + } + } + + async function fireNow(id: string) { + const result = await bridge.invoke(scheduledTasksFireNowRoute.name, { id }) + return { + task: parseTaskResponse(scheduledTasksFireNowRoute.name, result), + settings: parseSettingsResponse(scheduledTasksFireNowRoute.name, result) + } + } + + return { + list, + upsert, + remove, + toggle, + fireNow + } +} + +export type ScheduledTasksClient = ReturnType diff --git a/src/renderer/settings/components/ScheduledTasksSettings.vue b/src/renderer/settings/components/ScheduledTasksSettings.vue new file mode 100644 index 000000000..9be9fbaa0 --- /dev/null +++ b/src/renderer/settings/components/ScheduledTasksSettings.vue @@ -0,0 +1,681 @@ +