import { type AgentCheckConfig, CAPABILITY_CATALOG, type KnownCapabilityId, type ScheduledJob } from "@umt/contracts";
import { ChevronRight, History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } 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 { DetailSheet } from "~/components/console/detail-sheet.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 { formatTokens } from "~/lib/tokens.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, goalTemplateParts, RUN_STATUS } from "./labels.ts";
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
// 卡片统一尺寸(FIX-9):名称 / 描述 / 目标各一行省略,最近汇总(或失败原因)固定 2 行,模型与费用一行,
// 操作按钮贴底,网格按行等高。完整内容(目标、能力、预算、模型、最近会话)在「详情」抽屉里看。
/** 会话摘要是 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;
/** 上游的默认模型;上游没填时是模型目录的第一个(modelFromCatalog) */
model: string | null;
modelFromCatalog?: boolean;
} | null;
const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) =>
models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n");
/** 目标模板的纯文字版(悬浮提示用),占位符写成名称 */
const goalPlain = (template: string) =>
goalTemplateParts(template)
.map((p) => ("text" in p ? p.text : `[${p.label}]`))
.join("");
/** 目标模板:占位符显示成名称标签(执行时替换成实际值),不露 `{{now}}` */
function GoalTemplate({ template }: { template: string }) {
return (
<>
{goalTemplateParts(template).map((p, i) =>
"text" in p ? (
{p.text}
) : (
{p.label}
),
)}
>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<>
{label}
{children}
>
);
}
export function AgentCheckCard({
job,
now,
upstream,
highlight,
onEdit,
onHistory,
}: {
job: ScheduledJob;
now: number;
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 [detailOpen, setDetailOpen] = useState(false);
const c = job.config as Partial;
const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null;
// 费用 / 模型由后端按最近 100 次会话汇总(GET /schedules 的 agentRuns)
const stats = job.agentRuns;
const latest = stats?.recent[0];
const failed = job.lastStatus === "failed" && !!job.lastError;
return (
{job.name}
{job.description}
下次
{last ? (
job.lastStatus === "ok" ? (
上次成功
) : (
{last.label}
)
) : (
还没运行过
)}
{job.lastRunAt ? {formatRelative(job.lastRunAt)} : null}
目标
{c.goalTemplate ? : "—"}
{failed ? `失败原因:${job.lastError}` : latest ? plainSummary(latest.summary ?? latest.goal) : null}
{stats?.count ? · 每次约 {formatUsd(stats.avgCostUsd)} : null}
);
}
function ModelText({ stats, upstream }: { stats: ScheduledJob["agentRuns"]; upstream: DefaultUpstream }) {
if (stats?.lastModel) {
return (
<>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
{stats.lastModel}
{stats.models.length > 1 ? (
等 {stats.models.length} 个模型
) : null}
>
);
}
if (upstream) {
return (
<>
{upstream.name} · {upstream.model ?? "模型未指定"}
>
);
}
return 未配置模型服务;
}
/** 「详情」抽屉:卡片上省略掉的内容全在这里(目标全文、能力、触发条件、预算、模型、费用、失败原因、最近会话) */
function AgentCheckDetail({
job,
upstream,
open,
onOpenChange,
}: {
job: ScheduledJob;
upstream: DefaultUpstream;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const c = job.config as Partial;
const budget = c.budget ?? {};
const allow = c.capabilityAllowlist ?? [];
const stats = job.agentRuns;
const runs = stats?.recent ?? [];
const budgetParts = [
budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null,
budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null,
budget.maxTokens ? `${formatTokens(budget.maxTokens)} token` : null,
budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null,
].filter(Boolean);
return (
{job.lastStatus === "failed" && job.lastError ? (
失败原因:{job.lastError}
) : null}
{c.goalTemplate ? : "—"}
{allow.length ? (
{allow.map((id) => (
{capTitle(id)}
))}
) : (
"全部只读能力"
)}
{c.allowRecover ? · 允许授权内恢复 : null}
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
{c.reportAsDecision ? · 生成巡检报告 : null}
{budgetParts.length ? budgetParts.join(" · ") : "默认"}
{stats?.count ? (
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
) : null}
{runs.length ? (
最近会话
{runs.slice(0, 3).map((r) => {
const st = agentRunLabel(r.status);
return (
-
{r.status === "succeeded" ? null : {st.label}}
{formatRelative(r.finishedAt ?? r.createdAt)}
{plainSummary(r.summary ?? r.goal)}
);
})}
) : null}
);
}