305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { type AgentCheckConfig, 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;
|
|
/** 上游的默认模型;上游没填时是模型目录的第一个(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");
|
|
|
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<>
|
|
<dt className="text-meta text-muted-foreground">{label}</dt>
|
|
<dd className="min-w-0 text-caption text-foreground">{children}</dd>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<HTMLElement>(null);
|
|
useEffect(() => {
|
|
if (highlight) ref.current?.scrollIntoView({ block: "center" });
|
|
}, [highlight]);
|
|
|
|
const c = job.config as Partial<AgentCheckConfig>;
|
|
const budget = c.budget ?? {};
|
|
const allow = c.capabilityAllowlist ?? [];
|
|
const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null;
|
|
// 费用 / 模型由后端按最近 100 次会话汇总(GET /schedules 的 agentRuns)
|
|
const stats = job.agentRuns;
|
|
const runs = stats?.recent ?? [];
|
|
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 (
|
|
<article
|
|
ref={ref}
|
|
id={`job-${job.id}`}
|
|
aria-label={job.name}
|
|
className={cn(
|
|
"pg-slab grid min-w-0 scroll-mt-20 grid-cols-[minmax(0,1fr)] content-start gap-3 rounded-xl px-4 py-3.5",
|
|
highlight && "ring-2 ring-ring/50",
|
|
)}
|
|
>
|
|
<header className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="truncate text-lead font-medium text-foreground" title={job.name}>
|
|
{job.name}
|
|
</h3>
|
|
{job.description ? (
|
|
<p className="mt-0.5 line-clamp-2 text-meta text-muted-foreground">{job.description}</p>
|
|
) : null}
|
|
</div>
|
|
<Switch
|
|
aria-label={`${job.enabled ? "停用" : "启用"}${job.name}`}
|
|
checked={job.enabled && job.available}
|
|
disabled={!job.available || a.update.isPending}
|
|
onCheckedChange={a.setEnabled}
|
|
/>
|
|
</header>
|
|
|
|
<div className="rounded-tight border border-border/70 px-2.5 py-2">
|
|
<p className="line-clamp-3 text-caption text-foreground" title={c.goalTemplate}>
|
|
{c.goalTemplate ?? "—"}
|
|
</p>
|
|
</div>
|
|
|
|
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
|
<Field label="能力">
|
|
{allow.length ? (
|
|
<span className="flex flex-wrap gap-1">
|
|
{allow.slice(0, MAX_CAPS).map((id) => (
|
|
<span
|
|
key={id}
|
|
title={id}
|
|
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
|
|
>
|
|
{capTitle(id)}
|
|
</span>
|
|
))}
|
|
{allow.length > MAX_CAPS ? (
|
|
<span
|
|
className="px-1 text-meta text-muted-foreground"
|
|
title={allow.slice(MAX_CAPS).map(capTitle).join("、")}
|
|
>
|
|
+{allow.length - MAX_CAPS}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
) : (
|
|
"全部只读能力"
|
|
)}
|
|
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
|
|
</Field>
|
|
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
|
|
<Field label="上游">
|
|
{stats?.lastModel ? (
|
|
<span className="break-words" title={modelsTitle(stats.models)}>
|
|
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
|
|
{stats.lastModel}
|
|
{stats.models.length > 1 ? (
|
|
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
|
|
) : null}
|
|
</span>
|
|
) : upstream ? (
|
|
<span
|
|
className="break-words"
|
|
title={
|
|
upstream.modelFromCatalog
|
|
? "上游没有指定默认模型,运行时使用模型目录里的第一个"
|
|
: "还没有运行记录,按默认上游显示"
|
|
}
|
|
>
|
|
默认上游 {upstream.name} · {upstream.model ?? "模型未指定"}
|
|
{upstream.modelFromCatalog ? <span className="text-muted-foreground">(目录首个)</span> : null}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">未配置 Agent 上游</span>
|
|
)}
|
|
</Field>
|
|
<Field label="触发条件">
|
|
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
|
|
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
|
|
</Field>
|
|
<Field label="间隔">
|
|
<IntervalSelect
|
|
label={`${job.name}间隔`}
|
|
schedule={job.schedule}
|
|
disabled={!job.available}
|
|
pending={a.update.isPending}
|
|
onSave={a.setSchedule}
|
|
/>
|
|
</Field>
|
|
<Field label="下次">
|
|
<span className="text-meta">
|
|
<NextRun job={job} now={now} />
|
|
</span>
|
|
</Field>
|
|
<Field label="每次费用">
|
|
{stats?.count ? (
|
|
<span className="tabular-nums">
|
|
上次 {formatUsd(stats.lastCostUsd)}
|
|
<span className="text-muted-foreground">
|
|
{" "}
|
|
· 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">还没有会话</span>
|
|
)}
|
|
</Field>
|
|
<Field label="最近结果">
|
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
|
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
|
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
|
</span>
|
|
</Field>
|
|
</dl>
|
|
|
|
{job.lastStatus === "failed" && job.lastError ? (
|
|
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
|
<summary className="cursor-pointer truncate group-open:whitespace-normal">失败原因:{job.lastError}</summary>
|
|
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
|
</details>
|
|
) : null}
|
|
|
|
{runs.length ? (
|
|
<section aria-label="最近会话" className="grid min-w-0 gap-1 border-t border-border/70 pt-2.5">
|
|
<h4 className="text-meta text-muted-foreground">最近会话</h4>
|
|
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
|
|
{runs.slice(0, 3).map((r) => {
|
|
const s = agentRunLabel(r.status);
|
|
return (
|
|
<li key={r.id} className="min-w-0">
|
|
<Link
|
|
to={consolePath("agent", r.id)}
|
|
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
|
>
|
|
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
|
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
|
|
{plainSummary(r.summary ?? r.goal)}
|
|
</span>
|
|
<span className="shrink-0 tabular-nums">{formatUsd(r.costUsd)}</span>
|
|
<span className="shrink-0" title={r.createdAt}>
|
|
{formatRelative(r.finishedAt ?? r.createdAt)}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
|
|
<footer className="flex flex-wrap items-center gap-1.5">
|
|
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
|
|
{a.runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
|
|
立即执行
|
|
</Button>
|
|
<Button size="sm" variant="ghost" disabled={!a.canSkip} onClick={a.skipNext}>
|
|
<SkipForward />
|
|
跳过本次
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => onHistory(job)}>
|
|
<History />
|
|
运行记录
|
|
</Button>
|
|
<span className="ml-auto flex items-center gap-1">
|
|
{job.customized ? (
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
aria-label="恢复默认"
|
|
title="恢复默认"
|
|
disabled={a.update.isPending}
|
|
onClick={a.reset}
|
|
>
|
|
<RotateCcw />
|
|
</Button>
|
|
) : null}
|
|
<Button size="icon-sm" variant="ghost" aria-label="编辑巡检" title="编辑巡检" onClick={() => onEdit(job)}>
|
|
<Pencil />
|
|
</Button>
|
|
{!job.builtin ? (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button size="icon-sm" variant="ghost" aria-label="删除巡检" title="删除巡检">
|
|
<Trash2 />
|
|
</Button>
|
|
}
|
|
title={`删除「${job.name}」?`}
|
|
description="运行记录一并删除,已产生的 Agent 会话保留。"
|
|
confirmLabel="删除巡检"
|
|
destructive
|
|
onConfirm={() =>
|
|
del.mutateAsync({ id: job.id }).then(
|
|
() => toast.success("已删除"),
|
|
(e) => {
|
|
toast.error(errorMessage(e));
|
|
throw e;
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
) : null}
|
|
</span>
|
|
</footer>
|
|
</article>
|
|
);
|
|
}
|