mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 19:16:17 +00:00
feat(scheduled-tasks): search task titles and prompts (#5355)
* feat(scheduled-tasks): search task titles and prompts Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> * docs(scheduled-tasks): separate search from time validation notes Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> * fix(frontend): order scheduled-task imports --------- Signed-off-by: tiammomo <26957354+tiammomo@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
73590a626d
commit
d8db4e1bf4
@ -1792,6 +1792,7 @@ Current MVP capabilities:
|
||||
- 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`
|
||||
- Freeze a task's definition while an occurrence is `queued`, `launching`, or `running`, so a durable occurrence cannot silently pick up a different prompt, thread, or schedule; transitioning a task to paused or deleting it cancels an existing waiting occurrence, while `launching`/`running` work must finish before those mutations are retried and an explicit manual trigger may still wait and run without resuming a paused schedule
|
||||
- Pause, resume, trigger, inspect history, and delete tasks
|
||||
- Search task titles or prompts, combined with status/type filters and the current thread scope.
|
||||
- Execute scheduled work through the normal DeerFlow run lifecycle
|
||||
- Browse execution history in pages of 50; older pages pause automatic refresh, with an explicit return to the latest runs. Counts appear only after a successful read; loading and failed reads are not reported as zero runs.
|
||||
|
||||
|
||||
@ -849,6 +849,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled
|
||||
当前 MVP 能力:
|
||||
|
||||
- 在 `/workspace/scheduled-tasks` 管理任务
|
||||
- 支持按任务标题或提示词搜索,可与状态、类型筛选及当前会话范围组合使用
|
||||
- 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread
|
||||
- 每个任务可以固定使用 `lead_agent`(默认)或当前用户已有的自定义 agent;未知名字会被拒绝
|
||||
- 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史
|
||||
|
||||
@ -95,6 +95,11 @@ do not use HTML `maxLength`, which counts UTF-16 code units instead.
|
||||
- **Path alias**: `@/*` maps to `src/*`.
|
||||
- **Components**: `ui/` and `ai-elements/` are generated from registries (Shadcn, MagicUI, React Bits, Vercel AI SDK) — don't manually edit these.
|
||||
|
||||
Scheduled-task list search filters the current authorized query result by title or
|
||||
prompt, composing with status/type filters and thread scope. Selection must derive
|
||||
from the filtered list so hidden tasks cannot remain actionable. Keep literal
|
||||
matching in `core/scheduled-tasks/search.ts`; clearing search retains other filters.
|
||||
|
||||
Single-run schedule edits retain the mounted task's original `run_at` while its wall time and timezone match. The parent echoes edits through `initial`; retain a stable snapshot and reset the parent draft during render before remounting with a task key when switching tasks. Use the resolved timezone consistently for the snapshot and displayed wall time. Component and scheduled-task E2E tests cover DST folds and timestamp precision.
|
||||
|
||||
## Environment
|
||||
|
||||
@ -49,6 +49,7 @@ import {
|
||||
} from "@/core/scheduled-tasks/hooks";
|
||||
import { RECIPES, type Recipe } from "@/core/scheduled-tasks/recipes";
|
||||
import { useScheduledTaskRunHistory } from "@/core/scheduled-tasks/run-history";
|
||||
import { matchesScheduledTaskQuery } from "@/core/scheduled-tasks/search";
|
||||
import type {
|
||||
ScheduledTask,
|
||||
ScheduledTaskRun,
|
||||
@ -143,6 +144,7 @@ export default function ScheduledTasksPage() {
|
||||
const [typeFilter, setTypeFilter] = useState<
|
||||
"all" | "once" | "cron" | "interval"
|
||||
>("all");
|
||||
const [taskSearch, setTaskSearch] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editTaskId, setEditTaskId] = useState<string | undefined>(undefined);
|
||||
@ -184,7 +186,9 @@ export default function ScheduledTasksPage() {
|
||||
const filteredData = (data ?? []).filter((task) => {
|
||||
const statusPass = statusFilter === "all" || task.status === statusFilter;
|
||||
const typePass = typeFilter === "all" || task.schedule_type === typeFilter;
|
||||
return statusPass && typePass;
|
||||
return (
|
||||
statusPass && typePass && matchesScheduledTaskQuery(task, taskSearch)
|
||||
);
|
||||
});
|
||||
const selectedTask =
|
||||
filteredData.find((task) => task.id === selectedTaskId) ?? filteredData[0];
|
||||
@ -465,6 +469,20 @@ export default function ScheduledTasksPage() {
|
||||
{st.detail.loadFailed}: {queryError.message}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label={st.search.placeholder}
|
||||
placeholder={st.search.placeholder}
|
||||
value={taskSearch}
|
||||
onChange={(event) => setTaskSearch(event.target.value)}
|
||||
/>
|
||||
{taskSearch && (
|
||||
<Button variant="outline" onClick={() => setTaskSearch("")}>
|
||||
{st.search.clear}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={statusFilter === "all" ? "default" : "outline"}
|
||||
@ -535,6 +553,18 @@ export default function ScheduledTasksPage() {
|
||||
data-testid="scheduled-task-list"
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{data &&
|
||||
!queryError &&
|
||||
taskSearch.trim() &&
|
||||
filteredData.length === 0 && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="scheduled-task-search-empty"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{st.search.noResults}
|
||||
</p>
|
||||
)}
|
||||
{filteredData.map((task) => {
|
||||
const isSelected = selectedTask?.id === task.id;
|
||||
return (
|
||||
|
||||
@ -656,6 +656,11 @@ export const enUS: Translations = {
|
||||
reuseNoticeDescription:
|
||||
"If this thread has an active run at the scheduled time, DeerFlow queues this occurrence and starts it when the thread is available. It fails if the configured queue wait limit is exceeded.",
|
||||
},
|
||||
search: {
|
||||
placeholder: "Search task titles or prompts",
|
||||
clear: "Clear search",
|
||||
noResults: "No tasks match your search and filters.",
|
||||
},
|
||||
filters: {
|
||||
allStatuses: "All statuses",
|
||||
enabled: "Enabled",
|
||||
|
||||
@ -539,6 +539,7 @@ export interface Translations {
|
||||
reuseNoticeTitle: string;
|
||||
reuseNoticeDescription: string;
|
||||
};
|
||||
search: { placeholder: string; clear: string; noResults: string };
|
||||
filters: {
|
||||
allStatuses: string;
|
||||
enabled: string;
|
||||
|
||||
@ -612,6 +612,11 @@ export const zhCN: Translations = {
|
||||
reuseNoticeDescription:
|
||||
"如果触发时该线程正在运行,DeerFlow 会将本次执行排队,并在线程空闲后启动;超过配置的最长等待时间后会标记为失败。",
|
||||
},
|
||||
search: {
|
||||
placeholder: "搜索任务标题或提示词",
|
||||
clear: "清除搜索",
|
||||
noResults: "没有符合搜索内容和筛选条件的任务。",
|
||||
},
|
||||
filters: {
|
||||
allStatuses: "全部状态",
|
||||
enabled: "已启用",
|
||||
|
||||
14
frontend/src/core/scheduled-tasks/search.ts
Normal file
14
frontend/src/core/scheduled-tasks/search.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { ScheduledTask } from "./types";
|
||||
|
||||
/** Literal, case-insensitive title/prompt matching; blank queries match all tasks. */
|
||||
export function matchesScheduledTaskQuery(
|
||||
task: Pick<ScheduledTask, "title" | "prompt">,
|
||||
query: string,
|
||||
): boolean {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return (
|
||||
!needle ||
|
||||
task.title.toLowerCase().includes(needle) ||
|
||||
task.prompt.toLowerCase().includes(needle)
|
||||
);
|
||||
}
|
||||
135
frontend/tests/e2e/scheduled-task-search.spec.ts
Normal file
135
frontend/tests/e2e/scheduled-task-search.spec.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
const common = {
|
||||
thread_id: "scope-thread",
|
||||
timezone: "UTC",
|
||||
next_run_at: "2027-01-01T09:00:00Z",
|
||||
last_run_at: null,
|
||||
last_run_id: null,
|
||||
last_error: null,
|
||||
run_count: 0,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
};
|
||||
const tasks = [
|
||||
{
|
||||
...common,
|
||||
id: "report",
|
||||
title: "Weekly REPORT",
|
||||
prompt: "Summarize revenue",
|
||||
schedule_type: "cron" as const,
|
||||
schedule_spec: { cron: "0 9 * * *" },
|
||||
status: "enabled" as const,
|
||||
},
|
||||
{
|
||||
...common,
|
||||
id: "digest",
|
||||
title: "Project digest",
|
||||
prompt: "汇总项目进度",
|
||||
schedule_type: "once" as const,
|
||||
schedule_spec: { run_at: "2027-01-01T09:00:00Z" },
|
||||
status: "paused" as const,
|
||||
},
|
||||
{
|
||||
...common,
|
||||
id: "archive",
|
||||
title: "Archive report",
|
||||
prompt: "Store the report",
|
||||
schedule_type: "once" as const,
|
||||
schedule_spec: { run_at: "2027-01-01T09:00:00Z" },
|
||||
status: "paused" as const,
|
||||
},
|
||||
];
|
||||
|
||||
test("searches titles and prompts, clears results, and hides stale detail actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const writes: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
["POST", "PATCH", "DELETE"].includes(request.method()) &&
|
||||
request.url().includes("/api/scheduled-tasks")
|
||||
)
|
||||
writes.push(request.url());
|
||||
});
|
||||
mockLangGraphAPI(page, { threads: [], scheduledTasks: tasks });
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
const search = page.getByRole("searchbox", {
|
||||
name: "Search task titles or prompts",
|
||||
});
|
||||
const list = page.getByTestId("scheduled-task-list");
|
||||
const detail = page.getByTestId("scheduled-task-detail");
|
||||
await expect(list.getByRole("button")).toHaveCount(3);
|
||||
await search.fill(" REVENUE ");
|
||||
await expect(list.getByRole("button")).toHaveCount(1);
|
||||
await expect(detail).toContainText("Summarize revenue");
|
||||
await search.fill("项目进度");
|
||||
await expect(list.getByRole("button")).toHaveCount(1);
|
||||
await expect(detail).toContainText("汇总项目进度");
|
||||
await search.fill("does-not-exist");
|
||||
await expect(list.getByRole("button")).toHaveCount(0);
|
||||
await expect(page.getByTestId("scheduled-task-search-empty")).toHaveText(
|
||||
"No tasks match your search and filters.",
|
||||
);
|
||||
await expect(
|
||||
detail.getByRole("button", { name: "Edit", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Clear search", exact: true }).click();
|
||||
await expect(search).toHaveValue("");
|
||||
await expect(list.getByRole("button")).toHaveCount(3);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("search composes with status, type and thread scope", async ({ page }) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [],
|
||||
scheduledTasks: [
|
||||
...tasks,
|
||||
{
|
||||
...tasks[0]!,
|
||||
id: "outside",
|
||||
thread_id: "other-thread",
|
||||
title: "Outside report",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/workspace/scheduled-tasks?thread_id=scope-thread");
|
||||
const list = page.getByTestId("scheduled-task-list");
|
||||
await expect(list.getByRole("button")).toHaveCount(3);
|
||||
await page
|
||||
.getByRole("searchbox", { name: "Search task titles or prompts" })
|
||||
.fill("report");
|
||||
await expect(list.getByRole("button")).toHaveCount(2);
|
||||
await page.getByRole("button", { name: "Paused", exact: true }).click();
|
||||
await expect(list.getByRole("button")).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Cron", exact: true }).click();
|
||||
await expect(list.getByRole("button")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Clear search", exact: true }).click();
|
||||
await expect(list.getByRole("button")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "All types", exact: true }).click();
|
||||
await expect(list.getByRole("button")).toHaveCount(2);
|
||||
await expect(page.getByTestId("scheduled-task-item-outside")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("search controls and no-match feedback are localized", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, { threads: [], scheduledTasks: tasks });
|
||||
await page.goto("/workspace/scheduled-tasks");
|
||||
await page.evaluate(() => {
|
||||
document.cookie = "locale=zh-CN; path=/";
|
||||
});
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole("searchbox", { name: "搜索任务标题或提示词" })
|
||||
.fill("不存在的任务");
|
||||
await expect(page.getByTestId("scheduled-task-search-empty")).toHaveText(
|
||||
"没有符合搜索内容和筛选条件的任务。",
|
||||
);
|
||||
await page.getByRole("button", { name: "清除搜索", exact: true }).click();
|
||||
await expect(
|
||||
page.getByTestId("scheduled-task-list").getByRole("button"),
|
||||
).toHaveCount(3);
|
||||
});
|
||||
25
frontend/tests/unit/core/scheduled-tasks/search.test.ts
Normal file
25
frontend/tests/unit/core/scheduled-tasks/search.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import { matchesScheduledTaskQuery } from "@/core/scheduled-tasks/search";
|
||||
|
||||
const task = {
|
||||
title: "Weekly REPORT [draft]",
|
||||
prompt: "汇总项目进度 and revenue",
|
||||
};
|
||||
|
||||
test.each([
|
||||
["", true],
|
||||
[" ", true],
|
||||
[" report ", true],
|
||||
["REVENUE", true],
|
||||
["项目进度", true],
|
||||
["[draft]", true],
|
||||
[".*", false],
|
||||
["missing", false],
|
||||
["draft] 汇总", false],
|
||||
])(
|
||||
"matches query %s as a literal title or prompt substring",
|
||||
(query, expected) => {
|
||||
expect(matchesScheduledTaskQuery(task, query)).toBe(expected);
|
||||
},
|
||||
);
|
||||
Loading…
x
Reference in New Issue
Block a user