diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index ed991cea..07dde5b4 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -413,3 +413,31 @@ export function useRunInvocationAudit(runId: string, enabled: boolean, version = }, }); } + +/** + * 巡检触发的最近会话(trigger=schedule,createdBy = schedule:<定时任务 id>): + * 计划页的巡检卡片、首页计划带按定时任务分组取费用、模型与最近结果。一次取最近 100 条。 + */ +export function useScheduleAgentRuns() { + return useQuery({ + queryKey: [...agentKeys.runs(undefined, "schedule"), "plan"], + queryFn: ({ signal }) => + apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ trigger: "schedule", limit: 100 }), signal }), + select: (page) => page.items, + staleTime: 30 * TTL_SECOND, + refetchInterval: 60 * TTL_SECOND, + }); +} + +/** 按定时任务 id 分组(新 → 旧,保持接口顺序) */ +export function groupRunsBySchedule(runs: AgentRun[]): Map { + const out = new Map(); + for (const r of runs) { + const id = r.createdBy?.startsWith("schedule:") ? r.createdBy.slice("schedule:".length) : null; + if (!id) continue; + const list = out.get(id); + if (list) list.push(r); + else out.set(id, [r]); + } + return out; +} diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 3a3db02c..e2b78838 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -19,22 +19,21 @@ describe("控制台导航", () => { it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => { expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]); expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([ - ["首页", "任务", "Agent 会话"], + ["首页", "任务", "计划", "Agent 会话"], ["站点", "RSS 订阅"], ["订阅", "规则"], ["下载面板", "整理记录", "文件管理", "媒体概览"], ["设置", "审计", "运行状态"], ]); - // 定稿文字写"共 12 项",逐项清单实际是 14 项;以清单为准 - expect(NAV_ITEMS).toHaveLength(14); + // IA-3 逐项清单 14 项 + FE-6 计划页 + expect(NAV_ITEMS).toHaveLength(15); expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); - it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 定时任务 / 身份并入别处", () => { + it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 身份并入别处", () => { const paths = NAV_ITEMS.map((i) => i.to); - for (const p of ["/console/inbox", "/console/engine", "/console/schedules", "/console/identity"]) - expect(paths).not.toContain(p); + for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p); expect(pageTitle("/console/inbox")).toBe("待处理"); }); @@ -47,6 +46,7 @@ describe("控制台导航", () => { expect(findNavItem("/console/")?.label).toBe("首页"); expect(findNavItem("/console/library/abc")?.label).toBe("媒体概览"); expect(findNavItem("/console/tasks/r1")?.label).toBe("任务"); + expect(findNavItem("/console/schedules")?.label).toBe("计划"); expect(findNavItem("/console/downloads")?.label).toBe("下载面板"); expect(findNavItem("/console/downloads/plans")?.label).toBe("下载面板"); expect(findNavItem("/console/downloads/abc")?.label).toBe("下载面板"); @@ -83,7 +83,7 @@ describe("旧链接重定向", () => { it("不相关的 query 不跳转", () => { expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBeNull(); - expect(legacyRedirect("/console/tasks", "?tab=plan&job=trash.purge")).toBeNull(); + expect(legacyRedirect("/console/schedules", "?job=trash.purge")).toBeNull(); expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull(); expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull(); expect(legacyRedirect("/console/subscriptions", "")).toBeNull(); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 88b46ebf..b735ca90 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -1,6 +1,7 @@ import { Bot, CalendarClock, + CalendarCog, Download, FileStack, FolderTree, @@ -34,7 +35,7 @@ export function consolePath(...segments: (string | number)[]): string { } /** - * 侧栏 5 组 14 项(IA-3 定稿清单;定稿文字写"共 12 项",以逐项清单为准,docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。 + * 侧栏 5 组 15 项(IA-3 定稿清单 14 项 + FE-6 从任务页拆出的"计划",docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。 * 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 */ export const NAV_GROUPS: NavGroup[] = [ @@ -51,7 +52,13 @@ export const NAV_GROUPS: NavGroup[] = [ to: "/console/tasks", label: "任务", icon: ListTodo, - description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里,含定时任务计划", + description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里", + }, + { + to: "/console/schedules", + label: "计划", + icon: CalendarCog, + description: "定时任务的开关、间隔和下次运行:自动任务与 Agent 巡检", }, { to: "/console/agent", diff --git a/apps/web/app/components/schedules/agent-check-card.tsx b/apps/web/app/components/schedules/agent-check-card.tsx new file mode 100644 index 00000000..27eb4c8e --- /dev/null +++ b/apps/web/app/components/schedules/agent-check-card.tsx @@ -0,0 +1,295 @@ +import { + type AgentCheckConfig, + type AgentRun, + CAPABILITY_CATALOG, + type KnownCapabilityId, + type ScheduledJob, +} from "@umt/contracts"; +import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react"; +import { type ReactNode, useEffect, useRef } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { useDeleteSchedule } from "~/api/schedules.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { agentRunLabel, formatRelative } from "~/lib/labels.ts"; +import { cn } from "~/lib/utils.ts"; +import { NextRun, useJobActions } from "./auto-task-list.tsx"; +import { IntervalSelect } from "./interval-select.tsx"; +import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts"; + +// 计划页「Agent 巡检」:到点按目标模板发起一次 Agent 会话(kind=agent_check),一个巡检一张卡片。 + +/** 卡片上能力白名单最多列几个,其余折成"+N" */ +const MAX_CAPS = 6; + +/** 会话摘要是 Markdown:列表里只取纯文字 */ +const plainSummary = (t: string) => + t + .replace(/[#*`>_]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + +const capTitle = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id; + +/** 默认上游(巡检没有单独的上游设置,跟随 Agent 设置里的默认上游) */ +export type DefaultUpstream = { name: string; model: string | null } | null; + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + <> +
{label}
+
{children}
+ + ); +} + +export function AgentCheckCard({ + job, + now, + runs, + upstream, + highlight, + onEdit, + onHistory, +}: { + job: ScheduledJob; + now: number; + /** 该巡检最近的会话(新 → 旧) */ + runs: AgentRun[]; + upstream: DefaultUpstream; + highlight: boolean; + onEdit: (job: ScheduledJob) => void; + onHistory: (job: ScheduledJob) => void; +}) { + const a = useJobActions(job); + const del = useDeleteSchedule(); + const ref = useRef(null); + useEffect(() => { + if (highlight) ref.current?.scrollIntoView({ block: "center" }); + }, [highlight]); + + const c = job.config as Partial; + const budget = c.budget ?? {}; + const allow = c.capabilityAllowlist ?? []; + const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null; + const latest = runs[0] ?? null; + const costs = runs.map((r) => r.usage.costUsd).filter((v): v is number => typeof v === "number"); + const avg = costs.length ? costs.reduce((s, v) => s + v, 0) / costs.length : null; + const budgetParts = [ + budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null, + budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null, + budget.maxTokens ? `${budget.maxTokens.toLocaleString("zh-CN")} tokens` : null, + budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null, + ].filter(Boolean); + + return ( +
+
+
+

+ {job.name} +

+ {job.description ? ( +

{job.description}

+ ) : null} +
+ +
+ +
+

+ {c.goalTemplate ?? "—"} +

+
+ +
+ + {allow.length ? ( + + {allow.slice(0, MAX_CAPS).map((id) => ( + + {capTitle(id)} + + ))} + {allow.length > MAX_CAPS ? ( + + +{allow.length - MAX_CAPS} + + ) : null} + + ) : ( + "全部只读能力" + )} + {c.allowRecover ? · 允许授权内恢复 : null} + + {budgetParts.length ? budgetParts.join(" · ") : "默认"} + + {latest?.model ? ( + + {latest.provider ? `${latest.provider} · ` : ""} + {latest.model} + + ) : upstream ? ( + + 默认上游 {upstream.name} + {upstream.model ? ` · ${upstream.model}` : ""} + + ) : ( + 未配置 Agent 上游 + )} + + + {CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]} + {c.reportAsDecision ? · 结果写入待处理 : null} + + + + + + + + + + + {costs.length ? ( + + 上次 {formatUsd(costs[0])} + + {" "} + · 近 {costs.length} 次平均 {formatUsd(avg)} + + + ) : ( + 还没有会话 + )} + + + + {last ? {last.label} : 未运行} + {job.lastRunAt ? {formatRelative(job.lastRunAt)} : null} + + +
+ + {job.lastStatus === "failed" && job.lastError ? ( +
+ 失败原因:{job.lastError} +

{job.lastError}

+
+ ) : null} + + {runs.length ? ( +
+

最近会话

+
    + {runs.slice(0, 3).map((r) => { + const s = agentRunLabel(r.status); + return ( +
  • + + {s.label} + + {plainSummary(r.summary ?? r.goal)} + + {formatUsd(r.usage.costUsd)} + + {formatRelative(r.finishedAt ?? r.createdAt)} + + +
  • + ); + })} +
+
+ ) : null} + +
+ + + + + {job.customized ? ( + + ) : null} + + {!job.builtin ? ( + + + + } + title={`删除「${job.name}」?`} + description="运行记录一并删除,已产生的 Agent 会话保留。" + confirmLabel="删除巡检" + destructive + onConfirm={() => + del.mutateAsync({ id: job.id }).then( + () => toast.success("已删除"), + (e) => { + toast.error(errorMessage(e)); + throw e; + }, + ) + } + /> + ) : null} + +
+
+ ); +} diff --git a/apps/web/app/components/schedules/auto-task-list.tsx b/apps/web/app/components/schedules/auto-task-list.tsx new file mode 100644 index 00000000..f9d8d335 --- /dev/null +++ b/apps/web/app/components/schedules/auto-task-list.tsx @@ -0,0 +1,228 @@ +import type { ScheduledJob, ScheduleGroup, ScheduleSpecInput } from "@umt/contracts"; +import { History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatRelative } from "~/lib/labels.ts"; +import { cn } from "~/lib/utils.ts"; +import { IntervalSelect } from "./interval-select.tsx"; +import { countdown, formatMs, GROUP_LABEL, RUN_STATUS, scheduleStreamPath, TASK_GROUPS } from "./labels.ts"; + +// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组的紧凑列表。 + +/** 下次运行:运行中 > 排队中 > 未配置 > 已停用 > 倒计时 */ +export function NextRun({ job, now }: { job: ScheduledJob; now: number }) { + if (job.runningSince) + return ( + + + 运行中 · 已 {formatMs(Math.max(0, now - new Date(job.runningSince).getTime()))} + + ); + if (job.runRequestedAt) return 排队中; + if (!job.available) return 未配置; + if (!job.enabled) return 已停用; + if (!job.nextRunAt) return 待调度; + return {countdown(job.nextRunAt, now)}; +} + +/** 开关、间隔、立即执行、跳过本次、恢复默认:列表行与巡检卡片共用 */ +export function useJobActions(job: ScheduledJob) { + const update = useUpdateSchedule(); + const runNow = useRunScheduleNow(); + const skip = useSkipSchedule(); + const patch = (body: Parameters[0]["body"], ok: string) => + update.mutate( + { id: job.id, body }, + { onSuccess: () => toast.success(ok), onError: (e) => toast.error(errorMessage(e)) }, + ); + const busy = !!job.runningSince || !!job.runRequestedAt; + return { + update, + runNow, + skip, + patch, + busy, + setEnabled: (v: boolean) => patch({ enabled: v }, v ? `已启用${job.name}` : `已停用${job.name}`), + setSchedule: (schedule: ScheduleSpecInput) => patch({ schedule }, "间隔已更新"), + reset: () => patch({ reset: true }, "已恢复默认"), + canRun: job.available && !busy && !runNow.isPending, + canSkip: job.available && job.enabled && !!job.nextRunAt && !skip.isPending, + run: () => + runNow.mutate( + { id: job.id }, + { + onSuccess: (r) => toast.success(r.queued ? "已排队执行" : "已在排队或运行中"), + onError: (e) => toast.error(errorMessage(e)), + }, + ), + skipNext: () => + skip.mutate( + { id: job.id }, + { onSuccess: () => toast.success("已跳过本次"), onError: (e) => toast.error(errorMessage(e)) }, + ), + }; +} + +const COLS = + "lg:grid-cols-[minmax(0,1.5fr)_2.75rem_8.5rem_minmax(0,1.35fr)_minmax(0,1fr)_auto] lg:items-center lg:gap-x-4"; + +function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number; highlight: boolean }) { + const a = useJobActions(job); + const ref = useRef(null); + useEffect(() => { + if (highlight) ref.current?.scrollIntoView({ block: "center" }); + }, [highlight]); + const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null; + const failed = job.lastStatus === "failed" && !!job.lastError; + + return ( +
  • +
    +

    + {job.name} +

    + {job.description ? ( +

    + {job.description} +

    + ) : null} +
    +
    + +
    +
    + +
    +
    + {last ? {last.label} : 未运行} + {job.lastRunAt ? {formatRelative(job.lastRunAt)} : null} + {job.lastDurationMs !== null ? {formatMs(job.lastDurationMs)} : null} +
    +
    + 下次 + +
    +
    + + + + {job.customized ? ( + + ) : null} +
    + {failed ? ( +
    + 失败原因:{job.lastError} +

    {job.lastError}

    +
    + ) : null} +
  • + ); +} + +export function AutoTaskList({ + jobs, + now, + highlightJob, +}: { + jobs: ScheduledJob[]; + now: number; + highlightJob?: string; +}) { + const byGroup = new Map(); + for (const j of jobs) { + const list = byGroup.get(j.group); + if (list) list.push(j); + else byGroup.set(j.group, [j]); + } + const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.length); + return ( +
    +
    + 名称 + 开关 + 间隔 + 上次结果 + 下次运行 + 操作 +
    + {groups.map((g) => { + const list = byGroup.get(g) ?? []; + return ( +
    +

    + {GROUP_LABEL[g]} + {list.length} +

    +
      + {list.map((j) => ( + + ))} +
    +
    + ); + })} +
    + ); +} diff --git a/apps/web/app/components/schedules/interval-editor.tsx b/apps/web/app/components/schedules/interval-editor.tsx index 36f3756d..e0bec82b 100644 --- a/apps/web/app/components/schedules/interval-editor.tsx +++ b/apps/web/app/components/schedules/interval-editor.tsx @@ -12,13 +12,23 @@ export function IntervalEditor({ disabled, pending, onSave, + defaultEditing = false, + onClose, }: { schedule: ScheduleSpec; disabled?: boolean; pending?: boolean; onSave: (next: ScheduleSpecInput) => void; + /** 一出现就处于编辑态(下拉选"自定义"时) */ + defaultEditing?: boolean; + /** 保存或取消后回调(回到下拉) */ + onClose?: () => void; }) { - const [editing, setEditing] = useState(false); + const [editing, setEditingState] = useState(defaultEditing); + const setEditing = (v: boolean) => { + setEditingState(v); + if (!v) onClose?.(); + }; const init = schedule.type === "interval" ? splitInterval(schedule.everyMs) : { value: 1, unit: "m" as IntervalUnit }; const [value, setValue] = useState(String(init.value)); const [unit, setUnit] = useState(init.unit); diff --git a/apps/web/app/components/schedules/interval-select.tsx b/apps/web/app/components/schedules/interval-select.tsx new file mode 100644 index 00000000..f3324ac4 --- /dev/null +++ b/apps/web/app/components/schedules/interval-select.tsx @@ -0,0 +1,65 @@ +import type { ScheduleSpec, ScheduleSpecInput } from "@umt/contracts"; +import { useState } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { IntervalEditor } from "./interval-editor.tsx"; +import { describeSchedule, INTERVAL_PRESETS } from "./labels.ts"; + +const CUSTOM = "custom"; + +/** + * 间隔就地改:常用选项下拉,最后一项"自定义…"切到数字 + 单位输入。 + * cron 调度没有下拉,直接用 IntervalEditor 改表达式。 + */ +export function IntervalSelect({ + schedule, + disabled, + pending, + onSave, + label, +}: { + schedule: ScheduleSpec; + disabled?: boolean; + pending?: boolean; + onSave: (next: ScheduleSpecInput) => void; + /** 无障碍名:"<任务名>间隔" */ + label: string; +}) { + const [custom, setCustom] = useState(false); + if (schedule.type === "cron" || custom) + return ( + setCustom(false)} + /> + ); + const current = String(schedule.everyMs); + const options = INTERVAL_PRESETS.includes(schedule.everyMs) + ? INTERVAL_PRESETS + : [...INTERVAL_PRESETS, schedule.everyMs].sort((a, b) => a - b); + return ( + + ); +} diff --git a/apps/web/app/components/schedules/labels.test.ts b/apps/web/app/components/schedules/labels.test.ts new file mode 100644 index 00000000..ca147590 --- /dev/null +++ b/apps/web/app/components/schedules/labels.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { describeSchedule, formatUsd, INTERVAL_PRESETS, planPath, scheduleStreamPath } from "./labels.ts"; + +describe("计划页标签", () => { + it("planPath:节锚点与 ?job= 高亮", () => { + expect(planPath()).toBe("/console/schedules"); + expect(planPath({ section: "auto" })).toBe("/console/schedules#auto"); + expect(planPath({ section: "checks", job: "check.pending" })).toBe("/console/schedules?job=check.pending#checks"); + }); + + it("运行记录跳任务页按定时任务筛选", () => { + expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge"); + }); + + it("formatUsd:小额保留 4 位", () => { + expect(formatUsd(undefined)).toBe("—"); + expect(formatUsd(0)).toBe("$0"); + expect(formatUsd(0.0031258)).toBe("$0.0031"); + expect(formatUsd(1.234)).toBe("$1.23"); + }); + + it("间隔选项按天、小时、分钟、秒说", () => { + expect(INTERVAL_PRESETS.map((ms) => describeSchedule({ type: "interval", everyMs: ms }))).toEqual([ + "每 30 秒", + "每 1 分钟", + "每 5 分钟", + "每 10 分钟", + "每 30 分钟", + "每 1 小时", + "每 6 小时", + "每 12 小时", + "每 1 天", + "每 7 天", + ]); + }); +}); diff --git a/apps/web/app/components/schedules/labels.ts b/apps/web/app/components/schedules/labels.ts index a27260c5..80d1b0ef 100644 --- a/apps/web/app/components/schedules/labels.ts +++ b/apps/web/app/components/schedules/labels.ts @@ -10,6 +10,19 @@ import type { StatusTone } from "~/components/console/status-badge.tsx"; // 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts) export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"]; +/** 计划页「自动任务」一节的分组(Agent 巡检单独一节) */ +export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"]; + +/** 计划页两节的锚点:/console/schedules#auto、#checks */ +export type PlanSection = "auto" | "checks"; +export const PLAN_PATH = "/console/schedules"; +export function planPath(opts: { section?: PlanSection; job?: string } = {}): string { + const q = opts.job ? `?job=${encodeURIComponent(opts.job)}` : ""; + return `${PLAN_PATH}${q}${opts.section ? `#${opts.section}` : ""}`; +} + +/** 任务页里某个定时任务触发的任务(businessKind=schedule) */ +export const scheduleStreamPath = (id: string) => `/console/tasks?tab=all&kind=schedule&ref=${encodeURIComponent(id)}`; export const GROUP_LABEL: Record = { collect: "采集", @@ -43,6 +56,27 @@ export type IntervalUnit = "s" | "m" | "h"; export const UNIT_MS: Record = { s: 1000, m: 60_000, h: 3600_000 }; export const UNIT_LABEL: Record = { s: "秒", m: "分钟", h: "小时" }; +/** 间隔下拉的常用选项(毫秒);当前值不在其中时下拉另列一项 */ +export const INTERVAL_PRESETS: number[] = [ + 30_000, + 60_000, + 5 * 60_000, + 10 * 60_000, + 30 * 60_000, + 3600_000, + 6 * 3600_000, + 12 * 3600_000, + 24 * 3600_000, + 7 * 24 * 3600_000, +]; + +/** 美元费用:"$0.0031" / "$0.12";空值 "—" */ +export function formatUsd(v: number | null | undefined): string { + if (v === null || v === undefined) return "—"; + if (v === 0) return "$0"; + return `$${v < 0.1 ? v.toFixed(4) : v.toFixed(2)}`; +} + /** 毫秒 → 最大整除单位 */ export function splitInterval(ms: number): { value: number; unit: IntervalUnit } { if (ms % UNIT_MS.h === 0) return { value: ms / UNIT_MS.h, unit: "h" }; diff --git a/apps/web/app/components/schedules/schedule-board.tsx b/apps/web/app/components/schedules/schedule-board.tsx index 80ab8717..939ca7d3 100644 --- a/apps/web/app/components/schedules/schedule-board.tsx +++ b/apps/web/app/components/schedules/schedule-board.tsx @@ -1,19 +1,24 @@ import { useQueryClient } from "@tanstack/react-query"; -import { SCHEDULE_CHANGED_EVENT, type ScheduledJob, type ScheduleGroup } from "@umt/contracts"; -import { AlertTriangle, CalendarClock, CircleCheck, LoaderCircle, Plus, XCircle } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router"; +import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts"; +import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router"; +import { groupRunsBySchedule, useScheduleAgentRuns } from "~/api/agent.ts"; +import { usePiUpstreams } from "~/api/pi.ts"; import { useScheduleBoard } from "~/api/schedules.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { StatTile } from "~/components/console/stat-tile.tsx"; import { Button } from "~/components/ui/button.tsx"; import { qk } from "~/lib/query-keys.ts"; import { useSse } from "~/lib/sse.ts"; +import { AgentCheckCard, type DefaultUpstream } from "./agent-check-card.tsx"; import { AgentCheckDialog } from "./agent-check-dialog.tsx"; -import { GROUP_LABEL, GROUP_ORDER } from "./labels.ts"; -import { ScheduleCard } from "./schedule-card.tsx"; +import { AutoTaskList } from "./auto-task-list.tsx"; +import { formatUsd } from "./labels.ts"; +import { RunsSheet } from "./runs-sheet.tsx"; -// 定时任务看板(W7-C),IA-3 起是任务页的"计划"页签。卡片"运行记录"跳到任务流按该定时任务筛选。 +// 计划页(FE-6,/console/schedules):「自动任务」= 程序按固定周期执行的定时任务(紧凑列表); +// 「Agent 巡检」= 到点发起 Agent 会话的定时任务(卡片)。自动任务的运行记录跳任务页按该定时任务筛选。 /** 每秒一跳的当前时间(倒计时用) */ export function useNow(ms = 1000) { @@ -45,46 +50,125 @@ export function useLiveSchedules() { return useScheduleBoard(); } -/** 任务流里某个定时任务的筛选地址 */ -export const scheduleStreamPath = (id: string) => `/console/tasks?tab=all&kind=schedule&ref=${encodeURIComponent(id)}`; +const WEEK_MS = 7 * 24 * 3600_000; -export function ScheduleBoard({ highlightJob }: { highlightJob?: string }) { - const _qc = useQueryClient(); - const navigate = useNavigate(); +/** 两节的计数:自动任务 启用/失败/运行中;巡检 启用/失败/近 7 天费用 */ +export function planStats(items: ScheduledJob[], checkCosts: { at: string; cost: number | undefined }[], now: number) { + const auto = items.filter((j) => j.kind === "task"); + const checks = items.filter((j) => j.kind === "agent_check"); + const count = (list: ScheduledJob[]) => ({ + total: list.length, + enabled: list.filter((j) => j.enabled && j.available).length, + failed: list.filter((j) => j.lastStatus === "failed").length, + running: list.filter((j) => !!j.runningSince).length, + }); + const recent = checkCosts.filter((c) => now - new Date(c.at).getTime() <= WEEK_MS); + return { + auto: count(auto), + checks: { + ...count(checks), + weekRuns: recent.length, + weekCost: recent.reduce((s, c) => s + (c.cost ?? 0), 0), + }, + }; +} + +function SectionHead({ id, title, hint, actions }: { id: string; title: string; hint: string; actions?: ReactNode }) { + return ( +
    +
    +

    + {title} +

    +

    {hint}

    +
    + {actions ?
    {actions}
    : null} +
    + ); +} + +export function PlanBoard({ highlightJob }: { highlightJob?: string }) { const board = useLiveSchedules(); + const agentRuns = useScheduleAgentRuns(); + const upstreams = usePiUpstreams(); const now = useNow(); + const { hash } = useLocation(); const [editing, setEditing] = useState<{ job: ScheduledJob | null; key: number } | null>(null); + const [history, setHistory] = useState(null); const data = board.data; const items = data?.items ?? []; - const byGroup = new Map(); - for (const g of GROUP_ORDER) byGroup.set(g, []); - for (const j of items) byGroup.get(j.group)?.push(j); - const stats = data?.stats; - const val = (n: number | undefined) => (board.isPending ? "…" : board.isError ? "—" : (n ?? 0)); + const autoJobs = items.filter((j) => j.kind === "task"); + const checks = items.filter((j) => j.kind === "agent_check"); + const runsByJob = groupRunsBySchedule(agentRuns.data ?? []); + const def = upstreams.data?.items.find((u) => u.isDefault); + const upstream: DefaultUpstream = def ? { name: def.name, model: def.defaultModel ?? null } : null; + const s = planStats( + items, + (agentRuns.data ?? []).map((r) => ({ at: r.createdAt, cost: r.usage.costUsd })), + now, + ); + const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n); + + // 首页计数链到 #auto / #checks:数据到了再滚过去 + const loaded = !!data; + useEffect(() => { + if (!loaded || highlightJob) return; + const id = hash.replace(/^#/, ""); + if (id === "auto" || id === "checks") document.getElementById(id)?.scrollIntoView({ block: "start" }); + }, [loaded, hash, highlightJob]); + + const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: "start", behavior: "smooth" }); return ( <> -
    -

    周期任务与 Agent 巡检的开关、间隔和下次运行。

    -
    - +
    +
    +

    自动任务

    +
    + jump("auto")} + /> + 0 ? "destructive" : "neutral"} + value={val(s.auto.failed)} + onClick={() => jump("auto")} + /> + jump("auto")} /> +
    +
    +
    +

    Agent 巡检

    +
    + jump("checks")} + /> + 0 ? "destructive" : "neutral"} + value={val(s.checks.failed)} + onClick={() => jump("checks")} + /> + jump("checks")} + /> +
    -
    - -
    - - 0 ? "destructive" : "neutral"} - value={val(stats?.failed)} - /> - -
    {data && !data.schedulerOnline ? ( @@ -93,53 +177,67 @@ export function ScheduleBoard({ highlightJob }: { highlightJob?: string }) { className="mb-4 flex items-center gap-2 rounded-tight border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning" > - 调度器未在线,任务不会按时运行。 + 调度器未在线,定时任务不会按时运行。

    - ) : ( -
    - )} + ) : null} {board.isPending ? ( - + ) : board.isError ? ( board.refetch()} /> - ) : items.length === 0 ? ( - ) : ( -
    - {GROUP_ORDER.map((g) => { - const jobs = byGroup.get(g) ?? []; - return ( -
    -

    - {GROUP_LABEL[g]} - {jobs.length} -

    - {jobs.length === 0 ? ( -

    - {g === "agent" ? "还没有巡检。" : "还没有定时任务。"} -

    - ) : ( - jobs.map((j) => ( - navigate(scheduleStreamPath(job.id))} - onEdit={(job) => setEditing({ job, key: Date.now() })} - /> - )) - )} -
    - ); - })} +
    +
    + + {autoJobs.length === 0 ? ( + + ) : ( + + )} +
    + +
    + setEditing({ job: null, key: Date.now() })}> + + 新建巡检 + + } + /> + {checks.length === 0 ? ( + + ) : ( +
    + {checks.map((j) => ( + setEditing({ job, key: Date.now() })} + onHistory={setHistory} + /> + ))} +
    + )} +
    )} {editing ? ( !o && setEditing(null)} /> ) : null} + setHistory(null)} /> ); } diff --git a/apps/web/app/components/schedules/schedule-card.tsx b/apps/web/app/components/schedules/schedule-card.tsx deleted file mode 100644 index b8ac6b4e..00000000 --- a/apps/web/app/components/schedules/schedule-card.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import type { AgentCheckConfig, ScheduledJob, ScheduleSpecInput } from "@umt/contracts"; -import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react"; -import { useEffect, useRef } from "react"; -import { toast } from "sonner"; -import { useDeleteSchedule, useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts"; -import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { Switch } from "~/components/ui/switch.tsx"; -import { errorMessage } from "~/lib/api.ts"; -import { formatRelative } from "~/lib/labels.ts"; -import { cn } from "~/lib/utils.ts"; -import { IntervalEditor } from "./interval-editor.tsx"; -import { CHECK_TRIGGER_LABEL, countdown, formatMs, RUN_STATUS } from "./labels.ts"; - -/** 下次运行一栏:运行中 > 排队中 > 已停用 > 倒计时 */ -function NextRun({ job, now }: { job: ScheduledJob; now: number }) { - if (job.runningSince) - return ( - - - 运行中 · 已 {formatMs(Math.max(0, now - new Date(job.runningSince).getTime()))} - - ); - if (job.runRequestedAt) return 排队中; - if (!job.available) return 未配置; - if (!job.enabled) return 已停用; - if (!job.nextRunAt) return 待调度; - return {countdown(job.nextRunAt, now)}; -} - -function AgentCheckSummary({ config }: { config: Partial }) { - const budget = config.budget ?? {}; - const allow = config.capabilityAllowlist ?? []; - return ( -
    -

    - {config.goalTemplate ?? "—"} -

    -
    - 条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]} - - 预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"} - {budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""} - - 能力:{allow.length ? `${allow.length} 项` : "全部只读能力"} - {config.allowRecover ? 允许授权内恢复 : null} - {config.reportAsDecision ? 写入待处理 : null} -
    -
    - ); -} - -export function ScheduleCard({ - job, - now, - onHistory, - onEdit, - highlight = false, -}: { - job: ScheduledJob; - now: number; - onHistory: (job: ScheduledJob) => void; - onEdit: (job: ScheduledJob) => void; - /** 从任务流跳来时高亮并滚到可见 */ - highlight?: boolean; -}) { - const update = useUpdateSchedule(); - const runNow = useRunScheduleNow(); - const skip = useSkipSchedule(); - const del = useDeleteSchedule(); - const ref = useRef(null); - useEffect(() => { - if (highlight) ref.current?.scrollIntoView({ block: "center" }); - }, [highlight]); - const busy = !!job.runningSince || !!job.runRequestedAt; - const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null; - - const patch = (body: Parameters[0]["body"], ok: string) => - update.mutate( - { id: job.id, body }, - { onSuccess: () => toast.success(ok), onError: (e) => toast.error(errorMessage(e)) }, - ); - - return ( -
    -
    -
    -

    - {job.name} -

    - {job.description ? ( -

    {job.description}

    - ) : null} -
    - patch({ enabled: v }, v ? "已启用" : "已停用")} - /> -
    - - {job.kind === "agent_check" ? } /> : null} - -
    -
    间隔
    -
    - patch({ schedule }, "间隔已更新")} - /> -
    -
    上次
    -
    - {last ? {last.label} : 未运行} - {job.lastRunAt ? {formatRelative(job.lastRunAt)} : null} - {job.lastDurationMs !== null ? 耗时 {formatMs(job.lastDurationMs)} : null} -
    -
    下次
    -
    - -
    -
    - - {job.lastStatus === "failed" && job.lastError ? ( -
    - {job.lastError} -

    {job.lastError}

    -
    - ) : null} - -
    - - - - - {job.customized ? ( - - ) : null} - {job.kind === "agent_check" ? ( - - ) : null} - {!job.builtin ? ( - - - - } - title={`删除「${job.name}」?`} - description="运行记录一并删除,已产生的 Agent 会话保留。" - confirmLabel="删除巡检" - destructive - onConfirm={() => - del.mutateAsync({ id: job.id }).then( - () => toast.success("已删除"), - (e) => { - toast.error(errorMessage(e)); - throw e; - }, - ) - } - /> - ) : null} - -
    -
    - ); -} diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index edf33676..4389c2bd 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -117,7 +117,7 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s case "agent": return { to: `/console/agent/${id}`, label: "Agent 会话" }; case "schedule": - return { to: `/console/tasks?tab=plan&job=${id}`, label: "定时任务" }; + return { to: `/console/schedules?job=${id}`, label: "定时任务" }; case "feed": return { to: consolePath("sources"), label: "Feed" }; case "site": diff --git a/apps/web/app/components/tasks/task-stream.tsx b/apps/web/app/components/tasks/task-stream.tsx index 34cfd7fb..4c33d97d 100644 --- a/apps/web/app/components/tasks/task-stream.tsx +++ b/apps/web/app/components/tasks/task-stream.tsx @@ -94,7 +94,7 @@ function ScheduleBanner({ jobId, onClear }: { jobId: string; onClear: () => void 运行记录 + } + /> + + + ); } diff --git a/apps/web/app/routes/console/status.tsx b/apps/web/app/routes/console/status.tsx index eaef8bbe..245398d9 100644 --- a/apps/web/app/routes/console/status.tsx +++ b/apps/web/app/routes/console/status.tsx @@ -543,7 +543,7 @@ export default function StatusPage() { icon={Timer} level={d.schedules.level} action={ - + 查看计划 } @@ -560,7 +560,7 @@ export default function StatusPage() { {d.schedules.failing.map((f) => (
  • {f.name} diff --git a/apps/web/app/routes/console/tasks.tsx b/apps/web/app/routes/console/tasks.tsx index 29e47f74..f45f9388 100644 --- a/apps/web/app/routes/console/tasks.tsx +++ b/apps/web/app/routes/console/tasks.tsx @@ -5,7 +5,6 @@ import { useEngineRun, useEngineStats } from "~/api/engine.ts"; import { DetailSheet } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; -import { ScheduleBoard } from "~/components/schedules/schedule-board.tsx"; import { runTitle } from "~/components/tasks/labels.ts"; import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx"; import { type StreamFilters, type StreamTab, TaskStream } from "~/components/tasks/task-stream.tsx"; @@ -16,11 +15,11 @@ import { legacyRedirectLoader } from "./redirects.ts"; export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }]; -// 旧的 ?tab=downloads / plans / business 跳到下载面板 / 下载计划 / 任务流 +// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载计划 / 任务流 / 计划页 export const clientLoader = legacyRedirectLoader; -type Tab = StreamTab | "plan"; -const TABS: Tab[] = ["all", "active", "failed", "plan"]; +type Tab = StreamTab; +const TABS: Tab[] = ["all", "active", "failed"]; const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"]; function Count({ n }: { n: number | undefined }) { @@ -102,14 +101,7 @@ export default function TasksPage() { title="任务" description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。" actions={ - @@ -120,7 +112,6 @@ export default function TasksPage() { onValueChange={(v) => update((p) => { p.set("tab", v); - p.delete("job"); if (v !== "all") p.delete("status"); }) } @@ -135,26 +126,21 @@ export default function TasksPage() { 失败 - 计划
    - {tab === "plan" ? ( - - ) : ( - - update((p) => { - for (const k of FILTER_KEYS) p.delete(k); - }) - } - onOpen={(id) => update((p) => p.set("run", id))} - selectedId={runId} - /> - )} + + update((p) => { + for (const k of FILTER_KEYS) p.delete(k); + }) + } + onOpen={(id) => update((p) => p.set("run", id))} + selectedId={runId} + />
    ` 分组。 + - `?job=` 高亮并滚到该行 / 卡片。 +- 首页计划带改为两个计数块:自动任务(启用 / 失败)→ `#auto`,Agent 巡检(启用 / 近 7 天费用)→ `#checks`。 + ### 7.5 运行状态页(`/console/status`) 顶部横幅按 `level` 列出问题;进程表(类型、主机/pid、版本、启动、最后心跳、延迟/峰值、并发、运行中、SSE、内存);趋势(1/6/24/168 小时,事件循环延迟峰值与 SSE 连接数,recharts 无彩,多进程靠线型和图例区分);数据库、任务引擎、定时任务(失败项链到计划卡)、磁盘(阈值刻线)、挂载根。 diff --git a/docs/design/glossary.md b/docs/design/glossary.md index 300a9acb..0b4d3de1 100644 --- a/docs/design/glossary.md +++ b/docs/design/glossary.md @@ -33,12 +33,13 @@ | OAuth 型上游的登录 | 订阅登录 | OAuth 登录、账号授权 | | 密钥、Cookie、密码、令牌的统称 | 凭证 | 凭据、认证信息 | | ScheduledJob | 定时任务 | 计划任务、周期作业、cron 任务 | +| 定时任务的配置页 `/console/schedules` | 计划;两节叫"自动任务"(kind=task)与"Agent 巡检" | 定时任务看板、计划任务 | | kind=agent_check 的定时任务 | 巡检 | 巡查、Agent 定时任务 | | 定时任务每次执行的记录 | 运行记录 | 历史、执行历史 | | AgentRun(parent_run_id 非空) | 子会话;派生它的叫"父会话" | 子代理、子 Agent、subagent | | AgentRun 被中止 | 停止;状态"已停止" | 中止、取消 | | AutomationGrant | 持续授权 | 自动化授权、automation_grant | -| agent.plan 的步骤清单 | 步骤;进度写"步骤 2/5" | 计划(计划只指下载计划 / 整理计划)、TODO、任务列表 | +| agent.plan 的步骤清单 | 步骤;进度写"步骤 2/5" | 计划(计划只指下载计划 / 整理计划 / 计划页)、TODO、任务列表 | | web.search / web.fetch | 网页搜索 / 读取网页;设置卡叫"网页搜索",提供方叫"搜索服务" | 联网、上网、爬取、网络代理 | | agent.propose_options | 方案;动作叫"选定",都不要叫"退回" | 选项、提案 | | scope.proposeFirst | 先看方案再执行 | 只要方案、只给方案、plan_only 模式 | diff --git a/docs/runbook/schedules.md b/docs/runbook/schedules.md index 0109eeac..b7385e86 100644 --- a/docs/runbook/schedules.md +++ b/docs/runbook/schedules.md @@ -1,6 +1,6 @@ # 定时任务与看板 -所有周期任务都登记在 `scheduled_job` 表里,由 io worker 上唯一的调度器按表驱动。控制台「定时任务」页(`/console/schedules`)可以查看和调整。 +所有周期任务都登记在 `scheduled_job` 表里,由 io worker 上唯一的调度器按表驱动。控制台「计划」页(`/console/schedules`,分「自动任务」与「Agent 巡检」两节)可以查看和调整。 ## 组成