- 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]>
292 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|