From 246fe89cb98c5e7f8a0f547903f20a56cbf71cb9 Mon Sep 17 00:00:00 2001 From: Andre-1998 Date: Mon, 5 Oct 2026 16:55:12 +0800 Subject: [PATCH 1/2] feat(webui): the scheduled-task panel on top of ADR 0012's store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #21 shipped the backend for Module L: its own store at `/webui/scheduled-tasks.sqlite` and an in-process tick beside the heartbeat in `WebuiService`. The user-visible half was never built — the 定时 rail row was still rendered `inert` and the client had no scheduled-task component at all. This adds it, against the six operations PR #21 registered. The panel's 创建 dropdown offers 手动创建 and 在对话中创建. The manual dialog carries the fields the store actually has — name, agent, prompt, run mode, and a schedule that is either 仅一次 or 每 N 分钟. The conversational path opens a new session, sends a guiding message, and creates the task when the user says they are done. The list groups by agent and shows last/next run with the last result; a slot missed while no host was running surfaces as `missed` with its count rather than being presented as "it never existed". Two things this deliberately does not do. It does not invent a `listAgents` operation — the agent choices are derived from the session listing the client already has, so no server file changes and nothing collides with merged work. And it does not draw controls the store cannot persist: 项目, 模型, and calendar periods (每天几点 / 每周几) have no columns, so the dialog says so in one line rather than shipping a disabled control that silently drops the value. That gap is a storage extension, not a UI decision. Client-only by construction: no `src/server/**`, no `local-runtime*`, no `docs/**`, no dependency change. The panel opens on `getScheduledTaskCapability` and reports `reason` when the capability is unavailable, so an absent scheduler is a message rather than an empty screen. --- .../client/components/CronChatCreateFlow.tsx | 134 ++++ .../client/components/CronCreateDialog.tsx | 522 +++++++++++++ .../src/client/components/SchedulesPanel.tsx | 685 ++++++++++++++++++ .../components/WebuiClientFoundationApp.tsx | 29 +- packages/webui/src/client/contracts.ts | 42 ++ packages/webui/src/client/transport.ts | 6 + packages/webui/test/unit/webui-shell.test.ts | 443 ++++++++++- release/public-source.json | 5 +- 8 files changed, 1859 insertions(+), 7 deletions(-) create mode 100644 packages/webui/src/client/components/CronChatCreateFlow.tsx create mode 100644 packages/webui/src/client/components/CronCreateDialog.tsx create mode 100644 packages/webui/src/client/components/SchedulesPanel.tsx diff --git a/packages/webui/src/client/components/CronChatCreateFlow.tsx b/packages/webui/src/client/components/CronChatCreateFlow.tsx new file mode 100644 index 00000000..16d48f28 --- /dev/null +++ b/packages/webui/src/client/components/CronChatCreateFlow.tsx @@ -0,0 +1,134 @@ +// 定时任务 — 在对话中创建. +// +// The second creation path. Instead of filling the form first, the user gets a +// real conversation: the panel opens a session, seeds it with one guidance +// message, and the user describes the task in their own words. The closing +// action is the user's own click on 「完成并创建」 — this version is +// WebUI-orchestrated, it does not parse the transcript into a schedule. +// +// The created task targets that conversation (`sessionTarget: "existing"` with +// the session's id), so the run continues where the user already described it +// rather than starting cold in a new session. + +import type { ReactElement } from "react"; +import type { WebuiCreateScheduledTaskRequest } from "../contracts.js"; +import { + WebuiCronCreateDialog, + createScheduledTaskRequestFromDraft, + type WebuiScheduledTaskDraft, +} from "./CronCreateDialog.js"; + +/** The one message the flow seeds the new conversation with. */ +export const CHAT_CREATE_GUIDE_PROMPT = + "我想创建一个定时任务。请帮我把它说清楚:\n" + + "1. 这个 Agent 要执行什么(例如「汇总昨天的提交并写进 CHANGELOG.md」);\n" + + "2. 多久执行一次(仅在某个时刻执行一次,或每 N 分钟执行一次)。\n" + + "说完之后回到「定时」页面,点「完成并创建」把它保存成定时任务。"; + +/** The frozen create request for the conversational path: same fields as the + * manual dialog, but the session the user just talked in is the target. A + * session is required here — this path always knows which conversation it + * means. */ +export function buildChatScheduledTaskRequest( + draft: WebuiScheduledTaskDraft, + sessionId: string, +): WebuiCreateScheduledTaskRequest | undefined { + const trimmed = sessionId.trim(); + if (!trimmed) return undefined; + return createScheduledTaskRequestFromDraft({ + ...draft, + sessionTarget: "existing", + sessionId: trimmed, + }); +} + +export interface WebuiCronChatCreateFlowProps { + /** The conversation opened for this task, or undefined before the user + * starts one. Lifted by the panel so it survives the view switch into the + * conversation and back. */ + readonly sessionId?: string; + readonly draft: WebuiScheduledTaskDraft; + readonly onDraftChange: (next: WebuiScheduledTaskDraft) => void; + readonly agents?: Parameters[0]["agents"]; + readonly busy?: boolean; + readonly onStart: () => void; + readonly onSubmit: () => void; + readonly onClose: () => void; +} + +export function WebuiCronChatCreateFlow({ + sessionId, + draft, + onDraftChange, + agents, + busy = false, + onStart, + onSubmit, + onClose, +}: WebuiCronChatCreateFlowProps): ReactElement { + if (!sessionId) { + return ( +
+

在对话中创建

+

+ 先开一个对话,把定时任务说清楚:我会新创建一个会话并把引导语发进去, + 你在对话里描述想让 Agent 执行什么、多久执行一次, + 说完回到这里点「完成并创建」保存。 +

+
+ + +
+
+ ); + } + + return ( +
+
+

在对话中创建

+

+ 已在会话 {sessionId}{" "} + 里发起了引导。请在对话中把任务说清楚,然后回到这里补全下面的字段并点「完成并创建」; + 这个任务会继续使用该会话。 +

+
+ {CHAT_CREATE_GUIDE_PROMPT} +
+
+ +
+ ); +} + +export default WebuiCronChatCreateFlow; diff --git a/packages/webui/src/client/components/CronCreateDialog.tsx b/packages/webui/src/client/components/CronCreateDialog.tsx new file mode 100644 index 00000000..34927186 --- /dev/null +++ b/packages/webui/src/client/components/CronCreateDialog.tsx @@ -0,0 +1,522 @@ +// 定时任务 — the create/edit form, field for field, over the WebUI's own +// scheduled-task wire contract (`packages/webui/src/server/port.ts`). +// +// The file name is inherited from the retired v2 cron branch, where the form +// built a cron expression. This module does not: the merged backend takes +// exactly two schedule kinds, `once` and `interval`, so the schedule is a pair +// of structured controls and there is no expression to type, decode, or keep +// verbatim. The name stays so the panel's import reads the same as the branch +// the layout came from. +// +// It also owns the controls the store does not have columns for. The desktop +// dialog offers 项目 / 模型 / 每天几点 / 每周几; the merged table has no +// `project` and no `model`, and `schedule_kind` is only `once` | `interval`. +// Inventing those fields into the request body would either be dropped by +// validation or stored nowhere, and rendering them disabled would look like a +// feature. So the gap is stated in a line under the form instead. +// +// The component is controlled and presentational: it owns no transport and no +// effects, so the panel owns the wire and the shell's SSR tests can render the +// real form without a DOM. + +import type { ReactElement } from "react"; +import type { + WebuiCreateScheduledTaskRequest, + WebuiScheduledTask, + WebuiScheduledTaskScheduleKind, + WebuiScheduledTaskSessionTarget, + WebuiUpdateScheduledTaskRequest, +} from "../contracts.js"; + +/** Desktop limits: name `n/50`, instruction `n/8000`. */ +export const SCHEDULED_TASK_NAME_LIMIT = 50; +export const SCHEDULED_TASK_PROMPT_LIMIT = 8000; + +/** + * The server's floor for `intervalMs` (`MIN_INTERVAL_MS` in + * `operation/scheduled-task.ts`). One minute clears it with room to spare, and + * a sub-minute schedule is not a thing the panel offers anyway. + */ +export const SCHEDULED_TASK_MIN_INTERVAL_MINUTES = 1; + +/** The interval sub-selector's values, in minutes. */ +export const SCHEDULED_TASK_INTERVAL_MINUTES: readonly number[] = [ + 1, 5, 10, 15, 30, 60, 180, 360, 1440, +]; + +/** + * The runtime's own default agent, as the rest of the WebUI resolves it + * (`WebuiClientFoundationApp`'s `selectedAgentName`). Only a fallback for a + * panel opened before the session list answered. + */ +export const DEFAULT_AGENT_NAME = "main"; + +/** The two schedule kinds, as the two labels the control shows. */ +export const SCHEDULED_TASK_SCHEDULE_OPTIONS: readonly { + readonly value: WebuiScheduledTaskScheduleKind; + readonly label: string; +}[] = [ + { value: "once", label: "仅一次" }, + { value: "interval", label: "每 N 分钟" }, +]; + +export interface WebuiScheduledTaskDraft { + readonly name: string; + readonly agentName: string; + readonly prompt: string; + /** `new` opens a fresh session per run; `existing` reuses `sessionId`. */ + readonly sessionTarget: WebuiScheduledTaskSessionTarget; + readonly sessionId: string; + readonly scheduleKind: WebuiScheduledTaskScheduleKind; + /** `YYYY-MM-DDTHH:mm`, the value a `datetime-local` input speaks. */ + readonly runAtLocal: string; + /** N in 「每 N 分钟」. */ + readonly intervalMinutes: number; +} + +const pad2 = (value: number): string => String(value).padStart(2, "0"); + +/** The `datetime-local` value one hour from `now`, to the minute. */ +export function defaultRunAtLocal(now: number = Date.now()): string { + const at = new Date(now + 60 * 60 * 1000); + if (Number.isNaN(at.getTime())) return ""; + return `${at.getFullYear()}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())}T${pad2(at.getHours())}:${pad2(at.getMinutes())}`; +} + +export function emptyScheduledTaskDraft(agentName: string = DEFAULT_AGENT_NAME): WebuiScheduledTaskDraft { + return { + name: "", + agentName, + prompt: "", + sessionTarget: "new", + sessionId: "", + scheduleKind: "once", + runAtLocal: defaultRunAtLocal(), + intervalMinutes: 5, + }; +} + +/** `2026-10-05 16:30`, or an empty string when the input holds nothing usable. */ +export function formatScheduledTaskTime(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return ""; + const at = new Date(value); + if (Number.isNaN(at.getTime())) return ""; + return `${at.getFullYear()}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())} ${pad2(at.getHours())}:${pad2(at.getMinutes())}`; +} + +/** The `datetime-local` value of a timestamp, for the once-subject input. */ +export function toRunAtLocal(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return ""; + const at = new Date(value); + if (Number.isNaN(at.getTime())) return ""; + return `${at.getFullYear()}-${pad2(at.getMonth() + 1)}-${pad2(at.getDate())}T${pad2(at.getHours())}:${pad2(at.getMinutes())}`; +} + +/** The first listed agent, else the runtime's default. */ +export function resolveDefaultAgentName(agents: readonly string[]): string { + return agents[0] ?? DEFAULT_AGENT_NAME; +} + +/** + * The Agent dropdown's data source is the session list the client already + * loads — the merged backend has no `listAgents`, and adding one would mean + * touching the server this change must not touch. Deduplicated, order kept. + */ +export function deriveAgentNames( + sessions: readonly { readonly agentName?: string }[] | undefined, +): readonly string[] { + const names: string[] = []; + for (const session of sessions ?? []) { + const name = session.agentName?.trim(); + if (name && !names.includes(name)) names.push(name); + } + return names; +} + +/** The three fields the schedule is made of, or undefined while unfillable. */ +export function buildScheduledTaskSchedule( + draft: WebuiScheduledTaskDraft, +): { readonly scheduleKind: WebuiScheduledTaskScheduleKind; readonly runAtMs: number | null; readonly intervalMs: number | null } | undefined { + if (draft.scheduleKind === "once") { + const at = Date.parse(draft.runAtLocal); + if (!Number.isFinite(at)) return undefined; + return { scheduleKind: "once", runAtMs: at, intervalMs: null }; + } + const minutes = draft.intervalMinutes; + if (!Number.isInteger(minutes) || minutes < SCHEDULED_TASK_MIN_INTERVAL_MINUTES) return undefined; + return { scheduleKind: "interval", runAtMs: null, intervalMs: minutes * 60_000 }; +} + +/** Empty string when 确认 may be pressed; otherwise the reason it may not. */ +export function scheduledTaskDraftIssue(draft: WebuiScheduledTaskDraft): string { + if (!draft.name.trim()) return "请填写名称"; + if (draft.name.length > SCHEDULED_TASK_NAME_LIMIT) return `名称不能超过 ${SCHEDULED_TASK_NAME_LIMIT} 个字符`; + if (!draft.agentName.trim()) return "请选择 Agent"; + if (!draft.prompt.trim()) return "请填写指令"; + if (draft.prompt.length > SCHEDULED_TASK_PROMPT_LIMIT) return `指令不能超过 ${SCHEDULED_TASK_PROMPT_LIMIT} 个字符`; + // The store binds a session itself for `new`, but `existing` names one and + // the server rejects the row without it — so it is required here, not optional. + if (draft.sessionTarget === "existing" && !draft.sessionId.trim()) return "请选择要使用的对话"; + if (!buildScheduledTaskSchedule(draft)) return "请填写执行时间"; + return ""; +} + +/** + * The frozen create request, or undefined while the draft is still incomplete — + * the same condition that disables 确认. `sessionId` is sent only for + * `existing`, because the create operation requires it there and ignores it + * otherwise. + */ +export function createScheduledTaskRequestFromDraft( + draft: WebuiScheduledTaskDraft, +): WebuiCreateScheduledTaskRequest | undefined { + if (scheduledTaskDraftIssue(draft)) return undefined; + const schedule = buildScheduledTaskSchedule(draft); + if (!schedule) return undefined; + return { + name: draft.name.trim(), + agentName: draft.agentName.trim(), + prompt: draft.prompt.trim(), + sessionTarget: draft.sessionTarget, + ...(draft.sessionTarget === "existing" ? { sessionId: draft.sessionId.trim() } : {}), + scheduleKind: schedule.scheduleKind, + runAtMs: schedule.runAtMs, + intervalMs: schedule.intervalMs, + }; +} + +/** The frozen update request. The panel owns every field the form shows, so an + * edit sends the whole form back rather than a partial patch. */ +export function updateScheduledTaskRequestFromDraft( + draft: WebuiScheduledTaskDraft, + taskId: string, +): WebuiUpdateScheduledTaskRequest | undefined { + const create = createScheduledTaskRequestFromDraft(draft); + const trimmed = taskId.trim(); + if (!create || !trimmed) return undefined; + return { + taskId: trimmed, + name: create.name, + prompt: create.prompt, + agentName: create.agentName, + sessionTarget: create.sessionTarget, + ...(create.sessionId !== undefined ? { sessionId: create.sessionId } : {}), + scheduleKind: create.scheduleKind, + runAtMs: create.runAtMs, + intervalMs: create.intervalMs, + }; +} + +/** The form seeded from a stored task, so 编辑 shows what the store holds. */ +export function scheduledTaskDraftFromTask(task: WebuiScheduledTask): WebuiScheduledTaskDraft { + return { + name: task.name, + agentName: task.agentName, + prompt: task.prompt, + sessionTarget: task.sessionTarget, + sessionId: task.sessionId ?? "", + scheduleKind: task.scheduleKind, + runAtLocal: task.scheduleKind === "once" ? toRunAtLocal(task.runAtMs) : "", + intervalMinutes: + task.scheduleKind === "interval" && typeof task.intervalMs === "number" && Number.isFinite(task.intervalMs) + ? Math.max(1, Math.round(task.intervalMs / 60_000)) + : 5, + }; +} + +const DIALOG_MASK = "fixed inset-0 z-50 flex items-center justify-center bg-[rgba(0,0,0,0.25)]"; +const DIALOG_SURFACE = "w-[520px] max-w-[calc(100vw-32px)] overflow-clip rounded-[20px] bg-bg_grouped_secondary p-6"; +const FIELD_LABEL = "flex min-w-0 flex-1 flex-col gap-1 text-sm text-text_default_secondary"; +const FIELD_CONTROL = + "h-9 w-full min-w-0 rounded-[8px] border-[0.5px] border-border_default bg-bg_default_primary px-2 text-sm text-text_default_primary outline-none disabled:opacity-50"; +const GHOST_BUTTON = + "inline-flex h-9 min-w-[68px] items-center justify-center gap-1.5 rounded-[8px] px-4 text-sm text-text_default_secondary transition-colors hover:bg-bg_interaction_tertiary_hover disabled:opacity-50"; +const PRIMARY_BUTTON = + "inline-flex h-9 min-w-[68px] items-center justify-center gap-1.5 rounded-[8px] bg-bg_interaction_primary_default px-4 text-sm text-text_default_inverted transition-colors hover:bg-bg_interaction_primary_hover disabled:opacity-50"; + +function RequiredMark(): ReactElement { + return ( + + ); +} + +function FieldLabel({ children }: { readonly children: ReactElement | string }): ReactElement { + return {children}; +} + +export interface WebuiCronCreateDialogProps { + readonly title?: string; + readonly draft: WebuiScheduledTaskDraft; + readonly onDraftChange: (next: WebuiScheduledTaskDraft) => void; + /** Agent names derived from the session list. Empty renders the empty state + * below rather than a select with nothing in it. */ + readonly agents?: readonly string[]; + /** Session ids offered for 始终使用同一对话. */ + readonly sessions?: readonly { readonly sessionId: string; readonly title?: string }[]; + readonly busy?: boolean; + /** 确认 on the manual path; the 在对话中创建 flow closes with 完成并创建. */ + readonly submitLabel?: string; + /** Set by the 在对话中创建 path: the conversation session is the target and + * cannot be pointed elsewhere from the form. */ + readonly lockedSessionId?: string; + readonly onSubmit: () => void; + readonly onClose: () => void; +} + +/** 名称 / Agent, 指令, 运行模式, 执行时间, 取消 / 确认. 确认 stays disabled + * until every starred field is filled. */ +export function WebuiCronCreateDialog({ + title = "定时任务", + draft, + onDraftChange, + agents = [], + sessions = [], + busy = false, + submitLabel = "确认", + lockedSessionId, + onSubmit, + onClose, +}: WebuiCronCreateDialogProps): ReactElement { + const issue = scheduledTaskDraftIssue(draft); + const patch = (next: Partial): void => onDraftChange({ ...draft, ...next }); + const sessionId = lockedSessionId ?? draft.sessionId; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") onClose(); + }} + > +
+

{title}

+ +
+ +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+ + +
+ +