import { useQueryClient } from "@tanstack/react-query"; import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts"; import { AlertTriangle, Bot, CalendarClock, Plus } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { useLocation } from "react-router"; import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts"; import { useScheduleBoard } from "~/api/schedules.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 { AutoTaskList } from "./auto-task-list.tsx"; import { formatUsd } from "./labels.ts"; import { RunsSheet } from "./runs-sheet.tsx"; // 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面: // 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务 // (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。 /** 每秒一跳的当前时间(倒计时用) */ export function useNow(ms = 1000) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const t = setInterval(() => setNow(Date.now()), ms); return () => clearInterval(t); }, [ms]); return now; } /** SSE:调度器每次开始/结束一次运行都会发 schedule.changed;1 秒节流 */ export function useLiveSchedules() { const qc = useQueryClient(); const timer = useRef | null>(null); useSse([SCHEDULE_CHANGED_EVENT], () => { if (timer.current) return; timer.current = setTimeout(() => { timer.current = null; qc.invalidateQueries({ queryKey: qk.all("schedules") }); }, 1000); }); useEffect( () => () => { if (timer.current) clearTimeout(timer.current); }, [], ); return useScheduleBoard(); } /** 两节的计数:自动任务 启用/失败/运行中;巡检 启用/失败/近 7 天费用(后端按巡检汇总的 agentRuns) */ export function planStats(items: ScheduledJob[]) { 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, }); return { auto: count(auto), checks: { ...count(checks), weekRuns: checks.reduce((s, j) => s + (j.agentRuns?.weekCount ?? 0), 0), weekCost: checks.reduce((s, j) => s + (j.agentRuns?.weekCostUsd ?? 0), 0), }, }; } function SectionHead({ id, title, hint, actions, }: { id: string; title: string; hint: ReactNode; actions?: ReactNode; }) { return (

{title}

{hint}

{actions ?
{actions}
: null}
); } export function PlanBoard({ highlightJob }: { highlightJob?: string }) { const board = useLiveSchedules(); 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 autoJobs = items.filter((j) => j.kind === "task"); const checks = items.filter((j) => j.kind === "agent_check"); const def = upstreams.data?.items.find((u) => u.isDefault); // 上游没填默认模型时,运行时用模型目录的第一个(packages/agent pi/upstream.ts pickModel),这里同样取目录首个 const catalog = usePiUpstreamModels(def && !def.defaultModel ? def.id : null); const catalogModel = catalog.data?.models[0]?.id ?? null; const upstream: DefaultUpstream = def ? { name: def.name, model: def.defaultModel ?? catalogModel, modelFromCatalog: !def.defaultModel && !!catalogModel, } : null; const s = planStats(items); // 首页计数链到 #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]); return ( <> {data && !data.schedulerOnline ? (

调度器未在线,定时任务不会按时运行。

) : null} {board.isPending ? ( ) : board.isError ? ( board.refetch()} /> ) : (
setEditing({ job: null, key: Date.now() })}> 新建巡检 } /> {checks.length === 0 ? ( ) : (
{checks.map((j) => ( setEditing({ job, key: Date.now() })} onHistory={setHistory} /> ))}
)}
{autoJobs.length === 0 ? ( ) : ( )}
)} {editing ? ( !o && setEditing(null)} /> ) : null} setHistory(null)} /> ); }