Files
UHD_Media_Tool/apps/web/app/components/schedules/agent-check-dialog.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

292 lines
11 KiB
TypeScript

import {
AgentCheckConfig,
type AgentCheckTrigger,
CAPABILITY_CATALOG,
type KnownCapabilityId,
MIN_INTERVAL_MS,
type ScheduledJob,
} from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useCreateAgentCheck, useUpdateSchedule } from "~/api/schedules.ts";
import { FormField } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { CHECK_TRIGGER_LABEL, splitInterval, UNIT_MS } from "./labels.ts";
/** 巡检只开放只读 / 预览 / 提案类能力 */
const READONLY_CAPS = Object.values(CAPABILITY_CATALOG).filter((c) =>
(["read", "preview", "propose"] as string[]).includes(c.kind),
);
const PLACEHOLDERS = "{{now}} {{date}} {{openDecisions}} {{stalledDownloads}} {{activeSubscriptions}}";
type Unit = "m" | "h";
type Draft = {
name: string;
goal: string;
caps: string[];
maxToolCalls: string;
maxDurationSec: string;
trigger: AgentCheckTrigger;
every: string;
unit: Unit;
reportAsDecision: boolean;
allowRecover: boolean;
};
function initial(job: ScheduledJob | null): Draft {
const c = (job?.config ?? {}) as Partial<AgentCheckConfig>;
let every = "6";
let unit: Unit = "h";
if (job?.schedule.type === "interval") {
const s = splitInterval(job.schedule.everyMs);
if (s.unit === "h") every = String(s.value);
else {
every = String(Math.max(1, Math.round(job.schedule.everyMs / UNIT_MS.m)));
unit = "m";
}
}
return {
name: job?.name ?? "",
goal: c.goalTemplate ?? "",
caps: c.capabilityAllowlist ?? [],
maxToolCalls: c.budget?.maxToolCalls ? String(c.budget.maxToolCalls) : "20",
maxDurationSec: c.budget?.maxDurationSec ? String(c.budget.maxDurationSec) : "300",
trigger: c.triggerCondition ?? "always",
every,
unit,
reportAsDecision: c.reportAsDecision ?? false,
allowRecover: c.allowRecover ?? false,
};
}
/** 新建 / 编辑 Agent 巡检。内置巡检名称不可改,自定义巡检可改名 */
export function AgentCheckDialog({
open,
onOpenChange,
job,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
job: ScheduledJob | null;
}) {
const [d, setD] = useState<Draft>(() => initial(job));
const [errors, setErrors] = useState<Record<string, string>>({});
const create = useCreateAgentCheck();
const update = useUpdateSchedule();
const pending = create.isPending || update.isPending;
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v }));
const cronJob = job?.schedule.type === "cron";
const submit = () => {
const e: Record<string, string> = {};
if (!job?.builtin && !d.name.trim()) e.name = "名称不能为空";
const everyMs = Math.round(Number(d.every) * UNIT_MS[d.unit]);
if (!cronJob && (!Number.isFinite(everyMs) || everyMs < MIN_INTERVAL_MS)) e.every = "填正数";
const tools = Number(d.maxToolCalls);
const secs = Number(d.maxDurationSec);
if (!Number.isInteger(tools) || tools <= 0) e.maxToolCalls = "填正整数";
if (!Number.isInteger(secs) || secs <= 0) e.maxDurationSec = "填正整数";
const cfg = AgentCheckConfig.safeParse({
goalTemplate: d.goal,
capabilityAllowlist: d.caps,
budget: { maxToolCalls: tools, maxDurationSec: secs },
triggerCondition: d.trigger,
allowRecover: d.allowRecover,
reportAsDecision: d.reportAsDecision,
});
if (!cfg.success && cfg.error.issues.some((i) => i.path[0] === "goalTemplate")) e.goal = "目标至少 4 个字";
if (Object.keys(e).length || !cfg.success) {
setErrors(Object.keys(e).length ? e : { goal: cfg.error?.issues[0]?.message ?? "配置不合法" });
return;
}
setErrors({});
const schedule = { type: "interval" as const, everyMs };
const done = {
onSuccess: () => {
toast.success(job ? "已保存" : "巡检已创建");
onOpenChange(false);
},
onError: (err: unknown) => toast.error(errorMessage(err)),
};
if (job)
update.mutate(
{
id: job.id,
body: {
config: cfg.data,
...(cronJob ? {} : { schedule }),
...(!job.builtin && d.name.trim() !== job.name ? { name: d.name.trim() } : {}),
},
},
done,
);
else create.mutate({ name: d.name.trim(), schedule, config: cfg.data, enabled: false }, done);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{job ? "编辑巡检" : "新建巡检"}</DialogTitle>
<DialogDescription>到点按目标发起一次 Agent 会话,只在白名单和预算内执行。</DialogDescription>
</DialogHeader>
<form
className="grid gap-1"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<FormField label="名称" htmlFor="ac-name" required={!job?.builtin} error={errors.name}>
<Input
id="ac-name"
value={d.name}
disabled={!!job?.builtin}
maxLength={60}
aria-invalid={!!errors.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<FormField label="目标" htmlFor="ac-goal" required error={errors.goal} hint={`可用占位符:${PLACEHOLDERS}`}>
<Textarea
id="ac-goal"
rows={4}
value={d.goal}
aria-invalid={!!errors.goal}
placeholder="检查 {{openDecisions}} 条待处理事项,给出处理建议"
onChange={(e) => set("goal", e.target.value)}
/>
</FormField>
<FormField label="能力白名单" hint="不选则放行全部只读能力">
<div className="grid max-h-48 gap-1 overflow-y-auto rounded-tight border border-border/70 px-2.5 py-2 sm:grid-cols-2">
{READONLY_CAPS.map((c) => {
const on = d.caps.includes(c.id);
return (
<label
key={c.id}
className="flex cursor-pointer items-center gap-2 text-caption"
title={c.description}
>
<input
type="checkbox"
className="h-4 w-4 accent-current"
checked={on}
onChange={() =>
set("caps", on ? d.caps.filter((x) => x !== c.id) : [...d.caps, c.id as KnownCapabilityId])
}
/>
<span className="truncate">{c.title}</span>
<code className="truncate text-meta text-muted-foreground">{c.id}</code>
</label>
);
})}
</div>
</FormField>
<div className="grid gap-x-3 sm:grid-cols-2">
<FormField label="最多工具调用" htmlFor="ac-tools" error={errors.maxToolCalls}>
<Input
id="ac-tools"
type="number"
min={1}
value={d.maxToolCalls}
aria-invalid={!!errors.maxToolCalls}
onChange={(e) => set("maxToolCalls", e.target.value)}
/>
</FormField>
<FormField label="最长时长(秒)" htmlFor="ac-secs" error={errors.maxDurationSec}>
<Input
id="ac-secs"
type="number"
min={1}
value={d.maxDurationSec}
aria-invalid={!!errors.maxDurationSec}
onChange={(e) => set("maxDurationSec", e.target.value)}
/>
</FormField>
<FormField label="触发条件" htmlFor="ac-trigger" hint="条件不满足时跳过,不唤醒模型">
<Select value={d.trigger} onValueChange={(v) => set("trigger", v as AgentCheckTrigger)}>
<SelectTrigger id="ac-trigger" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{(Object.keys(CHECK_TRIGGER_LABEL) as AgentCheckTrigger[]).map((k) => (
<SelectItem key={k} value={k}>
{CHECK_TRIGGER_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label="间隔"
htmlFor="ac-every"
error={errors.every}
hint={cronJob ? "cron 表达式在卡片上修改" : undefined}
>
<div className="flex gap-2">
<Input
id="ac-every"
type="number"
min={1}
disabled={cronJob}
value={d.every}
aria-invalid={!!errors.every}
onChange={(e) => set("every", e.target.value)}
/>
<Select value={d.unit} disabled={cronJob} onValueChange={(v) => set("unit", v as Unit)}>
<SelectTrigger className="w-24" aria-label="单位">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="m">分钟</SelectItem>
<SelectItem value="h">小时</SelectItem>
</SelectContent>
</Select>
</div>
</FormField>
</div>
<div className="grid gap-2 pb-2">
<label htmlFor="ac-report" className="flex items-center justify-between gap-3 text-caption">
<span>
写入待处理
<span className="ml-2 text-meta text-muted-foreground">结果汇总成一条待处理事项</span>
</span>
<Switch id="ac-report" checked={d.reportAsDecision} onCheckedChange={(v) => set("reportAsDecision", v)} />
</label>
<label htmlFor="ac-recover" className="flex items-center justify-between gap-3 text-caption">
<span>
允许授权内恢复
<span className="ml-2 text-meta text-muted-foreground">需有效持续授权,否则只读</span>
</span>
<Switch id="ac-recover" checked={d.allowRecover} onCheckedChange={(v) => set("allowRecover", v)} />
</label>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button type="submit" disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
{job ? "保存" : "创建巡检"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}