feat(定时任务): 内置「模板数据转换器」按 HTML 模板填充 JSON 生成大屏

- 模板填充引擎 template_fill.py:模板注册表 + schema 骨架 + 结构校验 + 行级安全填充;
  模板自带 DATA 即 schema 来源。no.1 可转换填充(含 MANIFEST 更新时间刷新),
  no.2 暂为「只展示」(convertible=False) 原样输出已有数据
- 内置 agent template-json-builder(seed + SOUL),随 lifespan 注入,自动出现在
  定时任务「执行智能体」下拉
- 调度器新增模板填充执行分支 _run_template_fill_attempt:源 JSON→大模型转换→
  结构校验→失败回喂重试;空源/只展示模板直接用模板默认数据,绝不失败
- API:GET /api/scheduled-tasks/html-templates
- 前端:选中该 agent 后条件渲染「HTML模板」下拉 + .json 上传,只展示型模板隐藏 JSON 输入
- 文档同步(前后端 CLAUDE.md)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-23 18:00:00 +08:00
1 parent 1a2ef0a537
commit cd33aaeded
17 files changed
+1511 -57

No files matched your search

+169 -40
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@@ -21,6 +21,7 @@ import {
RefreshCw,
TagsIcon,
Trash2,
Upload,
X,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
@@ -54,6 +55,7 @@ import {
type ScheduledTaskExecutionContext,
useHtmlPageFavoriteActions,
useHtmlPageFavorites,
useHtmlTemplates,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
@@ -107,6 +109,9 @@ function displayUsername(value?: string | null): string {
return at === -1 ? raw : raw.slice(0, at);
}
/** 内置「模板数据转换器」agent 的稳定 id(后端 seed)。选中它时表单露出 HTML 模板下拉。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
const emptyForm = {
name: "",
description: "",
@@ -114,6 +119,8 @@ const emptyForm = {
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
// 模板填充模式:选中「模板数据转换器」后要把源 JSON 填进哪个 HTML 模板。
template_id: "",
// Two independent output switches (may both be on): require a Markdown file
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
// 页面" tabs.
@@ -172,6 +179,25 @@ export default function ScheduledTasksPage() {
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(emptyForm);
// 模板填充模式的 .json 上传:读入文件文本填进 prompt 框(不改后端契约)。
const jsonUploadRef = useRef<HTMLInputElement | null>(null);
const handleJsonUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = ""; // 允许重复上传同名文件
if (!file) return;
try {
const text = await file.text();
// 校验是 JSON,给即时反馈;非法也照样填入让用户修正。
try {
JSON.parse(text);
} catch {
toast.error("文件不是合法 JSON,已填入文本框请检查", darkToastOpts.error);
}
setForm((value) => ({ ...value, prompt: text }));
} catch {
toast.error("读取文件失败", darkToastOpts.error);
}
};
const dialogOpen = creating || !!editing;
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
const htmlFavoriteActions = useHtmlPageFavoriteActions();
@@ -285,6 +311,7 @@ export default function ScheduledTasksPage() {
// stored that under ``html_config.page_prompt``, so prefer it on edit.
prompt: html.page_prompt || task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
template_id: task.execution_context?.template_config?.template_id ?? "",
require_markdown: !!task.execution_context?.require_markdown,
// Backward compatible: legacy HTML tasks only carry ``task_kind``.
require_html:
@@ -311,7 +338,15 @@ export default function ScheduledTasksPage() {
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const isHtmlTask = form.require_html;
// 模板填充模式:选中内置「模板数据转换器」agent。此时表单露出 HTML 模板下拉,
// 并隐藏「必须生成 HTML/Markdown」开关(本模式必产出 HTML)。
const isTemplateAgent = form.execution_agent_name === TEMPLATE_BUILDER_AGENT_ID;
const { data: htmlTemplates = [] } = useHtmlTemplates(isTemplateAgent);
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
const templateDisplayOnly =
isTemplateAgent && selectedTemplate?.convertible === false;
const isHtmlTask = !isTemplateAgent && form.require_html;
// The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
@@ -322,8 +357,22 @@ export default function ScheduledTasksPage() {
// as the page requirement; ``task_kind`` is kept in sync only for backward
// compatibility with older readers.
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
// 模板填充模式:只带 template_config,后端据此走「转换 JSON → 填充模板」分支。
if (isTemplateAgent && form.template_id) {
return {
template_config: { template_id: form.template_id },
require_html: false,
task_kind: "agent",
html_config: undefined,
};
}
if (!isHtmlTask) {
return { require_html: false, task_kind: "agent", html_config: undefined };
return {
require_html: false,
task_kind: "agent",
html_config: undefined,
template_config: undefined,
};
}
return {
require_html: true,
@@ -1110,46 +1159,126 @@ export default function ScheduledTasksPage() {
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
{isTemplateAgent && (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 p-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">HTML 模板</p>
<p className="text-muted-foreground text-xs">
{templateDisplayOnly ? (
<>选择要展示的 HTML 模板。该模板暂为「直接展示已有数据」,执行时将原样输出其现有页面。</>
) : (
<>
选择要把源数据填充进去的 HTML 模板。执行时智能体会把下方源 JSON 转换成该模板所需的结构,校验后填充生成可预览的页面。
<span className="text-foreground/70">未提供源 JSON 时,将直接展示该模板的默认数据。</span>
</>
)}
</p>
</div>
<Select
value={form.template_id || undefined}
onValueChange={(v) =>
setForm((value) => ({ ...value, template_id: v }))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择 HTML 模板" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
>
{htmlTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
<span className="block whitespace-normal break-words">
{tpl.name}
{tpl.description ? ` — ${tpl.description}` : ""}
</span>
</SelectItem>
))}
{htmlTemplates.length === 0 && (
<div className="text-muted-foreground px-2 py-1.5 text-xs">
暂无可用模板
</div>
)}
</SelectContent>
</Select>
{templateDisplayOnly ? (
<p className="text-muted-foreground text-xs">
该模板暂为「直接展示已有数据」,无需填写源 JSON;执行时将原样输出模板的现有页面。
</p>
) : (
<div className="flex items-center gap-2">
<input
ref={jsonUploadRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={handleJsonUpload}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => jsonUploadRef.current?.click()}
>
<Upload className="mr-1 size-4" />
上传 .json 文件
</Button>
<span className="text-muted-foreground text-xs">
也可直接在下方粘贴源 JSON(上传会填入下方文本框)
</span>
</div>
)}
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
)}
{!templateDisplayOnly && (
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isTemplateAgent
? "源 JSON 数据(粘贴或上传 .json),将被智能体转换并填充进所选 HTML 模板;留空则展示模板默认数据"
: isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
)}
{!isTemplateAgent && (
<>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
</>
)}
{isHtmlTask && (
<>