feat: add scheduled tasks feature with CRUD operations and UI integration

- Implemented database migration for scheduled tasks table.
- Added API endpoints for managing scheduled tasks (list, save, delete, enable/disable).
- Created ScheduledTasksView component for displaying and managing scheduled tasks.
- Integrated scheduled tasks functionality into the WorkbenchComposer and ChatPage components.
- Added select options for AI preferences and execution permissions.
- Implemented unit tests for scheduled task scheduling logic.
This commit is contained in:
hy committed 2026-08-21 10:44:09 +08:00
1 parent a75478e2e7
commit 7d45870fe3
18 files changed
+1488 -120

No files matched your search

+114
View File
@@ -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 或圆角规则。
+42
View File
@@ -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:不验证已绑定目录工作空间的正常触发路径。
+1
View File
@@ -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",
+3
View File
@@ -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
+56
View File
@@ -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<ScheduledTaskMutationResponse> => {
return scheduledTaskService.save(payload)
}
)
ipcMain.handle(
'scheduled-task-delete',
async (_event, id: string): Promise<ScheduledTaskMutationResponse> => {
return scheduledTaskService.delete(id)
}
)
ipcMain.handle(
'scheduled-task-set-enabled',
async (
_event,
payload: ScheduledTaskSetEnabledRequest
): Promise<ScheduledTaskMutationResponse> => {
return scheduledTaskService.setEnabled(payload)
}
)
}
@@ -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<typeof setInterval>
type ScheduledTaskServiceDeps = {
taskStore: ScheduledTaskStore
workspaceStore: WorkspaceStore
chatStore: ChatStore
submitTask: (payload: ChatSubmitRequest) => Promise<ChatSubmitResponse>
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<Pick<ScheduledTaskServiceDeps, 'repeat' | 'clearRepeat' | 'now'>> &
Omit<ScheduledTaskServiceDeps, 'repeat' | 'clearRepeat' | 'now'>
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<ScheduledTask[]> {
return this.deps.taskStore.list()
}
public async save(input: ScheduledTaskSaveRequest): Promise<ScheduledTaskMutationResponse> {
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<ScheduledTaskMutationResponse> {
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<ScheduledTaskMutationResponse> {
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<void> {
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<void> {
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<void> {
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<void> {
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
}
@@ -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 {
@@ -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<ScheduledTask[]> {
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<ScheduledTask[]> {
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<ScheduledTask | null> {
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<void> {
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<void> {
this.sqlite.prepare('DELETE FROM scheduled_task WHERE id = ?').run(id)
}
}
@@ -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
}
]
+11
View File
@@ -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<ChatMessage[]>
scheduledTaskList: () => Promise<ScheduledTask[]>
scheduledTaskSave: (payload: ScheduledTaskSaveRequest) => Promise<ScheduledTaskMutationResponse>
scheduledTaskDelete: (id: string) => Promise<ScheduledTaskMutationResponse>
scheduledTaskSetEnabled: (
payload: ScheduledTaskSetEnabledRequest
) => Promise<ScheduledTaskMutationResponse>
onChatSessionListRefresh: (cb: () => void) => () => void
chatSessionOpen: (payload: ChatSessionOpenRequest) => Promise<ChatSessionOpenResponse>
chatSessionUiStateSave: (
payload: ChatSessionUiStateSaveRequest
+11
View File
@@ -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'),
@@ -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<ScheduledTasksViewProps> = ({ onClose }) => {
const { message, modal } = AntdApp.useApp()
const navigate = useNavigate()
const [tasks, setTasks] = useState<ScheduledTask[]>([])
const [loading, setLoading] = useState(true)
const [workspaces, setWorkspaces] = useState<WorkspaceListItem[]>([])
const [modelConfigs, setModelConfigs] = useState<LLMConfig[]>([])
const [modelLists, setModelLists] = useState<Record<string, LlmRemoteModel[]>>({})
const [formOpen, setFormOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<FormState>(emptyForm())
const availableWorkspaces = useMemo(
() => workspaces.filter((workspace) => Boolean(workspace.root_path)),
[workspaces]
)
const loadTasks = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
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 (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-[44px] shrink-0 items-center justify-between border-b border-[#EEEEEC] bg-white px-4">
<div className="flex items-center gap-2">
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-[6px] text-[#91918E] transition-colors hover:bg-[#EFEFEE] hover:text-[#37352F]"
onClick={onClose}
aria-label="关闭定时任务"
>
<HiOutlineXMark className="h-4 w-4" />
</button>
<HiOutlineClock className="h-4 w-4 text-[#787774]" />
<span className="text-[13px] font-medium text-[#37352F]">定时任务</span>
</div>
<Button type="primary" size="small" icon={<HiOutlinePlus />} onClick={openCreate}>
新增配置
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex h-32 items-center justify-center">
<Spin />
</div>
) : tasks.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="还没有定时任务,点击右上角「新增配置」创建"
/>
) : (
<div className="flex flex-col gap-2">
{tasks.map((task) => (
<div
key={task.id}
className="group flex items-center gap-3 rounded-[8px] border border-[#EEEEEC] bg-white px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[#37352F]">{task.name}</div>
<div className="mt-0.5 truncate text-[12px] text-[#91918E]">
{scheduleLabel(task)} · {task.mainModel.modelName}
</div>
</div>
<Switch
size="small"
checked={task.enabled}
onChange={(checked) => void handleToggle(task, checked)}
/>
<Button type="text" size="small" onClick={() => openEdit(task)}>
编辑
</Button>
<Button
type="text"
size="small"
danger
icon={<HiOutlineTrash />}
onClick={() => handleDelete(task)}
aria-label={`删除 ${task.name}`}
/>
</div>
))}
</div>
)}
</div>
<Modal
open={formOpen}
title={form.id ? '编辑定时任务' : '新增定时任务'}
okText="保存"
cancelText="取消"
confirmLoading={saving}
onOk={() => void handleSave()}
onCancel={() => setFormOpen(false)}
width={560}
maskClosable={false}
>
<div className="flex flex-col gap-4 pt-2">
<label className="flex flex-col gap-1.5">
<span className="text-[13px] text-[#37352F]">名称</span>
<Input
value={form.name}
placeholder="例如:每日晨报"
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[13px] text-[#37352F]">任务提示词</span>
<Input.TextArea
value={form.prompt}
rows={3}
placeholder="描述想让 AI 完成的事"
onChange={(event) =>
setForm((current) => ({ ...current, prompt: event.target.value }))
}
/>
</label>
<div className="flex flex-col gap-1.5">
<span className="text-[13px] text-[#37352F]">执行时间</span>
<div className="flex items-center gap-3">
<Segmented
value={form.scheduleType}
options={SCHEDULE_TYPE_OPTIONS}
onChange={(value) =>
setForm((current) => ({
...current,
scheduleType: value as ScheduledTaskScheduleType
}))
}
/>
{form.scheduleType === 'weekly' ? (
<Select
value={form.weekday ?? undefined}
style={{ width: 120 }}
placeholder="选择星期"
options={WEEKDAY_OPTIONS}
onChange={(value) =>
setForm((current) => ({ ...current, weekday: normalizeWeekday(value) }))
}
/>
) : null}
<TimePicker
format="HH:mm"
value={dayjs(form.timeOfDay, 'HH:mm')}
allowClear={false}
minuteStep={1}
onChange={(time) =>
setForm((current) => ({
...current,
timeOfDay: time ? time.format('HH:mm') : ''
}))
}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[13px] text-[#37352F]">工作空间</span>
<WorkspaceQuickSelect
workspaces={availableWorkspaces}
selectedWorkspaceId={form.workspaceId || null}
onWorkspaceChange={(workspaceId) =>
setForm((current) => ({ ...current, workspaceId }))
}
onEmptyAction={() => navigate('/settings/workspace')}
className="w-full"
data-testid="scheduled-task-workspace-select"
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-[13px] text-[#37352F]">模型</span>
<WorkbenchModelSelector
configs={modelConfigs}
modelListsByConfigId={modelLists}
value={form.modelSelection}
onChange={(selection) =>
setForm((current) => ({ ...current, modelSelection: selection }))
}
className="w-full"
data-testid="scheduled-task-model-select"
/>
</div>
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-[13px] text-[#37352F]">偏好</span>
<WorkbenchSelect
value={form.aiPreference}
options={AI_PREFERENCE_OPTIONS}
onChange={(nextValue) =>
setForm((current) => ({
...current,
aiPreference: nextValue as WorkbenchAiPreference
}))
}
placeholder="偏好"
minMenuWidth={220}
data-testid="scheduled-task-preference-select"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-[13px] text-[#37352F]">执行权限</span>
<WorkbenchSelect
value={form.executionPermission}
options={EXECUTION_PERMISSION_OPTIONS}
onChange={(nextValue) =>
setForm((current) => ({
...current,
executionPermission: nextValue as WorkbenchExecutionPermission
}))
}
placeholder="权限"
minMenuWidth={220}
showDescriptions
data-testid="scheduled-task-permission-select"
/>
</div>
</div>
{form.executionPermission === 'review' ? (
<p className="text-[12px] text-[#E8805C]">
定时任务在无人值守时执行,选择「审核」可能导致任务因等待审批而一直阻塞,建议使用「完全访问」。
</p>
) : null}
</div>
</Modal>
</div>
)
}
export default ScheduledTasksView
@@ -2,20 +2,15 @@
import { App as AntdApp, Spin, Tooltip } from 'antd'
import {
HiArrowUp,
HiCircleStack,
HiFolder,
HiMagnifyingGlass,
HiOutlineFolderPlus,
HiPhoto,
HiPlus,
HiQuestionMarkCircle,
HiShieldCheck,
HiSparkles,
HiStop,
HiExclamationTriangle,
HiXMark
} from 'react-icons/hi2'
import { FaCircle } from 'react-icons/fa'
import type {
ChatAttachment,
LLMConfig,
@@ -51,6 +46,7 @@ import {
} from './composer/utils'
import WorkbenchSelect from './WorkbenchSelect'
import WorkbenchModelSelector from './WorkbenchModelSelector'
import { AI_PREFERENCE_OPTIONS, EXECUTION_PERMISSION_OPTIONS } from './selectOptions'
import type { ImageLightboxImage } from './ImageLightbox'
import { getAttachmentTypeLabel } from './utils'
@@ -120,14 +116,6 @@ const filterMentionReferences = (
})
}
const PreferenceDots: React.FC<{ count: 1 | 2 }> = ({ count }) => (
<span className="inline-flex items-center gap-0.5 text-[#787774]">
{Array.from({ length: count }).map((_, index) => (
<FaCircle key={index} className="h-1.5 w-1.5" />
))}
</span>
)
const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
mode,
onModeChange,
@@ -837,87 +825,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
],
[]
)
const aiPreferenceOptions = useMemo<WorkbenchSelectOption[]>(
() => [
{
value: 'default',
label: '默认',
icon: <HiSparkles className="h-3.5 w-3.5" />,
description: 'AI 自主判断如何推进任务',
trailing: (
<Tooltip title="AI 自主判断如何推进任务">
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#9B9A97] transition hover:bg-[#EFEFED] hover:text-[#37352F]"
aria-label="默认偏好说明"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<HiQuestionMarkCircle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)
},
{
value: 'local_sources',
label: '本地资料',
icon: <HiCircleStack className="h-3.5 w-3.5" />,
description: '系统管理知识库和本地资料',
children: [
{
value: 'local_sources_relaxed',
label: '宽松',
triggerLabel: '本地资料',
triggerIcon: <HiCircleStack className="h-3.5 w-3.5" />,
triggerSuffix: (
<span className="inline-flex items-center gap-1">
<PreferenceDots count={1} />
</span>
),
icon: <PreferenceDots count={1} />,
description: '优先依据本地资料推进任务',
trailing: (
<Tooltip title="优先依据本地资料内容推进任务">
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#9B9A97] transition hover:bg-[#EFEFED] hover:text-[#37352F]"
aria-label="本地资料宽松模式说明"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<HiQuestionMarkCircle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)
},
{
value: 'local_sources_strict',
label: '严谨',
triggerLabel: '本地资料',
triggerIcon: <HiCircleStack className="h-3.5 w-3.5" />,
triggerSuffix: (
<span className="inline-flex items-center gap-1">
<PreferenceDots count={2} />
</span>
),
icon: <PreferenceDots count={2} />,
description: 'AI 只能依据本地资料推进任务',
trailing: (
<Tooltip title="完全只根据本地资料的内容推进任务">
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#9B9A97] transition hover:bg-[#EFEFED] hover:text-[#37352F]"
aria-label="本地资料严谨模式说明"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<HiQuestionMarkCircle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)
}
]
}
],
[]
)
const workspaceOptions = useMemo<WorkbenchSelectOption[]>(
() =>
workspaces.map((workspace) => ({
@@ -928,27 +835,6 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
})),
[workspaces]
)
const executionPermissionOptions = useMemo<WorkbenchSelectOption[]>(
() => [
{
value: 'review',
label: '审核',
icon: <HiShieldCheck className="h-4 w-4 text-[#787774]" />,
triggerIcon: <HiShieldCheck className="h-3.5 w-3.5" />,
description: '涉及到风险操作需要你审核'
},
{
value: 'full_access',
label: '完全访问',
icon: <HiExclamationTriangle className="h-4 w-4 text-[#E85D04]" />,
triggerIcon: <HiExclamationTriangle className="h-3.5 w-3.5 text-[#E85D04]" />,
description: '不受限制的操作你的电脑',
labelClassName: '!text-[#E85D04]',
descriptionClassName: '!text-[#E85D04]'
}
],
[]
)
const currentAttachments = useMemo(() => draftValue.attachments || [], [draftValue.attachments])
useEffect(() => {
@@ -1200,7 +1086,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
<span className="mx-1 h-4 w-px shrink-0 bg-[#E5E4E0]" aria-hidden="true" />
<WorkbenchSelect
value={aiPreference}
options={aiPreferenceOptions}
options={AI_PREFERENCE_OPTIONS}
onChange={(nextValue) => {
onAiPreferenceChange(nextValue as WorkbenchAiPreference)
focusEditorAfterControlChange()
@@ -1216,7 +1102,7 @@ const WorkbenchComposer: React.FC<WorkbenchComposerProps> = ({
/>
<WorkbenchSelect
value={executionPermission}
options={executionPermissionOptions}
options={EXECUTION_PERMISSION_OPTIONS}
onChange={(nextValue) => {
onExecutionPermissionChange(nextValue as WorkbenchExecutionPermission)
focusEditorAfterControlChange()
@@ -0,0 +1,117 @@
import React from 'react'
import { Tooltip } from 'antd'
import {
HiCircleStack,
HiExclamationTriangle,
HiQuestionMarkCircle,
HiShieldCheck,
HiSparkles
} from 'react-icons/hi2'
import { FaCircle } from 'react-icons/fa'
import type { WorkbenchSelectOption } from './types'
const preferenceDots = (count: 1 | 2): React.ReactNode => (
<span className="inline-flex items-center gap-0.5 text-[#787774]">
{Array.from({ length: count }).map((_, index) => (
<FaCircle key={index} className="h-1.5 w-1.5" />
))}
</span>
)
export const AI_PREFERENCE_OPTIONS: WorkbenchSelectOption[] = [
{
value: 'default',
label: '默认',
icon: <HiSparkles className="h-3.5 w-3.5" />,
description: 'AI 自主判断如何推进任务',
trailing: (
<Tooltip title="AI 自主判断如何推进任务">
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#9B9A97] transition hover:bg-[#EFEFED] hover:text-[#37352F]"
aria-label="默认偏好说明"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<HiQuestionMarkCircle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)
},
{
value: 'local_sources',
label: '本地资料',
icon: <HiCircleStack className="h-3.5 w-3.5" />,
description: '系统管理知识库和本地资料',
children: [
{
value: 'local_sources_relaxed',
label: '宽松',
triggerLabel: '本地资料',
triggerIcon: <HiCircleStack className="h-3.5 w-3.5" />,
triggerSuffix: (
<span className="inline-flex items-center gap-1">
{preferenceDots(1)}
</span>
),
icon: preferenceDots(1),
description: '优先依据本地资料推进任务',
trailing: (
<Tooltip title="优先依据本地资料内容推进任务">
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#9B9A97] transition hover:bg-[#EFEFED] hover:text-[#37352F]"
aria-label="本地资料宽松模式说明"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<HiQuestionMarkCircle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)
},
{
value: 'local_sources_strict',
label: '严谨',
triggerLabel: '本地资料',
triggerIcon: <HiCircleStack className="h-3.5 w-3.5" />,
triggerSuffix: (
<span className="inline-flex items-center gap-1">
{preferenceDots(2)}
</span>
),
icon: preferenceDots(2),
description: 'AI 只能依据本地资料推进任务',
trailing: (
<Tooltip title="完全只根据本地资料的内容推进任务">
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-[#9B9A97] transition hover:bg-[#EFEFED] hover:text-[#37352F]"
aria-label="本地资料严谨模式说明"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<HiQuestionMarkCircle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)
}
]
}
]
export const EXECUTION_PERMISSION_OPTIONS: WorkbenchSelectOption[] = [
{
value: 'review',
label: '审核',
icon: <HiShieldCheck className="h-4 w-4 text-[#787774]" />,
triggerIcon: <HiShieldCheck className="h-3.5 w-3.5" />,
description: '涉及到风险操作需要你审核'
},
{
value: 'full_access',
label: '完全访问',
icon: <HiExclamationTriangle className="h-4 w-4 text-[#E85D04]" />,
triggerIcon: <HiExclamationTriangle className="h-3.5 w-3.5 text-[#E85D04]" />,
description: '不受限制的操作你的电脑',
labelClassName: '!text-[#E85D04]',
descriptionClassName: '!text-[#E85D04]'
}
]
+39 -3
View File
@@ -9,6 +9,7 @@ import {
HiClock,
HiFolder,
HiOutlineClipboardDocument,
HiOutlineClock,
HiOutlineExclamationCircle,
HiOutlineFolderPlus,
HiOutlinePencilSquare,
@@ -49,6 +50,7 @@ import { HiOutlineCog6Tooth } from 'react-icons/hi2'
import AssistantTurnCard from '../components/workbench/AssistantTurnCard'
import WorkbenchComposer from '../components/workbench/WorkbenchComposer'
import WorkbenchContentPanel from '../components/workbench/WorkbenchContentPanel'
import ScheduledTasksView from '../components/scheduledTasks/ScheduledTasksView'
import UserTakeoverPanel from '../components/workbench/UserTakeoverPanel'
import ImageLightbox from '../components/workbench/ImageLightbox'
import AttachmentFileIcon from '../components/workbench/composer/AttachmentFileIcon'
@@ -732,6 +734,7 @@ const ChatPage: React.FC = () => {
const [contentPanelCollapsed, setContentPanelCollapsed] = useState(true)
const [contentPanelShellMounted, setContentPanelShellMounted] = useState(false)
const [contentPanelShellVisible, setContentPanelShellVisible] = useState(false)
const [scheduledTasksActive, setScheduledTasksActive] = useState(false)
const [browserPresentationEnabled, setBrowserPresentationEnabled] = useState(false)
const [browserTransitionImage, setBrowserTransitionImage] = useState<{
tabId: string
@@ -1436,6 +1439,7 @@ const ChatPage: React.FC = () => {
const showContentPanel = useCallback(
(nextState: WorkbenchContentPanelState): void => {
setScheduledTasksActive(false)
if (contentPanel.kind === 'browser') {
void window.api.browserPanelHide()
}
@@ -1449,8 +1453,17 @@ const ChatPage: React.FC = () => {
[contentPanel.kind, expandContentPanel, tabWorkspaceKey, updateCurrentTabWorkspace]
)
const openScheduledTasks = useCallback((): void => {
setScheduledTasksActive(true)
}, [])
const closeScheduledTasks = useCallback((): void => {
setScheduledTasksActive(false)
}, [])
const openResourceTab = useCallback(
(tab: WorkbenchResourceTab): void => {
setScheduledTasksActive(false)
const switchingAwayFromBrowser =
contentPanel.kind === 'browser' &&
(tab.kind !== 'browser' || tab.browserTabId !== contentPanel.browserTabId)
@@ -1973,6 +1986,7 @@ const ChatPage: React.FC = () => {
// Do this before awaiting the previous draft flush so a late bootstrap response cannot
// restore the last-opened view over an explicitly created new conversation.
uiStateInteractionVersionRef.current += 1
setScheduledTasksActive(false)
await flushCurrentUiState()
const previousDraftWorkspace = tabWorkspacesRef.current.__draft__
for (const tab of previousDraftWorkspace?.tabs || []) {
@@ -3009,6 +3023,12 @@ const ChatPage: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSessionId, loadSessions, message, refreshContextUsage, setSessionRunningState])
useEffect(() => {
return window.api.onChatSessionListRefresh(() => {
void loadSessions()
})
}, [loadSessions])
const createDraftSessionForSubmit = (workspace: WorkspaceSource): ChatSession => {
return {
id: crypto.randomUUID(),
@@ -3860,6 +3880,16 @@ const ChatPage: React.FC = () => {
<HiOutlinePencilSquare className="h-[18px] w-[18px] shrink-0 text-[#787774]" />
<span>新对话</span>
</button>
<button
type="button"
className={`nc-sidebar-nav-row ${scheduledTasksActive ? 'nc-sidebar-item-active' : ''}`}
onClick={() => void openScheduledTasks()}
data-testid={workbenchSelectors.scheduledTasksButton}
aria-label="定时任务"
>
<HiOutlineClock className="h-[18px] w-[18px] shrink-0 text-[#787774]" />
<span>定时任务</span>
</button>
</div>
<div
@@ -4070,9 +4100,13 @@ const ChatPage: React.FC = () => {
</aside>
<div className="nc-main-pane min-w-0 flex-1" style={WORKBENCH_POSITIONED_PANE_STYLE}>
<div className="flex h-full min-h-0 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col">
{isNewSession ? (
{scheduledTasksActive ? (
<ScheduledTasksView onClose={closeScheduledTasks} />
) : (
<>
<div className="flex h-full min-h-0 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col">
{isNewSession ? (
<div className="nc-main-empty-titlebar" aria-hidden="true">
<div
className="nc-main-empty-titlebar-safe-zone"
@@ -4523,6 +4557,8 @@ const ChatPage: React.FC = () => {
</div>
) : null}
</Modal>
</>
)}
</div>
</div>
)
+1
View File
@@ -138,6 +138,7 @@ export const workbenchSelectors = {
sessionDeleteConfirmButton: 'workbench-session-delete-confirm-button',
sessionDeleteCancelButton: 'workbench-session-delete-cancel-button',
newSessionButton: 'workbench-new-session-button',
scheduledTasksButton: 'workbench-scheduled-tasks-button',
sessionsToggle: 'workbench-sessions-toggle',
sidebarResizer: 'workbench-sidebar-resizer',
settingsHelpButton: 'workbench-settings-help-button',
+46
View File
@@ -1094,6 +1094,52 @@ export type ChatSubmitMode = 'ai' | 'search'
export type AiPreference = 'default' | 'local_sources_relaxed' | 'local_sources_strict'
export type AiExecutionPermission = 'review' | 'full_access'
export type ScheduledTaskScheduleType = 'daily' | 'weekly'
export type ScheduledTask = {
id: string
name: string
prompt: string
enabled: boolean
scheduleType: ScheduledTaskScheduleType
timeOfDay: string
weekday: number | null
workspaceId: string
mainModel: WorkbenchSelectedModel
visionModel: WorkbenchSelectedModel | null
aiPreference: AiPreference
executionPermission: AiExecutionPermission
nextRunAt: number | null
createdAt: number
updatedAt: number
}
export type ScheduledTaskSaveRequest = {
id?: string
name: string
prompt: string
enabled: boolean
scheduleType: ScheduledTaskScheduleType
timeOfDay: string
weekday?: number | null
workspaceId: string
mainModel: WorkbenchSelectedModel
visionModel?: WorkbenchSelectedModel | null
aiPreference?: AiPreference
executionPermission?: AiExecutionPermission
}
export type ScheduledTaskMutationResponse = {
success: boolean
task?: ScheduledTask
error?: string
}
export type ScheduledTaskSetEnabledRequest = {
id: string
enabled: boolean
}
export type ChatSubmitRequest = {
mode: ChatSubmitMode
preference?: AiPreference
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { computeNextRunAt } from '../../../src/main/services/scheduler/ScheduledTaskService'
const localAt = (
year: number,
month: number,
day: number,
hours: number,
minutes: number
): number => new Date(year, month, day, hours, minutes).getTime()
describe('computeNextRunAt', () => {
it('returns today slot for a daily task when the time is still ahead', () => {
const after = localAt(2026, 7, 20, 8, 0)
expect(computeNextRunAt({ scheduleType: 'daily', timeOfDay: '09:00', weekday: null }, after)).toBe(
localAt(2026, 7, 20, 9, 0)
)
})
it('rolls to tomorrow for a daily task when the time already passed', () => {
const after = localAt(2026, 7, 20, 8, 0)
expect(computeNextRunAt({ scheduleType: 'daily', timeOfDay: '07:00', weekday: null }, after)).toBe(
localAt(2026, 7, 21, 7, 0)
)
})
it('returns later today for a weekly task on the same weekday', () => {
const after = localAt(2026, 7, 20, 8, 0)
expect(computeNextRunAt({ scheduleType: 'weekly', timeOfDay: '09:00', weekday: 4 }, after)).toBe(
localAt(2026, 7, 20, 9, 0)
)
})
it('rolls to next week for a weekly task whose time already passed', () => {
const after = localAt(2026, 7, 20, 8, 0)
expect(computeNextRunAt({ scheduleType: 'weekly', timeOfDay: '07:00', weekday: 4 }, after)).toBe(
localAt(2026, 7, 27, 7, 0)
)
})
it('returns a later weekday within the same week', () => {
const after = localAt(2026, 7, 20, 8, 0)
expect(computeNextRunAt({ scheduleType: 'weekly', timeOfDay: '09:00', weekday: 5 }, after)).toBe(
localAt(2026, 7, 21, 9, 0)
)
})
it('wraps to the following week for an earlier weekday', () => {
const after = localAt(2026, 7, 20, 8, 0)
expect(computeNextRunAt({ scheduleType: 'weekly', timeOfDay: '09:00', weekday: 2 }, after)).toBe(
localAt(2026, 7, 25, 9, 0)
)
})
it('returns null for an invalid time of day', () => {
expect(
computeNextRunAt({ scheduleType: 'daily', timeOfDay: '25:00', weekday: null }, Date.now())
).toBeNull()
expect(
computeNextRunAt({ scheduleType: 'daily', timeOfDay: '9', weekday: null }, Date.now())
).toBeNull()
})
it('returns null for a weekly task without a valid weekday', () => {
expect(
computeNextRunAt({ scheduleType: 'weekly', timeOfDay: '09:00', weekday: null }, Date.now())
).toBeNull()
expect(
computeNextRunAt({ scheduleType: 'weekly', timeOfDay: '09:00', weekday: 7 }, Date.now())
).toBeNull()
})
})