From ff75aefcdd0885abd0bf5cbfc193dd6c29b3c0e8 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Fri, 14 Aug 2026 15:52:06 +0800 Subject: [PATCH] docs: precipitate HTML report contract for data management --- CHANGELOG.md | 6 ++ SKILL.md | 4 +- assets/README.md | 3 +- assets/examples/README.md | 1 + .../examples/render-html.action.example.json | 28 +++++++ assets/html/example-report.html | 60 +++++++++++++++ development/CONFIG.md | 2 +- development/DATA_PATHS.md | 16 +++- development/DEVELOPMENT.md | 2 + development/SKILL_ACTION_RUNTIME.md | 5 +- references/ACTIONS.md | 58 +++++++++++++- references/CLI.md | 4 +- references/SCHEMA.md | 5 +- scripts/service/html_page_service.py | 77 +++++++++++++++++++ scripts/util/constants.py | 2 +- scripts/util/runtime_paths.py | 14 +++- tests/test_html_page_service.py | 66 ++++++++++++++++ tests/test_runtime_paths.py | 5 +- 18 files changed, 339 insertions(+), 19 deletions(-) create mode 100644 assets/examples/render-html.action.example.json create mode 100644 assets/html/example-report.html create mode 100644 scripts/service/html_page_service.py create mode 100644 tests/test_html_page_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a517df..0c7203b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.80 + +- 沉淀数据管理「HTML 页面报告」契约:写入 `{skill_data}/html/*.html`,重新生成 Action id 固定为 `render-html` +- 新增 `get_html_dir()`、薄写入 helper(`html_page_service`)、示例页与 Action 示例 JSON +- 文档:`ACTIONS.md` / `DATA_PATHS.md` / `SKILL_ACTION_RUNTIME.md` 等已区分 HTML 报告与 `export-*` / 宿主表导出 + ## 1.0.79 - 沉淀数据管理表级「默认展示列」契约:`_jiangchang_tables.default_visible_columns_json` diff --git a/SKILL.md b/SKILL.md index 9d5265c..3a66cd4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.79 +version: 1.0.80 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -37,7 +37,7 @@ allowed-tools: - **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。 - **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。 - **`cellPresentation`(可选)**:需要数据管理「某格特殊标色/强调」时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。 -- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。 +- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;文件型特殊报告才另做 `export-*`(落 `output/`);**HTML 整页报告**写 `html/` 并用固定 Action id `render-html`(见 `references/ACTIONS.md`)。 - **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。 ### 如何触发(能力类型) diff --git a/assets/README.md b/assets/README.md index 2106efe..38393f6 100644 --- a/assets/README.md +++ b/assets/README.md @@ -1,7 +1,8 @@ # assets - `actions.json`:Skill Action manifest 最小示例(**可选**;不需要宿主入口时可删除)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。 -- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 +- `html/example-report.html`:HTML 报告页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);渲染后须写入数据目录 `html/`,见 `references/ACTIONS.md`「HTML 页面报告」。 +- `examples/`:CLI 成功输出形状示例、以及 `render-html.action.example.json`(虚构路径与数据)。 - `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**;`task-log-record.schema.json` 等)。 `skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping`;placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 diff --git a/assets/examples/README.md b/assets/examples/README.md index 60242da..4a57d52 100644 --- a/assets/examples/README.md +++ b/assets/examples/README.md @@ -4,3 +4,4 @@ - `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。 - `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。 - `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。 +- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。 diff --git a/assets/examples/render-html.action.example.json b/assets/examples/render-html.action.example.json new file mode 100644 index 0000000..717f90e --- /dev/null +++ b/assets/examples/render-html.action.example.json @@ -0,0 +1,28 @@ +{ + "id": "render-html", + "label": "生成 HTML", + "description": "根据业务数据生成 HTML 报告,写入技能数据目录 html/(示例:请改 bind.tables / entrypoint)。", + "placements": [ + "toolbar", + "skill-detail", + "agent" + ], + "executionProfile": "sync", + "interaction": "direct", + "bind": { + "tables": [ + "your_source_table" + ] + }, + "entrypoint": { + "type": "cli", + "command": "render-html", + "args": [] + }, + "steps": [ + { + "when": "afterSuccess", + "do": "refreshCatalog" + } + ] +} diff --git a/assets/html/example-report.html b/assets/html/example-report.html new file mode 100644 index 0000000..f5a7442 --- /dev/null +++ b/assets/html/example-report.html @@ -0,0 +1,60 @@ + + + + + + {{TITLE}} + + + +
+

{{TITLE}}

+

报告生成于 · {{GENERATED_AT}}

+
+
+
+ {{BODY}} +
+
+ + diff --git a/development/CONFIG.md b/development/CONFIG.md index ddf65c4..c77f2c3 100644 --- a/development/CONFIG.md +++ b/development/CONFIG.md @@ -115,7 +115,7 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调 ### 数据目录子路径(输入 / 输出) -用户可见目录只有 **`input/`(上行)** 与 **`output/`(下行)**。技能写入的业务产出默认落在 `output/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 +用户可见目录:文件浏览只有 **`input/`(上行)** 与 **`output/`(下行)**;另有可选 **`html/`** 供宿主扫描 HTML 报告页(见 `DATA_PATHS.md`)。技能写入的业务文件产出默认落在 `output/`;整页报告落在 `html/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` / `get_html_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 - 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md) - `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法 diff --git a/development/DATA_PATHS.md b/development/DATA_PATHS.md index d888ea0..0a72a97 100644 --- a/development/DATA_PATHS.md +++ b/development/DATA_PATHS.md @@ -30,13 +30,19 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\ ## 2. 标准子目录树 -用户可见目录(数据管理侧栏只挂这两个)按 **上行 / 下行** 划分: +用户可见 · **文件目录**(数据管理「输入 / 输出」文件浏览器只挂这两个): | 子目录 | 方向 | 用途 | 默认 helper | |--------|------|------|-------------| | `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` | | `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` | +用户可见 · **HTML 报告页**(宿主扫描挂到技能下,**不是** input/output 文件浏览器): + +| 子目录 | 用途 | 默认 helper | +|--------|------|-------------| +| `html/` | 技能写出的静态 `.html` 报告;宿主一层扫描 `*.html`,侧栏预览 / 右键重新生成 | `get_html_dir()` | + 内部 / 可选目录(**不上侧栏**): | 子目录 | 用途 | 默认 helper | @@ -55,10 +61,14 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\ - 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`) - 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`) -- 禁止再引入业务侧栏第三目录(如旧的 `downloads` / `imports` / `exports` 三分法) +- 可浏览的 **HTML 报告页** → `html/`(`get_html_dir()` / `service.html_page_service`);**不要**把整页报告塞进 `output/` 指望侧栏预览 +- 禁止再引入业务「文件浏览器」第三目录(如旧的 `downloads` / `imports` / `exports` 三分法);`html/` 是报告页扫描面,不是第三种文件浏览器 - 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」 -宿主约定(与本模板对齐):工具栏「导入」默认从 `input/` 取文件;「导出」默认写到 `output/`。 +宿主约定(与本模板对齐): + +- 工具栏「导入」默认从 `input/` 取文件;「导出」默认写到 `output/` +- 数据管理扫描 `{skill_data}/html/*.html`(一层、非递归);`` → 侧栏显示名;「重新生成」调用 Action id **`render-html`**(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」) --- diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index 7d5f236..e95be23 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -586,6 +586,7 @@ cmd_stats() # 只读 sync:JSON,无 job_context - 批量数量用 **`pickCount` / `--pick N` 参数**(任意正整数),不写死条数。 - **顺序执行、不并发**同一浏览器 Profile / account lease。 - **同步数据**与**宿主表导出**职责分离:同步不写 CSV/Excel;表导出交给数据管理。 +- **HTML 页面报告**(可选):需要侧栏预览整页时,写入 `html/` + Action `render-html`;契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」、[`DATA_PATHS.md`](DATA_PATHS.md);薄写入 helper 见 `scripts/service/html_page_service.py`。 - 详情见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md);契约样例见 `tests/samples/test_sync_contract.py.sample`、`tests/samples/test_action_core_contract.py.sample`。 ### 队列 + pick(可选) @@ -841,6 +842,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea |-------------------------|----------------|--------------| | **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 | | **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 | +| **数据管理 · HTML 报告** | 写出 `html/*.html`;提供 Action id `render-html` | 侧栏出现报告项;预览正常;右键「重新生成」成功且时间/内容更新 | | **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 | | **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 | | **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 | diff --git a/development/SKILL_ACTION_RUNTIME.md b/development/SKILL_ACTION_RUNTIME.md index 6c88aa4..3987c0b 100644 --- a/development/SKILL_ACTION_RUNTIME.md +++ b/development/SKILL_ACTION_RUNTIME.md @@ -164,9 +164,10 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context |------|------|--------| | 同步 / refresh / collect | 外部来源 → 技能本地 SQLite | 技能 domain service | | 导出当前数据表 | 本地库 → CSV/Excel | **宿主**数据管理统一导出 | -| 特殊业务报告 | 多表合并 / 特殊格式 / 附加计算 | 仅系统导出不足时提供 `export-*` | +| 特殊业务报告(文件) | 多表合并 / 特殊格式 → `output/` | 仅系统导出不足时提供 `export-*` | +| HTML 页面报告 | 写入 `{skill_data}/html/*.html` | 技能生成;宿主扫描预览;重新生成 Action id 固定为 **`render-html`** | -同步 Action 默认写库、不写导出文件。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。 +同步 Action 默认写库、不写导出文件。HTML 报告契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」与 [`DATA_PATHS.md`](DATA_PATHS.md)。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。 --- diff --git a/references/ACTIONS.md b/references/ACTIONS.md index 5ffd824..4a1b50c 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -490,7 +490,7 @@ HINT: <next step> | 导入 / 重置 | sync 或 async | skill-detail, agent | 可选 | | 单条副作用 RPA | async(建议) | agent, skill-detail | **建议** | | 批量顺序 pick RPA | async(建议) | toolbar, cron, agent | **建议** | -## 同步数据 vs 导出当前表 vs 特殊业务报告 +## 同步数据 vs 导出当前表 vs 特殊业务报告 vs HTML 页面 不要混用「导出」一词: @@ -498,12 +498,64 @@ HINT: <next step> |------|------|--------| | **同步数据**(sync / refresh / collect) | 外部来源 → 技能本地数据库 | 技能 Action / domain service | | **导出当前数据表** | 技能本地数据库 → CSV/Excel 等 | 数据管理页**宿主统一导出** | -| **生成特殊业务报告** | 多表合并、特殊排版或附加计算 | 仅当系统导出无法满足时,才提供独立 `export-*` Action | +| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的 **xlsx/csv/json 等文件** | 仅当系统导出无法满足时,才提供独立 `export-*` Action;文件落 `output/` | +| **HTML 页面报告** | 可在数据管理侧栏预览的整页 HTML | 技能写入 `{skill_data}/html/*.html` + Action id **`render-html`**(见下节) | 同步型 Action 默认:读外部数据、写技能自己的库、**不**生成 CSV/Excel/JSON 文件、不重复实现宿主表导出。兼容旧 export 命令若需要刷新数据,必须调用同一个 sync 业务内核。通用同步语义见 [`SCHEMA.md`](SCHEMA.md) 与 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。 -## inputSchema 与参数表单 +## HTML 页面报告(数据管理预览) +> 一句话:**技能负责生成并覆盖 `html/*.html`;宿主负责发现、预览、用 `render-html` 触发重新生成。** + +### 何时用 + +- 需要**可浏览的整页报告**(KPI + 表格 + 自带样式),而不是再导出一张表 +- 与 `export-*` / 宿主表导出**并列**:HTML 走 `html/`;文件型特殊报告仍走 `output/` + `export-*` + +### 目录与发现(宿主契约) + +| 项 | 约定 | +|----|------| +| 路径 | `{skill_data}/html/*.html`,**只扫一层**(子目录忽略) | +| helper | `util.runtime_paths.get_html_dir()`;写入用 `service.html_page_service.write_html_page` | +| 侧栏名 | 取文件内 `<title>`(缺省则用文件名) | +| 预览 | 宿主 iframe 预览;右键:重新生成 / 浏览器打开 / 刷新 | +| 模板资源 | 建议放 `assets/html/*.html`(安装包内),渲染后**写出**到数据目录 `html/` | + +### Action:`render-html`(固定 id) + +宿主「重新生成」**硬编码**查找 Action id `render-html`(见 `service.html_page_service.HOST_HTML_RENDER_ACTION_ID`)。技能未提供该 id → 提示「该技能未提供生成 HTML」。 + +推荐形态: + +| 字段 | 建议 | +|------|------| +| `id` | **必须** `render-html` | +| `executionProfile` | `sync` | +| `interaction` | `direct` | +| `placements` | `toolbar` + `skill-detail`(配合表顶栏与 HTML 右键);可选 `agent` | +| `bind.tables` | 数据**来源表**(含 `toolbar` 时必填);宿主从 HTML 页重新生成时会用 bind 补齐跑表上下文 | +| `steps` | `afterSuccess` → **`refreshCatalog`**(重扫 `htmlPages`;不要只 `reloadTable`) | +| `entrypoint` | CLI 子命令(如 `render-html`)调用同一写入内核 | + +完整 JSON 形状见 [`../assets/examples/render-html.action.example.json`](../assets/examples/render-html.action.example.json)。最小页面壳见 [`../assets/html/example-report.html`](../assets/html/example-report.html)。 + +### 实现要点 + +1. 用 `load_html_template` + `render_html_placeholders` + `write_html_page`(原子 `*.tmp` + `os.replace`)覆盖同名文件;**不要**每次另存带时间戳的副本(除非产品明确要求历史版本)。 +2. 页头时间建议用**写出时刻**(「报告生成于」),不要误用业务表里过期的 `generated_at` 冒充「刚重新生成」。 +3. 可选:数据重算成功后调用静默写入(吞掉错误),保持侧栏报告与库一致。 +4. 权限:按业务决定谁能生成(管理员 / 角色);在 CLI/`cmd_render_html` 内强制校验。 +5. CHANGELOG / 用户说明:模板或样式升级后,提醒「请在数据管理对报告执行一次重新生成」。 + +### 验收(复制后自测) + +- [ ] `html/` 下出现 `.html`,数据管理侧栏能看到报告项,预览正常 +- [ ] 表顶栏(若声明 toolbar)或报告右键「重新生成」能跑通;成功后预览刷新 +- [ ] 未登录/无权限用户被拒绝,且有可读错误 +- [ ] 成功路径带 `refreshCatalog`(或等价让侧栏/预览看到更新) + +## inputSchema 与参数表单 ### 表单字段类型对照(须与 CRUD 对称) Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件语义**。CRUD 侧配置见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」;本表只写 Action 怎么配。 diff --git a/references/CLI.md b/references/CLI.md index 4c9e2b5..c75952f 100644 --- a/references/CLI.md +++ b/references/CLI.md @@ -24,10 +24,10 @@ python {baseDir}/scripts/main.py <command> … | 类型 | 本技能命令(自填) | Agent | |------|--------------------|-------| -| **短 CLI 白名单** | (例)`health` / `version` / `logs` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` | +| **短 CLI 白名单** | (例)`health` / `version` / `logs` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` | | **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` | -细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。 +细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。 ## 最小命令 diff --git a/references/SCHEMA.md b/references/SCHEMA.md index 040834c..7161aa6 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -114,13 +114,14 @@ metadata: } ``` -### 同步数据 vs 导出当前数据表 vs 特殊业务报告 +### 同步数据 vs 导出当前数据表 vs 特殊业务报告 vs HTML 页面 | 能力 | 语义 | 负责方 | |------|------|--------| | **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service | | **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** | -| **生成特殊业务报告** | 多表合并、特殊排版或附加计算 | 仅当系统导出无法满足时,才提供独立 `export-*` | +| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` | +| **HTML 页面报告** | 侧栏可预览的整页 HTML | 写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) | 同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。 diff --git a/scripts/service/html_page_service.py b/scripts/service/html_page_service.py new file mode 100644 index 0000000..7b0455b --- /dev/null +++ b/scripts/service/html_page_service.py @@ -0,0 +1,77 @@ +"""技能 HTML 报告页:写入 `{skill_data}/html/*.html`(宿主扫描预览)。 + +契约要点(权威说明见 `references/ACTIONS.md`「HTML 页面报告」): + +- 目录:仅一层 `html/*.html`(非递归) +- 重新生成 Action id 必须为 `render-html`(宿主硬编码) +- 成功后建议 `steps: refreshCatalog` 以重扫侧栏 +- 本模块只提供路径/模板/原子写;业务 KPI/表格由各技能自行填充 +""" + +from __future__ import annotations + +import os +import re +from typing import Mapping + +from util.logging_config import get_skill_logger +from util.runtime_paths import get_html_dir, get_skill_root + +# Host Data Management looks up this exact Skill Action id for「重新生成」. +HOST_HTML_RENDER_ACTION_ID = "render-html" + +_UNSAFE_NAME = re.compile(r"[\\/]|^\.\.|\.\.$") + + +def _log(): + try: + return get_skill_logger() + except RuntimeError: + return None + + +def assert_html_filename(filename: str) -> str: + name = (filename or "").strip() + if not name.lower().endswith(".html"): + raise ValueError(f"html filename must end with .html: {filename!r}") + if _UNSAFE_NAME.search(name) or "/" in name or "\\" in name: + raise ValueError(f"html filename must be a basename only: {filename!r}") + if len(name) > 200: + raise ValueError(f"html filename too long: {filename!r}") + return name + + +def html_page_path(filename: str, *, create_dir: bool = True) -> str: + return os.path.join(get_html_dir(create=create_dir), assert_html_filename(filename)) + + +def load_html_template(relative_under_skill_root: str) -> str: + """Load UTF-8 template from skill root (e.g. ``assets/html/example-report.html``).""" + rel = (relative_under_skill_root or "").strip().replace("\\", "/") + if not rel or rel.startswith("/") or ".." in rel.split("/"): + raise ValueError(f"invalid template path: {relative_under_skill_root!r}") + path = os.path.join(get_skill_root(), *rel.split("/")) + with open(path, encoding="utf-8") as f: + return f.read() + + +def render_html_placeholders(template: str, mapping: Mapping[str, str]) -> str: + """Replace ``{{KEY}}`` tokens. Unknown keys are left as-is.""" + page = template + for key, value in mapping.items(): + page = page.replace("{{" + str(key) + "}}", str(value)) + return page + + +def write_html_page(filename: str, html: str) -> str: + """Atomically write UTF-8 HTML under ``html/``. Returns absolute path.""" + dest = html_page_path(filename, create_dir=True) + tmp = dest + ".tmp" + text = html if isinstance(html, str) else str(html) + with open(tmp, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + os.replace(tmp, dest) + log = _log() + if log: + log.info("html_page_written path=%s bytes=%s", dest, len(text.encode("utf-8"))) + return dest diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 117e910..4319a42 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.78" +SKILL_VERSION = "1.0.80" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py index 9d0e5ce..f38a631 100644 --- a/scripts/util/runtime_paths.py +++ b/scripts/util/runtime_paths.py @@ -10,10 +10,13 @@ from util.constants import SKILL_SLUG _SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -# 用户可见的上行/下行目录(数据管理侧栏只挂这两个) +# 用户可见的上行/下行目录(数据管理「输入/输出」文件浏览器) STANDARD_SUBDIR_INPUT = "input" STANDARD_SUBDIR_OUTPUT = "output" +# 宿主扫描的 HTML 报告页(数据管理侧栏 htmlPages,非 input/output 文件浏览器) +STANDARD_SUBDIR_HTML = "html" + # 内部/可选目录(不上侧栏) STANDARD_SUBDIR_UPLOADS = "uploads" STANDARD_SUBDIR_CACHE = "cache" @@ -130,12 +133,21 @@ def get_videos_dir(*, create: bool = True) -> str: return path +def get_html_dir(*, create: bool = True) -> str: + """HTML 报告页目录:宿主扫描 `{skill_data}/html/*.html`(一层、非递归)。""" + path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_HTML) + if create: + os.makedirs(path, exist_ok=True) + return path + + def list_resolved_data_paths() -> dict[str, str]: """供 health / config-path 输出已解析的数据子目录(不创建目录)。""" return { "skill_data_dir": get_skill_data_dir(), "input_dir": get_input_dir(create=False), "output_dir": get_output_dir(create=False), + "html_dir": get_html_dir(create=False), "uploads_dir": get_uploads_dir(create=False), "cache_dir": get_cache_dir(create=False), "temp_dir": get_temp_dir(create=False), diff --git a/tests/test_html_page_service.py b/tests/test_html_page_service.py new file mode 100644 index 0000000..21b5976 --- /dev/null +++ b/tests/test_html_page_service.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""HTML 报告页薄写入 helper。""" + +from __future__ import annotations + +import os +import unittest + +from _support import IsolatedDataRoot + +from service.html_page_service import ( + HOST_HTML_RENDER_ACTION_ID, + load_html_template, + render_html_placeholders, + write_html_page, +) + + +class TestHtmlPageService(unittest.TestCase): + def test_host_action_id_is_stable(self) -> None: + self.assertEqual(HOST_HTML_RENDER_ACTION_ID, "render-html") + + def test_write_html_page_atomic_under_html_dir(self) -> None: + with IsolatedDataRoot() as tmp: + path = write_html_page( + "example-report.html", + "<!DOCTYPE html><html><head><title>示例ok", + ) + self.assertTrue(os.path.isfile(path)) + norm = path.replace("\\", "/") + self.assertIn("/html/example-report.html", norm) + self.assertTrue(os.path.normcase(os.path.normpath(path)).startswith( + os.path.normcase(os.path.normpath(tmp)), + )) + self.assertFalse(os.path.isfile(path + ".tmp")) + with open(path, encoding="utf-8") as f: + self.assertIn("示例", f.read()) + + def test_load_and_fill_example_template(self) -> None: + with IsolatedDataRoot(): + template = load_html_template("assets/html/example-report.html") + page = render_html_placeholders( + template, + { + "TITLE": "示例报告", + "GENERATED_AT": "2026-08-14 15:49:00", + "BODY": "

hello

", + }, + ) + self.assertIn("示例报告", page) + self.assertIn("报告生成于 · 2026-08-14 15:49:00", page) + self.assertIn("

hello

", page) + self.assertNotIn("{{TITLE}}", page) + dest = write_html_page("example-report.html", page) + self.assertTrue(os.path.isfile(dest)) + + def test_rejects_unsafe_filename(self) -> None: + with IsolatedDataRoot(): + with self.assertRaises(ValueError): + write_html_page("../x.html", "") + with self.assertRaises(ValueError): + write_html_page("note.txt", "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py index 0111a6d..fdea9b8 100644 --- a/tests/test_runtime_paths.py +++ b/tests/test_runtime_paths.py @@ -136,11 +136,14 @@ class TestRuntimePaths(unittest.TestCase): importlib.reload(rp) input_dir = rp.get_input_dir(create=True) output_dir = rp.get_output_dir(create=True) + html_dir = rp.get_html_dir(create=True) norm_tmp = os.path.normcase(os.path.normpath(tmp)) self.assertTrue(os.path.normcase(os.path.normpath(input_dir)).startswith(norm_tmp)) self.assertTrue(os.path.normcase(os.path.normpath(output_dir)).startswith(norm_tmp)) + self.assertTrue(os.path.normcase(os.path.normpath(html_dir)).startswith(norm_tmp)) self.assertTrue(input_dir.replace("\\", "/").endswith("/input")) self.assertTrue(output_dir.replace("\\", "/").endswith("/output")) + self.assertTrue(html_dir.replace("\\", "/").endswith("/html")) def test_list_resolved_data_paths_keys(self) -> None: with IsolatedDataRoot(): @@ -154,6 +157,7 @@ class TestRuntimePaths(unittest.TestCase): "skill_data_dir", "input_dir", "output_dir", + "html_dir", ): self.assertIn(key, paths) self.assertTrue(paths[key]) @@ -161,6 +165,5 @@ class TestRuntimePaths(unittest.TestCase): self.assertNotIn("imports_dir", paths) self.assertNotIn("exports_dir", paths) - if __name__ == "__main__": unittest.main()