mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 05:58:36 +00:00
feat(frontend): duplicate scheduled tasks (#5064)
* docs: design scheduled task duplication * docs: plan scheduled task duplication * feat(frontend): build scheduled task duplicate drafts * feat(frontend): duplicate scheduled tasks into create form * docs: document scheduled task duplication * test(frontend): verify duplicate schedule preview * refactor(frontend): simplify scheduled task duplication
This commit is contained in:
parent
846c716523
commit
848eea289c
@ -1372,6 +1372,7 @@ Current MVP capabilities:
|
||||
|
||||
- Manage tasks at `/workspace/scheduled-tasks`
|
||||
- Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run
|
||||
- Duplicate an existing task into the create form as an editable draft without copying its run history
|
||||
- Support `once` and `cron` schedules
|
||||
- Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there)
|
||||
- Persist a due execution as `queued` when its reused thread or the global execution budget is busy, then launch it when capacity is available; queued occurrences survive Gateway restarts and fail after `scheduler.queue_timeout_seconds`
|
||||
|
||||
@ -730,6 +730,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled
|
||||
|
||||
- 在 `/workspace/scheduled-tasks` 管理任务
|
||||
- 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread
|
||||
- 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史
|
||||
- 支持 `once` 和 `cron` 两种调度方式
|
||||
- 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`)
|
||||
- 当所复用的 thread 或全局执行配额正忙时,到期执行会持久化为 `queued`,并在可用后启动;队列项在 Gateway 重启后保留,超过 `scheduler.queue_timeout_seconds` 后标记为失败
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import { CopyIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -118,6 +118,8 @@ export default function ScheduledTasksPage() {
|
||||
timezone: "UTC",
|
||||
});
|
||||
const [createNonce, setCreateNonce] = useState(0);
|
||||
const createFormRef = useRef<HTMLDivElement>(null);
|
||||
const createTitleRef = useRef<HTMLInputElement>(null);
|
||||
const filteredData = (data ?? []).filter((task) => {
|
||||
const statusPass = statusFilter === "all" || task.status === statusFilter;
|
||||
const typePass = typeFilter === "all" || task.schedule_type === typeFilter;
|
||||
@ -163,6 +165,24 @@ export default function ScheduledTasksPage() {
|
||||
setContextMode("fresh_thread_per_run");
|
||||
setCreateNonce((n) => n + 1);
|
||||
};
|
||||
const duplicateTask = (task: ScheduledTask) => {
|
||||
setTitle(`${task.title}${st.actions.duplicateTitleSuffix}`);
|
||||
setPrompt(task.prompt);
|
||||
setContextMode(task.context_mode);
|
||||
setTargetThreadId(task.thread_id ?? "");
|
||||
setCreateSchedule({
|
||||
schedule_type: task.schedule_type,
|
||||
schedule_spec: { ...task.schedule_spec },
|
||||
timezone: task.timezone,
|
||||
});
|
||||
setFormError(null);
|
||||
setCreateNonce((nonce) => nonce + 1);
|
||||
createFormRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
createTitleRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t.sidebar.scheduledTasks} - ${t.pages.appName}`;
|
||||
@ -212,6 +232,7 @@ export default function ScheduledTasksPage() {
|
||||
<div className="mx-auto flex w-full max-w-(--container-width-md) flex-col gap-4 p-6">
|
||||
<h1 className="text-2xl font-semibold">{t.sidebar.scheduledTasks}</h1>
|
||||
<div
|
||||
ref={createFormRef}
|
||||
className="grid gap-2 rounded-lg border p-4"
|
||||
data-testid="scheduled-task-create-form"
|
||||
>
|
||||
@ -267,6 +288,7 @@ export default function ScheduledTasksPage() {
|
||||
</>
|
||||
)}
|
||||
<Input
|
||||
ref={createTitleRef}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={st.create.taskTitle}
|
||||
@ -546,6 +568,14 @@ export default function ScheduledTasksPage() {
|
||||
>
|
||||
{st.actions.trigger}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => duplicateTask(selectedTask)}
|
||||
>
|
||||
<CopyIcon />
|
||||
{st.actions.duplicate}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
|
||||
@ -458,6 +458,8 @@ export const enUS: Translations = {
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
trigger: "Trigger now",
|
||||
duplicate: "Duplicate",
|
||||
duplicateTitleSuffix: " (Copy)",
|
||||
delete: "Delete",
|
||||
},
|
||||
deleteConfirm:
|
||||
|
||||
@ -375,6 +375,8 @@ export interface Translations {
|
||||
pause: string;
|
||||
resume: string;
|
||||
trigger: string;
|
||||
duplicate: string;
|
||||
duplicateTitleSuffix: string;
|
||||
delete: string;
|
||||
};
|
||||
deleteConfirm: string;
|
||||
|
||||
@ -440,6 +440,8 @@ export const zhCN: Translations = {
|
||||
pause: "暂停",
|
||||
resume: "恢复",
|
||||
trigger: "立即触发",
|
||||
duplicate: "复制",
|
||||
duplicateTitleSuffix: "(副本)",
|
||||
delete: "删除",
|
||||
},
|
||||
deleteConfirm: "确定要删除该定时任务吗?此操作不可撤销。",
|
||||
|
||||
@ -94,6 +94,66 @@ test("user can create a scheduled task from the page", async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("duplicate fills the create form without creating a task", async ({
|
||||
page,
|
||||
}) => {
|
||||
let createRequests = 0;
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname.endsWith("/api/scheduled-tasks")
|
||||
) {
|
||||
createRequests += 1;
|
||||
}
|
||||
});
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [],
|
||||
scheduledTasks: [
|
||||
{
|
||||
id: "task-copy-source",
|
||||
thread_id: "thread-copy-source",
|
||||
context_mode: "reuse_thread",
|
||||
title: "Daily summary",
|
||||
prompt: "Summarize this conversation",
|
||||
schedule_type: "cron",
|
||||
schedule_spec: { cron: "0 18 * * *" },
|
||||
timezone: "UTC",
|
||||
status: "enabled",
|
||||
next_run_at: "2026-08-28T18:00:00Z",
|
||||
last_run_at: null,
|
||||
last_run_id: null,
|
||||
last_thread_id: null,
|
||||
last_error: null,
|
||||
run_count: 0,
|
||||
created_at: "2026-08-01T00:00:00Z",
|
||||
updated_at: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
await page
|
||||
.getByTestId("scheduled-task-detail")
|
||||
.getByRole("button", { name: "Duplicate" })
|
||||
.click();
|
||||
|
||||
const createForm = page.getByTestId("scheduled-task-create-form");
|
||||
await expect(createForm.getByPlaceholder("Task title")).toHaveValue(
|
||||
"Daily summary (Copy)",
|
||||
);
|
||||
await expect(createForm.getByPlaceholder("Prompt")).toHaveValue(
|
||||
"Summarize this conversation",
|
||||
);
|
||||
await expect(createForm.getByPlaceholder("Thread ID")).toHaveValue(
|
||||
"thread-copy-source",
|
||||
);
|
||||
await expect(createForm.getByTestId("schedule-preview")).toHaveText(
|
||||
"Every day at 18:00 (UTC)",
|
||||
);
|
||||
await expect(createForm.getByPlaceholder("Task title")).toBeFocused();
|
||||
expect(createRequests).toBe(0);
|
||||
});
|
||||
|
||||
test("reuse-thread tasks explain their context and busy-thread queue behavior", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user