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:
1 parent
1a2ef0a537
commit
cd33aaeded
17 files changed
+1511
-57
No files matched your search
@@ -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 && (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user