386 lines
17 KiB
Python
386 lines
17 KiB
Python
"""HTML 模板管理 API(模板管理页)。
|
|
|
|
后端存储 :mod:`deerflow.runtime.scheduler.template_store`(文件型,
|
|
``{base_dir}/html-templates/<id>/``),启动时把随包内置模板 seed 进去(缺失才复制)。
|
|
读对所有登录用户开放(定时任务表单下拉也要用),写(创建/编辑/上传覆盖/删除/重置)
|
|
仅管理员——与敏感词管理同一鉴权口径。旧端点 ``GET /api/scheduled-tasks/html-templates``
|
|
保持不变(内部同样读存储),本路由是它的管理超集。
|
|
|
|
结构化模板(kind="structured",如 no.1)额外暴露默认数据的读写接口:
|
|
``GET /{id}/data`` 读、``PUT /{id}/data`` 整体替换(管理页「编辑数据」的持久化入口,
|
|
替换前经转换契约校验)。``GET /{id}/content`` 默认返回组合后的完整文档(预览用),
|
|
``?raw=1`` 返回骨架原文(编辑用)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
|
|
from app.gateway.deps import get_optional_user_from_request
|
|
from deerflow.runtime.scheduler import template_fill, template_store
|
|
|
|
router = APIRouter(prefix="/api/html-templates", tags=["html-templates"])
|
|
|
|
|
|
async def _require_admin(request: Request) -> None:
|
|
"""写操作仅管理员;关闭鉴权时(request 无用户)放行,与 scheduled_tasks 口径一致。"""
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None:
|
|
return
|
|
if getattr(user, "system_role", None) != "admin":
|
|
raise HTTPException(status_code=403, detail="仅管理员可管理 HTML 模板")
|
|
|
|
|
|
def _not_found(template_id: str) -> HTTPException:
|
|
return HTTPException(status_code=404, detail=f"模板不存在:{template_id}")
|
|
|
|
|
|
class TemplateCreateRequest(BaseModel):
|
|
template_id: str = Field(..., description="模板 id(字母/数字/./-/_,创建后不可改)")
|
|
name: str = Field(..., description="显示名称,如 no.28")
|
|
description: str = ""
|
|
html: str = Field(..., description="完整 HTML 模板正文")
|
|
|
|
|
|
class TemplateUpdateRequest(BaseModel):
|
|
name: str | None = None
|
|
description: str | None = None
|
|
html: str | None = Field(default=None, description="新的模板正文(上传覆盖走这里)")
|
|
|
|
|
|
class TemplateDataUpdateRequest(BaseModel):
|
|
data: dict[str, Any] = Field(..., description="新的默认数据(整体替换 data.json,须通过契约校验)")
|
|
manifest: dict[str, Any] | None = Field(
|
|
default=None,
|
|
description="可选:新的 MANIFEST(整体替换 manifest.json,系统名称/导航页面/交互规则)",
|
|
)
|
|
|
|
|
|
class FieldVisibilityUpdateRequest(BaseModel):
|
|
hidden: list[str] = Field(default_factory=list, description="要隐藏的字段显示名列表(其余字段全部显示)")
|
|
extract_locked: list[str] | None = Field(
|
|
default=None,
|
|
description="可选:不接受抽取更新的字段名单(None=不动;给出则整体替换,空表=全部接受)",
|
|
)
|
|
|
|
|
|
class CustomModuleListUpdateRequest(BaseModel):
|
|
modules: list[dict[str, Any]] = Field(..., description="自定义模块全量列表(整体替换)")
|
|
|
|
|
|
class CustomModuleGenerateRequest(BaseModel):
|
|
page: str = Field(..., description="挂载的页面编号(manifest.页面 的 编号)")
|
|
title: str = Field(default="", description="模块标题(留空由模型起名)")
|
|
description: str = Field(..., min_length=4, description="用自然语言描述想新增的展示内容")
|
|
model_name: str | None = Field(default=None, description="可选:指定生成模型")
|
|
|
|
|
|
@router.get("")
|
|
async def list_html_templates(request: Request) -> dict:
|
|
"""模板清单(含 builtin/convertible/size/时间),供管理页与定时任务表单共用。"""
|
|
async def load() -> dict:
|
|
return {"templates": await asyncio.to_thread(template_store.list_templates)}
|
|
|
|
return await cached_catalog(request, "html_templates", "admin-list", load)
|
|
|
|
|
|
@router.get("/{template_id}")
|
|
async def get_html_template(template_id: str, request: Request) -> dict[str, Any]:
|
|
async def load() -> dict[str, Any]:
|
|
meta = await asyncio.to_thread(template_store.get_template_meta, template_id)
|
|
if meta is None:
|
|
raise _not_found(template_id)
|
|
return meta
|
|
|
|
return await cached_catalog(request, "html_templates", catalog_key("meta", template_id), load)
|
|
|
|
|
|
@router.get("/{template_id}/content")
|
|
async def get_html_template_content(
|
|
template_id: str, request: Request, raw: bool = False
|
|
) -> dict:
|
|
"""模板内容。默认返回可预览的完整文档(结构化模板 = 默认数据组合产物);
|
|
``?raw=1`` 返回正文原文(结构化模板 = 带 __PAGE_* 标记的骨架,编辑用)。"""
|
|
|
|
async def load() -> dict:
|
|
if raw:
|
|
try:
|
|
html = await asyncio.to_thread(template_store.read_template_html, template_id)
|
|
except template_store.TemplateStoreError:
|
|
raise _not_found(template_id)
|
|
else:
|
|
try:
|
|
html = await asyncio.to_thread(template_fill.read_template_text, template_id)
|
|
except template_fill.TemplateError:
|
|
raise _not_found(template_id)
|
|
return {"template_id": template_id, "html": html}
|
|
|
|
suffix = "raw" if raw else "doc"
|
|
return await cached_catalog(request, "html_templates", catalog_key("content", template_id, suffix), load)
|
|
|
|
|
|
@router.get("/{template_id}/data")
|
|
async def get_html_template_data(template_id: str, request: Request) -> dict[str, Any]:
|
|
"""结构化模板的默认数据(data.json) + MANIFEST + 各模块中文名(管理页「编辑数据」用)。"""
|
|
|
|
async def load() -> dict[str, Any]:
|
|
try:
|
|
data, manifest, modules = await asyncio.gather(
|
|
asyncio.to_thread(template_store.read_template_data, template_id),
|
|
asyncio.to_thread(template_store.read_template_manifest, template_id),
|
|
asyncio.to_thread(template_fill.structured_module_labels, template_id),
|
|
)
|
|
except (template_store.TemplateStoreError, template_fill.TemplateError) as exc:
|
|
status = 404 if "不存在" in str(exc) else 400
|
|
raise HTTPException(status_code=status, detail=str(exc))
|
|
return {
|
|
"template_id": template_id,
|
|
"data": data,
|
|
"manifest": manifest,
|
|
"modules": modules,
|
|
}
|
|
|
|
return await cached_catalog(request, "html_templates", catalog_key("data", template_id), load)
|
|
|
|
|
|
@router.put("/{template_id}/data")
|
|
async def update_html_template_data(
|
|
template_id: str, body: TemplateDataUpdateRequest, request: Request
|
|
) -> dict[str, Any]:
|
|
"""整体替换结构化模板的默认数据/MANIFEST 并持久化(仅管理员;先经契约校验)。"""
|
|
await _require_admin(request)
|
|
try:
|
|
meta = template_store.write_template_data(
|
|
template_id, body.data, manifest=body.manifest
|
|
)
|
|
except template_store.TemplateStoreError as exc:
|
|
status = 404 if "不存在" in str(exc) else 400
|
|
raise HTTPException(status_code=status, detail=str(exc))
|
|
await invalidate_catalog(request, "html_templates")
|
|
return meta
|
|
|
|
|
|
# ── 字段显示 / 自定义模块 ────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/{template_id}/fields")
|
|
async def get_html_template_fields(template_id: str, request: Request) -> dict[str, Any]:
|
|
"""字段目录(fields.json,含中文说明)× 当前可见性 + 页面清单 + 自定义模块列表。"""
|
|
|
|
async def load() -> dict[str, Any]:
|
|
try:
|
|
catalog, manifest = await asyncio.gather(
|
|
asyncio.to_thread(template_store.read_template_fields, template_id),
|
|
asyncio.to_thread(template_store.read_template_manifest, template_id),
|
|
)
|
|
except template_store.TemplateStoreError as exc:
|
|
status = 404 if "不存在" in str(exc) else 400
|
|
raise HTTPException(status_code=status, detail=str(exc))
|
|
hidden = manifest.get("字段显示")
|
|
hidden = hidden if isinstance(hidden, dict) else {}
|
|
locks = manifest.get("字段抽取关闭")
|
|
locks = locks if isinstance(locks, dict) else {}
|
|
fields = [
|
|
{
|
|
"label": str(f.get("label") or ""),
|
|
"pages": [str(p) for p in (f.get("pages") or [])],
|
|
"description": str(f.get("description") or ""),
|
|
"visible": hidden.get(str(f.get("label"))) is not False,
|
|
# 是否接受定时任务抽取数据更新(默认 True);布局分区无独立数据键,恒为 True 且前端禁用开关。
|
|
"extract": locks.get(str(f.get("label"))) is not True,
|
|
"has_key": bool(f.get("keys")),
|
|
}
|
|
for f in catalog
|
|
]
|
|
pages = [
|
|
{"key": str(p.get("编号") or ""), "label": str(p.get("名称") or p.get("编号") or "")}
|
|
for p in manifest.get("页面") or []
|
|
if isinstance(p, dict)
|
|
]
|
|
custom = manifest.get("自定义模块")
|
|
return {
|
|
"template_id": template_id,
|
|
"fields": fields,
|
|
"pages": pages,
|
|
"custom_modules": custom if isinstance(custom, list) else [],
|
|
}
|
|
|
|
return await cached_catalog(request, "html_templates", catalog_key("fields", template_id), load)
|
|
|
|
|
|
@router.put("/{template_id}/fields")
|
|
async def update_html_template_fields(
|
|
template_id: str, body: FieldVisibilityUpdateRequest, request: Request
|
|
) -> dict[str, Any]:
|
|
"""整体替换字段显示/隐藏配置(可选一并替换抽取锁定名单;仅管理员)。"""
|
|
await _require_admin(request)
|
|
try:
|
|
meta = template_store.write_template_field_visibility(template_id, body.hidden)
|
|
if body.extract_locked is not None:
|
|
template_store.write_template_field_locks(template_id, body.extract_locked)
|
|
except template_store.TemplateStoreError as exc:
|
|
status = 404 if "不存在" in str(exc) else 400
|
|
raise HTTPException(status_code=status, detail=str(exc))
|
|
await invalidate_catalog(request, "html_templates")
|
|
return meta
|
|
|
|
|
|
@router.put("/{template_id}/custom-modules")
|
|
async def update_html_template_custom_modules(
|
|
template_id: str, body: CustomModuleListUpdateRequest, request: Request
|
|
) -> dict[str, Any]:
|
|
"""自定义模块列表整体替换(入库前逐条消毒;仅管理员)。"""
|
|
await _require_admin(request)
|
|
try:
|
|
meta = template_store.write_template_custom_modules(template_id, body.modules)
|
|
except template_store.TemplateStoreError as exc:
|
|
status = 404 if "不存在" in str(exc) else 400
|
|
raise HTTPException(status_code=status, detail=str(exc))
|
|
await invalidate_catalog(request, "html_templates")
|
|
return meta
|
|
|
|
|
|
_CUSTOM_MODULE_PROMPT = """你是资深前端设计师,为知识数据展示系统(红白主题)绘制一个自定义展示模块的 HTML 片段。
|
|
|
|
主题与可直接使用的样式(不要自造 CSS,尽量复用):
|
|
- 颜色: 背景 #f5f5f6、卡片白 #fff、主红 #c8102e、深红 #8f1024、浅红底 #fff1f3、正文 #262626、次要 #737373
|
|
- 布局类: panel(白卡容器) panel-title(红色竖条标题) grid2(两列) metrics(三列指标组)
|
|
- 组件类: metric metric-label metric-value metric-unit metric-note(指标卡) | chip chip-red chip-green chip-orange(标签)
|
|
- 组件类: text-list text-item(条目列表) | info-grid info-item(信息格) | bar-row bar(横条统计) | entry-grid entry-card(入口卡)
|
|
- 也可以用少量 inline style 微调(颜色只允许上述色板)
|
|
|
|
硬性约束:
|
|
1. 只输出一个最外层 <div> 包裹的 HTML 片段,不要 <html>/<head>/<body>/<script>/<style>/<iframe>/<form>
|
|
2. 禁止任何 on* 事件属性、javascript: 链接、外部资源引用(图片/字体/CDN)
|
|
3. 内容用简体中文;基于下面的描述合理组织版式;没有的数据写「待补充」,不要编造精确数字
|
|
4. **文字极简(最重要)**:整个模块总文字量 ≤120 字;任何一条文字 ≤30 字;严禁出现大段连续正文或 <p> 长段落
|
|
5. **呈现方式**:数字/统计一律用 metric 指标卡(大数字+短标签+一句 ≤20 字注释);分类/状态用 chip 标签;多条信息用 text-item 短条目(每条一行短句);条目间用并列结构,不要写成文章
|
|
6. 只输出片段本身,不要任何解释文字或 markdown 代码块
|
|
|
|
挂载页面:{page}
|
|
模块标题:{title}
|
|
模块描述:{description}"""
|
|
|
|
|
|
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.IGNORECASE | re.DOTALL)
|
|
_THINK_TAG_RE = re.compile(r"</?think>", re.IGNORECASE)
|
|
_FENCE_HTML_RE = re.compile(r"```(?:html)?\s*(.*?)```", re.DOTALL | re.IGNORECASE)
|
|
|
|
|
|
def _extract_fragment(raw: str) -> str:
|
|
text = str(raw or "").strip()
|
|
text = _THINK_BLOCK_RE.sub("", text)
|
|
text = _THINK_TAG_RE.sub("", text).strip()
|
|
fence = _FENCE_HTML_RE.search(text)
|
|
if fence:
|
|
text = fence.group(1).strip()
|
|
body = re.search(r"<body[^>]*>(.*?)</body>", text, re.IGNORECASE | re.DOTALL)
|
|
if body:
|
|
text = body.group(1).strip()
|
|
# 思考文字混在片段前(无标签包裹)时,从第一个 "<div" 起截取。
|
|
div_at = text.find("<div")
|
|
if div_at > 0:
|
|
text = text[div_at:].strip()
|
|
return text
|
|
|
|
|
|
@router.post("/{template_id}/custom-modules/generate")
|
|
async def generate_html_template_custom_module(
|
|
template_id: str, body: CustomModuleGenerateRequest, request: Request
|
|
) -> dict[str, Any]:
|
|
"""按用户的自然语言描述调用模型生成自定义模块 HTML 片段(已消毒,未入库)。"""
|
|
await _require_admin(request)
|
|
try:
|
|
# 模板必须存在且页面编号合法,防止生成挂在不存在的页面上。
|
|
manifest = await asyncio.to_thread(template_store.read_template_manifest, template_id)
|
|
except template_store.TemplateStoreError as exc:
|
|
status = 404 if "不存在" in str(exc) else 400
|
|
raise HTTPException(status_code=status, detail=str(exc))
|
|
valid_pages = {
|
|
str(p.get("编号") or "") for p in manifest.get("页面") or [] if isinstance(p, dict)
|
|
}
|
|
if body.page not in valid_pages:
|
|
raise HTTPException(status_code=400, detail=f"未知页面编号:{body.page}")
|
|
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
from deerflow.models import create_chat_model
|
|
|
|
prompt = _CUSTOM_MODULE_PROMPT.format(
|
|
page=body.page, title=body.title or "(由你起一个简短标题)", description=body.description
|
|
)
|
|
model = create_chat_model(name=body.model_name or None, thinking_enabled=False)
|
|
response = await model.ainvoke([HumanMessage(content=prompt)])
|
|
content = getattr(response, "content", response)
|
|
if isinstance(content, list):
|
|
content = "\n".join(
|
|
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
|
|
)
|
|
fragment = template_store.sanitize_html_fragment(_extract_fragment(str(content)))
|
|
if "<div" not in fragment:
|
|
raise HTTPException(status_code=502, detail="模型未返回有效的 HTML 片段,请调整描述后重试")
|
|
return {"template_id": template_id, "page": body.page, "html": fragment}
|
|
|
|
|
|
@router.post("")
|
|
async def create_html_template(body: TemplateCreateRequest, request: Request) -> dict[str, Any]:
|
|
await _require_admin(request)
|
|
try:
|
|
meta = template_store.create_template(
|
|
template_id=body.template_id,
|
|
name=body.name,
|
|
description=body.description,
|
|
html=body.html,
|
|
)
|
|
except template_store.TemplateStoreError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
await invalidate_catalog(request, "html_templates")
|
|
return meta
|
|
|
|
|
|
@router.put("/{template_id}")
|
|
async def update_html_template(
|
|
template_id: str, body: TemplateUpdateRequest, request: Request
|
|
) -> dict[str, Any]:
|
|
await _require_admin(request)
|
|
try:
|
|
meta = template_store.update_template(
|
|
template_id,
|
|
name=body.name,
|
|
description=body.description,
|
|
html=body.html,
|
|
)
|
|
except template_store.TemplateStoreError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
await invalidate_catalog(request, "html_templates")
|
|
return meta
|
|
|
|
|
|
@router.delete("/{template_id}")
|
|
async def delete_html_template(template_id: str, request: Request) -> dict:
|
|
await _require_admin(request)
|
|
try:
|
|
template_store.delete_template(template_id)
|
|
except template_store.TemplateStoreError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
await invalidate_catalog(request, "html_templates")
|
|
return {"ok": True, "template_id": template_id}
|
|
|
|
|
|
@router.post("/{template_id}/reset")
|
|
async def reset_html_template(template_id: str, request: Request) -> dict[str, Any]:
|
|
"""把内置模板正文重置回随包原版(自建模板无原版,400)。"""
|
|
await _require_admin(request)
|
|
try:
|
|
meta = template_store.reset_template(template_id)
|
|
except template_store.TemplateStoreError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
await invalidate_catalog(request, "html_templates")
|
|
return meta
|