feat(定时任务): 必须生成HTML的「参考收藏页面」加入 no.1/no.2 模板作为风格参考
- 引擎 template_fill.style_reference:抽取模板 <style> CSS + 去脚本结构骨架(~13-28KB), 剥掉巨大数据常量,供 require_html 渲染模型模仿风格;reference_template_id 解析 tpl:<id> - 调度器 _run_html_page_attempt:参考来源为 tpl:<id> 时用模板风格参考,否则走收藏 store - 前端 ScheduledTasksPage:require_html 模式下「参考收藏页面」列表加入「模板:no.1/no.2」, 值存 tpl:<id>;htmlTemplates 在 require_html 时也拉取 - 文档同步 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ae3788a87d
commit
ec60b77a71
4 files changed
+86
-10
No files matched your search
@@ -341,7 +341,10 @@ export default function ScheduledTasksPage() {
|
||||
// 模板填充模式:选中内置「模板数据转换器」agent。此时表单露出 HTML 模板下拉,
|
||||
// 并隐藏「必须生成 HTML/Markdown」开关(本模式必产出 HTML)。
|
||||
const isTemplateAgent = form.execution_agent_name === TEMPLATE_BUILDER_AGENT_ID;
|
||||
const { data: htmlTemplates = [] } = useHtmlTemplates(isTemplateAgent);
|
||||
// 模板清单:模板填充模式需要(选模板),require_html 模式也需要(作为「参考收藏页面」选项)。
|
||||
const { data: htmlTemplates = [] } = useHtmlTemplates(
|
||||
isTemplateAgent || form.require_html,
|
||||
);
|
||||
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
|
||||
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
|
||||
const templateDisplayOnly =
|
||||
@@ -1305,6 +1308,30 @@ export default function ScheduledTasksPage() {
|
||||
>
|
||||
不参考(默认)
|
||||
</button>
|
||||
{/* 内置 HTML 模板作为风格参考:值存为 ``tpl:<id>``,生成时参照其 CSS/版式。 */}
|
||||
{htmlTemplates.map((tpl) => {
|
||||
const refValue = `tpl:${tpl.id}`;
|
||||
const selected = form.html_reference_favorite_id === refValue;
|
||||
return (
|
||||
<button
|
||||
key={refValue}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
html_reference_favorite_id: refValue,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
"rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
|
||||
selected && "bg-accent",
|
||||
)}
|
||||
title={`参照模板「${tpl.name}」的视觉风格与版式`}
|
||||
>
|
||||
模板:{tpl.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{htmlFavorites.map((fav) => {
|
||||
const selected = form.html_reference_favorite_id === fav.id;
|
||||
return (
|
||||
|
||||
@@ -300,7 +300,7 @@ Two on-demand/automatic refresh paths layer on top:
|
||||
|
||||
**Unified output switches (`require_html` + `require_markdown`).** The task form no longer splits "???? / HTML ??" into separate kinds. `execution_context` now carries two **independent** booleans that may both be on: `require_html` (???? HTML) and `require_markdown` (???? Markdown). `_task_requires_html()` returns true for `require_html` **or** legacy `task_kind == "html_page"` (so old tasks need no data migration), and `_run_task_attempt()` dispatches to `_run_html_page_attempt` when it is true, else the regular agent attempt. When **both** switches are on, `_gather_html_data()` returns `(text, files)` ? the gather agent run is additionally told to save a `.md` file ? and `_run_html_page_attempt` validates it (`MarkdownRequirementError` on failure) and surfaces it alongside the generated page, so the run delivers both artifacts. The single main `prompt` doubles as the HTML page requirement (`html_config.page_prompt`); there is no separate page-prompt field. Tests: `tests/test_scheduled_task_require_html.py` + the markdown-coexistence cases in `tests/test_html_page_tasks.py`.
|
||||
|
||||
**Template-fill mode (JSON → 固定 HTML 模板填充).** A third execution mode, distinct from the free-form `require_html` render pipeline: convert a user-supplied JSON into a **fixed** HTML template's data structure and fill it. Triggered when `execution_context.template_config.template_id` is set (chosen in the task form after selecting the built-in **「模板数据转换器」** agent `template-json-builder`). `_task_template_id()` reads it and `_run_task_attempt()` dispatches to `_run_template_fill_attempt` **before** the `require_html` check. Pipeline: take the source (the task `prompt`, pasted or uploaded .json) — which may be **pure JSON, plain text, or text-with-embedded-JSON** (`template_fill.extract_embedded_json` pre-extracts any embedded `{…}`/`[…]` with a string-aware balanced scan and surfaces it as a labeled block while keeping the full original text; plain text → no block, the model reads it semantically) → load the selected agent's `SOUL.md`+model and run a **direct** `_invoke_conversion_model` call (system=SOUL, human=conversion prompt carrying the template's compact **schema skeleton** + the recognized-JSON block + source) → `extract_json_object` → `template_fill.validate_data` (top-level keys + container types) → **on failure feed the structure errors + `field_coverage_gaps` back and retry up to `_MAX_TEMPLATE_FILL_RETRIES`=2** → `template_fill.fill_template` replaces the template's `const DATA={…}` line (and refreshes `MANIFEST.系统.更新时间`) → archive `index.html` + persist a `scheduled_html_pages` row → return `format:"html"` (reusing the existing HTML preview at `/runs/{id}/html`). Any terminal failure raises `TemplateFillError` → run marked failed (no retry, message shown). **Pure engine** `deerflow/runtime/scheduler/template_fill.py` (harness, no `app.*`): the template registry (`TEMPLATES`: id=`no1` name=`no.1` 台湾滨海防卫大屏, and id=`no2` name=`no.2` 台湾飞行部队平台 with **`convertible=False`** — a 「只展示」 template that renders its existing HTML **as-is**, no model/validation/fill, until conversion is wired in later; `_run_template_fill_attempt` short-circuits to `read_template_text` for it, and `list_templates`/`is_convertible` expose the flag so the form hides the JSON input), `list_templates`/`reference_data`/`schema_skeleton_json`/`validate_data`/`field_coverage_gaps`/`fill_template` — **the template's own embedded `DATA` is the schema source of truth** (parsed at load, 1-sample-per-array skeleton fed to the LLM, top-level structure as the validation basis); template HTML files live under `runtime/scheduler/templates/<id>/`, add a `TemplateSpec` to register a no.2. The agent is seeded like the AI-writing ones: `app/gateway/routers/_template_builder_seed.py` + `_template_builder_seed_assets/template-json-builder/{config.yaml,SOUL.md}`, called from the lifespan before `_sync_legacy_agents` (which upserts it into the `agents` table as built-in/published so it shows in `/api/agents` → the task form's 执行智能体 dropdown). Template list API: `GET /api/scheduled-tasks/html-templates` → `{templates:[{id,name,description,convertible}]}` (open). **Debug-render API** `POST /api/scheduled-tasks/template-fill/render` `{template_id, text?|data?}` → `{ok, display_only?, errors, gaps, html}` (open): no model call — `extract_embedded_json(text)` (or `data`) → `validate_data` → `fill_template`, returning the filled HTML **plus** the validation errors/field-gaps (fills even when invalid so you can see the effect). Powers the chat-page debug button below; non-convertible templates return their raw HTML. Frontend: when the agent dropdown = `template-json-builder` the form hides the HTML/Markdown switches and shows a 「HTML模板」 select (`useHtmlTemplates`) + a .json upload (reads file text into the prompt box). Tests: `tests/test_template_fill.py`, `tests/test_template_fill_attempt.py`, `tests/test_template_builder_seed.py`.
|
||||
**Template-fill mode (JSON → 固定 HTML 模板填充).** A third execution mode, distinct from the free-form `require_html` render pipeline: convert a user-supplied JSON into a **fixed** HTML template's data structure and fill it. Triggered when `execution_context.template_config.template_id` is set (chosen in the task form after selecting the built-in **「模板数据转换器」** agent `template-json-builder`). `_task_template_id()` reads it and `_run_task_attempt()` dispatches to `_run_template_fill_attempt` **before** the `require_html` check. Pipeline: take the source (the task `prompt`, pasted or uploaded .json) — which may be **pure JSON, plain text, or text-with-embedded-JSON** (`template_fill.extract_embedded_json` pre-extracts any embedded `{…}`/`[…]` with a string-aware balanced scan and surfaces it as a labeled block while keeping the full original text; plain text → no block, the model reads it semantically) → load the selected agent's `SOUL.md`+model and run a **direct** `_invoke_conversion_model` call (system=SOUL, human=conversion prompt carrying the template's compact **schema skeleton** + the recognized-JSON block + source) → `extract_json_object` → `template_fill.validate_data` (top-level keys + container types) → **on failure feed the structure errors + `field_coverage_gaps` back and retry up to `_MAX_TEMPLATE_FILL_RETRIES`=2** → `template_fill.fill_template` replaces the template's `const DATA={…}` line (and refreshes `MANIFEST.系统.更新时间`) → archive `index.html` + persist a `scheduled_html_pages` row → return `format:"html"` (reusing the existing HTML preview at `/runs/{id}/html`). Any terminal failure raises `TemplateFillError` → run marked failed (no retry, message shown). **Pure engine** `deerflow/runtime/scheduler/template_fill.py` (harness, no `app.*`): the template registry (`TEMPLATES`: id=`no1` name=`no.1` 台湾滨海防卫大屏, and id=`no2` name=`no.2` 台湾飞行部队平台 with **`convertible=False`** — a 「只展示」 template that renders its existing HTML **as-is**, no model/validation/fill, until conversion is wired in later; `_run_template_fill_attempt` short-circuits to `read_template_text` for it, and `list_templates`/`is_convertible` expose the flag so the form hides the JSON input), `list_templates`/`reference_data`/`schema_skeleton_json`/`validate_data`/`field_coverage_gaps`/`fill_template` — **the template's own embedded `DATA` is the schema source of truth** (parsed at load, 1-sample-per-array skeleton fed to the LLM, top-level structure as the validation basis); template HTML files live under `runtime/scheduler/templates/<id>/`, add a `TemplateSpec` to register a no.2. The agent is seeded like the AI-writing ones: `app/gateway/routers/_template_builder_seed.py` + `_template_builder_seed_assets/template-json-builder/{config.yaml,SOUL.md}`, called from the lifespan before `_sync_legacy_agents` (which upserts it into the `agents` table as built-in/published so it shows in `/api/agents` → the task form's 执行智能体 dropdown). Template list API: `GET /api/scheduled-tasks/html-templates` → `{templates:[{id,name,description,convertible}]}` (open). **Debug-render API** `POST /api/scheduled-tasks/template-fill/render` `{template_id, text?|data?}` → `{ok, display_only?, errors, gaps, html}` (open): no model call — `extract_embedded_json(text)` (or `data`) → `validate_data` → `fill_template`, returning the filled HTML **plus** the validation errors/field-gaps (fills even when invalid so you can see the effect). Powers the chat-page debug button below; non-convertible templates return their raw HTML. **Templates as `require_html` style references**: the 参考收藏页面 list in the task form also offers the templates (value `tpl:<id>`, e.g. `tpl:no1`); when one is picked, `_run_html_page_attempt` calls `template_fill.style_reference(id)` (extracts the `<style>` CSS + script-stripped body skeleton, ~13–28KB — the giant DATA/scripts are dropped) and feeds it as `reference_style`/`reference_layout` so the free-form HTML render mimics the template's look. `template_fill.reference_template_id("tpl:no1")→"no1"` distinguishes a template marker from a real favorite id. Frontend: when the agent dropdown = `template-json-builder` the form hides the HTML/Markdown switches and shows a 「HTML模板」 select (`useHtmlTemplates`) + a .json upload (reads file text into the prompt box). Tests: `tests/test_template_fill.py`, `tests/test_template_fill_attempt.py`, `tests/test_template_builder_seed.py`.
|
||||
|
||||
### Per-user Prompts & UI Preferences
|
||||
|
||||
|
||||
+19
-8
@@ -1210,19 +1210,30 @@ class ScheduledTaskService:
|
||||
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
|
||||
)
|
||||
|
||||
# 2) Load reference style/layout from a favorite if one was selected.
|
||||
# 2) Load reference style/layout. 参考来源有两种:
|
||||
# - ``tpl:<id>``:内置 HTML 模板(no.1/no.2),抽其 CSS+结构骨架当风格参考;
|
||||
# - 普通 id:用户在执行记录里收藏的页面(html store)。
|
||||
reference_style = None
|
||||
reference_layout = None
|
||||
if reference_favorite_id:
|
||||
store = self._html_store
|
||||
if store is not None:
|
||||
tpl_id = template_fill.reference_template_id(str(reference_favorite_id))
|
||||
if tpl_id is not None:
|
||||
try:
|
||||
favorite = await store.get_favorite(str(reference_favorite_id))
|
||||
ref = template_fill.style_reference(tpl_id)
|
||||
reference_style = ref.get("style") or None
|
||||
reference_layout = ref.get("layout") or None
|
||||
except Exception:
|
||||
favorite = None
|
||||
if favorite is not None:
|
||||
reference_style = favorite.get("reuse_prompt") or favorite.get("style_summary")
|
||||
reference_layout = favorite.get("layout_summary")
|
||||
logger.warning("加载模板样式参考失败:%s", reference_favorite_id, exc_info=True)
|
||||
else:
|
||||
store = self._html_store
|
||||
if store is not None:
|
||||
try:
|
||||
favorite = await store.get_favorite(str(reference_favorite_id))
|
||||
except Exception:
|
||||
favorite = None
|
||||
if favorite is not None:
|
||||
reference_style = favorite.get("reuse_prompt") or favorite.get("style_summary")
|
||||
reference_layout = favorite.get("layout_summary")
|
||||
|
||||
# 3) Render with the chosen model, injecting any enabled HTML skills.
|
||||
skill_instructions = self._collect_html_skill_instructions()
|
||||
|
||||
+38
@@ -354,3 +354,41 @@ def _deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> None:
|
||||
_deep_merge(base[k], v)
|
||||
else:
|
||||
base[k] = v
|
||||
|
||||
|
||||
# ── 作为 require_html 渲染的「风格参考」───────────────────────────────────────────
|
||||
# 「参考收藏页面」下拉里把模板选项的值存成 ``tpl:<id>``,与用户收藏(普通 id)区分。
|
||||
REFERENCE_PREFIX = "tpl:"
|
||||
|
||||
_STYLE_RE = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE)
|
||||
_SCRIPT_BLOCK_RE = re.compile(r"<script[^>]*>.*?</script>", re.DOTALL | re.IGNORECASE)
|
||||
_BODY_RE = re.compile(r"<body[^>]*>(.*?)</body>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
def reference_template_id(reference: str | None) -> str | None:
|
||||
"""``tpl:no1`` → ``no1``;非模板参考(普通收藏 id / 空)返回 None。"""
|
||||
if isinstance(reference, str) and reference.startswith(REFERENCE_PREFIX):
|
||||
return reference[len(REFERENCE_PREFIX) :] or None
|
||||
return None
|
||||
|
||||
|
||||
def style_reference(template_id: str, *, max_style: int = 24000, max_layout: int = 6000) -> dict[str, str]:
|
||||
"""抽取模板的紧凑「风格/版式参考」(``<style>`` CSS + 去脚本的静态结构骨架)。
|
||||
|
||||
模板内嵌的巨大 DATA/脚本会被剥掉,只留视觉相关的 CSS 与 body 结构,供
|
||||
require_html 的渲染模型**模仿其风格**,而不会把整份(几百 KB~MB 的)模板塞进 prompt。
|
||||
"""
|
||||
text = read_template_text(template_id)
|
||||
styles = _STYLE_RE.findall(text)
|
||||
css = "\n".join(s.strip() for s in styles).strip()
|
||||
if len(css) > max_style:
|
||||
css = css[:max_style] + "\n/* …(CSS 截断) */"
|
||||
layout = ""
|
||||
body = _BODY_RE.search(text)
|
||||
if body is not None:
|
||||
skeleton = _SCRIPT_BLOCK_RE.sub("", body.group(1))
|
||||
skeleton = re.sub(r"\n[ \t]*\n+", "\n", skeleton).strip()
|
||||
if len(skeleton) > max_layout:
|
||||
skeleton = skeleton[:max_layout] + "\n<!-- …(结构截断) -->"
|
||||
layout = skeleton
|
||||
return {"style": css, "layout": layout}
|
||||
Reference in new issue
Block a user