feat(web): 计划页智能体巡检卡片统一尺寸,多出内容省略、详情抽屉看全文(FIX-9)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
cdff88dcc9
commit
c67fd96639
2 files changed
+197
-134
No files matched your search
@@ -1,13 +1,13 @@
|
||||
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 { 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 { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -19,10 +19,8 @@ import { IntervalSelect } from "./interval-select.tsx";
|
||||
import { CHECK_TRIGGER_LABEL, formatUsd, goalTemplateParts, RUN_STATUS } from "./labels.ts";
|
||||
|
||||
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
|
||||
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
|
||||
|
||||
/** 卡片上能力白名单最多列几个,其余折成"+N" */
|
||||
const MAX_CAPS = 6;
|
||||
// 卡片统一尺寸(FIX-9):名称 / 描述 / 目标各一行省略,最近汇总(或失败原因)固定 2 行,模型与费用一行,
|
||||
// 操作按钮贴底,网格按行等高。完整内容(目标、能力、预算、模型、最近会话)在「详情」抽屉里看。
|
||||
|
||||
/** 会话摘要是 Markdown:列表里只取纯文字 */
|
||||
const plainSummary = (t: string) =>
|
||||
@@ -44,6 +42,12 @@ export type DefaultUpstream = {
|
||||
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 (
|
||||
@@ -96,27 +100,22 @@ export function AgentCheckCard({
|
||||
if (highlight) ref.current?.scrollIntoView({ block: "center" });
|
||||
}, [highlight]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
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 ? `${formatTokens(budget.maxTokens)} token` : null,
|
||||
budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null,
|
||||
].filter(Boolean);
|
||||
const latest = stats?.recent[0];
|
||||
const failed = job.lastStatus === "failed" && !!job.lastError;
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={ref}
|
||||
id={`job-${job.id}`}
|
||||
aria-label={job.name}
|
||||
data-slot="agent-check-card"
|
||||
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",
|
||||
"pg-slab flex h-full min-w-0 scroll-mt-20 flex-col gap-3 rounded-xl px-4 py-3.5",
|
||||
highlight && "ring-2 ring-ring/50",
|
||||
)}
|
||||
>
|
||||
@@ -125,9 +124,9 @@ export function AgentCheckCard({
|
||||
<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}
|
||||
<p className="mt-0.5 h-[1lh] truncate text-meta text-muted-foreground" title={job.description ?? undefined}>
|
||||
{job.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={`${job.enabled ? "停用" : "启用"}${job.name}`}
|
||||
@@ -137,7 +136,7 @@ export function AgentCheckCard({
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-meta text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-nowrap items-center gap-x-4 text-meta whitespace-nowrap text-muted-foreground">
|
||||
<IntervalSelect
|
||||
label={`${job.name}间隔`}
|
||||
schedule={job.schedule}
|
||||
@@ -145,123 +144,59 @@ export function AgentCheckCard({
|
||||
pending={a.update.isPending}
|
||||
onSave={a.setSchedule}
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-flex min-w-0 items-center gap-1 truncate">
|
||||
下次 <NextRun job={job} now={now} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
||||
{last ? (
|
||||
job.lastStatus === "ok" ? (
|
||||
<span>上次成功</span>
|
||||
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<p className="flex min-w-0 items-center gap-2 text-meta whitespace-nowrap text-muted-foreground">
|
||||
{last ? (
|
||||
job.lastStatus === "ok" ? (
|
||||
<span>上次成功</span>
|
||||
) : (
|
||||
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
|
||||
)
|
||||
) : (
|
||||
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
|
||||
)
|
||||
) : (
|
||||
<span>还没运行过</span>
|
||||
)}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
{stats?.count ? <span className="tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
|
||||
</p>
|
||||
<span>还没运行过</span>
|
||||
)}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
</p>
|
||||
<p className="flex min-w-0 items-baseline gap-2 text-caption">
|
||||
<span className="shrink-0 text-meta text-muted-foreground">目标</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-foreground"
|
||||
title={c.goalTemplate ? goalPlain(c.goalTemplate) : undefined}
|
||||
>
|
||||
{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"line-clamp-2 h-[2lh] text-meta break-words",
|
||||
failed ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
title={failed ? (job.lastError ?? undefined) : (latest?.summary ?? undefined)}
|
||||
>
|
||||
{failed ? `失败原因:${job.lastError}` : latest ? plainSummary(latest.summary ?? latest.goal) : null}
|
||||
</p>
|
||||
<p className="flex min-w-0 items-center gap-2 text-meta whitespace-nowrap text-muted-foreground">
|
||||
<span className="min-w-0 truncate" title={stats?.lastModel ? modelsTitle(stats.models) : undefined}>
|
||||
<ModelText stats={stats} upstream={upstream} />
|
||||
</span>
|
||||
{stats?.count ? <span className="shrink-0 tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-0.5 rounded-md py-0.5 text-meta text-muted-foreground transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
详情
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
|
||||
<TechInfo title="详情" columns={1}>
|
||||
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
||||
<Field label="目标">
|
||||
<span className="line-clamp-4 break-words">
|
||||
{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}
|
||||
</span>
|
||||
</Field>
|
||||
<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="触发条件">
|
||||
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
|
||||
{c.reportAsDecision ? <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">
|
||||
{upstream.name} · {upstream.model ?? "模型未指定"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">未配置模型服务</span>
|
||||
)}
|
||||
</Field>
|
||||
{stats?.count ? (
|
||||
<Field label="费用">
|
||||
<span className="tabular-nums">
|
||||
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
|
||||
</span>
|
||||
</Field>
|
||||
) : null}
|
||||
</dl>
|
||||
{runs.length ? (
|
||||
<section aria-label="最近会话" className="grid min-w-0 gap-1">
|
||||
<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 st = 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"
|
||||
>
|
||||
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
|
||||
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
|
||||
{plainSummary(r.summary ?? r.goal)}
|
||||
</span>
|
||||
<span className="shrink-0" title={r.createdAt}>
|
||||
{formatRelative(r.finishedAt ?? r.createdAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</TechInfo>
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-1.5">
|
||||
<footer className="mt-auto 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 />}
|
||||
立即执行
|
||||
@@ -314,6 +249,134 @@ export function AgentCheckCard({
|
||||
) : null}
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
<AgentCheckDetail job={job} upstream={upstream} open={detailOpen} onOpenChange={setDetailOpen} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelText({ stats, upstream }: { stats: ScheduledJob["agentRuns"]; upstream: DefaultUpstream }) {
|
||||
if (stats?.lastModel) {
|
||||
return (
|
||||
<>
|
||||
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
|
||||
{stats.lastModel}
|
||||
{stats.models.length > 1 ? (
|
||||
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (upstream) {
|
||||
return (
|
||||
<>
|
||||
{upstream.name} · {upstream.model ?? "模型未指定"}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <span className="text-muted-foreground">未配置模型服务</span>;
|
||||
}
|
||||
|
||||
/** 「详情」抽屉:卡片上省略掉的内容全在这里(目标全文、能力、触发条件、预算、模型、费用、失败原因、最近会话) */
|
||||
function AgentCheckDetail({
|
||||
job,
|
||||
upstream,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
job: ScheduledJob;
|
||||
upstream: DefaultUpstream;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const c = job.config as Partial<AgentCheckConfig>;
|
||||
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 (
|
||||
<DetailSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={`${job.name} · 详情`}
|
||||
description={job.description ?? undefined}
|
||||
>
|
||||
{job.lastStatus === "failed" && job.lastError ? (
|
||||
<p className="rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta break-words whitespace-pre-wrap text-destructive">
|
||||
失败原因:{job.lastError}
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
||||
<Field label="目标">
|
||||
<span className="break-words">{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}</span>
|
||||
</Field>
|
||||
<Field label="能力">
|
||||
{allow.length ? (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{allow.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>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
"全部只读能力"
|
||||
)}
|
||||
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
|
||||
</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="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
|
||||
<Field label="模型">
|
||||
<span className="break-words" title={stats?.lastModel ? modelsTitle(stats.models) : undefined}>
|
||||
<ModelText stats={stats} upstream={upstream} />
|
||||
</span>
|
||||
</Field>
|
||||
{stats?.count ? (
|
||||
<Field label="费用">
|
||||
<span className="tabular-nums">
|
||||
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
|
||||
</span>
|
||||
</Field>
|
||||
) : null}
|
||||
</dl>
|
||||
{runs.length ? (
|
||||
<section aria-label="最近会话" className="grid min-w-0 gap-1">
|
||||
<h4 className="text-meta text-muted-foreground">最近会话</h4>
|
||||
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
|
||||
{runs.slice(0, 3).map((r) => {
|
||||
const st = agentRunLabel(r.status);
|
||||
return (
|
||||
<li key={r.id} className="min-w-0">
|
||||
<Link
|
||||
to={consolePath("agent", r.id)}
|
||||
className="grid min-w-0 gap-1 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
|
||||
<span title={r.createdAt}>{formatRelative(r.finishedAt ?? r.createdAt)}</span>
|
||||
</span>
|
||||
<span className="break-words text-foreground">{plainSummary(r.summary ?? r.goal)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
{checks.length === 0 ? (
|
||||
<EmptyState icon={Bot} title="还没有巡检" description="新建一个巡检,到点由智能体按目标检查并汇报。" />
|
||||
) : (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
<div className="grid auto-rows-fr gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{checks.map((j) => (
|
||||
<AgentCheckCard
|
||||
key={j.id}
|
||||
|
||||
Reference in new issue
Block a user