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:
1 parent
a75478e2e7
commit
7d45870fe3
18 files changed
+1488
-120
No files matched your search
@@ -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 或圆角规则。
|
||||
@@ -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:不验证已绑定目录工作空间的正常触发路径。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]'
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user