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(); + }} + > +
+ + +
+ +