Automation editor with graphical schedule and assistant; movable chat list groups
Automations
- Job files hold only the instructions; name, schedule, mode, model, approval
mode and time limit are settings in the database (jobs.settings). Existing
files are adopted on start-up (frontmatter into the database and removed from
the file); plain Markdown files registered later get default settings and stay
switched off until scheduled.
- New automation: name, project and a starting point (blank example structure or
a template) create .codecraft/jobs/<name>.md and open the editor: settings with
a graphical cron builder (next runs in server time via /jobs/schedule), the
instructions in the Markdown editor and an assistant chat
(POST /jobs/{id}/assistant) whose model edits the file; the editor reloads it
after each turn and warns about conflicts with unsaved edits.
- The schedule tool writes prompt-only files with settings.
Chat list
- Folders and projects keep a user-defined order and can be pinned (drag and
drop, or the group's menu), stored per user via /account/sidebar. A new
message moves the chat to the top of its group, no longer the project itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
872ebb94dd
commit
05c215f685
19 files changed
+1661
-356
No files matched your search
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get } from "../api";
|
||||
import { getLang, useT } from "../i18n";
|
||||
|
||||
/**
|
||||
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
|
||||
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
|
||||
* custom expression. Shows the next runs as computed by the server.
|
||||
*/
|
||||
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
|
||||
|
||||
interface State {
|
||||
mode: Mode;
|
||||
every: number;
|
||||
minute: number;
|
||||
time: string; // HH:MM
|
||||
days: number[]; // cron weekdays, 0 = Sunday
|
||||
dom: number;
|
||||
custom: string;
|
||||
}
|
||||
|
||||
const MINUTE_STEPS = [5, 10, 15, 20, 30];
|
||||
/** Monday first; cron numbers (0 = Sunday). */
|
||||
const WEEK = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
|
||||
|
||||
function expandDays(field: string): number[] | null {
|
||||
const out = new Set<number>();
|
||||
for (const part of field.split(",")) {
|
||||
const m = part.match(/^(\d)(?:-(\d))?$/);
|
||||
if (!m) return null;
|
||||
const a = Number(m[1]);
|
||||
const b = m[2] ? Number(m[2]) : a;
|
||||
if (a > 7 || b > 7 || b < a) return null;
|
||||
for (let d = a; d <= b; d++) out.add(d % 7);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
export function parseCron(expr: string): State {
|
||||
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
|
||||
const f = expr.trim().split(/\s+/);
|
||||
if (f.length !== 5) return base;
|
||||
const [mi, h, dom, mon, dow] = f;
|
||||
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
|
||||
if (mon !== "*") return base;
|
||||
let m;
|
||||
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
|
||||
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
|
||||
if (!num(mi, 59) || !num(h, 23)) return base;
|
||||
const time = hhmm(h, mi);
|
||||
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
|
||||
if (dom === "*") {
|
||||
const days = expandDays(dow);
|
||||
return days?.length ? { ...base, mode: "weekly", time, days } : base;
|
||||
}
|
||||
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildCron(s: State): string {
|
||||
const [h, m] = s.time.split(":").map(Number);
|
||||
switch (s.mode) {
|
||||
case "minutes":
|
||||
return `*/${s.every} * * * *`;
|
||||
case "hourly":
|
||||
return `${s.minute} * * * *`;
|
||||
case "daily":
|
||||
return `${m} ${h} * * *`;
|
||||
case "weekly": {
|
||||
const days = [...s.days].sort((a, b) => a - b);
|
||||
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
|
||||
}
|
||||
case "monthly":
|
||||
return `${m} ${h} ${s.dom} * *`;
|
||||
default:
|
||||
return s.custom.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const dayName = (d: number, style: "short" | "long") => {
|
||||
// 2023-01-01 was a Sunday.
|
||||
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
|
||||
};
|
||||
|
||||
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
|
||||
const fmtWall = (s: string) => {
|
||||
const [d, hm] = s.split("T");
|
||||
const [y, mo, da] = d.split("-").map(Number);
|
||||
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
|
||||
return `${date}, ${hm}`;
|
||||
};
|
||||
|
||||
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
|
||||
const t = useT();
|
||||
const [s, setS] = useState<State>(() => parseCron(value));
|
||||
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
|
||||
|
||||
// A value from outside (another automation, reset) that the builder did not produce.
|
||||
useEffect(() => {
|
||||
if (value.trim() !== buildCron(s)) setS(parseCron(value));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const expr = value.trim();
|
||||
if (!expr) return setPreview(undefined);
|
||||
let alive = true;
|
||||
const h = setTimeout(() => {
|
||||
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
|
||||
.then((p) => alive && setPreview(p))
|
||||
.catch(() => alive && setPreview(undefined));
|
||||
}, 250);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(h);
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
const set = (patch: Partial<State>) => {
|
||||
const next = { ...s, ...patch };
|
||||
// Switching to "custom" starts from the current expression.
|
||||
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
|
||||
setS(next);
|
||||
onChange(buildCron(next));
|
||||
};
|
||||
|
||||
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
|
||||
return (
|
||||
<div className="cron-builder">
|
||||
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
|
||||
{modes.map((m) => (
|
||||
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
|
||||
{t(`cron.mode.${m}` as const)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cron-fields">
|
||||
{s.mode === "minutes" && (
|
||||
<label>
|
||||
{t("cron.every")}
|
||||
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
|
||||
{MINUTE_STEPS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{t("cron.minutes")}
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "hourly" && (
|
||||
<label>
|
||||
{t("cron.atMinute")}
|
||||
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "weekly" && (
|
||||
<div className="cron-days" role="group" aria-label={t("cron.days")}>
|
||||
{WEEK.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
aria-pressed={s.days.includes(d)}
|
||||
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
|
||||
title={dayName(d, "long")}
|
||||
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
|
||||
>
|
||||
{dayName(d, "short")}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
|
||||
{t("cron.workdays")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{s.mode === "monthly" && (
|
||||
<label>
|
||||
{t("cron.onDay")}
|
||||
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
|
||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}.
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
|
||||
<label>
|
||||
{t("cron.at")}
|
||||
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
|
||||
</label>
|
||||
)}
|
||||
{s.mode === "custom" && (
|
||||
<label className="cron-custom">
|
||||
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
|
||||
<small className="hint">{t("cron.customHint")}</small>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="cron-preview">
|
||||
<code>{value || "–"}</code>
|
||||
{preview?.ok === false && <span className="err">{preview.error}</span>}
|
||||
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
|
||||
<span>
|
||||
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
|
||||
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user