fix(web): 巡检目标模板的占位符显示成名称(FIX-7)
执行时 renderGoal 已正确替换(补单测);界面显示的是模板本身,{{now}} 等改成「当前时间」标签。
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
417af9f253
commit
23bd8cfe45
5 files changed
+97
-6
No files matched your search
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BUILTIN_AGENT_CHECKS, renderGoal } from "../../src/modules/schedules/agent-checks.ts";
|
||||||
|
|
||||||
|
// FIX-7:界面上看到的 {{now}} / {{openDecisions}} 是模板本身;执行时必须全部替换成实际值。
|
||||||
|
|
||||||
|
describe("renderGoal(巡检目标模板)", () => {
|
||||||
|
const now = new Date("2026-09-28T18:53:50.468Z");
|
||||||
|
const facts = { openDecisions: 3, stalledDownloads: 1, activeSubscriptions: 5 };
|
||||||
|
|
||||||
|
it("内置巡检的模板执行时不留占位符", () => {
|
||||||
|
for (const c of BUILTIN_AGENT_CHECKS) {
|
||||||
|
const goal = renderGoal(c.config.goalTemplate, now, facts);
|
||||||
|
expect(goal).not.toMatch(/\{\{/);
|
||||||
|
}
|
||||||
|
const pending = BUILTIN_AGENT_CHECKS.find((c) => c.key === "check.pending");
|
||||||
|
expect(renderGoal(pending?.config.goalTemplate ?? "", now, facts)).toContain(
|
||||||
|
"巡检待处理(2026-09-28T18:53:50.468Z):有 3 条未关闭的待处理事项",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("容许空格;不认识的占位符原样保留", () => {
|
||||||
|
expect(renderGoal("{{ date }} 待处理 {{openDecisions}} 条 {{other}}", now, facts)).toBe(
|
||||||
|
"2026-09-28 待处理 3 条 {{other}}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import { agentRunLabel, formatRelative } from "~/lib/labels.ts";
|
|||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { NextRun, useJobActions } from "./auto-task-list.tsx";
|
import { NextRun, useJobActions } from "./auto-task-list.tsx";
|
||||||
import { IntervalSelect } from "./interval-select.tsx";
|
import { IntervalSelect } from "./interval-select.tsx";
|
||||||
import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts";
|
import { CHECK_TRIGGER_LABEL, formatUsd, goalTemplateParts, RUN_STATUS } from "./labels.ts";
|
||||||
|
|
||||||
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
|
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
|
||||||
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
|
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
|
||||||
@@ -43,6 +43,27 @@ export type DefaultUpstream = {
|
|||||||
const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) =>
|
const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) =>
|
||||||
models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n");
|
models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n");
|
||||||
|
|
||||||
|
/** 目标模板:占位符显示成名称标签(执行时替换成实际值),不露 `{{now}}` */
|
||||||
|
function GoalTemplate({ template }: { template: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{goalTemplateParts(template).map((p, i) =>
|
||||||
|
"text" in p ? (
|
||||||
|
<span key={i}>{p.text}</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
title="执行时替换成实际值"
|
||||||
|
className="mx-0.5 rounded-tight bg-foreground/[0.07] px-1 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -151,8 +172,8 @@ export function AgentCheckCard({
|
|||||||
<TechInfo title="详情" columns={1}>
|
<TechInfo title="详情" columns={1}>
|
||||||
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
||||||
<Field label="目标">
|
<Field label="目标">
|
||||||
<span className="line-clamp-4 break-words" title={c.goalTemplate}>
|
<span className="line-clamp-4 break-words">
|
||||||
{c.goalTemplate ?? "—"}
|
{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}
|
||||||
</span>
|
</span>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="能力">
|
<Field label="能力">
|
||||||
|
|||||||
@@ -25,13 +25,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
|||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { CHECK_TRIGGER_LABEL, splitInterval, UNIT_MS } from "./labels.ts";
|
import { CHECK_TRIGGER_LABEL, GOAL_PLACEHOLDER_LABEL, splitInterval, UNIT_MS } from "./labels.ts";
|
||||||
|
|
||||||
/** 巡检只开放只读 / 预览 / 提案类能力 */
|
/** 巡检只开放只读 / 预览 / 提案类能力 */
|
||||||
const READONLY_CAPS = Object.values(CAPABILITY_CATALOG).filter((c) =>
|
const READONLY_CAPS = Object.values(CAPABILITY_CATALOG).filter((c) =>
|
||||||
(["read", "preview", "propose"] as string[]).includes(c.kind),
|
(["read", "preview", "propose"] as string[]).includes(c.kind),
|
||||||
);
|
);
|
||||||
const PLACEHOLDERS = "{{now}} {{date}} {{openDecisions}} {{stalledDownloads}} {{activeSubscriptions}}";
|
const PLACEHOLDERS = Object.entries(GOAL_PLACEHOLDER_LABEL)
|
||||||
|
.map(([k, v]) => `{{${k}}} ${v}`)
|
||||||
|
.join("、");
|
||||||
|
|
||||||
type Unit = "m" | "h";
|
type Unit = "m" | "h";
|
||||||
type Draft = {
|
type Draft = {
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { describeSchedule, formatUsd, INTERVAL_PRESETS, planPath, scheduleStreamPath } from "./labels.ts";
|
import {
|
||||||
|
describeSchedule,
|
||||||
|
formatUsd,
|
||||||
|
goalTemplateParts,
|
||||||
|
INTERVAL_PRESETS,
|
||||||
|
planPath,
|
||||||
|
scheduleStreamPath,
|
||||||
|
} from "./labels.ts";
|
||||||
|
|
||||||
describe("计划页标签", () => {
|
describe("计划页标签", () => {
|
||||||
|
it("巡检目标模板:占位符显示成名称(FIX-7)", () => {
|
||||||
|
expect(goalTemplateParts("巡检待处理({{now}}):有 {{ openDecisions }} 条,{{unknown}}")).toEqual([
|
||||||
|
{ text: "巡检待处理(" },
|
||||||
|
{ placeholder: "now", label: "当前时间" },
|
||||||
|
{ text: "):有 " },
|
||||||
|
{ placeholder: "openDecisions", label: "待处理事项数" },
|
||||||
|
{ text: " 条,{{unknown}}" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("planPath:节锚点与 ?job= 高亮", () => {
|
it("planPath:节锚点与 ?job= 高亮", () => {
|
||||||
expect(planPath()).toBe("/console/schedules");
|
expect(planPath()).toBe("/console/schedules");
|
||||||
expect(planPath({ section: "auto" })).toBe("/console/schedules#auto");
|
expect(planPath({ section: "auto" })).toBe("/console/schedules#auto");
|
||||||
|
|||||||
@@ -129,3 +129,28 @@ export function formatClock(iso: string | null | undefined): string {
|
|||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 巡检目标模板的占位符(服务端 renderGoal 执行时替换);界面上显示成名称,不露 `{{now}}` */
|
||||||
|
export const GOAL_PLACEHOLDER_LABEL: Record<string, string> = {
|
||||||
|
now: "当前时间",
|
||||||
|
date: "今天日期",
|
||||||
|
openDecisions: "待处理事项数",
|
||||||
|
stalledDownloads: "异常下载数",
|
||||||
|
activeSubscriptions: "追踪中的订阅数",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 目标模板拆成文字与占位符两段(纯函数,单测覆盖);不认识的占位符原样当文字 */
|
||||||
|
export function goalTemplateParts(template: string): ({ text: string } | { placeholder: string; label: string })[] {
|
||||||
|
const out: ({ text: string } | { placeholder: string; label: string })[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const m of template.matchAll(/\{\{\s*(\w+)\s*\}\}/g)) {
|
||||||
|
const key = m[1] ?? "";
|
||||||
|
const label = GOAL_PLACEHOLDER_LABEL[key];
|
||||||
|
if (!label) continue;
|
||||||
|
if (m.index > last) out.push({ text: template.slice(last, m.index) });
|
||||||
|
out.push({ placeholder: key, label });
|
||||||
|
last = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < template.length) out.push({ text: template.slice(last) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user