feat: 任务页新增定时计划——指定执行智能体(默认通用助手)与间隔,后台到点自动在智能体会话中执行并记录结果

This commit is contained in:
xianrenge
2026-08-17 12:21:22 +08:00
parent 45ed10f64f
commit 1a13915f72
12 changed files with 1327 additions and 22 deletions
+7
View File
@@ -43,6 +43,7 @@ export default function App() {
const refreshConversations = useStore((s) => s.refreshConversations);
const refreshAgents = useStore((s) => s.refreshAgents);
const refreshWorkflows = useStore((s) => s.refreshWorkflows);
const refreshScheduledTasks = useStore((s) => s.refreshScheduledTasks);
const setDeployState = useStore((s) => s.setDeployState);
const setDeployProgress = useStore((s) => s.setDeployProgress);
const setChatSuggestions = useStore((s) => s.setChatSuggestions);
@@ -65,6 +66,7 @@ export default function App() {
refreshConversations();
refreshAgents();
refreshWorkflows();
refreshScheduledTasks();
const un1 = onEvent("engine://status", () => refreshEngine());
const un2 = onEvent("server://status", () => refreshServer());
const un3start = onEvent<DownloadProgressEvent>("download://started", (e) => {
@@ -152,6 +154,8 @@ export default function App() {
const un7 = onEvent<ChatSuggestionsEvent>("chat://suggestions", (e) => {
setChatSuggestions(e.message_id, e.suggestions);
});
const un8 = onEvent("scheduled://updated", () => refreshScheduledTasks());
const un9 = onEvent("conversations://updated", () => refreshConversations());
return () => {
un1.then((f) => f());
un2.then((f) => f());
@@ -163,6 +167,8 @@ export default function App() {
un5.then((f) => f());
un6.then((f) => f());
un7.then((f) => f());
un8.then((f) => f());
un9.then((f) => f());
};
}, [
refreshModels,
@@ -171,6 +177,7 @@ export default function App() {
refreshConversations,
refreshAgents,
refreshWorkflows,
refreshScheduledTasks,
setDeployState,
setDeployProgress,
setChatSuggestions,
+35
View File
@@ -160,6 +160,30 @@ export interface WorkflowNodeStatusEvent {
text: string | null;
}
export interface ScheduledTask {
id: string;
name: string;
agent_id: string | null;
prompt: string;
interval_minutes: number;
enabled: boolean;
next_run_at: string | null;
last_run_at: string | null;
last_status: string;
last_result: string | null;
last_error: string | null;
created_at: string;
updated_at: string;
}
export interface ScheduledTaskInput {
name: string;
agent_id: string | null;
prompt: string;
interval_minutes: number;
enabled: boolean;
}
export interface McpServer {
id: string;
name: string;
@@ -363,6 +387,17 @@ export const api = {
input: string;
model_id: string | null;
}) => invoke<WorkflowRunResult>("run_workflow", { payload }),
listScheduledTasks: () => invoke<ScheduledTask[]>("list_scheduled_tasks"),
addScheduledTask: (input: ScheduledTaskInput) =>
invoke<ScheduledTask>("add_scheduled_task", { input }),
updateScheduledTask: (id: string, input: ScheduledTaskInput) =>
invoke<void>("update_scheduled_task", { id, input }),
removeScheduledTask: (id: string) =>
invoke<void>("remove_scheduled_task", { id }),
setScheduledTaskEnabled: (id: string, enabled: boolean) =>
invoke<void>("set_scheduled_task_enabled", { id, enabled }),
runScheduledTaskNow: (id: string) =>
invoke<void>("run_scheduled_task_now", { id }),
listModels: () => invoke<ModelInfo[]>("list_models"),
importModel: (path: string) => invoke<ModelInfo>("import_model", { path }),
removeModel: (id: string) => invoke<void>("remove_model", { id }),
+19
View File
@@ -103,6 +103,25 @@ const paths: Record<string, ReactNode> = {
paperclip: (
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
),
clock: (
<>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</>
),
calendar: (
<>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</>
),
refresh: (
<>
<path d="M23 4v6h-6M1 20v-6h6" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</>
),
play: <polygon points="5 3 19 12 5 21 5 3" />,
};
export default function Icon({
+528 -17
View File
@@ -1,41 +1,552 @@
import { useEffect, useMemo, useState } from "react";
import { api, ScheduledTask, ScheduledTaskInput } from "../api";
import { useStore, TaskItem } from "../store";
import Icon from "../components/Icon";
const PRESET_GENERAL_ASSISTANT = "preset-general-assistant";
const INTERVAL_PRESETS: { label: string; value: number; unit: IntervalUnit }[] = [
{ label: "30 分钟", value: 30, unit: "min" },
{ label: "1 小时", value: 1, unit: "hour" },
{ label: "6 小时", value: 6, unit: "hour" },
{ label: "1 天", value: 1, unit: "day" },
];
type IntervalUnit = "min" | "hour" | "day";
interface Draft {
name: string;
agentId: string;
prompt: string;
intervalValue: number;
intervalUnit: IntervalUnit;
enabled: boolean;
}
function intervalToMinutes(value: number, unit: IntervalUnit) {
const factor = unit === "hour" ? 60 : unit === "day" ? 1440 : 1;
return Math.max(1, Math.round(value * factor));
}
function fmtInterval(minutes: number) {
if (minutes % 1440 === 0) {
const days = minutes / 1440;
return days === 1 ? "每天" : `${days}`;
}
if (minutes % 60 === 0) {
const hours = minutes / 60;
return hours === 1 ? "每小时" : `${hours} 小时`;
}
return `${minutes} 分钟`;
}
function fmtDbTime(s: string | null) {
if (!s) return "—";
const d = new Date(s.replace(" ", "T") + "Z");
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
export default function TasksPage() {
const tasks = useStore((s) => s.tasks);
const clearFinishedTasks = useStore((s) => s.clearFinishedTasks);
const sorted = [...tasks].sort((a, b) => b.updated_at - a.updated_at);
const scheduledTasks = useStore((s) => s.scheduledTasks);
const refreshScheduledTasks = useStore((s) => s.refreshScheduledTasks);
const agents = useStore((s) => s.agents);
const refreshAgents = useStore((s) => s.refreshAgents);
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
refreshScheduledTasks();
refreshAgents();
}, [refreshScheduledTasks, refreshAgents]);
const sorted = useMemo(
() => [...tasks].sort((a, b) => b.updated_at - a.updated_at),
[tasks],
);
const hasFinished = tasks.some((t) => t.status !== "running");
const agentNameById = useMemo(() => {
const map = new Map<string, { name: string; icon: string }>();
for (const a of agents) map.set(a.id, { name: a.name, icon: a.icon });
return map;
}, [agents]);
const defaultAgentId = useMemo(() => {
if (agents.some((a) => a.id === PRESET_GENERAL_ASSISTANT)) {
return PRESET_GENERAL_ASSISTANT;
}
return agents[0]?.id ?? "";
}, [agents]);
function openNew() {
setEditing(null);
setShowModal(true);
}
function openEdit(task: ScheduledTask) {
setEditing(task);
setShowModal(true);
}
async function handleSave(draft: Draft) {
const intervalMinutes = intervalToMinutes(draft.intervalValue, draft.intervalUnit);
const input: ScheduledTaskInput = {
name: draft.name.trim(),
agent_id: draft.agentId || null,
prompt: draft.prompt.trim(),
interval_minutes: intervalMinutes,
enabled: draft.enabled,
};
setBusy(true);
try {
if (editing) {
await api.updateScheduledTask(editing.id, input);
setMsg("定时计划已更新");
} else {
await api.addScheduledTask(input);
setMsg("定时计划已创建");
}
await refreshScheduledTasks();
setShowModal(false);
} catch (e) {
setMsg(`保存失败:${String(e)}`);
} finally {
setBusy(false);
}
}
async function handleRunNow(task: ScheduledTask) {
setBusy(true);
try {
await api.runScheduledTaskNow(task.id);
setMsg(`${task.name}」已开始执行`);
} catch (e) {
setMsg(`执行失败:${String(e)}`);
} finally {
setBusy(false);
}
}
async function handleToggle(task: ScheduledTask) {
try {
await api.setScheduledTaskEnabled(task.id, !task.enabled);
await refreshScheduledTasks();
} catch (e) {
setMsg(`操作失败:${String(e)}`);
}
}
async function handleDelete(task: ScheduledTask) {
if (!confirm(`确定删除定时计划「${task.name}」吗?`)) return;
try {
await api.removeScheduledTask(task.id);
setMsg("定时计划已删除");
await refreshScheduledTasks();
} catch (e) {
setMsg(`删除失败:${String(e)}`);
}
}
return (
<div className="h-full overflow-auto p-6">
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400"></p>
<p className="mt-1 text-sm text-slate-400">
</p>
</div>
{hasFinished ? (
<button className="btn-secondary" onClick={clearFinishedTasks}>
<div className="flex shrink-0 gap-2">
{hasFinished ? (
<button className="btn-secondary" onClick={clearFinishedTasks}>
</button>
) : null}
<button className="btn-primary" onClick={openNew} disabled={busy}>
+
</button>
</div>
</div>
{msg ? (
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-panel px-4 py-2 text-xs text-slate-300">
<span>{msg}</span>
<button className="text-slate-500 hover:text-slate-200" onClick={() => setMsg(null)}>
×
</button>
</div>
) : null}
<section className="mb-8">
<div className="mb-2 flex items-center gap-2 text-xs uppercase text-slate-500">
<span className="rounded bg-panel-2 px-1.5 py-0.5 text-[10px] text-slate-400">
{scheduledTasks.length}
</span>
</div>
{scheduledTasks.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-8 text-center text-sm text-slate-500">
</div>
) : (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
{scheduledTasks.map((task) => (
<ScheduledTaskCard
key={task.id}
task={task}
agentNameById={agentNameById}
onRun={() => handleRunNow(task)}
onEdit={() => openEdit(task)}
onToggle={() => handleToggle(task)}
onDelete={() => handleDelete(task)}
/>
))}
</div>
)}
</section>
<section>
<div className="mb-2 text-xs uppercase text-slate-500"> / </div>
{sorted.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-8 text-center text-sm text-slate-500">
广
</div>
) : (
<div className="space-y-2">
{sorted.map((t) => (
<TaskRow key={t.id} task={t} />
))}
</div>
)}
</section>
{showModal ? (
<ScheduleModal
task={editing}
agents={agents}
defaultAgentId={defaultAgentId}
busy={busy}
onSave={handleSave}
onClose={() => setShowModal(false)}
/>
) : null}
</div>
);
}
function ScheduledTaskCard({
task,
agentNameById,
onRun,
onEdit,
onToggle,
onDelete,
}: {
task: ScheduledTask;
agentNameById: Map<string, { name: string; icon: string }>;
onRun: () => void;
onEdit: () => void;
onToggle: () => void;
onDelete: () => void;
}) {
const agent = task.agent_id ? agentNameById.get(task.agent_id) : undefined;
const agentName = agent?.name ?? (task.agent_id ? "通用助手" : "普通对话");
const agentIcon = agent?.icon ?? (task.agent_id ? "🤖" : "🗒️");
return (
<div
className={`flex flex-col rounded-xl border border-border bg-panel p-4 ${
task.enabled ? "" : "opacity-60"
}`}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-panel-2 text-xl">
<Icon name="clock" className="h-5 w-5 text-slate-400" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{task.name}</span>
<ScheduleStatusChip status={task.last_status} enabled={task.enabled} />
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-slate-400">
<span>{agentIcon}</span>
<span className="truncate">
{agentName}
{!task.enabled ? " · 已停用" : ""}
</span>
</div>
</div>
<Toggle checked={task.enabled} onChange={onToggle} />
</div>
<div className="mt-2 line-clamp-2 text-xs text-slate-400">{task.prompt}</div>
<div className="mt-3 space-y-1 text-[11px] text-slate-500">
<div className="flex items-center gap-1">
<Icon name="calendar" className="h-3 w-3" />
<span>
{fmtInterval(task.interval_minutes)}
{task.enabled ? ` · 下次执行 ${fmtDbTime(task.next_run_at)}` : ""}
</span>
</div>
{task.last_run_at ? (
<div className="flex items-center gap-1">
<Icon name="refresh" className="h-3 w-3" />
<span> {fmtDbTime(task.last_run_at)}</span>
</div>
) : null}
{task.last_error ? (
<div className="line-clamp-2 text-red-400/90">{task.last_error}</div>
) : task.last_result ? (
<div className="line-clamp-2 text-emerald-400/80">{task.last_result}</div>
) : null}
</div>
{sorted.length === 0 ? (
<div className="mt-16 text-center text-sm text-slate-500">
广
</div>
) : (
<div className="space-y-2">
{sorted.map((t) => (
<TaskRow key={t.id} task={t} />
))}
</div>
)}
<div className="mt-3 flex items-center gap-2">
<button className="btn-primary flex-1 px-2 py-1.5 text-xs" onClick={onRun}>
<Icon name="play" className="mr-1 inline h-3.5 w-3.5" />
</button>
<button
className="rounded-lg border border-border bg-panel-2 px-2.5 py-1.5 text-xs text-slate-400 hover:text-slate-200"
onClick={onEdit}
title="编辑"
>
<Icon name="edit" className="h-3.5 w-3.5" />
</button>
<button
className="rounded-lg border border-border bg-panel-2 px-2.5 py-1.5 text-xs text-slate-400 hover:text-red-400"
onClick={onDelete}
title="删除"
>
<Icon name="trash" className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
function ScheduleStatusChip({ status, enabled }: { status: string; enabled: boolean }) {
if (!enabled) {
return (
<span className="shrink-0 rounded bg-panel-2 px-2 py-0.5 text-xs text-slate-400">
</span>
);
}
const map: Record<string, { cls: string; label: string }> = {
idle: { cls: "bg-slate-500/20 text-slate-300", label: "待执行" },
running: { cls: "bg-sky-500/20 text-sky-300", label: "执行中" },
success: { cls: "bg-emerald-500/20 text-emerald-300", label: "成功" },
error: { cls: "bg-red-500/20 text-red-300", label: "失败" },
};
const s = map[status] ?? map.idle;
return <span className={`shrink-0 rounded px-2 py-0.5 text-xs ${s.cls}`}>{s.label}</span>;
}
function ScheduleModal({
task,
agents,
defaultAgentId,
busy,
onSave,
onClose,
}: {
task: ScheduledTask | null;
agents: { id: string; name: string; icon: string; enabled: boolean }[];
defaultAgentId: string;
busy: boolean;
onSave: (draft: Draft) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState<Draft>(() => ({
name: task?.name ?? "",
agentId: task?.agent_id ?? defaultAgentId,
prompt: task?.prompt ?? "",
intervalValue: task ? splitInterval(task.interval_minutes).value : 1,
intervalUnit: task ? splitInterval(task.interval_minutes).unit : "hour",
enabled: task?.enabled ?? true,
}));
const valid = draft.name.trim() && draft.prompt.trim() && draft.intervalValue >= 1;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6"
onClick={onClose}
>
<div
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<span className="text-sm font-medium">{task ? "编辑定时计划" : "新建定时计划"}</span>
<button className="text-slate-500 hover:text-slate-200" onClick={onClose}>
×
</button>
</div>
<div className="flex-1 space-y-4 overflow-auto px-5 py-4">
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<input
className="input"
value={draft.name}
placeholder="例如:每日工作总结"
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<select
className="input"
value={draft.agentId}
onChange={(e) => setDraft({ ...draft, agentId: e.target.value })}
>
<option value=""></option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.icon} {a.name}
{a.enabled ? "" : "(已停用)"}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-slate-500">
使使
</p>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<textarea
className="input min-h-[96px] resize-y"
value={draft.prompt}
placeholder="每次到点发送给智能体的任务内容,例如:把最近一周的会话按主题整理成报告"
onChange={(e) => setDraft({ ...draft, prompt: e.target.value })}
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400"></label>
<div className="flex flex-wrap gap-2">
{INTERVAL_PRESETS.map((p) => {
const active =
draft.intervalValue === p.value && draft.intervalUnit === p.unit;
return (
<button
key={p.label}
type="button"
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
active
? "border-accent bg-accent/20 text-white"
: "border-border bg-panel-2 text-slate-400 hover:text-slate-200"
}`}
onClick={() =>
setDraft({ ...draft, intervalValue: p.value, intervalUnit: p.unit })
}
>
{p.label}
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={1}
className="input w-24"
value={draft.intervalValue}
onChange={(e) =>
setDraft({ ...draft, intervalValue: Number(e.target.value) || 1 })
}
/>
<select
className="input w-28"
value={draft.intervalUnit}
onChange={(e) =>
setDraft({ ...draft, intervalUnit: e.target.value as IntervalUnit })
}
>
<option value="min"></option>
<option value="hour"></option>
<option value="day"></option>
</select>
<span className="text-xs text-slate-500">
{fmtInterval(intervalToMinutes(draft.intervalValue, draft.intervalUnit))}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Toggle
checked={draft.enabled}
onChange={(v) => setDraft({ ...draft, enabled: v })}
/>
<span className="text-xs text-slate-400">
{draft.enabled ? "启用后到点自动执行" : "已停用,不会自动执行"}
</span>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
<button className="btn-secondary" onClick={onClose} disabled={busy}>
</button>
<button
className="btn-primary"
onClick={() => onSave(draft)}
disabled={busy || !valid}
>
{busy ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
);
}
function splitInterval(minutes: number): { value: number; unit: IntervalUnit } {
if (minutes % 1440 === 0) return { value: minutes / 1440, unit: "day" };
if (minutes % 60 === 0) return { value: minutes / 60, unit: "hour" };
return { value: minutes, unit: "min" };
}
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
checked ? "bg-accent" : "bg-panel-2"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${
checked ? "left-[22px]" : "left-0.5"
}`}
/>
</button>
);
}
function TaskRow({ task }: { task: TaskItem }) {
return (
<div className="rounded-xl border border-border bg-panel p-4">
+8
View File
@@ -5,6 +5,7 @@ import {
Conversation,
EngineStatus,
ModelInfo,
ScheduledTask,
ServerStatus,
Workflow,
} from "./api";
@@ -15,6 +16,7 @@ interface Store {
agentConversations: Record<string, Conversation[]>;
workflows: Workflow[];
workflowsLoaded: boolean;
scheduledTasks: ScheduledTask[];
models: ModelInfo[];
engine: EngineStatus | null;
server: ServerStatus | null;
@@ -30,6 +32,7 @@ interface Store {
clearFinishedTasks: () => void;
refreshAgents: () => Promise<void>;
refreshWorkflows: () => Promise<void>;
refreshScheduledTasks: () => Promise<void>;
refreshModels: () => Promise<void>;
refreshEngine: () => Promise<void>;
refreshServer: () => Promise<void>;
@@ -53,6 +56,7 @@ export const useStore = create<Store>((set) => ({
agentConversations: {},
workflows: [],
workflowsLoaded: false,
scheduledTasks: [],
models: [],
engine: null,
server: null,
@@ -88,6 +92,10 @@ export const useStore = create<Store>((set) => ({
const workflows = await api.listWorkflows();
set({ workflows, workflowsLoaded: true });
},
refreshScheduledTasks: async () => {
const scheduledTasks = await api.listScheduledTasks();
set({ scheduledTasks });
},
refreshModels: async () => set({ models: await api.listModels() }),
refreshEngine: async () => set({ engine: await api.engineStatus() }),
refreshServer: async () => set({ server: await api.serverStatus() }),