diff --git a/doc/prd/scheduled-task.md b/doc/prd/scheduled-task.md new file mode 100644 index 00000000..b497855a --- /dev/null +++ b/doc/prd/scheduled-task.md @@ -0,0 +1,114 @@ +# Scheduled Task PRD(定时任务) + +## 1. 文档定位 + +- 本文是「定时任务」功能的用户可见行为规格来源,描述入口、面板交互、配置字段、调度执行与边界。 +- 定时任务的用户可见行为、入口、反馈或调度规则发生变化时,必须同步更新本文。 +- 后续实现、测试、评审和 AI 最终汇报都应引用本文中的需求编号与验收编号。 + +## 2. 产品目标 + +- 用户可配置一个或多个定时任务,在程序运行期间按每天 / 每周的指定时间点,自动在对应工作空间下创建新会话并发送任务提示词。 +- 定时任务自动执行时不操作用户当前页面,但对应会话列表需要刷新,让用户能看到新会话及其运行状态。 + +## 3. 范围 + +- 包含左侧栏「定时任务」入口与独立右侧面板。 +- 包含定时任务的列表展示、新增、编辑、删除和启用开关。 +- 包含调度时间(每天 / 每周 + 具体时间点)、名称、工作空间、任务提示词、主模型、视觉模型、AI 推进偏好和执行权限的配置。 +- 包含到点后在对应工作空间下创建新会话、发送任务消息并刷新会话列表。 +- 不包含设置页中的大模型配置、工作空间与网页资料管理逻辑(复用现有能力)。 +- 不包含视觉风格与基础控件样式定义。 + +## 4. 术语 + +- 定时任务:一条持久化的调度配置,包含触发时间与一次 AI 任务的完整参数。 +- 调度时间:每天一个时间点,或每周某个星期几 + 一个时间点。 +- 任务提示词:到点发送给 AI 的用户消息正文,用于描述想让 AI 完成的事。 +- 执行权限:与 workbench 一致,`审核` / `完全访问`;定时任务默认 `完全访问`。 + +## 5. 需求规则 + +- 需求编号使用 `ST-<模块>-<三位序号>`。 +- 验收编号使用 `ST-<模块>-<三位序号>-AC<序号>`。 + +## 6. 需求明细 + +### 6.1 入口与视图 + +#### ST-ENTRY-001 左侧栏入口 + +- 系统行为:Workbench 左侧栏「新对话」下方展示同级、同样式的「定时任务」入口;点击后整个主区域(除左侧会话列表外)切换为定时任务视图,不展示聊天对话和资源面板。 +- 验收: + - `ST-ENTRY-001-AC1` 「定时任务」入口位于「新对话」下方,使用与「新对话」一致的侧栏选项样式。 + - `ST-ENTRY-001-AC2` 点击入口后,主区域整块切换为定时任务视图,聊天对话与资源面板不再显示。 + +#### ST-VIEW-001 视图独立与返回 + +- 系统行为:定时任务视图是独立自包含组件,占据除左侧会话列表外的整个主区域;该视图没有聊天对话。视图顶部提供标题与关闭入口,点击关闭或点击「新对话」后返回聊天视图。打开搜索 / 文件预览 / 浏览器等资源动作同样返回聊天视图。 +- 验收: + - `ST-VIEW-001-AC1` 定时任务视图中不展示聊天对话或资源面板。 + - `ST-VIEW-001-AC2` 视图顶部标题栏提供关闭入口,点击后收起定时任务视图并返回聊天。 + - `ST-VIEW-001-AC3` 点击「新对话」或打开搜索 / 文件预览 / 浏览器等资源动作后,定时任务视图关闭。 + +### 6.2 列表与配置 + +#### ST-LIST-001 列表展示 + +- 系统行为:面板打开后拉取全部定时任务并展示;每个任务项展示名称、调度时间摘要、主模型名称和启用开关。无任务时展示空态与「新增配置」入口。 +- 验收: + - `ST-LIST-001-AC1` 面板打开后可见已配置任务列表,而不是未初始化状态。 + - `ST-LIST-001-AC2` 任务项展示名称、调度时间摘要和主模型名称。 + - `ST-LIST-001-AC3` 无任务时展示空态和「新增配置」入口。 + +#### ST-FORM-001 新增与编辑配置 + +- 系统行为:新增打开空表单,编辑打开预填表单。字段包括名称、任务提示词、执行时间(每天 / 每周 + 星期 + 时间点)、工作空间、主模型、视觉模型(可选,不选跟随主模型)、AI 推进偏好和执行权限。保存前校验必填项;模型选择复用现有大模型配置与远端模型列表。 +- 验收: + - `ST-FORM-001-AC1` 新增时可填写全部字段并保存,编辑时带出当前配置值。 + - `ST-FORM-001-AC2` 名称、任务提示词、执行时间、工作空间或主模型缺失时,保存被拦截并给出明确提示。 + - `ST-FORM-001-AC3` 选择「每周」时必须选择星期几;选择「审核」时展示无人值守可能阻塞的提示。 + - `ST-FORM-001-AC4` 新增表单点击遮罩不关闭,避免误触丢失输入。 + +#### ST-SWITCH-001 启用开关 + +- 系统行为:每个任务项提供启用开关,默认关闭;只有开启后该定时任务才会在到点执行。切换后立即生效。 +- 验收: + - `ST-SWITCH-001-AC1` 新建任务默认关闭,需用户主动开启才会执行。 + - `ST-SWITCH-001-AC2` 关闭后该任务到点不再触发。 + +#### ST-DELETE-001 删除 + +- 系统行为:删除前二次确认,确认后从列表移除。 +- 验收: + - `ST-DELETE-001-AC1` 删除前有确认弹窗,取消不删除。 + - `ST-DELETE-001-AC2` 确认后任务从列表移除。 + +### 6.3 调度执行 + +#### ST-RUN-001 到点触发 + +- 系统行为:程序运行期间,已启用任务到达调度时间时,在其配置的工作空间下创建新会话并发送任务提示词,使用配置的主模型、视觉模型、偏好和执行权限;不操作用户当前页面。工作空间不可用(未绑定目录)或模型失效时跳过本次并推进到下一调度时间。 +- 验收: + - `ST-RUN-001-AC1` 到点后在工作空间下创建新会话并发送任务消息,新会话标题取任务提示词前 20 个字符。 + - `ST-RUN-001-AC2` 触发过程不改变用户当前正在查看的会话或输入草稿。 + - `ST-RUN-001-AC3` 工作空间未绑定目录时跳过本次执行,不产生空会话。 + +#### ST-RUN-002 会话列表刷新 + +- 系统行为:定时任务触发并创建新会话后,广播会话列表刷新,Workbench 会话列表展示新会话及其运行状态。 +- 验收: + - `ST-RUN-002-AC1` 定时任务触发后,会话列表出现新会话并显示运行状态。 + - `ST-RUN-002-AC2` 用户无需手动刷新即可看到新会话。 + +#### ST-RUN-003 程序关闭错过不补跑 + +- 系统行为:程序关闭期间错过的调度时间不补跑,重新打开后只等待下一次到点。 +- 验收: + - `ST-RUN-003-AC1` 程序关闭期间错过的调度时间,重新打开后不会立即触发补跑。 + +## 7. 非目标 + +- 不定义大模型配置、工作空间与网页资料的管理逻辑。 +- 不定义 workbench 聊天、搜索、资料范围与已修改文件回退逻辑。 +- 不定义页面视觉风格、配色 token 或圆角规则。 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index 8fa18a43..624f971c 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -66,6 +66,7 @@ tests/case/workbench/ search/ preview/ apply/ + scheduled-task/ ``` 回归测试命令: @@ -579,3 +580,44 @@ tests/case/workbench/ - How:真实 AI 修改一个 Markdown 文件并打开审核模式;折叠面板后新建第二个会话,再切回原会话,等待异步记录刷新窗口;确认面板保持折叠,再手动展开并检查审核内容与文件 Tab。 - Oracle:切回后 shell 不存在且仅保留右缘展开按钮,对齐 `WB-PANEL-001-AC5`;手动展开后展示目标文件的最新审核内容且文件 Tab 保留,对齐 `WB-PANEL-001-AC1`。 - Not:不验证记录刷新时序的精确毫秒数,只验证刷新完成后面板不改变折叠状态。 + +### 5.4 `scheduled-task` + +定时任务回归单独归类,复用 Workbench 的 UI E2E 与 live 规则,Oracle 引用 [scheduled-task.md](../prd/scheduled-task.md) 的 `ST-*` 编号。调度触发时点由真实程序运行与系统时间决定,case 优先通过「下一分钟」级别的短周期调度验证端到端触发,不伪造系统时钟。 + +#### `scheduled-task-view-entry-and-isolation` + +- 测试类型:`e2e` +- What:左侧栏「新对话」下方存在「定时任务」入口;点击后整个主区域(除左侧会话列表外)切换为定时任务视图,聊天对话与资源面板不再显示;点击关闭 / 点击新对话 / 打开搜索或文件预览后返回聊天视图。 +- Why:定时任务是一个独立的全主区视图,不与聊天对话或资源面板并存,避免状态交叉。 +- How:通过真实 UI 点击「定时任务」入口确认整块主区切换为定时任务视图且无聊天;随后触发关闭、资源动作(搜索或文件预览)与「新对话」,确认返回聊天视图。 +- Oracle:入口位置与样式对齐 `ST-ENTRY-001-AC1`;点击后主区整块切换且无聊天对齐 `ST-ENTRY-001-AC2`;视图无聊天 / 无资源面板对齐 `ST-VIEW-001-AC1`;关闭入口可用对齐 `ST-VIEW-001-AC2`;资源动作与新对话返回聊天对齐 `ST-VIEW-001-AC3`。 +- Not:不验证定时任务实际触发调度,只验证入口与视图切换。 + +#### `scheduled-task-config-lifecycle` + +- 测试类型:`e2e` +- What:新增、编辑、启用开关与删除完整闭环;必填项缺失被拦截;每周模式必须选择星期几;审核权限展示无人值守阻塞提示。 +- Why:配置是定时任务可执行的前提,字段校验与开关语义决定任务是否按预期触发。 +- How:通过真实 UI 打开面板新增任务并填写名称、提示词、执行时间、工作空间、主模型、偏好与权限,保存后确认出现在列表;编辑并保存;切换启用开关;删除并二次确认。 +- Oracle:新增与编辑预填对齐 `ST-FORM-001-AC1`;必填缺失拦截对齐 `ST-FORM-001-AC2`;每周必选星期与审核提示对齐 `ST-FORM-001-AC3`;默认关闭对齐 `ST-SWITCH-001-AC1`;删除二次确认对齐 `ST-DELETE-001-AC1/AC2`;列表展示对齐 `ST-LIST-001-AC2`。 +- Not:不验证到点后的真实 AI 执行,只验证配置与开关生命周期。 + +#### `scheduled-task-trigger-and-session-refresh` + +- 测试类型:`e2e / live` +- What:启用一个下一分钟触发的定时任务,到点后在配置工作空间下创建新会话并发送任务消息,会话列表无需手动刷新即出现新会话及其运行状态;当前会话与输入草稿不被改变。 +- Why:这是定时任务的核心闭环,验证真实调度、真实 AI run 与前端会话列表刷新联动。 +- How:在真实 UI 配置并启用一个每天 + 当前系统时间下一分钟的任务(提示词可触发一次轻量真实 AI 请求);等待到点,观察会话列表出现新会话与运行中标记,并确认当前会话未被切换、草稿未被覆盖。 +- Oracle:新会话创建与标题取提示词前 20 字符对齐 `ST-RUN-001-AC1`;不改变当前会话与草稿对齐 `ST-RUN-001-AC2`;会话列表刷新对齐 `ST-RUN-002-AC1/AC2`;记录模型、耗时、完成度评分和失败归因。 +- Not:不验证跨天 / 跨周边界调度,不验证程序关闭期间错过补跑(该行为由 backend 单测覆盖)。 + +#### `scheduled-task-skip-unavailable-workspace` + +- 测试类型:`e2e` +- What:任务配置的工作空间未绑定目录时,到点跳过执行且不产生空会话。 +- Why:定时任务复用 Workbench 的「可用工作区」前置条件,未绑定目录不能发送任务,也不能留下无消息的空会话。 +- How:配置并启用一个指向未绑定目录工作空间、下一分钟触发的任务,到点后确认列表不新增会话。 +- Oracle:跳过执行且不产生空会话对齐 `ST-RUN-001-AC3`。 +- Not:不验证已绑定目录工作空间的正常触发路径。 + diff --git a/package.json b/package.json index 8ea57b5a..8ed2a864 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "better-sqlite3": "^12.11.1", "cheerio": "^1.2.0", "chromium-bidi": "0.8.1", + "dayjs": "^1.11.19", "diff": "^9.0.0", "docx": "^9.5.1", "electron": "^39.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab413e37..ec5c1e20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: chromium-bidi: specifier: 0.8.1 version: 0.8.1(devtools-protocol@0.0.1464554) + dayjs: + specifier: ^1.11.19 + version: 1.11.19 diff: specifier: ^9.0.0 version: 9.0.0 diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 9e3ce0c2..3826cde2 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -13,6 +13,7 @@ import { fetchLlmRemoteModels } from '../services/llm/modelCatalog' import { AttachmentService } from '../services/attachments/AttachmentService' import { isModelSource } from '../../shared/modelSources' import { ChatRunService } from '../services/ai/ChatRunService' +import { ScheduledTaskService } from '../services/scheduler/ScheduledTaskService' import { AiRunResourceManager } from '../services/ai/run/AiRunResourceManager' import { AiFileChangeService } from '../services/ai/fileChanges/AiFileChangeService' import { getAiContextUsage } from '../services/ai/contextUsage' @@ -28,6 +29,9 @@ import type { FileWatchStartRequest, FileWatchStartResponse, FileWatchStopResponse, + ScheduledTaskMutationResponse, + ScheduledTaskSaveRequest, + ScheduledTaskSetEnabledRequest, WorkbenchSelectedModel, WriteTextFileRequest, WriteTextFileResponse @@ -319,6 +323,23 @@ export function registerHandlers(services: { }) const webSourceService = new WebSourceService() + const emitSessionListRefreshToAll = () => { + BrowserWindow.getAllWindows().forEach((window) => { + if (!window.webContents.isDestroyed()) { + window.webContents.send('chat-session-list-refresh') + } + }) + } + + const scheduledTaskService = new ScheduledTaskService({ + taskStore: storageService.scheduledTask, + workspaceStore, + chatStore, + submitTask: (payload) => chatRunService.submit(payload), + emitSessionListRefresh: emitSessionListRefreshToAll + }) + scheduledTaskService.start() + const emitSourceListRefresh = (sender: Electron.WebContents) => { if (sender.isDestroyed()) return sender.send('source-list-refresh') @@ -3098,4 +3119,39 @@ export function registerHandlers(services: { return { success: true } } ) + + // === Scheduled Task Handlers === + + ipcMain.handle('scheduled-task-list', async () => { + try { + return await scheduledTaskService.list() + } catch (e: unknown) { + console.error('[SCHEDULED-TASK-LIST] Error:', e) + return [] + } + }) + + ipcMain.handle( + 'scheduled-task-save', + async (_event, payload: ScheduledTaskSaveRequest): Promise => { + return scheduledTaskService.save(payload) + } + ) + + ipcMain.handle( + 'scheduled-task-delete', + async (_event, id: string): Promise => { + return scheduledTaskService.delete(id) + } + ) + + ipcMain.handle( + 'scheduled-task-set-enabled', + async ( + _event, + payload: ScheduledTaskSetEnabledRequest + ): Promise => { + return scheduledTaskService.setEnabled(payload) + } + ) } diff --git a/src/main/services/scheduler/ScheduledTaskService.ts b/src/main/services/scheduler/ScheduledTaskService.ts new file mode 100644 index 00000000..bc81cfaf --- /dev/null +++ b/src/main/services/scheduler/ScheduledTaskService.ts @@ -0,0 +1,305 @@ +import { v4 as uuidv4 } from 'uuid' +import type { + AiExecutionPermission, + AiPreference, + ChatSubmitRequest, + ChatSubmitResponse, + ScheduledTask, + ScheduledTaskMutationResponse, + ScheduledTaskSaveRequest, + ScheduledTaskScheduleType, + ScheduledTaskSetEnabledRequest +} from '../../../shared/types' +import type { ChatStore } from '../storage/domains/ChatStore' +import type { ScheduledTaskStore } from '../storage/domains/ScheduledTaskStore' +import type { WorkspaceStore } from '../storage/domains/WorkspaceStore' + +const TICK_INTERVAL_MS = 30_000 + +type RepeatHandle = ReturnType + +type ScheduledTaskServiceDeps = { + taskStore: ScheduledTaskStore + workspaceStore: WorkspaceStore + chatStore: ChatStore + submitTask: (payload: ChatSubmitRequest) => Promise + emitSessionListRefresh: () => void + repeat?: (callback: () => void, intervalMs: number) => RepeatHandle + clearRepeat?: (handle: RepeatHandle) => void + now?: () => number +} + +const normalizeScheduleType = (value: unknown): ScheduledTaskScheduleType => + value === 'weekly' ? 'weekly' : 'daily' + +const normalizeAiPreference = (value: unknown): AiPreference => + value === 'local_sources_relaxed' || value === 'local_sources_strict' ? value : 'default' + +const normalizeExecutionPermission = (value: unknown): AiExecutionPermission => + value === 'full_access' ? 'full_access' : 'review' + +const parseTimeOfDay = (value: string): number | null => { + const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim()) + if (!match) return null + const hours = Number(match[1]) + const minutes = Number(match[2]) + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null + return hours * 60 + minutes +} + +const slotEpochForDate = (year: number, month: number, day: number, minutesOfDay: number): number => { + const date = new Date(year, month, day) + date.setMinutes(minutesOfDay) + return date.getTime() +} + +const nextDailySlot = (after: number, minutesOfDay: number): number => { + const current = new Date(after) + const todaySlot = slotEpochForDate( + current.getFullYear(), + current.getMonth(), + current.getDate(), + minutesOfDay + ) + if (todaySlot > after) return todaySlot + return slotEpochForDate(current.getFullYear(), current.getMonth(), current.getDate() + 1, minutesOfDay) +} + +const nextWeeklySlot = (after: number, minutesOfDay: number, weekday: number): number => { + const current = new Date(after) + const daysUntil = (weekday - current.getDay() + 7) % 7 + const target = new Date( + current.getFullYear(), + current.getMonth(), + current.getDate() + daysUntil + ) + const slot = slotEpochForDate(target.getFullYear(), target.getMonth(), target.getDate(), minutesOfDay) + if (slot > after) return slot + return slotEpochForDate(target.getFullYear(), target.getMonth(), target.getDate() + 7, minutesOfDay) +} + +export const computeNextRunAt = ( + input: { scheduleType: ScheduledTaskScheduleType; timeOfDay: string; weekday: number | null }, + after: number +): number | null => { + const minutesOfDay = parseTimeOfDay(input.timeOfDay) + if (minutesOfDay === null) return null + if (input.scheduleType === 'weekly') { + if (input.weekday === null || input.weekday < 0 || input.weekday > 6) return null + return nextWeeklySlot(after, minutesOfDay, input.weekday) + } + return nextDailySlot(after, minutesOfDay) +} + +export class ScheduledTaskService { + private readonly deps: Required> & + Omit + private timer: RepeatHandle | null = null + private ticking = false + + public constructor(deps: ScheduledTaskServiceDeps) { + this.deps = { + repeat: (callback, intervalMs) => setInterval(callback, intervalMs), + clearRepeat: (handle) => clearInterval(handle), + now: () => Date.now(), + ...deps + } + } + + public start(): void { + if (this.timer) return + void (async () => { + try { + await this.reconcileMissed() + } catch (error) { + console.error('[SCHEDULED-TASK] reconcile missed failed:', error) + } + if (this.timer) return + this.timer = this.deps.repeat(() => { + void this.runDueTasks() + }, TICK_INTERVAL_MS) + await this.runDueTasks() + })() + } + + public stop(): void { + if (!this.timer) return + this.deps.clearRepeat(this.timer) + this.timer = null + } + + public async list(): Promise { + return this.deps.taskStore.list() + } + + public async save(input: ScheduledTaskSaveRequest): Promise { + try { + const task = this.buildTask(input) + await this.deps.taskStore.save(task) + return { success: true, task } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + public async delete(id: string): Promise { + try { + await this.deps.taskStore.delete(String(id || '').trim()) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + public async setEnabled( + input: ScheduledTaskSetEnabledRequest + ): Promise { + try { + const existing = await this.deps.taskStore.getById(String(input.id || '').trim()) + if (!existing) return { success: false, error: '定时任务不存在' } + const enabled = Boolean(input.enabled) + const now = this.deps.now() + const task: ScheduledTask = { + ...existing, + enabled, + nextRunAt: enabled ? computeNextRunAt(existing, now) : null, + updatedAt: now + } + await this.deps.taskStore.save(task) + return { success: true, task } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + private buildTask(input: ScheduledTaskSaveRequest): ScheduledTask { + const name = String(input.name || '').trim() + const prompt = String(input.prompt || '').trim() + const workspaceId = String(input.workspaceId || '').trim() + const mainModel = input.mainModel + const scheduleType = normalizeScheduleType(input.scheduleType) + if (!name) throw new Error('请填写任务名称') + if (!prompt) throw new Error('请填写任务提示词') + if (!workspaceId) throw new Error('请选择工作空间') + if (!mainModel?.configId || !mainModel?.modelName) throw new Error('请选择主模型') + if (parseTimeOfDay(input.timeOfDay) === null) throw new Error('执行时间格式不正确') + + const weekday = scheduleType === 'weekly' ? normalizeWeekday(input.weekday) : null + if (scheduleType === 'weekly' && weekday === null) throw new Error('请选择星期几') + const visionModel = input.visionModel?.configId && input.visionModel?.modelName ? input.visionModel : null + const now = this.deps.now() + const existingId = String(input.id || '').trim() + const id = existingId || uuidv4() + const schedule = { + scheduleType, + timeOfDay: String(input.timeOfDay || '').trim(), + weekday + } + return { + id, + name, + prompt, + enabled: Boolean(input.enabled), + scheduleType, + timeOfDay: schedule.timeOfDay, + weekday, + workspaceId, + mainModel: { configId: mainModel.configId, modelName: mainModel.modelName }, + visionModel, + aiPreference: normalizeAiPreference(input.aiPreference), + executionPermission: normalizeExecutionPermission(input.executionPermission), + nextRunAt: input.enabled ? computeNextRunAt(schedule, now) : null, + createdAt: now, + updatedAt: now + } + } + + private async reconcileMissed(): Promise { + const now = this.deps.now() + const tasks = await this.deps.taskStore.listEnabled() + for (const task of tasks) { + if (task.nextRunAt !== null && task.nextRunAt <= now) { + await this.deps.taskStore.save({ + ...task, + nextRunAt: computeNextRunAt(task, now), + updatedAt: now + }) + } + } + } + + private async runDueTasks(): Promise { + if (this.ticking) return + this.ticking = true + try { + const now = this.deps.now() + const tasks = await this.deps.taskStore.listEnabled() + for (const task of tasks) { + if (task.nextRunAt === null || now < task.nextRunAt) continue + await this.fireTask(task, now) + } + } catch (error) { + console.error('[SCHEDULED-TASK] tick failed:', error) + } finally { + this.ticking = false + } + } + + private async fireTask(task: ScheduledTask, now: number): Promise { + let sessionId: string | null = null + let submitted = false + try { + const workspace = await this.deps.workspaceStore.getWorkspaceById(task.workspaceId) + const rootPath = String(workspace?.root_path || '').trim() + if (!workspace || !rootPath) return + sessionId = uuidv4() + await this.deps.chatStore.saveChatSession({ + id: sessionId, + title: task.name || '定时任务', + created_at: now, + workspace_id: task.workspaceId + }) + const response = await this.deps.submitTask({ + mode: 'ai', + sessionId, + input: task.prompt, + mainModel: task.mainModel, + visionModel: task.visionModel ?? undefined, + preference: task.aiPreference, + executionPermission: task.executionPermission, + workspace: { + workspaceId: task.workspaceId, + workspaceName: workspace.name, + rootPath + } + }) + if (!response.success) { + console.error('[SCHEDULED-TASK] submit failed:', response.error) + return + } + submitted = true + this.deps.emitSessionListRefresh() + } catch (error) { + console.error('[SCHEDULED-TASK] fire failed:', error) + } finally { + if (sessionId && !submitted) { + await this.deps.chatStore.deleteChatSession(sessionId) + } + await this.advanceNextRun(task, now) + } + } + + private async advanceNextRun(task: ScheduledTask, now: number): Promise { + await this.deps.taskStore.save({ + ...task, + nextRunAt: computeNextRunAt(task, now), + updatedAt: now + }) + } +} + +const normalizeWeekday = (value: unknown): number | null => { + const number = Number(value) + if (!Number.isInteger(number) || number < 0 || number > 6) return null + return number +} diff --git a/src/main/services/storage/StorageService.ts b/src/main/services/storage/StorageService.ts index 895fefd1..a6803c3a 100644 --- a/src/main/services/storage/StorageService.ts +++ b/src/main/services/storage/StorageService.ts @@ -13,6 +13,7 @@ import { WorkspaceIndexJobStore } from './domains/WorkspaceIndexJobStore' import { WorkspaceStore } from './domains/WorkspaceStore' import { WorkbenchUiStore } from './domains/WorkbenchUiStore' import { BrowserTabStateStore } from './domains/BrowserTabStateStore' +import { ScheduledTaskStore } from './domains/ScheduledTaskStore' import type { LegacyLocalImportMigrationStatus } from '../../../shared/types' import type { AppErrorLogService } from '../logging/AppErrorLogService' @@ -34,6 +35,7 @@ export class StorageService { public readonly workspaceIndexJob: WorkspaceIndexJobStore public readonly workbenchUi: WorkbenchUiStore public readonly browserTabs: BrowserTabStateStore + public readonly scheduledTask: ScheduledTaskStore private legacyLocalImportMigrationStatus: LegacyLocalImportMigrationStatus | null = null public constructor( @@ -62,6 +64,7 @@ export class StorageService { this.workspaceIndexJob = new WorkspaceIndexJobStore(this.sqlite) this.workbenchUi = new WorkbenchUiStore(this.sqlite) this.browserTabs = new BrowserTabStateStore(this.sqlite) + this.scheduledTask = new ScheduledTaskStore(this.sqlite) } public static getInstance(input: { errorLogService?: AppErrorLogService } = {}): StorageService { diff --git a/src/main/services/storage/domains/ScheduledTaskStore.ts b/src/main/services/storage/domains/ScheduledTaskStore.ts new file mode 100644 index 00000000..b418565c --- /dev/null +++ b/src/main/services/storage/domains/ScheduledTaskStore.ts @@ -0,0 +1,157 @@ +import type { + AiExecutionPermission, + AiPreference, + ScheduledTask, + ScheduledTaskScheduleType, + WorkbenchSelectedModel +} from '../../../../shared/types' +import type { SqliteCore } from '../sqlite/SqliteCore' +import { fromSqliteBoolean, toOptionalNumber } from '../sqlite/sqliteUtils' + +type ScheduledTaskRow = { + id?: unknown + name?: unknown + prompt?: unknown + enabled?: unknown + schedule_type?: unknown + time_of_day?: unknown + weekday?: unknown + workspace_id?: unknown + main_model_config_id?: unknown + main_model_name?: unknown + vision_model_config_id?: unknown + vision_model_name?: unknown + ai_preference?: unknown + execution_permission?: unknown + next_run_at?: unknown + created_at?: unknown + updated_at?: unknown +} + +const toScheduleType = (value: unknown): ScheduledTaskScheduleType => + value === 'weekly' ? 'weekly' : 'daily' + +const toPreference = (value: unknown): AiPreference => + value === 'local_sources_relaxed' || value === 'local_sources_strict' ? value : 'default' + +const toPermission = (value: unknown): AiExecutionPermission => + value === 'full_access' ? 'full_access' : 'review' + +const toModel = ( + configId: unknown, + modelName: unknown +): WorkbenchSelectedModel | null => { + const id = String(configId || '').trim() + const name = String(modelName || '').trim() + if (!id || !name) return null + return { configId: id, modelName: name } +} + +const toTask = (row: ScheduledTaskRow): ScheduledTask => { + const mainModel = toModel(row.main_model_config_id, row.main_model_name) + const visionModel = toModel(row.vision_model_config_id, row.vision_model_name) + const weekday = toOptionalNumber(row.weekday) + if (!mainModel) { + throw new Error(`scheduled_task_missing_main_model:${String(row.id || '')}`) + } + return { + id: String(row.id || ''), + name: String(row.name || ''), + prompt: String(row.prompt || ''), + enabled: fromSqliteBoolean(row.enabled), + scheduleType: toScheduleType(row.schedule_type), + timeOfDay: String(row.time_of_day || ''), + weekday: weekday ?? null, + workspaceId: String(row.workspace_id || ''), + mainModel, + visionModel, + aiPreference: toPreference(row.ai_preference), + executionPermission: toPermission(row.execution_permission), + nextRunAt: toOptionalNumber(row.next_run_at) ?? null, + createdAt: Number(row.created_at || 0), + updatedAt: Number(row.updated_at || 0) + } +} + +export class ScheduledTaskStore { + public constructor(private readonly sqlite: SqliteCore) {} + + public async list(): Promise { + const rows = this.sqlite + .prepare('SELECT * FROM scheduled_task ORDER BY created_at ASC') + .all() as ScheduledTaskRow[] + return rows.map(toTask) + } + + public async listEnabled(): Promise { + const rows = this.sqlite + .prepare('SELECT * FROM scheduled_task WHERE enabled = 1 ORDER BY created_at ASC') + .all() as ScheduledTaskRow[] + return rows.map(toTask) + } + + public async getById(id: string): Promise { + const safeId = String(id || '').trim() + if (!safeId) return null + const row = this.sqlite + .prepare('SELECT * FROM scheduled_task WHERE id = ?') + .get(safeId) as ScheduledTaskRow | undefined + return row ? toTask(row) : null + } + + public async save(task: ScheduledTask): Promise { + this.sqlite + .prepare( + `INSERT INTO scheduled_task ( + id, name, prompt, enabled, schedule_type, time_of_day, weekday, + workspace_id, main_model_config_id, main_model_name, + vision_model_config_id, vision_model_name, ai_preference, + execution_permission, next_run_at, created_at, updated_at + ) VALUES ( + @id, @name, @prompt, @enabled, @schedule_type, @time_of_day, @weekday, + @workspace_id, @main_model_config_id, @main_model_name, + @vision_model_config_id, @vision_model_name, @ai_preference, + @execution_permission, @next_run_at, @created_at, @updated_at + ) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + prompt = excluded.prompt, + enabled = excluded.enabled, + schedule_type = excluded.schedule_type, + time_of_day = excluded.time_of_day, + weekday = excluded.weekday, + workspace_id = excluded.workspace_id, + main_model_config_id = excluded.main_model_config_id, + main_model_name = excluded.main_model_name, + vision_model_config_id = excluded.vision_model_config_id, + vision_model_name = excluded.vision_model_name, + ai_preference = excluded.ai_preference, + execution_permission = excluded.execution_permission, + next_run_at = excluded.next_run_at, + updated_at = excluded.updated_at` + ) + .run({ + id: task.id, + name: task.name, + prompt: task.prompt, + enabled: task.enabled ? 1 : 0, + schedule_type: task.scheduleType, + time_of_day: task.timeOfDay, + weekday: task.weekday, + workspace_id: task.workspaceId, + main_model_config_id: task.mainModel.configId, + main_model_name: task.mainModel.modelName, + vision_model_config_id: task.visionModel?.configId ?? null, + vision_model_name: task.visionModel?.modelName ?? null, + ai_preference: task.aiPreference, + execution_permission: task.executionPermission, + next_run_at: task.nextRunAt, + created_at: task.createdAt, + updated_at: task.updatedAt + }) + } + + public async delete(id: string): Promise { + this.sqlite.prepare('DELETE FROM scheduled_task WHERE id = ?').run(id) + } +} diff --git a/src/main/services/storage/sqlite/SqliteCore.ts b/src/main/services/storage/sqlite/SqliteCore.ts index 3b816271..dc1711ca 100644 --- a/src/main/services/storage/sqlite/SqliteCore.ts +++ b/src/main/services/storage/sqlite/SqliteCore.ts @@ -74,6 +74,30 @@ const aiRunExecutionPermissionMigration = ` ALTER TABLE ai_run ADD COLUMN execution_permission TEXT NOT NULL DEFAULT 'review'; ` +const scheduledTaskMigration = ` +CREATE TABLE IF NOT EXISTS scheduled_task ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0, + schedule_type TEXT NOT NULL DEFAULT 'daily', + time_of_day TEXT NOT NULL DEFAULT '09:00', + weekday INTEGER, + workspace_id TEXT NOT NULL DEFAULT '', + main_model_config_id TEXT NOT NULL DEFAULT '', + main_model_name TEXT NOT NULL DEFAULT '', + vision_model_config_id TEXT, + vision_model_name TEXT, + ai_preference TEXT NOT NULL DEFAULT 'default', + execution_permission TEXT NOT NULL DEFAULT 'full_access', + next_run_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_scheduled_task_enabled + ON scheduled_task(enabled, next_run_at); +` + type SqliteColumnInfo = { name: string } @@ -141,6 +165,11 @@ const MIGRATIONS: Migration[] = [ version: 9, name: 'repair_ai_runtime_system_notifications_column', run: repairNotificationSettingsColumn + }, + { + version: 10, + name: 'scheduled_task', + sql: scheduledTaskMigration } ] diff --git a/src/preload/api.ts b/src/preload/api.ts index 64f946ea..3b14f7c7 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -94,6 +94,10 @@ import type { ListAiFileChangeRecordsResponse, RevertAiFileChangeRequest, RevertAiFileChangeResponse, + ScheduledTask, + ScheduledTaskMutationResponse, + ScheduledTaskSaveRequest, + ScheduledTaskSetEnabledRequest, WorkspaceIndexJobGetRequest, WorkspaceIndexJobRecord, WorkspaceIndexJobResponse, @@ -204,6 +208,13 @@ export type DesktopApi = { chatSessionMarkViewed: (id: string) => Promise<{ success: boolean; error?: string }> chatSessionDelete: (id: string) => Promise<{ success: boolean; error?: string }> chatMessageList: (sessionId: string) => Promise + scheduledTaskList: () => Promise + scheduledTaskSave: (payload: ScheduledTaskSaveRequest) => Promise + scheduledTaskDelete: (id: string) => Promise + scheduledTaskSetEnabled: ( + payload: ScheduledTaskSetEnabledRequest + ) => Promise + onChatSessionListRefresh: (cb: () => void) => () => void chatSessionOpen: (payload: ChatSessionOpenRequest) => Promise chatSessionUiStateSave: ( payload: ChatSessionUiStateSaveRequest diff --git a/src/preload/index.ts b/src/preload/index.ts index 03815a38..9b597afc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -271,6 +271,17 @@ const api: DesktopApi = { chatSessionMarkViewed: (id: string) => ipcRenderer.invoke('chat-session-mark-viewed', id), chatSessionDelete: (id: string) => ipcRenderer.invoke('chat-session-delete', id), chatMessageList: (sessionId: string) => ipcRenderer.invoke('chat-message-list', sessionId), + scheduledTaskList: () => ipcRenderer.invoke('scheduled-task-list'), + scheduledTaskSave: (payload) => ipcRenderer.invoke('scheduled-task-save', payload), + scheduledTaskDelete: (id: string) => ipcRenderer.invoke('scheduled-task-delete', id), + scheduledTaskSetEnabled: (payload) => ipcRenderer.invoke('scheduled-task-set-enabled', payload), + onChatSessionListRefresh: (cb: () => void) => { + const listener = (): void => cb() + ipcRenderer.on('chat-session-list-refresh', listener) + return () => { + ipcRenderer.removeListener('chat-session-list-refresh', listener) + } + }, chatSessionOpen: (payload) => ipcRenderer.invoke('chat-session-open', payload), chatSessionUiStateSave: (payload) => ipcRenderer.invoke('chat-session-ui-state-save', payload), workbenchUiStateGet: () => ipcRenderer.invoke('workbench-ui-state-get'), diff --git a/src/renderer/src/components/scheduledTasks/ScheduledTasksView.tsx b/src/renderer/src/components/scheduledTasks/ScheduledTasksView.tsx new file mode 100644 index 00000000..d83b2632 --- /dev/null +++ b/src/renderer/src/components/scheduledTasks/ScheduledTasksView.tsx @@ -0,0 +1,478 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { App as AntdApp, Button, Empty, Input, Modal, Segmented, Select, Spin, Switch, TimePicker } from 'antd' +import dayjs from 'dayjs' +import { HiOutlineClock, HiOutlinePlus, HiOutlineTrash, HiOutlineXMark } from 'react-icons/hi2' +import { useNavigate } from 'react-router-dom' +import type { + AiExecutionPermission, + AiPreference, + LLMConfig, + LlmRemoteModel, + ScheduledTask, + ScheduledTaskSaveRequest, + ScheduledTaskScheduleType, + WorkbenchModelSelection, + WorkspaceListItem +} from '../../../../shared/types' +import WorkbenchModelSelector from '../workbench/WorkbenchModelSelector' +import WorkbenchSelect from '../workbench/WorkbenchSelect' +import WorkspaceQuickSelect from '../workbench/WorkspaceQuickSelect' +import { AI_PREFERENCE_OPTIONS, EXECUTION_PERMISSION_OPTIONS } from '../workbench/selectOptions' +import type { WorkbenchAiPreference, WorkbenchExecutionPermission } from '../workbench/types' + +const WEEKDAY_OPTIONS = [ + { value: 0, label: '周日' }, + { value: 1, label: '周一' }, + { value: 2, label: '周二' }, + { value: 3, label: '周三' }, + { value: 4, label: '周四' }, + { value: 5, label: '周五' }, + { value: 6, label: '周六' } +] + +const SCHEDULE_TYPE_OPTIONS = [ + { value: 'daily', label: '每天' }, + { value: 'weekly', label: '每周' } +] + +type FormState = { + id: string + name: string + prompt: string + scheduleType: ScheduledTaskScheduleType + timeOfDay: string + weekday: number | null + workspaceId: string + modelSelection: WorkbenchModelSelection + aiPreference: AiPreference + executionPermission: AiExecutionPermission +} + +const emptyForm = (): FormState => ({ + id: '', + name: '', + prompt: '', + scheduleType: 'daily', + timeOfDay: '09:00', + weekday: null, + workspaceId: '', + modelSelection: { mainModel: null, visionModel: null }, + aiPreference: 'default', + executionPermission: 'full_access' +}) + +const normalizeWeekday = (value: number | null | undefined): number | null => { + if (value === null || value === undefined) return null + return Number.isInteger(value) && value >= 0 && value <= 6 ? value : null +} + +const weekdayLabel = (weekday: number | null): string => { + if (weekday === null) return '' + return WEEKDAY_OPTIONS.find((item) => item.value === weekday)?.label || '' +} + +const scheduleLabel = (task: ScheduledTask): string => { + if (task.scheduleType === 'weekly') { + const day = weekdayLabel(task.weekday) + return `每周${day ? ` ${day}` : ''} ${task.timeOfDay}` + } + return `每天 ${task.timeOfDay}` +} + +type ScheduledTasksViewProps = { + onClose: () => void +} + +const ScheduledTasksView: React.FC = ({ onClose }) => { + const { message, modal } = AntdApp.useApp() + const navigate = useNavigate() + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [workspaces, setWorkspaces] = useState([]) + const [modelConfigs, setModelConfigs] = useState([]) + const [modelLists, setModelLists] = useState>({}) + const [formOpen, setFormOpen] = useState(false) + const [saving, setSaving] = useState(false) + const [form, setForm] = useState(emptyForm()) + + const availableWorkspaces = useMemo( + () => workspaces.filter((workspace) => Boolean(workspace.root_path)), + [workspaces] + ) + + const loadTasks = useCallback(async (): Promise => { + try { + setTasks(await window.api.scheduledTaskList()) + } catch (error) { + console.error('[SCHEDULED-TASKS] list failed:', error) + setTasks([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadTasks() + void (async () => { + try { + const list = await window.api.workspaceList() + setWorkspaces(list.items) + } catch (error) { + console.error('[SCHEDULED-TASKS] workspace list failed:', error) + } + })() + void (async () => { + try { + const configs = await window.api.llmConfigList() + setModelConfigs(configs) + const entries = await Promise.all( + configs.map(async (config): Promise<[string, LlmRemoteModel[]]> => { + try { + const response = await window.api.llmConfigModelList(config.id) + return [config.id, response.success ? response.items : []] + } catch { + return [config.id, []] + } + }) + ) + setModelLists(Object.fromEntries(entries)) + } catch (error) { + console.error('[SCHEDULED-TASKS] model config list failed:', error) + } + })() + }, [loadTasks]) + + const openCreate = (): void => { + setForm(emptyForm()) + setFormOpen(true) + } + + const openEdit = (task: ScheduledTask): void => { + setForm({ + id: task.id, + name: task.name, + prompt: task.prompt, + scheduleType: task.scheduleType, + timeOfDay: task.timeOfDay, + weekday: task.weekday, + workspaceId: task.workspaceId, + modelSelection: { + mainModel: task.mainModel, + visionModel: task.visionModel + }, + aiPreference: task.aiPreference, + executionPermission: task.executionPermission + }) + setFormOpen(true) + } + + const buildSaveRequest = (): ScheduledTaskSaveRequest | null => { + const timeOfDay = form.timeOfDay.trim() + if (!form.name.trim()) { + message.warning('请填写任务名称') + return null + } + if (!form.prompt.trim()) { + message.warning('请填写任务提示词') + return null + } + if (!timeOfDay) { + message.warning('请选择执行时间') + return null + } + if (!form.workspaceId) { + message.warning('请选择工作空间') + return null + } + if (!form.modelSelection.mainModel) { + message.warning('请选择主模型') + return null + } + if (form.scheduleType === 'weekly' && form.weekday === null) { + message.warning('请选择星期几') + return null + } + return { + id: form.id || undefined, + name: form.name.trim(), + prompt: form.prompt.trim(), + enabled: false, + scheduleType: form.scheduleType, + timeOfDay, + weekday: form.scheduleType === 'weekly' ? normalizeWeekday(form.weekday) : null, + workspaceId: form.workspaceId, + mainModel: form.modelSelection.mainModel, + visionModel: form.modelSelection.visionModel, + aiPreference: form.aiPreference, + executionPermission: form.executionPermission + } + } + + const handleSave = async (): Promise => { + const payload = buildSaveRequest() + if (!payload) return + setSaving(true) + try { + const response = await window.api.scheduledTaskSave(payload) + if (!response.success) { + message.error(response.error || '保存失败') + return + } + message.success('已保存') + setFormOpen(false) + await loadTasks() + } catch (error) { + message.error('保存失败') + console.error('[SCHEDULED-TASKS] save failed:', error) + } finally { + setSaving(false) + } + } + + const handleToggle = async (task: ScheduledTask, enabled: boolean): Promise => { + try { + const response = await window.api.scheduledTaskSetEnabled({ id: task.id, enabled }) + if (!response.success) { + message.error(response.error || '操作失败') + return + } + setTasks((current) => + current.map((item) => (item.id === task.id ? { ...item, enabled } : item)) + ) + } catch (error) { + message.error('操作失败') + console.error('[SCHEDULED-TASKS] toggle failed:', error) + } + } + + const handleDelete = (task: ScheduledTask): void => { + modal.confirm({ + title: '删除定时任务', + content: `确定删除「${task.name}」吗?`, + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + const response = await window.api.scheduledTaskDelete(task.id) + if (!response.success) { + message.error(response.error || '删除失败') + return + } + message.success('已删除') + await loadTasks() + } + }) + } + + return ( +
+
+
+ + + 定时任务 +
+ +
+ +
+ {loading ? ( +
+ +
+ ) : tasks.length === 0 ? ( + + ) : ( +
+ {tasks.map((task) => ( +
+
+
{task.name}
+
+ {scheduleLabel(task)} · {task.mainModel.modelName} +
+
+ void handleToggle(task, checked)} + /> + +
+ ))} +
+ )} +
+ + void handleSave()} + onCancel={() => setFormOpen(false)} + width={560} + maskClosable={false} + > +
+ + + + +
+ 执行时间 +
+ + setForm((current) => ({ + ...current, + scheduleType: value as ScheduledTaskScheduleType + })) + } + /> + {form.scheduleType === 'weekly' ? ( +