定时任务:修复智能体执行 422、新增描述字段、公开页删除按钮等

- 定时任务执行时按 agent.id 解析智能体,老数据中存的中文显示名会回退按 name 查找,避免 422
- scheduled_tasks 表新增 description 字段(迁移 20260522_01),创建/编辑表单支持,公开页标题下显示
- 公开页(/public/scheduled-tasks/{id})新增删除单次执行记录入口,使用样式化 Dialog 确认
- 公开页左侧标题区精简:去掉 schedule/timezone/最近状态两行;执行列表标题剥离尾部时间戳,"计划"改为"执行时间"
- 工作区定时任务列表:超长标题改为按字符换行不再截断;按 created_at 倒序(新建任务在前)
- 启动 Alembic 升级加超时与单独线程,MySQL 缺库时自动 CREATE DATABASE
- vite build 增加 8GB 内存上限

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-22 10:55:59 +08:00
1 parent a892f55cb2
commit a258122e22
14 files changed
+451 -75

No files matched your search

@@ -46,6 +46,7 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
export async function createScheduledTask(data: {
name: string;
description?: string | null;
prompt: string;
schedule: string;
timezone?: string | null;
@@ -203,6 +204,7 @@ export async function updateScheduledTask(
taskId: string,
data: Partial<{
name: string;
description: string | null;
prompt: string;
schedule: string;
timezone: string;
@@ -329,6 +331,7 @@ export async function adminUpdateScheduledTask(
taskId: string,
data: Partial<{
name: string;
description: string | null;
prompt: string;
schedule: string;
timezone: string;
@@ -14,6 +14,7 @@ export type ScheduledTaskSubscription = {
export type ScheduledTask = {
task_id: string;
name: string;
description?: string | null;
prompt: string;
schedule_text: string;
cron_expr: string;
@@ -27,6 +28,8 @@ export type ScheduledTask = {
scheduler_thread_id: string;
published?: boolean;
published_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null;
@@ -7,6 +7,7 @@ import {
FileText,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -16,6 +17,14 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
@@ -34,6 +43,7 @@ import { cn } from "@/lib/utils";
type PublicTask = {
task_id: string;
name: string;
description?: string | null;
schedule_text: string;
cron_expr?: string;
timezone: string;
@@ -142,6 +152,17 @@ async function updatePublicRunBody(runId: string, content: string): Promise<void
);
}
async function deletePublicRun(runId: string): Promise<void> {
const response = await fetch(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`,
{ method: "DELETE" },
);
if (!response.ok && response.status !== 204) {
const body = await response.json().catch(() => ({ detail: "Request failed" }));
throw new Error(typeof body.detail === "string" ? body.detail : "Request failed");
}
}
function publicRunFileDownloadUrl(runId: string, index: number): string {
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}/download`;
}
@@ -196,6 +217,20 @@ function stripExtension(name: string): string {
return name.slice(0, dot);
}
/**
* Strip a trailing ` - YYYY-MM-DD HH:MM[:SS]` (or `_YYYY-MM-DD ...`) suffix
* that the agent appends to run titles. The scheduled-for column is already
* displayed separately below the title, so the duplicated date looks like noise.
*/
function stripTimestampSuffix(title: string): string {
if (!title) return title;
const cleaned = title.replace(
/[\s_]*[-_]?\s*\d{4}-\d{1,2}-\d{1,2}[\sT]+\d{1,2}:\d{2}(?::\d{2})?\s*$/,
"",
);
return cleaned.trim() || title;
}
const BODY_TAB_VALUE = "__body__";
/**
@@ -212,6 +247,8 @@ const BODY_TAB_VALUE = "__body__";
export default function PublicScheduledTaskPage() {
const { taskId } = useParams<{ taskId: string }>();
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: string } | null>(null);
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["public-scheduled-task", taskId],
@@ -222,6 +259,21 @@ export default function PublicScheduledTaskPage() {
refetchInterval: 8000,
});
const deleteRunMutation = useMutation({
mutationFn: (runId: string) => deletePublicRun(runId),
onSuccess: async (_data, runId) => {
toast.success("已删除");
if (selectedRunId === runId) {
setSelectedRunId(null);
}
setPendingDeleteRun(null);
await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "删除失败");
},
});
// Public viewer only surfaces runs that have actual content to show:
// pending/running/failed are hidden, and succeeded runs with neither a body
// nor a Markdown attachment fall out too (nothing to render).
@@ -301,12 +353,11 @@ export default function PublicScheduledTaskPage() {
{task?.enabled ? "启用" : "暂停"}
</Badge>
</div>
<p className="text-muted-foreground truncate text-xs">
{task?.schedule_text} · {task?.timezone}
</p>
<p className="text-muted-foreground text-xs">
最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)}
</p>
{task?.description && task.description.trim() && (
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
{task.description.trim()}
</p>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -319,15 +370,27 @@ export default function PublicScheduledTaskPage() {
{runs.map((run) => {
const active = run.id === selectedRunId;
const runRunning = run.status === "running" || run.status === "pending";
const runTitle = run.result?.title || formatDate(run.scheduled_for);
const runTitle = stripTimestampSuffix(
run.result?.title || formatDate(run.scheduled_for),
);
const isDeletingThis =
deleteRunMutation.isPending && deleteRunMutation.variables === run.id;
return (
<button
<div
key={run.id}
role="button"
tabIndex={0}
className={cn(
"bg-card hover:bg-accent/60 rounded-md border p-3 text-left transition-colors",
"bg-card hover:bg-accent/60 group rounded-md border p-3 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active && "border-primary bg-accent",
)}
onClick={() => setSelectedRunId(run.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedRunId(run.id);
}
}}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
@@ -345,21 +408,40 @@ export default function PublicScheduledTaskPage() {
</TooltipContent>
</Tooltip>
<div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)}
执行时间:{formatDate(run.scheduled_for)}
</div>
</div>
<Badge
variant={statusVariant(run.status)}
className={cn(
"shrink-0",
runRunning && "border-primary/30 bg-primary/10 text-primary",
)}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
<div className="flex shrink-0 items-center gap-1">
<Badge
variant={statusVariant(run.status)}
className={cn(
"shrink-0",
runRunning && "border-primary/30 bg-primary/10 text-primary",
)}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-7"
disabled={isDeletingThis}
onClick={(event) => {
event.stopPropagation();
setPendingDeleteRun({ id: run.id, title: runTitle });
}}
title="删除记录"
>
{isDeletingThis ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</Button>
</div>
</div>
</button>
</div>
);
})}
</div>
@@ -377,6 +459,54 @@ export default function PublicScheduledTaskPage() {
)}
</Card>
</div>
<Dialog
open={pendingDeleteRun !== null}
onOpenChange={(open) => {
if (!open && !deleteRunMutation.isPending) {
setPendingDeleteRun(null);
}
}}
>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>删除执行记录</DialogTitle>
<DialogDescription>
确认删除这条执行记录吗?此操作不可恢复。
</DialogDescription>
</DialogHeader>
{pendingDeleteRun && (
<div className="bg-muted/40 text-foreground rounded-md border px-3 py-2 text-sm break-all">
{pendingDeleteRun.title}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setPendingDeleteRun(null)}
disabled={deleteRunMutation.isPending}
>
取消
</Button>
<Button
variant="destructive"
onClick={() => {
if (pendingDeleteRun) {
deleteRunMutation.mutate(pendingDeleteRun.id);
}
}}
disabled={deleteRunMutation.isPending}
>
{deleteRunMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}
+26 -8
View File
@@ -78,6 +78,7 @@ function displayUsername(value?: string | null): string {
const emptyForm = {
name: "",
description: "",
schedule: "",
timezone: "Asia/Shanghai",
prompt: "",
@@ -116,9 +117,13 @@ export default function ScheduledTasksPage() {
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) =>
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
),
[...tasks].sort((a, b) => {
// Newest task first. Fall back to next_run_at so tasks that lack a
// created_at (older rows / subscribed tasks) still order consistently.
const aKey = a.created_at ?? a.next_run_at ?? "";
const bKey = b.created_at ?? b.next_run_at ?? "";
return String(bKey).localeCompare(String(aKey));
}),
[tasks],
);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
@@ -162,6 +167,7 @@ export default function ScheduledTasksPage() {
setEditing(task);
setForm({
name: task.name,
description: task.description ?? "",
schedule: task.schedule_text,
timezone: task.timezone,
prompt: task.prompt,
@@ -190,6 +196,7 @@ export default function ScheduledTasksPage() {
if (creating) {
const task = await actions.create.mutateAsync({
name: form.name || "定时任务",
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt,
@@ -202,6 +209,7 @@ export default function ScheduledTasksPage() {
taskId: editing.task_id,
data: {
name: form.name,
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone,
prompt: form.prompt,
@@ -324,9 +332,11 @@ export default function ScheduledTasksPage() {
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate font-medium">{task.name}</div>
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug break-all">
{task.name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{isRunningNow && (
<Badge
variant="secondary"
@@ -351,7 +361,7 @@ export default function ScheduledTasksPage() {
{task.schedule_text} · {task.timezone}
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"}>
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
{task.enabled ? "启用" : "暂停"}
</Badge>
</div>
@@ -760,6 +770,14 @@ export default function ScheduledTasksPage() {
}
placeholder="任务名称"
/>
<Textarea
className="min-h-16"
value={form.description}
onChange={(event) =>
setForm((value) => ({ ...value, description: event.target.value }))
}
placeholder="任务描述(可选,将显示在公开页标题下方)"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
@@ -786,7 +804,7 @@ export default function ScheduledTasksPage() {
<SelectContent>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.name} value={agent.name}>
<SelectItem key={agent.id} value={agent.id}>
{agent.name}
{agent.description ? ` — ${agent.description}` : ""}
</SelectItem>