Compare commits

...

19 Commits

Author SHA1 Message Date
9fa5953713 docs: form field type contract Action/CRUD symmetry (v1.0.77)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-11 15:52:27 +08:00
db2325dbe4 docs: contract for CRUD form description and SQLite DEFAULT prefill (v1.0.76)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-11 14:38:32 +08:00
16ee121ab3 Release v1.0.75: cell presentation contract for data management
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-10 14:31:04 +08:00
139d176841 Release v1.0.74: hostEffects/steps Action contract
All checks were successful
技能自动化发布 / release (push) Successful in 12s
2026-08-10 12:05:58 +08:00
0b6bf0b2fa Release v1.0.73: Data Policy contract and schemas
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-09 10:53:31 +08:00
51be1517c0 docs: clarify row placement as row-level shell including context menu
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-09 10:20:20 +08:00
98f057e26b Release v1.0.71: sync timeout docs 60s
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-08-08 20:02:07 +08:00
8878a90315 docs: align toolbar button layout with host (no more-menu)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-08 19:32:46 +08:00
2ecb1172f4 Release v1.0.69: input/output data dirs
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-08 19:28:31 +08:00
d2c8c3240c docs: document data-management skill button order via actions array
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-08-08 18:52:39 +08:00
2c9f610a8e chore: bump version to 1.0.67
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-08-07 11:25:03 +08:00
2c9bf56054 chore: remove tools/ui_inspector; point docs to jiangchang-ui-recorder.
Product lives at https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder; prior commit keeps an archive snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:20:23 +08:00
633ecff4e2 chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:19:14 +08:00
46bc573777 Release v1.0.66: table default_sort_json for data management
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-08-02 12:17:55 +08:00
0002e86729 docs: columnMapping for replaces create/update lookup reuse
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-31 15:14:44 +08:00
caf0191366 feat: document optional replaces for CRUD mutation binding
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-31 14:43:15 +08:00
ba284922e5 docs: data-management column lookup via options_json
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-31 13:37:51 +08:00
7cf139dfd2 docs: Skill Action interaction direct/confirm/form
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 11:15:51 +08:00
6c1ffc73d3 Release v1.0.61: desktop RPA Name/Value and activate fixes
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-25 17:02:07 +08:00
41 changed files with 2442 additions and 181 deletions

View File

@@ -33,21 +33,19 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
# 以下为可选目录配置。一般不用改;取消注释并填写后,才覆盖默认子文件夹。 # 以下为可选目录配置。一般不用改;取消注释并填写后,才覆盖默认子文件夹。
# 填写相对路径时,相对于本技能的数据目录(不是当前工作目录)。 # 填写相对路径时,相对于本技能的数据目录(不是当前工作目录)。
# 用户可见只有 input(上行)与 output(下行);见 development/DATA_PATHS.md。
# 下载目录:技能保存下载文件的位置 # 输入目录:用户/Agent 放入的待处理文件(清单、CSV、产品表等)
# SKILL_DOWNLOAD_DIR=downloads # 相对本技能数据目录;留空或保持注释则用默认 downloads # SKILL_INPUT_DIR=input # 相对本技能数据目录;留空或保持注释则用默认 input
# 导入目录:待导入文件放置的位置 # 输出目录:技能/宿主交给用户拿走的结果(视频、报表、导出文件等)
# SKILL_IMPORT_DIR=imports # 相对本技能数据目录;留空或保持注释则用默认 imports # SKILL_OUTPUT_DIR=output # 相对本技能数据目录;留空或保持注释则用默认 output
# 导出目录:技能导出文件的保存位置 # 上传目录:待发布到外部平台的 staging(可选,不上侧栏)
# SKILL_EXPORT_DIR=exports # 相对本技能数据目录;留空或保持注释则用默认 exports
# 上传目录:待上传文件放置的位置
# SKILL_UPLOAD_DIR=uploads # 相对本技能数据目录;留空或保持注释则用默认 uploads # SKILL_UPLOAD_DIR=uploads # 相对本技能数据目录;留空或保持注释则用默认 uploads
# 缓存目录:临时缓存文件的位置 # 缓存目录:临时缓存文件的位置(不上侧栏)
# SKILL_CACHE_DIR=cache # 相对本技能数据目录;留空或保持注释则用默认 cache # SKILL_CACHE_DIR=cache # 相对本技能数据目录;留空或保持注释则用默认 cache
# 临时目录:运行中临时文件的位置 # 临时目录:运行中临时文件的位置(不上侧栏)
# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp # SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp

View File

@@ -9,6 +9,101 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.77
- 固化 Action / CRUD **对称**表单字段类型契约:text、textarea/json、number、boolean、enum/lookup、sensitive、date、datetime_unix_seconds、url/email(薄层)
- 明确可编辑业务时间用 Unix **秒**读写;审计 `created_at`/`updated_at` 仍只读展示
- 明确本版**不做**文件上传、多选/标签数组、复杂 nested 表单,避免宿主反复扩面
- 更新 `SCHEMA.md`、`ACTIONS.md`、`skill-actions.schema.json`(`display_type`/`format`)
## 1.0.76
- 固化数据管理「新增/编辑」表单契约:列 `description` 须在标签下展示;创建默认值取自 SQLite 列 `DEFAULT`(见 `references/SCHEMA.md`)
- 明确禁止把说明塞进 `display_name`;与 Skill Action 的 `inputSchema.description` / `default` 分工写清(见 `ACTIONS.md`)
- 宿主须按该契约实现下发与预填;技能侧用元数据说明 + DDL DEFAULT 即可,第一期不新增表单默认值元数据列
## 1.0.75
- 正式沉淀数据管理「单元格呈现」通用契约(`references/CELL_PRESENTATION.md`):技能声明某表某行某列的语义 tone,宿主映射主题样式
- 用途不限(超时未填、库存预警、状态异常、审核驳回等);推荐旁路表 `_jiangchang_cell_presentation`;填完后重算即取消强调
- 新增 Schema / 示例;`actions.json` 可选顶层 `cellPresentation.enabled`
## 1.0.74
- 沉淀宿主 Skill Action 编排契约:`hostEffects`(`onSuccess` / `onFailure`)与 `steps`(`before` / `afterSuccess` / `afterFailure`)
- 支持成功后 `reloadTable` 等 UI 副作用与级联删除步骤;需匠厂宿主 ≥2.8.5;省略则不自动刷表
- 更新 `skill-actions.schema.json`、ACTIONS.md、SKILL_ACTION_RUNTIME
## 1.0.73
- 正式发布 Data Policy 契约(`references/POLICY.md` v1.0):可选身份授权,默认 L0 零配置
- 新增 Snapshot Schema 与示例;`actions.json` 支持顶层 `policy` 与 Action 可选 `authz` 提示
- ACTIONS 扩展为六职责正交(含 policy)
## 1.0.72
- 澄清 `placements: "row"` 契约:表示数据管理**行级入口**;技能配置决定菜单项,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action,无独立菜单 DSL / `row-context-menu` placement)
- 更新 ACTIONS.md、SKILL_ACTION_RUNTIME、DEVELOPMENT 验收表与 skill-actions.schema 描述
## 1.0.71
- 文档对齐:Skill Action `sync` 默认超时约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`);接近或超过约 60s 的任务应标 `async`
- 更新 `SKILL_ACTION_RUNTIME.md`
## 1.0.70
- 对齐宿主:数据管理表顶栏技能按钮全部直接展示,取消「更多操作」溢出菜单
- 补充宽窄布局说明:够宽左右横排一行;不够宽左右分栏、栏内竖排
- 更新 ACTIONS.md 与 skill-actions.schema.json
## 1.0.69
- 用户数据目录收敛为上行 `input/` 与下行 `output/`(取代 downloads / imports / exports 三分法)
- `runtime_paths` 提供 `get_input_dir()` / `get_output_dir()`;相对输入路径解析到 `input/`
- 更新 DATA_PATHS.md、CONFIG、`.env.example`;缓存 / 临时 / RPA 录屏目录仍为内部目录
## 1.0.68
- 明确数据管理技能按钮顺序契约:同一 placement(toolbar / row / batch)下按 `actions` 数组相对顺序展示;调整数组先后即可改序
- 说明宿主内置刷新 / 导出 / 查看等不参与该顺序;无独立 `sortOrder` 字段
- 更新 ACTIONS.md 与 skill-actions.schema.json
## 1.0.67
- 移除模板内嵌的 `tools/ui_inspector`;UI 录制器产品迁至独立仓库 `jiangchang-ui-recorder`
- 相关开发文档改为指向 Gitea 产品仓库入口
## 1.0.66
- 数据管理支持表级默认排序:`_jiangchang_tables.default_sort_json`(多字段 JSON);宿主优先采用,未配置则仍按 updated_at → created_at → 主键
- 更新 SCHEMA.md 与 `upsert_table_metadata`;旧库幂等补列
## 1.0.65
- `replaces` create/update 可复用表列 lookup:字段名对齐列名,或使用 `bind.columnMapping` / 字段 `column`
- 更新 ACTIONS.md 与 skill-actions.schema.json
## 1.0.64
- Skill Action 新增可选 `replaces: create|update|delete`:接管数据管理默认新增/编辑/删除,无配置时宿主仍直写 SQLite
- 更新 ACTIONS.md 与 skill-actions.schema.json;`replaces` 须配合 `bind.tables`,可与空 `placements` 并用
## 1.0.63
- 数据管理支持字段级 lookup:`options_json` 可声明同库关联表(`ref_table` / `value_column` / `label_column`),宿主新建/编辑时渲染下拉
- 更新 SCHEMA.md 与元数据校验,兼容静态枚举数组与 lookup 对象两种形态
## 1.0.62
- Skill Action 新增 `interaction` / `interactionByPlacement`:声明数据管理点击是直通、轻确认还是参数表单(与 `executionProfile` 正交)
- 明确 `confirmation` 只负责风险文案;cron 触发与 agent 忽略 `interaction`
- 补充 ACTIONS.md 示例(打开文件位置 = direct,打开浏览器 = confirm)
## 1.0.61
- 桌面 RPA 标准反哺:明确 Name≠Value、主窗最大化与禁止误 `SW_RESTORE`、同名叶子须绑父容器
- 更新 `real_desktop_rpa` example:activate/Value 定位、element-map 补全「新的客户/添加」、README 与契约测试
## 1.0.60 ## 1.0.60
- 强化日志规范:验收铁律、固定事件词表、失败须先写文件日志再 print - 强化日志规范:验收铁律、固定事件词表、失败须先写文件日志再 print

View File

@@ -1,7 +1,7 @@
--- ---
name: 技能开发模板(通用业务版) name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.60 version: 1.0.77
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -32,9 +32,11 @@ allowed-tools:
### 架构铁律(先读) ### 架构铁律(先读)
- **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑,禁止按 `source` 分叉业务行为。 - **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑,禁止按 `source` 分叉业务行为。
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。 - **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。其中 `row` 表示**行级入口**(宿主可呈现为操作列、行右键、行内「⋯」等;技能配什么就显示什么,无独立菜单 DSL)。数据管理技能按钮顺序见 `actions` **数组相对顺序**(详见 `references/ACTIONS.md`),无独立 sortOrder。
- **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。 - **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。 - **`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-*`。
- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。 - **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。
@@ -68,7 +70,7 @@ allowed-tools:
5. **一个业务能力一个 Action**,用多个 `placements` 挂入口;底层仍是同一 `scripts/main.py` CLI。 5. **一个业务能力一个 Action**,用多个 `placements` 挂入口;底层仍是同一 `scripts/main.py` CLI。
6. 是否进任务中心**只**看该 Action 的 `executionProfile`,与数据管理 / 定时 / Agent 入口无关。 6. 是否进任务中心**只**看该 Action 的 `executionProfile`,与数据管理 / 定时 / Agent 入口无关。
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`(不要把 `development/` 当 Agent 主读路径)。 Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`、按需 `references/POLICY.md`(不要把 `development/` 当 Agent 主读路径)。
## 共享 Python(必读) ## 共享 Python(必读)

View File

@@ -2,3 +2,5 @@
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。 - `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
- `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。 - `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`)。

View File

@@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"table": "business_records",
"rowKey": "id",
"generatedAt": "2026-08-10T06:00:00Z",
"cells": [
{
"rowId": "42",
"column": "qty",
"tone": "danger",
"message": "库存低于安全线",
"alert": true
},
{
"rowId": "42",
"column": "status",
"tone": "warning",
"message": "待审核"
},
{
"rowId": "99",
"column": "print_done_at",
"tone": "danger",
"message": "环节到期未填",
"alert": true
}
]
}

View File

@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": {
"userId": "u1",
"displayName": "张三",
"roles": ["clerk"]
},
"actions": {
"mode": "allowlist",
"ids": ["whoami", "create-record", "sync-records"]
}
}

View File

@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": {
"userId": "u1"
},
"actions": {
"mode": "open"
},
"data": {
"tables": {
"orders": {
"rows": {
"mode": "scope",
"scope": {
"all": [
{
"field": "owner_user_id",
"op": "eq",
"valueFrom": "actor.userId"
}
]
}
}
}
}
}
}

View File

@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json", "$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
"title": "SkillActionManifest", "title": "SkillActionManifest",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。", "description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)。",
"type": "object", "type": "object",
"required": ["schemaVersion", "skill", "actions"], "required": ["schemaVersion", "skill", "actions"],
"additionalProperties": false, "additionalProperties": false,
@@ -19,14 +19,73 @@
"actions": { "actions": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,
"items": { "$ref": "#/$defs/action" } "items": { "$ref": "#/$defs/action" },
"description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序展示。toolbar/batch:表顶栏全部直接展示(无「更多操作」溢出)。row:行级入口,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action、同一顺序);不在本字段配置排序、截断或菜单树。调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。表顶栏宽时左右横排,窄时左右分栏且栏内竖排。"
},
"policy": {
"$ref": "#/$defs/policyDeclaration",
"description": "可选。启用 Data Policy(L1/L2)。缺省或不 enabled = L0,宿主不拉 Snapshot。详见 references/POLICY.md。"
},
"cellPresentation": {
"$ref": "#/$defs/cellPresentationDeclaration",
"description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。"
} }
}, },
"$defs": { "$defs": {
"cellPresentationDeclaration": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "仅 true 启用;false 等同 P0。"
}
}
},
"policyDeclaration": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "仅 true 启用;false 等同 L0。"
},
"entrypoint": {
"$ref": "#/$defs/entrypoint",
"description": "enabled=true 时必填。推荐 command=policy,args 可含 {{table}}/{{skill}}/{{userId}}。"
},
"appliesTo": {
"type": "array",
"items": {
"type": "string",
"enum": ["data-management"]
},
"description": "缺省 [\"data-management\"]。本期宿主只实现 data-management。"
}
},
"allOf": [
{
"if": {
"properties": { "enabled": { "const": true } },
"required": ["enabled"]
},
"then": {
"required": ["entrypoint"]
}
}
]
},
"placement": { "placement": {
"type": "string", "type": "string",
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"], "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。placements 与 executionProfile 正交,不做条件限制。" "description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。row=行级入口(操作列/行右键/⋯ 等由宿主呈现,无独立 row-context-menu placement)。placements 与 executionProfile 正交,不做条件限制。"
},
"mutationKind": {
"type": "string",
"enum": ["create", "update", "delete"],
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。"
}, },
"scalarArg": { "scalarArg": {
"type": ["string", "number", "boolean"] "type": ["string", "number", "boolean"]
@@ -52,6 +111,22 @@
"readOnly": { "readOnly": {
"type": "boolean", "type": "boolean",
"description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等" "description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等"
},
"column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "可选。绑定业务表物理列名;replaces create/update 时宿主可复用该列的中文名与 lookup 下拉"
},
"display_type": {
"type": "string",
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email"],
"description": "可选。表单控件提示;与 format 二选一即可。须与 CRUD display_type 语义对齐(见 ACTIONS.md / SCHEMA.md)。"
},
"format": {
"type": "string",
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email"],
"description": "可选。display_type 别名,语义相同。"
} }
} }
}, },
@@ -120,9 +195,87 @@
"type": "object", "type": "object",
"additionalProperties": { "type": "string", "minLength": 1 }, "additionalProperties": { "type": "string", "minLength": 1 },
"description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。" "description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。"
},
"columnMapping": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$"
},
"description": "可选。将 inputSchema 属性名映射到业务表物理列名,供 replaces create/update 抽屉复用列 displayName / lookup options。"
} }
}, },
"description": "表级绑定;toolbar 必填。可选 inputMapping 供行内/批量预填。concurrency/locks 勿写入。" "description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping。concurrency/locks 勿写入。"
},
"hostEffectId": {
"type": "string",
"enum": ["reloadTable", "refreshCatalog", "clearSelection"],
"description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。"
},
"hostEffects": {
"type": "object",
"additionalProperties": false,
"properties": {
"onSuccess": {
"type": "array",
"items": { "$ref": "#/$defs/hostEffectId" },
"uniqueItems": true,
"description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。"
},
"onFailure": {
"type": "array",
"items": { "$ref": "#/$defs/hostEffectId" },
"uniqueItems": true,
"description": "sync 失败,或 async 启动被拒时触发。"
}
},
"description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。"
},
"actionStep": {
"type": "object",
"required": ["when", "do"],
"additionalProperties": false,
"properties": {
"when": {
"type": "string",
"enum": ["before", "afterSuccess", "afterFailure"],
"description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。"
},
"do": {
"type": "string",
"enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"],
"description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。"
},
"table": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "deleteByFk / deleteRows 目标表(snake_case)。"
},
"fk": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "deleteByFk 时子表外键列名。"
}
},
"allOf": [
{
"if": {
"properties": { "do": { "const": "deleteByFk" } },
"required": ["do"]
},
"then": { "required": ["table", "fk"] }
},
{
"if": {
"properties": { "do": { "const": "deleteRows" } },
"required": ["do"]
},
"then": { "required": ["table"] }
}
]
}, },
"action": { "action": {
"type": "object", "type": "object",
@@ -144,10 +297,11 @@
}, },
"placements": { "placements": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 0,
"items": { "$ref": "#/$defs/placement" }, "items": { "$ref": "#/$defs/placement" },
"description": "决定 Action 出现在哪里;与 executionProfile 正交" "description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。"
}, },
"replaces": { "$ref": "#/$defs/mutationKind" },
"entrypoint": { "$ref": "#/$defs/entrypoint" }, "entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" }, "inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" }, "confirmation": { "$ref": "#/$defs/confirmation" },
@@ -156,7 +310,46 @@
"enum": ["sync", "async"], "enum": ["sync", "async"],
"description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。" "description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。"
}, },
"bind": { "$ref": "#/$defs/bind" } "interaction": {
"type": "string",
"enum": ["auto", "direct", "confirm", "form"],
"description": "数据管理等可点击入口的交互方式(与 executionProfile 正交)。direct=一点即跑;confirm=轻量确认(文案用 confirmation.message);form=参数表单;auto/省略=宿主按 inputSchema+confirmation 推断。cron 触发与 agent 忽略本字段。"
},
"interactionByPlacement": {
"type": "object",
"additionalProperties": false,
"properties": {
"toolbar": { "type": "string", "enum": ["direct", "confirm", "form"] },
"row": { "type": "string", "enum": ["direct", "confirm", "form"] },
"batch": { "type": "string", "enum": ["direct", "confirm", "form"] },
"cron": { "type": "string", "enum": ["direct", "confirm", "form"] },
"agent": { "type": "string", "enum": ["direct", "confirm", "form"] },
"skill-detail": { "type": "string", "enum": ["direct", "confirm", "form"] },
"create": { "type": "string", "enum": ["direct", "confirm", "form"] },
"update": { "type": "string", "enum": ["direct", "confirm", "form"] },
"delete": { "type": "string", "enum": ["direct", "confirm", "form"] }
},
"description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。"
},
"bind": { "$ref": "#/$defs/bind" },
"hostEffects": { "$ref": "#/$defs/hostEffects" },
"steps": {
"type": "array",
"maxItems": 8,
"items": { "$ref": "#/$defs/actionStep" },
"description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。"
},
"authz": {
"type": "object",
"additionalProperties": false,
"properties": {
"capability": {
"type": "string",
"minLength": 1,
"description": "可选文档/静态提示用能力名;宿主不得仅凭此显隐按钮,运行时以 policy Snapshot 为准。"
}
}
}
}, },
"if": { "if": {
"properties": { "properties": {
@@ -169,7 +362,17 @@
"then": { "then": {
"required": ["bind"] "required": ["bind"]
}, },
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 时必须声明 bind.tables。" "allOf": [
{
"if": {
"required": ["replaces"]
},
"then": {
"required": ["bind"]
}
}
],
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。"
} }
} }
} }

View File

@@ -0,0 +1,78 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json",
"title": "SkillCellPresentationDocument",
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。",
"type": "object",
"required": ["schemaVersion", "skill", "table", "rowKey", "cells"],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"skill": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 1,
"description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致"
},
"table": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "业务表物理名(snake_case)"
},
"rowKey": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "用于匹配行的业务表列名(如 id、feishu_record_id);cells[].rowId 的值取自该列"
},
"generatedAt": {
"type": "string",
"minLength": 1,
"description": "可选。ISO-8601 或技能约定的生成时间,便于排障"
},
"cells": {
"type": "array",
"description": "当前应对该表生效的呈现项;空数组 = 该表无特殊呈现(清除旧样式)。同 (rowId, column) 重复时宿主可后者覆盖前者。",
"items": { "$ref": "#/$defs/cellItem" }
}
},
"$defs": {
"tone": {
"type": "string",
"enum": ["danger", "warning", "info", "success", "muted"],
"description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。"
},
"cellItem": {
"type": "object",
"required": ["rowId", "column", "tone"],
"additionalProperties": false,
"properties": {
"rowId": {
"type": "string",
"minLength": 1,
"description": "rowKey 列的字符串形式主键/业务键"
},
"column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "要强调的业务列名(snake_case),必须是真实列"
},
"tone": { "$ref": "#/$defs/tone" },
"message": {
"type": "string",
"maxLength": 500,
"description": "可选。悬停/无障碍说明(短文案);不要塞长报告"
},
"alert": {
"type": "boolean",
"description": "可选,默认 false。true = 可计入进页/同步后的提醒汇总(如 Toast);填完数据并重算后该项消失则提醒自然关闭"
}
}
}
}
}

View File

@@ -0,0 +1,196 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-policy-snapshot.schema.json",
"title": "SkillDataPolicySnapshot",
"description": "匠厂 Skill Data Policy Snapshot(schemaVersion 1)。由技能 policy CLI 输出到 stdout;宿主用于数据管理按钮/行列过滤。详见 references/POLICY.md。",
"type": "object",
"required": ["schemaVersion", "skill", "actor", "actions"],
"additionalProperties": true,
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"skill": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 1
},
"actor": {
"type": "object",
"required": ["userId"],
"additionalProperties": true,
"properties": {
"userId": { "type": "string", "minLength": 1 },
"displayName": { "type": "string" },
"roles": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"attributes": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean", "null"]
}
}
}
},
"actions": {
"type": "object",
"required": ["mode"],
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["allowlist", "open"],
"description": "v1 仅允许 allowlist/open;denylist 非法。"
},
"ids": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"description": "mode=allowlist 时必填;mode=open 时应省略或 []。"
}
},
"allOf": [
{
"if": {
"properties": { "mode": { "const": "allowlist" } },
"required": ["mode"]
},
"then": {
"required": ["ids"]
}
}
]
},
"hostCapabilities": {
"type": "array",
"items": {
"type": "string",
"enum": ["refresh", "export", "view", "edit", "delete", "import"]
},
"description": "省略=不限制宿主内置能力;写出则按 allowlist(含 []=全禁)。"
},
"data": {
"type": "object",
"additionalProperties": false,
"properties": {
"tables": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/tablePolicy" }
}
}
},
"extensions": {
"type": "object",
"description": "技能私有扩展;宿主必须忽略未知键。"
}
},
"$defs": {
"predicate": {
"type": "object",
"required": ["field", "op"],
"additionalProperties": false,
"properties": {
"field": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$"
},
"op": {
"type": "string",
"enum": [
"eq",
"ne",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"is_null",
"is_not_null"
]
},
"value": {
"description": "与 valueFrom 互斥;in/not_in 时为非空数组。"
},
"valueFrom": {
"type": "string",
"const": "actor.userId",
"description": "v1 仅允许 actor.userId。"
}
}
},
"scope": {
"oneOf": [
{ "$ref": "#/$defs/predicate" },
{
"type": "object",
"required": ["all"],
"additionalProperties": false,
"properties": {
"all": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/predicate" }
}
}
},
{
"type": "object",
"required": ["any"],
"additionalProperties": false,
"properties": {
"any": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/predicate" }
}
}
}
]
},
"tablePolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"accessible": { "type": "boolean" },
"columns": {
"type": "object",
"additionalProperties": false,
"properties": {
"visible": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
}
},
"writable": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
}
}
}
},
"rows": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["scope", "open", "delegate"]
},
"scope": { "$ref": "#/$defs/scope" }
}
}
}
}
}
}

View File

@@ -108,15 +108,15 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
四档运行模式的**实现与测试约定**见 [`ADAPTER.md`](ADAPTER.md);录屏管线见 [`RPA.md`](RPA.md)。这些内容**不要**写回 `.env.example` 注释。 四档运行模式的**实现与测试约定**见 [`ADAPTER.md`](ADAPTER.md);录屏管线见 [`RPA.md`](RPA.md)。这些内容**不要**写回 `.env.example` 注释。
**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_DOWNLOAD_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。 **业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_OUTPUT_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
### 数据目录子路径(下载 / 导入 / 导出) ### 数据目录子路径(输入 / 输出)
技能**自己写入**的文件默认在 `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` 下标准子目录(`downloads/`、`imports/`、`exports/` 等)。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 用户可见目录只有 **`input/`(上行)** 与 **`output/`(下行)**。技能写入的业务产出默认落在 `output/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
- 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md) - 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md)
- `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法 - `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法
- 可选 env:`SKILL_DOWNLOAD_DIR`、`SKILL_IMPORT_DIR`、`SKILL_EXPORT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`) - 可选 env:`SKILL_INPUT_DIR`、`SKILL_OUTPUT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`)
### 录屏开关(`OPENCLAW_RECORD_VIDEO`) ### 录屏开关(`OPENCLAW_RECORD_VIDEO`)

View File

@@ -1,6 +1,6 @@
# 用户数据目录与文件路径黄金标准 # 用户数据目录与文件路径黄金标准
> 本文是 skill **读写本地文件** 的权威约定。凡涉及下载、导入、导出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。 > 本文是 skill **读写本地文件** 的权威约定。凡涉及输入、输出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。
--- ---
@@ -30,11 +30,17 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
## 2. 标准子目录树 ## 2. 标准子目录树
用户可见目录(数据管理侧栏只挂这两个)按 **上行 / 下行** 划分:
| 子目录 | 方向 | 用途 | 默认 helper |
|--------|------|------|-------------|
| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` |
| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` |
内部 / 可选目录(**不上侧栏**):
| 子目录 | 用途 | 默认 helper | | 子目录 | 用途 | 默认 helper |
|--------|------|-------------| |--------|------|-------------|
| `downloads/` | 从外部拉取的原始文件(视频、回单、PDF…) | `get_downloads_dir()` |
| `imports/` | 用户/Agent 放入的待处理清单(urls.txt、batch.json、CSV) | `get_imports_dir()` |
| `exports/` | 导出给用户的结果(xlsx/csv/json) | `get_exports_dir()` |
| `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` | | `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` |
| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` | | `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` |
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` | | `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
@@ -45,6 +51,15 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。 复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。
**开发者硬规则:**
- 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`)
- 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`)
- 禁止再引入业务侧栏第三目录(如旧的 `downloads` / `imports` / `exports` 三分法)
- 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」
宿主约定(与本模板对齐):工具栏「导入」默认从 `input/` 取文件;「导出」默认写到 `output/`。
--- ---
## 3. 两类路径 ## 3. 两类路径
@@ -54,6 +69,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。 技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。
- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。 - **默认**必须在 `{skill_data_dir}/<子目录>/` 下。
- 用户可拿走的业务产出 → **`output/`**。
- **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。 - **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
### B. 用户显式输入(user-owned) ### B. 用户显式输入(user-owned)
@@ -61,7 +77,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。 用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。
- 技能**只读**,路径可以是用户磁盘任意位置。 - 技能**只读**,路径可以是用户磁盘任意位置。
- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/imports/<相对路径>`,**不**相对 workspace/CWD。 - 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/input/<相对路径>`,**不**相对 workspace/CWD。
--- ---
@@ -72,9 +88,8 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
```ini ```ini
# ── 数据目录子路径(见 development/DATA_PATHS.md)── # ── 数据目录子路径(见 development/DATA_PATHS.md)──
# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace # 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace
# SKILL_DOWNLOAD_DIR=downloads/videos # SKILL_INPUT_DIR=input
# SKILL_IMPORT_DIR=imports # SKILL_OUTPUT_DIR=output/videos
# SKILL_EXPORT_DIR=exports
# SKILL_UPLOAD_DIR=uploads # SKILL_UPLOAD_DIR=uploads
# SKILL_CACHE_DIR=cache # SKILL_CACHE_DIR=cache
# SKILL_TEMP_DIR=temp # SKILL_TEMP_DIR=temp
@@ -83,8 +98,8 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
| 规则 | 说明 | | 规则 | 说明 |
|------|------| |------|------|
| 未配置 | `{skill_data_dir}/{default_subdir}` | | 未配置 | `{skill_data_dir}/{default_subdir}` |
| 相对值 `downloads/videos` | `{skill_data_dir}/downloads/videos` | | 相对值 `output/videos` | `{skill_data_dir}/output/videos` |
| 绝对值 `D:\nas\exports` | 高级用户/NAS 挂载,原样使用 | | 绝对值 `D:\nas\output` | 高级用户/NAS 挂载,原样使用 |
| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 | | **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 |
业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。 业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。
@@ -95,20 +110,20 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
```python ```python
from util.runtime_paths import ( from util.runtime_paths import (
get_downloads_dir, get_input_dir,
get_imports_dir, get_output_dir,
resolve_data_path, resolve_data_path,
resolve_input_path, resolve_input_path,
list_resolved_data_paths, list_resolved_data_paths,
) )
# 下载保存(A 类) # 下行产出(A 类)——视频、报表等
dest = os.path.join(get_downloads_dir(), f"{vid}.mp4") dest = os.path.join(get_output_dir(), f"{vid}.mp4")
# 或带 env 覆盖 # 或带 env 覆盖
out_dir = resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads") out_dir = resolve_data_path("SKILL_OUTPUT_DIR", "output")
# CLI -i urls.txt(B 类,相对路径 → imports/) # CLI -i urls.txt(B 类,相对路径 → input/)
manifest = resolve_input_path(args.input_path) manifest = resolve_input_path(args.input_path)
# health 诊断 # health 诊断
@@ -123,6 +138,8 @@ os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR"))
# ❌ .env.example 里写 ./outputs/videos # ❌ .env.example 里写 ./outputs/videos
DOWNLOAD_OUTPUT_DIR=./outputs/videos DOWNLOAD_OUTPUT_DIR=./outputs/videos
# ❌ 再拆 downloads / imports / exports 三个用户目录
``` ```
--- ---
@@ -138,7 +155,7 @@ DOWNLOAD_OUTPUT_DIR=./outputs/videos
- 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1) - 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1)
- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3) - 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3)
业务 `downloads/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。 业务 `output/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
--- ---

View File

@@ -158,7 +158,7 @@ scripts/
``` ```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。 上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。 4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。
5. **Windows 桌面**:技术路线已由 `add-friend-wecom` 验证。先读 `examples/real_desktop_rpa/README.md`,复用窗口唯一性、UIA作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。 5. **Windows 桌面**:技术路线已由 `add-friend-wecom` 全链路验证。先读 `examples/real_desktop_rpa/README.md` 与 `development/RPA_DESKTOP.md`(含 Name/Value、最大化激活、同名叶子父容器),复用窗口唯一性、UIA 作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。
6. **手机**:先按 `RPA_MOBILE.md` 完成设备、context、element map 和验收设计。当前 `examples/mobile_rpa/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。 6. **手机**:先按 `RPA_MOBILE.md` 完成设备、context、element map 和验收设计。当前 `examples/mobile_rpa/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。
**技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。 **技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。
@@ -841,7 +841,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|-------------------------|----------------|--------------| |-------------------------|----------------|--------------|
| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 | | **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 | | **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 |
| **数据管理 · 行内** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 行内操作链接可见(需有主键列);点按后入参/行为正确 | | **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 |
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 | | **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 | | **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 | | **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 |

View File

@@ -67,3 +67,6 @@
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` | | `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
| platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` | | platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |
| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 | | 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 |
| Data Policy 未启用(L0)时宿主不得拉 Snapshot / 不得改按钮与行列行为 | references/POLICY.md | soft;宿主实现契约 |
| Data Policy 启用后 fail-closed(禁止回退 L0 全开);写路径 domain 必须再鉴权 | references/POLICY.md §6–§7 | soft;`POLICY-SKILL-AUTHZ-*` 硬门禁可后续加 |
| `actions.mode` 仅 allowlist/open;`hostCapabilities` 省略=不限制;scope `valueFrom` 仅 `actor.userId` | references/POLICY.md §0.1 | soft;Schema 已约束 |

View File

@@ -15,14 +15,16 @@
## 1. 已验证基线 ## 1. 已验证基线
真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`。 真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`(含通讯录 → 新的客户 → 添加 → 搜索 → 发送全链路)。
该案例验证了: 该案例验证了:
- 通过进程/窗口类名/标题找到企业微信顶层窗口。 - 通过进程/窗口类名/标题找到企业微信顶层窗口。
- 从 HWND 获取 UIA 根控件。 - 主窗激活后**最大化**,避免侧栏折叠导致控件不可见或坐标漂移。
- 在父窗口和弹窗范围内按 UIA 属性定位。 - 从 HWND 获取 UIA 根控件;在**子业务 pane**(如「企业微信-通讯录」)内缩小作用域。
- 真实激活窗口、点击、逐字输入和轮询状态。 - 区分 UIA `Name` 与 `Value`:可见中文常在 Value / 子 Label,不在 Name。
- 通用叶子名(`item` / `status_layout` / `wbtn_container`)必须绑业务父容器,并用文案校验。
- 真实点击后校验后置状态(标题、专用容器、子窗口)。
- 处理独立弹窗与嵌套弹窗。 - 处理独立弹窗与嵌套弹窗。
- 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。 - 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。
@@ -34,31 +36,37 @@ example 只提炼通用模式;企业微信名称、窗口类、控件名、列
操作步骤: 操作步骤:
1. 启动目标软件,登录并手工进入准确页面。 1. 启动目标软件,登录并手工进入准确页面;需要完整侧栏时先**最大化**。
2. 记录目标进程名、PID、主窗口标题和 Window Class。 2. 记录目标进程名、PID、主窗口标题和 Window Class。
3. 用检查器点选目标元素,记录: 3. 用检查器点选目标元素,记录:
- AutomationId - AutomationId
- Name - **Name**(内部 id,可能是 `org_op_new` / `status_layout`)
- ControlType - **Value**(可见中文文案常在这里,如「通讯录」「新的客户」)
- ClassName - ControlType / ClassName / FrameworkId
- FrameworkId - IsEnabled / IsOffscreen / BoundingRectangle
- IsEnabled / IsOffscreen - 父容器链(含业务子窗/pane 标题)
- BoundingRectangle
- 父容器链
4. 保存截图和原始 UIA 树/拾取器导出文件。 4. 保存截图和原始 UIA 树/拾取器导出文件。
5. 从长路径中找出稳定窗口、业务父容器和叶子属性。 5. 从长路径中找出稳定窗口、业务父容器和叶子属性。
6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据。 6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据,且优先用文案定位。
7. 写入 element map。 7. 写入 element map(Name 与 Value 分开登记)。
8. 运行 `probe-ui`,验证关键窗口和元素。 8. 运行 `probe-ui`(建议先激活并最大化),验证关键窗口和元素。
9. 关闭重开软件、切换一次页面后再次验证。 9. 关闭重开软件、切换一次页面后再次验证。
### Name / Value(易错)
- 影刀/检查器里的**选择器显示名**(如「新的客户」)≠ 控件 UIA `Name`。
- 许多国产客户端:`Name` 是内部 id,**界面中文在 `Value` 或子控件 Value**。
- 拾取时两者都记;定位时勿把业务中文当成 `Name=` 去搜。
### 从拾取结果提炼定位器 ### 从拾取结果提炼定位器
影刀等工具可能导出很长的 UIA 路径。不要整条照抄: 影刀等工具可能导出很长的 UIA 路径。不要整条照抄:
```text ```text
原始:主窗口 → 多层布局 → 列表 → item[2] → status_layout 原始:主窗口 → 通讯录 pane → wlist_view → item[2] → status_layout
提炼:进程/窗口 → 通讯录列表父容器 → 业务文案或稳定叶子属性 → … → main_chattext(Value=新的客户)
提炼:通讯录 pane → wlist_view → 行内 main_chattext.Value=新的客户 → 点击该行 status_layout
禁止:全树第一个 Name=status_layout(常为「群聊」等其它行)
``` ```
长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。 长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。
@@ -67,25 +75,28 @@ example 只提炼通用模式;企业微信名称、窗口类、控件名、列
从高到低: 从高到低:
1. `Process/PID + WindowClass + WindowTitle` 1. `Process/PID + WindowClass + WindowTitle`(及稳定业务子 pane)
2. 目标窗口内 `AutomationId + ControlType` 2. 目标窗口内 `AutomationId + ControlType`
3. 稳定 `Name + ControlType` 3. 稳定内部 `Name + ControlType`(如 `org_op_new`、`searchedit`)
4. 业务父容器 + 稳定子元素属性 4. 业务父容器 + 稳定子元素属性
5. 可见业务文案 + 相对关系 5. **可见业务文案(优先读 Value / 子 Label)+ 相对关系**
6. `ClassName/FrameworkId` 辅助组合 6. `ClassName/FrameworkId` 辅助组合
7. 有业务依据的 index 7. 有业务依据的 index(仅兜底;须写清父容器与序号含义,禁止「过滤后的列表 [2]」与「父节点 child index=2」混用)
8. UIA 元素实时 BoundingRectangle 派生坐标 8. UIA 元素实时 BoundingRectangle 派生坐标
9. 局部截图模板/OCR 9. 局部截图模板/OCR
10. 写死绝对坐标:禁止作为生产方案 10. 写死绝对坐标:禁止作为生产方案
顶层窗口或关键按钮出现多个候选时,返回 `ERROR:ELEMENT_AMBIGUOUS`,不得默认使用第一个。 顶层窗口或关键按钮出现多个候选时,返回 `ERROR:ELEMENT_AMBIGUOUS`,不得默认使用第一个。
同名叶子(如多处 `wbtn_container`)必须限制在业务父容器内(如 `search_external`),并可用子文案(如 Value=`添加`)校验;禁止点搜索旁通用「+」。
## 4. 窗口与控件规则 ## 4. 窗口与控件规则
### 窗口 ### 窗口
- 校验窗口属于预期进程和当前用户会话。 - 校验窗口属于预期进程和当前用户会话。
- 主窗口、搜索窗、确认窗分别建立作用域。 - 主窗口、**应用内业务子 pane**、搜索窗、确认窗分别建立作用域。
- 进入依赖完整侧栏/工具栏的页面时:**激活后最大化**;`activate` **禁止**对已最大化窗口无条件调用 `SW_RESTORE`(会还原成普通窗,导致折叠与坐标漂移)。仅最小化时才 Restore。
- 应用可能重建窗口;页面切换后重新取 HWND 和 UIA root。 - 应用可能重建窗口;页面切换后重新取 HWND 和 UIA root。
- 自动化进程与目标进程权限级别必须兼容;不要为了绕过权限注入目标进程。 - 自动化进程与目标进程权限级别必须兼容;不要为了绕过权限注入目标进程。
- 多开场景必须由账号、PID或明确窗口信息选择,歧义时停止。 - 多开场景必须由账号、PID或明确窗口信息选择,歧义时停止。
@@ -102,11 +113,13 @@ example 只提炼通用模式;企业微信名称、窗口类、控件名、列
### 点击 ### 点击
1. 激活目标窗口。 1. 激活目标窗口(主窗按需最大化;子对话框不要误 Maximize)。
2. 重新定位元素。 2. 重新定位元素。
3. 检查边界矩形。 3. 检查边界矩形。
4. 使用 UIA Click/Invoke 或正常鼠标点击。 4. 使用 UIA Click/Invoke 或正常鼠标点击。
5. 等待后置窗口/控件/业务状态。 5. 等待后置窗口/控件/业务状态(如标题文案、专用容器、子窗口 Class)。
关键导航建议日志:`locate`(策略/文案)→ `click` → `verify`(后置)。这与 [`LOGGING.md`](LOGGING.md)「不必每个 Click 一行」不冲突:要求的是**关键跳转可观测**,不是像素级刷屏。
UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。 UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。
@@ -133,10 +146,11 @@ UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降
先检查: 先检查:
1. 权限级别和 Windows 会话是否一致。 1. 权限级别和 Windows 会话是否一致。
2. 是否选错进程或子窗口。 2. 是否选错进程或子窗口;主窗是否未最大化导致侧栏未实例化。
3. 控件是否离屏或尚未实例化。 3. 控件是否离屏或尚未实例化。
4. 应用是否为 Electron(可评估 Playwright)。 4. 应用是否为 Electron(可评估 Playwright)。
5. 是否为自绘、游戏引擎、远程桌面或安全控件。 5. 是否为自绘、游戏引擎、远程桌面或安全控件。
6. 空树/树抖动时:最大化主窗;用 Inspect/影刀附着一次后再 probe(远程桌面工具可能干扰无障碍树)。
只有控件树确认不可用时才使用图像: 只有控件树确认不可用时才使用图像:
@@ -153,11 +167,15 @@ U盾、人脸、动态口令和安全键盘交给人工,不用 OCR、Hook 或
桌面 `health` / `probe-ui` 至少输出: 桌面 `health` / `probe-ui` 至少输出:
- UIA 依赖是否可用。 - UIA 依赖是否可用。
- 目标进程和主窗口是否存在。 - 目标进程和主窗口是否存在;是否已最大化(若业务需要)。
- 关键窗口/控件是否各唯一匹配。 - 关键窗口/控件是否各唯一匹配。
- 当前窗口标题、Class、PID。 - 当前窗口标题、Class、PID。
- DPI/缩放诊断。 - DPI/缩放诊断。
example 里的最小 `probe`(只探主窗+个别入口)**不等于**生产 probe 完整度;业务技能应对关键跳转节点分别探测或做深探。
手开 PowerShell 跑 `stats` / `health` / dump 时,必须显式对齐宿主的 `CLAW_USER_ID` / 数据根,否则会读到默认用户空库,误判熔断/冷却。
定位失败保存: 定位失败保存:
- 目标窗口和全屏截图。 - 目标窗口和全屏截图。

View File

@@ -43,6 +43,8 @@
未完成第 2~6 步时,代码只能保留明确的 `TODO: NEED_REAL_UI_INSPECTION`,不得写“看起来可能正确”的 selector。 未完成第 2~6 步时,代码只能保留明确的 `TODO: NEED_REAL_UI_INSPECTION`,不得写“看起来可能正确”的 selector。
开发期推荐使用独立仓 [jiangchang-ui-recorder](https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder)(本机常见路径 `D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`,`python run.py`):以 **Raw Event Journal → Action → Element Catalog → 异步 Observation** 录制,优先把 `action_flow.json` 与 `element_catalog.json` 交给 AI 编程工具;原始事件可重新归并,tree/index 作为诊断证据。默认不再让完整页面 dump 阻塞动作采集。本模板仓内的 `tools/ui_inspector` 已移除,勿再从 skill-template 启动。
## 3. 元素清单 ## 3. 元素清单
每个关键输入、按钮、列表、弹窗、成功状态和错误状态都必须登记。推荐 YAML: 每个关键输入、按钮、列表、弹窗、成功状态和错误状态都必须登记。推荐 YAML:
@@ -155,7 +157,7 @@ verified_at: "YYYY-MM-DD"
1. 先阅读本总标准和对应分端手册。 1. 先阅读本总标准和对应分端手册。
2. 先输出业务步骤表和元素清单,再生成代码。 2. 先输出业务步骤表和元素清单,再生成代码。
3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。 3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。桌面端须同时记录 **Name 与 Value**(可见中文常在 Value;见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md) §2)。
4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。 4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。
5. 为每个动作实现前置状态、定位、动作和后置状态。 5. 为每个动作实现前置状态、定位、动作和后置状态。
6. 把诊断探针、界面树导出和失败截图作为实现的一部分。 6. 把诊断探针、界面树导出和失败截图作为实现的一部分。

View File

@@ -104,7 +104,7 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均
{...}/{skill_slug}.db {...}/{skill_slug}.db
``` ```
**业务文件(下载、导入、导出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。 **业务文件(输入、输出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。用户可见目录为 `input/`(上行)与 `output/`(下行)。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。
- 业务表使用英文 snake_case;中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。 - 业务表使用英文 snake_case;中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。
- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。 - 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。

View File

@@ -19,7 +19,7 @@
1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。 1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。
2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。 2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。 3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格语义标色;省略 = P0。
4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。 4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。
5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。 5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。
6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。 6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。
@@ -38,7 +38,7 @@
| 值 | 宿主行为 | 超时 | 任务中心 | | 值 | 宿主行为 | 超时 | 任务中心 |
|----|----------|------|----------| |----|----------|------|----------|
| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **30s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 | | **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
| **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 | | **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 |
直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。 直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。
@@ -48,7 +48,7 @@
| 场景 | 建议 | | 场景 | 建议 |
|------|------| |------|------|
| 浏览器 / Playwright / RPA 主路径 | `async` + 建议 `confirmation` | | 浏览器 / Playwright / RPA 主路径 | `async` + 建议 `confirmation` |
| 单次可能超过 ~20s 的网络/下载/同步 | `async` | | 单次可能接近或超过约 60s 的网络/下载/同步 | `async` |
| `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` | | `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` |
| 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 | | 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 |
@@ -66,7 +66,7 @@
|-------------------------|-------------------|--------|--------------| |-------------------------|-------------------|--------|--------------|
| **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 | | **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 |
| **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 | | **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 |
| **数据管理 · 行内** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 同行操作链接(需主键) | | **数据管理 · 行级** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 操作列和/或**行右键**(及可选「⋯」)可见并触发(需主键);技能配什么就显示什么 |
| **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 | | **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 |
| **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 | | **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 |
| **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement | | **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement |
@@ -141,7 +141,7 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context
| 能力 | 约定 | | 能力 | 约定 |
|------|------| |------|------|
| 队列表 | `status`: `pending` → `processing` → `success` / `failed` | | 队列表 | `status`: `pending` → `processing` → `success` / `failed` |
| 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/imports/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) | | 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/input/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) |
| 消费 | `run --pick N`,默认仅 `pending` | | 消费 | `run --pick N`,默认仅 `pending` |
| 重试 | `reset` 或数据管理改 `pending`;**failed 不要自动重试** | | 重试 | `reset` 或数据管理改 `pending`;**failed 不要自动重试** |
| 批次 | 可选 `batch_id`,便于筛选与任务中心对照 | | 批次 | 可选 `batch_id`,便于筛选与任务中心对照 |

View File

@@ -5,24 +5,24 @@
## 状态 ## 状态
- **技术路线已真实验证**:Windows UIA + Python `uiautomation` - **技术路线已真实验证**:Windows UIA + Python `uiautomation`
- **来源业务已真实跑通**:企业微信添加客户 - **来源业务已真实跑通**:企业微信添加客户(通讯录 → 新的客户 → 添加 → 搜索 → 发送)
- **本目录默认安全模式**:只提供 probe、dry-run、assisted 范式,不默认发送真实邀请 - **本目录默认安全模式**:只提供 probe、dry-run、assisted 范式,不默认发送真实邀请
- **不是完整 skill 仓库**:按 Copy map 选择性复制 - **不是完整 skill 仓库**:按 Copy map 选择性复制
## 先读 ## 先读
1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md) 1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md)
2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md) 2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md)(含 Name/Value、最大化激活、同名叶子)
3. 本 README 的“原始拾取到生产定位器” 3. 本 README 的“原始拾取到生产定位器”
## 成功案例验证了什么 ## 成功案例验证了什么
```text ```text
枚举目标顶层窗口 枚举目标顶层窗口
→ 按进程/窗口类/标题确认作用域 → 激活并最大化主窗(勿无条件 SW_RESTORE)
→ 从 HWND 获取 UIA root → 从 HWND 获取 UIA root / 业务子 pane
→ 在父容器中定位元素 → 在父容器中定位(Name 与 Value 分开看)
→ 激活窗口并真实点击/输入 → 真实点击并校验后置状态
→ 等待子窗口或业务结果 → 等待子窗口或业务结果
→ 处理独立/嵌套弹窗 → 处理独立/嵌套弹窗
→ probe-ui 诊断 → probe-ui 诊断
@@ -30,6 +30,8 @@
来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。 来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。
本目录 `probe()` 是**最小探针**(主窗 + 通讯录入口,可选尝试新的客户行),**不等于**生产技能完整 probe。
## 目录结构 ## 目录结构
```text ```text
@@ -51,25 +53,39 @@ real_desktop_rpa/
## 原始拾取到生产定位器 ## 原始拾取到生产定位器
来源案例的影刀 `selectorsV2` 导出通常包含: ### 搜索框(添加客户窗)
```text ```text
Window(class=SearchExternalsWnd,title=添加客户) 原始:Window(SearchExternalsWnd) → 多层 Layout/index → search_layout → searchedit
→ 多层 Layout/index 提炼:窗口 → search_layout → Name=searchedit
→ search_layout ```
→ searchedit
### 新的客户(易错)
```text
原始:通讯录 pane → wlist_view → item[2] → status_layout → main_chattext
提炼:wlist_view 内找 子控件 main_chattext.Value=新的客户 的那一行,再点该行 status_layout
禁止:全树第一个 Name=status_layout(常为「群聊」)
禁止:把选择器标题「新的客户」当成 UIA Name 去搜
```
### 添加按钮
```text
提炼:search_external → wbtn_container(子文案 Value=添加)
禁止:搜索旁 plus_button 下的同名 wbtn_container
``` ```
正确提炼方式: 正确提炼方式:
1. 保留稳定顶层窗口:进程、Window Class、标题。 1. 保留稳定顶层窗口:进程、Window Class、标题;需要时加上业务子 pane。
2. 保留最小业务父容器:如 `search_layout`。 2. 保留最小业务父容器。
3. 使用叶子元素稳定属性:如 `Name=searchedit`。 3. 叶子用稳定 Name **和/或** Value / 子控件 Value。
4. 删除只反映当前布局的深层 `index`。 4. 删除只反映当前布局的深层 index;index 仅作已验证兜底并写清含义。
5. 验证父容器内候选数量为 `1`。 5. 验证父容器内候选数量为 `1`。
6. 重启应用后再次验证。 6. 重启应用后再次验证。
不要把完整 XML 路径或 `item[0]` 原样变成生产定位器。 不要把完整 XML 路径或未校验的 `item[0]` 原样变成生产定位器。
## Element map ## Element map
@@ -79,8 +95,9 @@ Window(class=SearchExternalsWnd,title=添加客户)
- 窗口不唯一时失败,不取 `hwnds[0]`。 - 窗口不唯一时失败,不取 `hwnds[0]`。
- 控件不唯一时失败,不取第一个匹配。 - 控件不唯一时失败,不取第一个匹配。
- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器。 - `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器,并用文案校验。
- 行顺序不是身份;用业务文案或稳定属性选择结果行。 - 行顺序不是身份;用业务文案(Value)选择结果行。
- 主窗依赖完整侧栏时先 maximize;`activate(maximize=False)` 不得冲掉已最大化状态。
- 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。 - 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。
## 三种实施模式 ## 三种实施模式
@@ -97,8 +114,8 @@ Window(class=SearchExternalsWnd,title=添加客户)
| 来源 | 目标 | 说明 | | 来源 | 目标 | 说明 |
|---|---|---| |---|---|---|
| `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活 | | `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活/最大化 |
| `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位、点击、输入 | | `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位(含 Value)、点击、输入 |
| `scripts/util/selectors.py` | `scripts/util/<platform>_selectors.py` | element spec;必须全部替换 | | `scripts/util/selectors.py` | `scripts/util/<platform>_selectors.py` | element spec;必须全部替换 |
| `scripts/service/wecom_reference_flow.py` | `scripts/service/<business>_flow.py` | 只复制流程分层,不复制企微 selector | | `scripts/service/wecom_reference_flow.py` | `scripts/service/<business>_flow.py` | 只复制流程分层,不复制企微 selector |
| `assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实拾取后填写 | | `assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实拾取后填写 |
@@ -118,14 +135,14 @@ Window(class=SearchExternalsWnd,title=添加客户)
## 禁止照抄 ## 禁止照抄
- `WXWork`、`WeWorkWindow`、`SearchExternalsWnd` - `WXWork`、`WeWorkWindow`、`SearchExternalsWnd`
- `org_op_new`、`searchedit`、`wbtn_container` - `org_op_new`、`searchedit`、`wbtn_container`、`main_chattext`
- 企业微信列表第 0/1 行含义 - 企业微信列表第 0/1/2 行含义或固定 index
- 来源案例的本机路径、账号和业务数据 - 来源案例的本机路径、账号和业务数据
- 任何未经目标软件真实拾取验证的 fallback - 任何未经目标软件真实拾取验证的 fallback
## 验收 ## 验收
- `probe` 能明确报告依赖、窗口和关键元素 - `probe` 能明确报告依赖、窗口和关键元素(生产技能覆盖关键跳转节点)
- 每个关键元素在目标作用域内唯一 - 每个关键元素在目标作用域内唯一
- 关闭重开软件后仍能定位 - 关闭重开软件后仍能定位
- 多窗口时不会误选 - 多窗口时不会误选

View File

@@ -2,6 +2,10 @@ source_case: add-friend-wecom
status: verified_source_redacted_example status: verified_source_redacted_example
surface: windows surface: windows
driver: uiautomation driver: uiautomation
notes:
- 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label
- 禁止全树第一个 status_layout / wbtn_container
- 主窗流程先 maximize;activate 勿无条件 SW_RESTORE
elements: elements:
- logical_name: main_window - logical_name: main_window
purpose: 企业微信主窗口 purpose: 企业微信主窗口
@@ -11,7 +15,7 @@ elements:
window_class: WeWorkWindow window_class: WeWorkWindow
title: 企业微信 title: 企业微信
expected_count: 1 expected_count: 1
postcondition: UIA root 可读取 postcondition: UIA root 可读取;业务需要时已最大化
replace_in_new_skill: true replace_in_new_skill: true
- logical_name: contacts_entry - logical_name: contacts_entry
@@ -21,8 +25,57 @@ elements:
primary_locator: primary_locator:
name: org_op_new name: org_op_new
control_type: TreeItem control_type: TreeItem
value_hint: 通讯录
expected_count: 1 expected_count: 1
postcondition: 通讯录业务面板出现 postcondition: 出现企业微信-通讯录业务 pane
replace_in_new_skill: true
- logical_name: contacts_pane
purpose: 通讯录业务子层作用域
scope:
logical_parent: main_window
primary_locator:
name_contains: 通讯录
expected_count: 1
postcondition: 可枚举 wlist_view
replace_in_new_skill: true
- logical_name: new_customer_row
purpose: 侧栏进入「新的客户」
scope:
logical_parent: contacts_pane
ancestor_name: wlist_view
primary_locator:
row_leaf_name: status_layout
child_name: main_chattext
child_value: 新的客户
expected_count: 1
postcondition: panel_title_label Value=新的客户 或出现 search_external
failure_note: 禁止点全树第一个 status_layout(常为群聊)
replace_in_new_skill: true
- logical_name: panel_title
purpose: 校验已进入新的客户页
scope:
logical_parent: contacts_pane
primary_locator:
name: panel_title_label
value: 新的客户
expected_count: 1
replace_in_new_skill: true
- logical_name: add_customer_button
purpose: 打开添加客户窗口
scope:
logical_parent: contacts_pane
ancestor_name: search_external
primary_locator:
name: wbtn_container
child_name: ww_btn_text
child_value: 添加
expected_count: 1
postcondition: 出现 SearchExternalsWnd
failure_note: 禁止点搜索旁 plus_button 下的 wbtn_container
replace_in_new_skill: true replace_in_new_skill: true
- logical_name: add_customer_window - logical_name: add_customer_window

View File

@@ -10,3 +10,17 @@
<uia name="inner_search_layout" index="1" /> <uia name="inner_search_layout" index="1" />
<uia name="searchedit" index="1" /> <uia name="searchedit" index="1" />
</selector> </selector>
<!--
新的客户:长路径示意 → 生产应改为
wlist_view 内 child main_chattext.Value=新的客户 的行,再点该行 status_layout。
禁止全树第一个 status_layout。
-->
<selector name="new_customer_row" source="inspector-export">
<window process="WXWork" class="WeWorkWindow" title="企业微信" />
<pane name="企业微信-通讯录" />
<uia name="wlist_view" />
<uia name="item" index="2" />
<uia name="status_layout" />
<uia name="main_chattext" value="新的客户" />
</selector>

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import ctypes import ctypes
import ntpath import ntpath
import time
from ctypes import wintypes from ctypes import wintypes
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Iterable
@@ -31,6 +32,7 @@ class WindowCandidate:
user32 = ctypes.windll.user32 user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32 kernel32 = ctypes.windll.kernel32
SW_RESTORE = 9 SW_RESTORE = 9
SW_MAXIMIZE = 3
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
@@ -105,7 +107,23 @@ def resolve_unique_window(
return matched[0] return matched[0]
def activate_window(window: WindowCandidate) -> None: def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None:
user32.ShowWindow(window.hwnd, SW_RESTORE) """激活窗口。
if not user32.SetForegroundWindow(window.hwnd):
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={window.hwnd}") maximize=True:主窗进入依赖完整侧栏的流程时使用。
maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。
"""
hwnd = window.hwnd
iconic = bool(user32.IsIconic(hwnd))
zoomed = bool(user32.IsZoomed(hwnd))
if maximize:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not zoomed:
user32.ShowWindow(hwnd, SW_MAXIMIZE)
else:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not user32.SetForegroundWindow(hwnd):
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}")
time.sleep(0.35 if maximize else 0.2)

View File

@@ -22,6 +22,15 @@ def root_from_window(window: WindowCandidate):
return auto.ControlFromHandle(window.hwnd) return auto.ControlFromHandle(window.hwnd)
def control_value(control: Any) -> str:
try:
auto = require_uiautomation()
raw = control.GetPropertyValue(auto.PropertyId.ValueValueProperty)
return str(raw or "").strip()
except Exception:
return ""
def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]: def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
if depth > max_depth: if depth > max_depth:
return return
@@ -36,9 +45,21 @@ def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
yield from _walk(child, depth=depth + 1, max_depth=max_depth) yield from _walk(child, depth=depth + 1, max_depth=max_depth)
def _child_has_value(control: Any, child_name: str, expected_value: str, *, max_depth: int = 10) -> bool:
for node in _walk(control, max_depth=max_depth):
if (getattr(node, "Name", None) or "") != child_name:
continue
if control_value(node) == expected_value:
return True
return False
def _matches(control: Any, spec: ElementSpec) -> bool: def _matches(control: Any, spec: ElementSpec) -> bool:
if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name: if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name:
return False return False
if spec.value is not None and not spec.child_value_name:
if control_value(control) != spec.value:
return False
if spec.automation_id is not None and ( if spec.automation_id is not None and (
getattr(control, "AutomationId", None) or "" getattr(control, "AutomationId", None) or ""
) != spec.automation_id: ) != spec.automation_id:
@@ -52,6 +73,9 @@ def _matches(control: Any, spec: ElementSpec) -> bool:
getattr(control, "ClassName", None) or "" getattr(control, "ClassName", None) or ""
) != spec.class_name: ) != spec.class_name:
return False return False
if spec.child_value_name and spec.value:
if not _child_has_value(control, spec.child_value_name, spec.value):
return False
return True return True
@@ -64,7 +88,9 @@ def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
) )
search_root = locate_unique(root, ancestor_spec, max_depth=max_depth) search_root = locate_unique(root, ancestor_spec, max_depth=max_depth)
matched = [control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec)] matched = [
control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec)
]
if not matched: if not matched:
raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到") raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != spec.expected_count: if len(matched) != spec.expected_count:
@@ -75,8 +101,13 @@ def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
return matched[0] return matched[0]
def click_control(control: Any, *, window: WindowCandidate) -> None: def click_control(
activate_window(window) control: Any,
*,
window: WindowCandidate,
maximize: bool = False,
) -> None:
activate_window(window, maximize=maximize)
rect = control.BoundingRectangle rect = control.BoundingRectangle
if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000: if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000:
raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效") raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效")
@@ -89,7 +120,7 @@ def click_control(control: Any, *, window: WindowCandidate) -> None:
def type_text(control: Any, text: str, *, window: WindowCandidate) -> None: def type_text(control: Any, text: str, *, window: WindowCandidate) -> None:
auto = require_uiautomation() auto = require_uiautomation()
click_control(control, window=window) click_control(control, window=window, maximize=False)
auto.SendKeys("{Ctrl}a", waitTime=0.05) auto.SendKeys("{Ctrl}a", waitTime=0.05)
auto.SendKeys("{Delete}", waitTime=0.05) auto.SendKeys("{Delete}", waitTime=0.05)
for char in text: for char in text:

View File

@@ -5,28 +5,49 @@ from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from typing import Any from typing import Any
from service.desktop_session import resolve_unique_window from service.desktop_session import activate_window, resolve_unique_window
from service.uia_locator import locate_unique, root_from_window, type_text from service.uia_locator import locate_unique, root_from_window, type_text
from util.selectors import ADD_CUSTOMER_WINDOW, CONTACTS_ENTRY, MAIN_WINDOW, SEARCH_INPUT from util.selectors import (
ADD_CUSTOMER_WINDOW,
CONTACTS_ENTRY,
MAIN_WINDOW,
NEW_CUSTOMER_ROW,
SEARCH_INPUT,
)
def probe() -> dict[str, Any]: def probe() -> dict[str, Any]:
"""最小 probe:主窗 + 通讯录入口。
生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。
调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。
"""
main = resolve_unique_window(MAIN_WINDOW) main = resolve_unique_window(MAIN_WINDOW)
activate_window(main, maximize=True)
root = root_from_window(main) root = root_from_window(main)
contacts = locate_unique(root, CONTACTS_ENTRY) contacts = locate_unique(root, CONTACTS_ENTRY)
return { payload: dict[str, Any] = {
"success": True, "success": True,
"minimized_probe": True,
"main_window": asdict(main), "main_window": asdict(main),
"contacts_entry": { "contacts_entry": {
"name": getattr(contacts, "Name", ""), "name": getattr(contacts, "Name", ""),
"control_type": getattr(contacts, "ControlTypeName", ""), "control_type": getattr(contacts, "ControlTypeName", ""),
}, },
} }
# 可选:若已在通讯录页,尝试按 Value 定位「新的客户」行(失败不视为 probe 失败)
try:
row = locate_unique(root, NEW_CUSTOMER_ROW)
payload["new_customer_row"] = {"name": getattr(row, "Name", "")}
except Exception:
payload["new_customer_row"] = None
return payload
def fill_search_dry_run(target: str) -> dict[str, Any]: def fill_search_dry_run(target: str) -> dict[str, Any]:
"""要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。""" """要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。"""
search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW) search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW)
activate_window(search_window, maximize=False)
root = root_from_window(search_window) root = root_from_window(search_window)
search_input = locate_unique(root, SEARCH_INPUT) search_input = locate_unique(root, SEARCH_INPUT)
type_text(search_input, target, window=search_window) type_text(search_input, target, window=search_window)

View File

@@ -18,10 +18,16 @@ class WindowSpec:
class ElementSpec: class ElementSpec:
logical_name: str logical_name: str
name: Optional[str] = None name: Optional[str] = None
"""UIA Name(内部 id,未必是界面中文)。"""
value: Optional[str] = None
"""UIA Value(可见中文文案常在此)。"""
automation_id: Optional[str] = None automation_id: Optional[str] = None
control_type: Optional[str] = None control_type: Optional[str] = None
class_name: Optional[str] = None class_name: Optional[str] = None
ancestor_name: Optional[str] = None ancestor_name: Optional[str] = None
"""先定位到该 Name 的祖先,再在其下搜索。"""
child_value_name: Optional[str] = None
"""在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。"""
expected_count: int = 1 expected_count: int = 1
@@ -37,6 +43,19 @@ CONTACTS_ENTRY = ElementSpec(
name="org_op_new", name="org_op_new",
control_type="TreeItem", control_type="TreeItem",
) )
# 示意:用子控件 main_chattext 的 Value 定位「新的客户」行(禁止全树第一个 status_layout)
NEW_CUSTOMER_ROW = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
ADD_BUTTON = ElementSpec(
"add_customer_button",
name="wbtn_container",
ancestor_name="search_external",
)
SEARCH_INPUT = ElementSpec( SEARCH_INPUT = ElementSpec(
"search_input", "search_input",
name="searchedit", name="searchedit",

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import sys import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -11,9 +12,11 @@ sys.path.insert(0, str(ROOT / "scripts"))
from service.desktop_session import ( # noqa: E402 from service.desktop_session import ( # noqa: E402
DesktopRpaError, DesktopRpaError,
WindowCandidate, WindowCandidate,
activate_window,
resolve_unique_window, resolve_unique_window,
) )
from util.selectors import MAIN_WINDOW # noqa: E402 from service import uia_locator # noqa: E402
from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402
class SelectorContractTest(unittest.TestCase): class SelectorContractTest(unittest.TestCase):
@@ -39,6 +42,58 @@ class SelectorContractTest(unittest.TestCase):
resolve_unique_window(MAIN_WINDOW, candidates=[hidden]) resolve_unique_window(MAIN_WINDOW, candidates=[hidden])
self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND") self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND")
def test_activate_without_maximize_skips_restore_when_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = True
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=False)
user32.ShowWindow.assert_not_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_activate_maximize_when_not_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = False
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=True)
user32.ShowWindow.assert_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_locate_unique_matches_child_value(self) -> None:
row = MagicMock()
row.Name = "status_layout"
row.AutomationId = ""
row.ControlTypeName = "TreeControl"
row.ClassName = ""
label = MagicMock()
label.Name = "main_chattext"
label.GetChildren.return_value = []
row.GetChildren.return_value = [label]
root = MagicMock()
root.Name = "wlist_view"
root.AutomationId = ""
root.ControlTypeName = "TreeControl"
root.ClassName = ""
root.GetChildren.return_value = [row]
with patch.object(uia_locator, "control_value", side_effect=lambda c: "新的客户" if c is label else ""):
spec = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
# ancestor is root itself when searching from root with name wlist_view
found = uia_locator.locate_unique(root, ElementSpec("wlist", name="wlist_view"))
self.assertIs(found, root)
found_row = uia_locator.locate_unique(root, spec)
self.assertIs(found_row, row)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -72,34 +72,46 @@ Action 是**可选能力**:
|------|------|------| |------|------|------|
| `schemaVersion` | 是 | 当前固定为 `1` | | `schemaVersion` | 是 | 当前固定为 `1` |
| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 | | `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 |
| `actions` | 是 | Action 数组,每项 `id` 唯一 | | `actions` | 是 | Action 数组,每项 `id` 唯一;**数组相对顺序**决定数据管理 toolbar / row / batch 技能按钮的展示顺序(见下节) |
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。 模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) 与 [`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
## 五职责正交(必读) ## 六职责正交(必读)
| 字段 / 层 | 只决定什么 | 不决定什么 | | 字段 / 层 | 只决定什么 | 不决定什么 |
|-----------|------------|------------| |-----------|------------|------------|
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async | | **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 |
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除」 | 不强制;无配置时宿主仍直写 SQLite |
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | | **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口 | | **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 |
| **`cellPresentation`** | **可选**:某表某行某列的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) |
| **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 |
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 | | **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 | | **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。 一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。需要按用户/角色藏按钮或滤行时,**不要**按角色复制 Action,应启用顶层 `policy` 并由技能算出 Snapshot(见 [`POLICY.md`](POLICY.md))。
## Phase 1 当前稳定支持(新技能应只使用这些) ## Phase 1 当前稳定支持(新技能应只使用这些)
| 层级 | 稳定字段 / 能力 | | 层级 | 稳定字段 / 能力 |
|------|----------------| |------|----------------|
| 顶层 | `schemaVersion`、`skill`、`actions` | | 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(单元格呈现启用块) |
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`bind`** | | action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) |
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) | | entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron` | | placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` — 接管默认新增/编辑/删除(须 `bind.tables`) |
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) | | **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` | | **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 |
| confirmation | `{ "message": "..." }` | | **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载单元格呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly`;可选 `display_type` / `format`(`textarea` / `date` / `datetime_unix_seconds` / `url` / `email`) |
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) | | **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
| **hostEffects** | 可选;`onSuccess` / `onFailure` → `reloadTable` \| `refreshCatalog` \| `clearSelection`(匠厂宿主 ≥2.8.5) |
| **steps** | 可选;有序 `{ when, do, table?, fk? }`,`when`=`before`\|`afterSuccess`\|`afterFailure`;新技能优先(≥2.8.5) |
## `bind.tables`(表级绑定) ## `bind.tables`(表级绑定)
@@ -122,6 +134,203 @@ Action 是**可选能力**:
- **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。 - **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。
- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。 - `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。
## `replaces`(可选:接管默认新增 / 编辑 / 删除)
有领域副作用的表(建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明:
```json
{
"id": "account-add-web",
"label": "新增账号",
"description": "登记网页账号并初始化 profile 目录。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": { "tables": ["accounts"] },
"inputSchema": {
"type": "object",
"required": ["platform_key", "account_label"],
"properties": {
"platform_key": { "type": "string", "title": "平台" },
"account_label": { "type": "string", "title": "账号名称" }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["add-web", "--platform", "{{platform_key}}", "--label", "{{account_label}}"]
}
}
```
规则:
- **全可选**:无 `replaces` 时,宿主默认按钮仍直写库,行为与以前一致。
- 取值:`create`(新增)/ `update`(编辑)/ `delete`(删除)。
- **必须**非空 `bind.tables`;同一技能同一表同一 `replaces` 至多一个 Action。
- 与 `placements` 正交:可只接管默认按钮(`placements: []`),不必再挂 toolbar「第二个新增」。
- 点默认按钮时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。
- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。
- 宿主导入仍直写:有 `replaces:create` 的表慎用宿主导入,或另做 import Action。
- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。
- **列元数据复用(lookup)**:create/update 抽屉可将 `inputSchema` 字段对齐业务列,复用中文名与 lookup 下拉:
- 优先让属性名 = 物理列名(如 `platform_key`);
- 或 `bind.columnMapping`:`{ "platform": "platform_key" }`;
- 或字段上写 `"column": "platform_key"`。
- **不要**在 actions.json 里写死会过期的全量平台 `enum`。
## `interaction`(点击交互方式)
与 `executionProfile` **正交**:一个 Action 可以是 `async` + `direct`(一点就进任务中心),也可以是 `sync` + `form`(填参后当场返回)。
| 值 | 数据管理体验 | 典型场景 |
|----|--------------|----------|
| `direct` | **一点即跑**,无弹窗 | 打开文件位置、无参只读、行内主键已映射 |
| `confirm` | 轻量确认弹窗(无参数区;文案优先 `confirmation.message`) | 打开浏览器、有副作用但无可编辑入参 |
| `form` | 完整参数表单(现状默认路径) | 导入、查询条件、需用户改写的字段 |
| `auto` / 省略 | 宿主推断:有**可编辑**参数 → `form`;仅有 `confirmation` → `confirm`;否则 → `direct` | 旧技能兼容;新技能建议显式写出 |
```json
{
"id": "reveal-profile",
"label": "打开文件位置",
"description": "在资源管理器中打开 Profile 目录。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": { "type": "number", "title": "账号 ID", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["reveal-profile", "{{accountId}}"]
}
}
```
```json
{
"id": "open-browser",
"label": "打开浏览器",
"placements": ["row"],
"executionProfile": "async",
"interaction": "confirm",
"confirmation": { "message": "将用该账号 Profile 打开浏览器,仅供查看。" },
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": { "type": "number", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["open", "{{accountId}}"]
}
}
```
可选 `interactionByPlacement`:同一 Action 在不同入口用不同交互(例如 `row: "direct"`、`toolbar: "form"`)。值只能是 `direct`/`confirm`/`form`,不能写 `auto`。
**边界:**
- **cron 配置**:仍展示可编辑参数;`confirmation.message` 可作为配置页风险提示(由宿主决定)。
- **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。
- 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。
## `hostEffects` / `steps`(执行前后宿主编排,≥2.8.5)
与 `placements` / `executionProfile` / `interaction` **正交**。决定的是:数据管理里点完 Action 之后(或之前),宿主要不要**刷新当前表、刷新目录、清空多选、级联删子表**等。
- **省略 / 空** ⇒ 宿主**不**额外改 UI(≥2.8.5 起不再对 mutation 硬编码刷新)。本地库若已由技能写新,界面仍可能旧,直到用户点内置「刷新」或本 Action 声明了 `reloadTable`。
- **仅数据管理交互路径生效**(toolbar / row / batch / skill-detail / `replaces`);**cron 与 agent 忽略**(与 `interaction` 相同)。
- 需匠厂宿主 **≥ 2.8.5**;更旧宿主会忽略未知字段(若未做严格 Schema 校验)。
- **新技能优先写 `steps`**;`hostEffects` 仍可作为「只刷 UI」的简写。两者可并存;同一 UI 效果若两边都声明,宿主可能各跑一次。
### `hostEffects`(UI 副作用简写)
```json
{
"hostEffects": {
"onSuccess": ["reloadTable"],
"onFailure": ["clearSelection"]
}
}
```
| 字段 | 触发时机 |
|------|----------|
| `onSuccess` | **sync** 退出码 0;或 **async** 任务已被任务中心**接收**(不是 Job 跑完) |
| `onFailure` | **sync** 失败;或 **async** **启动被拒** |
闭集 effect id:`reloadTable`(重载当前表 + 常伴随 Data Policy 再拉)、`refreshCatalog`(刷新技能目录)、`clearSelection`(清空多选)。未知 id 丢弃。
**async 注意**:`sync-data` 一类长同步若写 `onSuccess: ["reloadTable"]`,会在**入队成功时**就刷表,可能仍是旧数据;更适合 sync 的新建/登记,或等 Job 真正完成后再刷(若宿主后续扩展完成回调)。
### `steps`(有序编排,推荐)
```json
{
"steps": [
{ "when": "before", "do": "deleteByFk", "table": "child_rows", "fk": "parent_id" },
{ "when": "afterSuccess", "do": "reloadTable" },
{ "when": "afterFailure", "do": "clearSelection" }
]
}
```
| `when` | 含义 |
|--------|------|
| `before` | 调用 CLI **之前** |
| `afterSuccess` | 同 `hostEffects.onSuccess` |
| `afterFailure` | 同 `hostEffects.onFailure` |
| `do` | 说明 |
|------|------|
| `reloadTable` / `refreshCatalog` / `clearSelection` | 同 hostEffects |
| `deleteByFk` | 按外键批量删子表;须 `table` + `fk`;行按钮会把当前行主键当父键 |
| `deleteRows` | 删指定表行;须 `table` |
规则:单次最多 **8** 步;未知步骤丢弃;`deleteByFk` / `deleteRows` 仅走 Skill Action steps 路径时可删 `metadata_readonly` 投影表(人工 CRUD 仍守只读)。整库只读 / 视图仍禁止写。
典型:业务表「新建 / 登记 / 更新」成功后加 `{ "when": "afterSuccess", "do": "reloadTable" }`,避免用户傻等旧表格。
```json
{
"id": "register-one",
"label": "登记本行",
"description": "更新当前行业务字段。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["business_records"],
"inputMapping": { "recordId": "$row.$pk" }
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "register",
"args": ["--id", "{{recordId}}"]
}
}
```
默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例: 默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例:
```json ```json
@@ -228,12 +437,44 @@ HINT: <next step>
| 值 | 含义 | | 值 | 含义 |
|----|------| |----|------|
| `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) | | `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) |
| `row` | 数据管理行内操作(建议 `bind.tables` + `inputMapping`;表需有主键) | | `row` | 数据管理**行级入口**(建议 `bind.tables` + `inputMapping`;表需有主键)。技能声明后,由宿主呈现为操作列链接、**行右键菜单**和/或行内「⋯」等壳;**不**新增 `row-context-menu` 等 placement |
| `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) | | `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) |
| `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) | | `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) |
| `agent` | Agent 可直接调用(`run_skill_action`) | | `agent` | Agent 可直接调用(`run_skill_action`) |
| `cron` | 定时任务「技能直调」 | | `cron` | 定时任务「技能直调」 |
### 行级入口 `row`(宿主呈现契约)
- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;配什么就出现什么。
- **宿主决定怎么摆**:可用操作列、行右键、行内「⋯」等呈现**同一批** Action;点击均走同一 `launch`(`placement: "row"`),`interaction` / `executionProfile` 与壳无关。
- **顺序**:各壳内技能项顺序 = 下方「数据管理技能按钮顺序」契约(`actions` 数组相对顺序)。
- **宿主内置项**:查看 / 编辑 / 删除由宿主提供,**不是**技能 `actions.json` 配置出来的;若出现在行右键,须与技能项分组(例如分隔线后),且不得要求技能再配一份菜单 DSL。
- **禁止**:为右键单独发明 placement、菜单注册表或第二套 Action 列表。
### 数据管理技能按钮顺序(宿主正式契约)
宿主**没有**单独的 `sortOrder` / `uiOrder` 字段。同一 `placement`(`toolbar` / `row` / `batch`)下,技能按钮的展示顺序 = `actions` 数组中**匹配该项 placement(且通过 `bind.tables` 过滤)的相对先后**。
规则:
1. 想调整顺序 → **调整 `actions` 数组先后**(不必改 `id` / `label`)。
2. 无关 placement 的 Action(如仅 `skill-detail` 的 `health`)夹在中间**不影响** toolbar/row 的相对顺序;宿主按匹配子集保序渲染。
3. 同一 Action 可同时挂多个 placement;它在各入口的位置,分别是该入口匹配子集里的相对位置。
4. **宿主内置按钮不参与本契约、位置固定**:
- 表顶栏右侧:删除(有勾选时)/ 刷新 / 新增 / 导入 / 导出 —— 技能 toolbar/batch 按钮在左侧,不能与右侧内置按钮混排。
- 行级:技能 `row` 项按本顺序出现在操作列 / 行右键(及可选「⋯」)中;宿主「查看」→(非只读)「编辑」→「删除」为内置项,位置由宿主固定(操作列常在技能项之后;右键中可与技能项分组)。
5. **表顶栏全部直接展示,无「更多操作」溢出菜单**:匹配到的 toolbar/batch 技能按钮一律渲染(顺序仍按本契约)。**行级**不以「顶栏溢出菜单」模型截断;行右键是 `row` 的正式呈现壳之一,不是 toolbar 式「更多操作」截断。
6. **表顶栏宽窄布局(宿主行为,技能无需配置)**:
- 够宽:左右各横排一行(左技能按钮 + 右宿主工具)。
- 不够宽:保持左右分栏,栏内按钮上下竖排(左栏技能、右栏刷新/导出等)。
禁止:
- 臆造 `sortOrder` / `order` / `priority` 等字段指望宿主排序(模板 Schema `additionalProperties: false` 会拒收)。
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
### Action 类型 × placements(常见示例,不是限制) ### Action 类型 × placements(常见示例,不是限制)
下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。 下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。
@@ -262,12 +503,53 @@ HINT: <next step>
## inputSchema 与参数表单 ## inputSchema 与参数表单
当前宿主稳定支持: ### 表单字段类型对照(须与 CRUD 对称)
- `object` / `string` / `number` / `boolean` Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件语义**。CRUD 侧配置见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」;本表只写 Action 怎么配。
| 控件语义 | Action `inputSchema` 怎么写 | CRUD 怎么写(对照) |
|----------|----------------------------|---------------------|
| 单行文本 | `type: string` | `display_type: text` 或缺省 |
| 多行文本 | `display_type` 或 `format`: `textarea` | `display_type: textarea` |
| JSON 长文本 | `type: object`/`array`(以 JSON 字符串编辑)或 `textarea` | `display_type: json` / `textarea` |
| 数字 | `type: number` | `display_type: number` 或 INT/REAL/NUMERIC |
| 开关 | `type: boolean` | `display_type: boolean` |
| 枚举下拉 | `enum: [...]` | `options_json` 数组 |
| 关联下拉 | 绑列 lookup(`column` / `columnMapping`) | `display_type: lookup` |
| 密码 | `sensitive: true` | 列 `sensitive` |
| 日期(日) | `display_type` 或 `format`: `date` | `display_type: date` → 存 `YYYY-MM-DD` |
| 日期时间 | `display_type` 或 `format`: `datetime_unix_seconds` | 同左;**存/传 Unix 秒** |
| URL(薄层) | `display_type` 或 `format`: `url` | `display_type: url` |
| Email(薄层) | `display_type` 或 `format`: `email` | `display_type: email` |
| 说明灰字 | `description` | `_jiangchang_columns.description` |
| 默认值 | `default` | SQLite 列 `DEFAULT` |
| 只读 | `readOnly: true` | 列 `editable: 0` / 宿主只读规则 |
**本版不做(勿宣传):** 文件上传、多选/标签数组、复杂 `oneOf` / nested object 专用表单。
日期时间约定:
- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致)
- `date` 只表示日历日,值为 `YYYY-MM-DD`
- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单
### 当前宿主稳定支持(摘要)
- 根:`object`;属性:`string` / `number` / `boolean`(`object`/`array` 仅作 JSON 多行编辑兼容,新技能慎用)
- `enum` / `default` / `required` / `sensitive` / `readOnly` - `enum` / `default` / `required` / `sensitive` / `readOnly`
- `display_type` / `format`:`textarea` | `date` | `datetime_unix_seconds` | `url` | `email`
- 属性级 `description`(表单标签下方灰字说明)
- 简单模板替换(`{{fieldName}}`) - 简单模板替换(`{{fieldName}}`)
**与数据管理「新增 / 编辑」表单的分工(勿混用配置入口):**
| 表单 | 短标题 | 说明灰字 | 默认值 | 控件类型 |
|------|--------|----------|--------|----------|
| Skill Action 弹窗 | `inputSchema.title` | `inputSchema.description` | `inputSchema.default` | `type` + `display_type`/`format` |
| 数据管理新增/编辑 | `_jiangchang_columns.display_name` | `_jiangchang_columns.description` | 物理列 SQLite `DEFAULT` | `display_type` + 物理类型推断 |
CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」「新增/编辑表单:字段说明与默认值」。同一业务含义两边文案与控件应对齐,但分别配置。
示例: 示例:
```json ```json
@@ -278,12 +560,31 @@ HINT: <next step>
"properties": { "properties": {
"query": { "query": {
"type": "string", "type": "string",
"title": "查询条件" "title": "查询条件",
"description": "支持关键词或完整业务编号"
}, },
"limit": { "limit": {
"type": "number", "type": "number",
"title": "返回数量", "title": "返回数量",
"description": "最多返回多少条;填 0 表示不限制(若业务支持)",
"default": 50 "default": 50
},
"dueDate": {
"type": "string",
"title": "截止日期",
"display_type": "date",
"description": "日历日,格式 YYYY-MM-DD"
},
"publishAt": {
"type": "number",
"title": "发布时间",
"display_type": "datetime_unix_seconds",
"description": "提交值为 Unix 秒"
},
"homepage": {
"type": "string",
"title": "主页链接",
"display_type": "url"
} }
} }
} }
@@ -308,6 +609,9 @@ HINT: <next step>
- `boolean` 在宿主中渲染为开关 - `boolean` 在宿主中渲染为开关
- `enum` 在宿主中渲染为选择框 - `enum` 在宿主中渲染为选择框
- `type: number` 渲染为数字输入
- `display_type`/`format: date` 渲染为日期(日);`datetime_unix_seconds` 渲染为日期时间并按 **Unix 秒**提交
- `display_type`/`format: url` / `email` 为薄层输入类型(校验从宽,勿当强校验网关)
- `required` 字段在提交前校验 - `required` 字段在提交前校验
- `sensitive` 字段不得出现在低信任入口 - `sensitive` 字段不得出现在低信任入口
- `readOnly: true` 时数据管理表单只读展示该字段(仍随提交传入);适合行内/勾选预填的主键、编号列表 - `readOnly: true` 时数据管理表单只读展示该字段(仍随提交传入);适合行内/勾选预填的主键、编号列表
@@ -315,8 +619,7 @@ HINT: <next step>
- 需要确认的副作用操作使用 `confirmation.message` - 需要确认的副作用操作使用 `confirmation.message`
- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明 - 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明
- `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null` - `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null`
- **不要**在当前模板中宣传 `nested object`、`oneOf`、复杂数组等尚未稳定支持的能力 - **不要**在当前模板中宣传文件上传、多选数组、`oneOf`、复杂 nested object 等尚未稳定支持的能力
## 风险与入口配置 ## 风险与入口配置
- 查询、诊断、版本检查可以放到 `skill-detail` 或 `agent` - 查询、诊断、版本检查可以放到 `skill-detail` 或 `agent`

View File

@@ -0,0 +1,210 @@
# 数据管理单元格呈现(Cell Presentation)契约(v1.0)
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。
> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。
---
## 0. 一句话
技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。
用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。
---
## 0.1 能力档位
| 档位 | 技能行为 | 宿主行为 |
|------|----------|----------|
| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 |
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 |
多数技能保持 **P0**。需要视觉强调时再启用。
---
## 1. 职责边界
| 角色 | 负责 | 不负责 |
|------|------|--------|
| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
**派生状态(重要):** 呈现是计算结果,不是粘性开关。
用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。
---
## 2. 文档形状(冻)
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"table": "business_records",
"rowKey": "id",
"generatedAt": "2026-08-10T06:00:00Z",
"cells": [
{
"rowId": "42",
"column": "qty",
"tone": "danger",
"message": "库存低于安全线",
"alert": true
},
{
"rowId": "42",
"column": "status",
"tone": "warning",
"message": "待审核"
}
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `schemaVersion` | 是 | 固定 `1` |
| `skill` | 是 | 与 slug 一致 |
| `table` | 是 | 当前文档作用的业务表 |
| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) |
| `generatedAt` | 否 | 排障用 |
| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** |
### 2.1 `cells[]` 项
| 字段 | 必填 | 说明 |
|------|------|------|
| `rowId` | 是 | `rowKey` 列值的字符串形式 |
| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 |
| `tone` | 是 | 见 §3 |
| `message` | 否 | 短说明(≤500);悬停 / 无障碍 |
| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 |
同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。
`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。
---
## 3. `tone` 闭集(冻)
| tone | 语义(技能怎么选) | 宿主怎么呈现(示例,主题可调) |
|------|-------------------|-------------------------------|
| `danger` | 必须立刻处理 / 已违规 / 严重超时 | 强强调(如浅红底) |
| `warning` | 即将到期 / 需注意 | 中等强调(如浅黄) |
| `info` | 提示性标记 | 轻强调(如浅蓝) |
| `success` | 正向完成态(少用在「待填」场景) | 浅绿等 |
| `muted` | 弱化 / 次要 | 灰底或灰字 |
**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。
以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。
「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。
---
## 4. 推荐持久化(给宿主实现)
v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列):
```sql
CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
row_key TEXT NOT NULL,
row_id TEXT NOT NULL,
column_name TEXT NOT NULL,
tone TEXT NOT NULL,
message TEXT,
alert INTEGER NOT NULL DEFAULT 0,
generated_at TEXT,
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE (table_name, row_id, column_name)
);
```
约定:
- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。
- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。
未创建该旁路表 = P0,宿主忽略。
---
## 5. 在 `actions.json` 中启用(可选,推荐)
与 `policy` 类似,顶层可选:
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"cellPresentation": {
"enabled": true
},
"actions": []
}
```
| 字段 | 说明 |
|------|------|
| `cellPresentation` | 省略 = P0 |
| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 |
v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。
---
## 6. 何时重算(技能必须遵守)
至少在以下时机用**同一套 domain 规则**重算并覆盖该表呈现:
1. 全量/增量把外部数据写入本地投影之后(如同步成功)
2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等)
3. (可选)用户显式「刷新呈现」类 Action
进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。
提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
---
## 7. 与其它契约的关系
| 契约 | 关系 |
|------|------|
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 |
| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 |
| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 |
| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 |
---
## 8. 反模式
- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案
- 技能下发 CSS / 内联颜色
- 只在首次超时时打标、填完不重算导致红字残留
- 宿主写死某个技能 slug 的特殊染色逻辑
- 把 Cell Presentation 做成仅服务 SLA 的专用协议
---
## 9. 示例场景(说明用途不限)
| 场景 | column | tone | message 例 |
|------|--------|------|------------|
| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 |
| 库存低于安全线 | `qty` | `warning` | 低于安全库存 |
| 审核驳回 | `status` | `danger` | 已驳回,请修改 |
| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 |
完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。

View File

@@ -45,6 +45,7 @@ python {baseDir}/scripts/main.py init-db
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。 - **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
- **`version`**:输出 JSON(`version`、`skill`)。 - **`version`**:输出 JSON(`version`、`skill`)。
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。 - **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。 - **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
- **排查顺序**(由快到慢): - **排查顺序**(由快到慢):
1. `python {baseDir}/scripts/main.py logs` 1. `python {baseDir}/scripts/main.py logs`

382
references/POLICY.md Normal file
View File

@@ -0,0 +1,382 @@
# Skill Data Policy 契约(v1.0)
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。
> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(`visibleWhen` 等)。
> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。
---
## 0. 能力档位(必须按此理解)
| 档位 | 技能配置 | 宿主行为 |
|------|----------|----------|
| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 |
| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 |
| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 |
| **L3** | (预留,本契约不冻结行为) | 如行态 `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等 |
**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。
---
## 0.1 已冻结最佳实践(v1.0)
| 议题 | 决定 | 技能开发者怎么做 |
|------|------|------------------|
| Skill Action 授权模式 | **仅** `allowlist` / `open`;v1 **禁止** `denylist` | 有角色就算出允许的 action id 列表;只控行不控按钮时用 `open` |
| `hostCapabilities` | **省略 = 不限制**宿主内置;写出则按 allowlist(`[]` = 全禁内置) | 只控业务按钮时**不要写**该字段;要限导出/删除时再显式列出 |
| scope 运算符 | 保留小白名单(见 §3.4);**禁止** like/raw/嵌套 | 日常优先 `eq` / `in` / `is_null`;比较符按需 |
| `valueFrom` | v1 **仅** `actor.userId` | 角色只在技能内用来算 Snapshot,**不要**用 `actor.roles` 做行过滤 |
---
## 1. 启用字段(冻在 `assets/actions.json` 顶层)
`actions.json` 根对象增加**可选**字段 `policy`(与 `schemaVersion` / `skill` / `actions` 同级)。
### 1.1 形状(L0/L1 必冻)
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"policy": {
"enabled": true,
"entrypoint": {
"type": "cli",
"command": "policy",
"args": ["--table", "{{table}}"]
},
"appliesTo": ["data-management"]
},
"actions": []
}
```
| 字段 | 必填 | 冻结说明 |
|------|------|----------|
| `policy` | 否 | 缺省 = L0 |
| `policy.enabled` | 若存在 `policy` 则**必填** | 仅 `true` 启用;`false` 等同 L0 |
| `policy.entrypoint` | `enabled: true` 时必填 | 与 Action `entrypoint` 同构:`type` 目前仅 `"cli"` |
| `policy.entrypoint.command` | 是 | 子命令名;**推荐**固定为 `policy` |
| `policy.entrypoint.args` | 是 | JSON 数组;可含占位符,由宿主替换后再拼接 CLI |
| `policy.appliesTo` | 否 | 缺省 `["data-management"]`。本期宿主**只实现** `data-management`;其它值忽略但不报错 |
### 1.2 占位符白名单(冻)
| 占位符 | 含义 | 例 |
|--------|------|----|
| `{{table}}` | 当前数据管理选中的**物理表名** | `customs_timelines` |
| `{{skill}}` | 当前技能 slug | 与 manifest `skill` 一致 |
| `{{userId}}` | 当前宿主用户 id | 与注入的 `JIANGCHANG_USER_ID` 一致 |
未列出的 `{{…}}`:**禁止**;宿主应视为契约错误(fail-closed,见 §6)。
### 1.3 显式不放进启用块的内容(防推翻)
以下**不得**出现在 `actions.json.policy` 中(属于技能内部或 Snapshot):
- 角色列表、人员表、飞书 app id、外部 IAM URL
- 具体 allowlist / scope(那是 Snapshot 的事)
- 按角色复制的 Action 定义
### 1.4 Action 级可选提示(不冻为运行时权威)
允许(文档/静态检查用),**宿主不得仅凭此显隐按钮**:
```json
"authz": { "capability": "optional.skill.defined.string" }
```
运行时权威仅为 Snapshot §2 的 `actions`。
---
## 2. Snapshot 最小字段集(`policy` CLI 标准输出)
- 编码:UTF-8 JSON 对象,写在 **stdout**(可先打日志到 stderr/文件,但 stdout 必须是单 JSON)。
- 成功退出码 `0`;业务上「无权限看任何东西」仍是 `0` + 合法 Snapshot(例如空 allowlist),不是靠非 0 表示未授权。
- 非 0 或非合法 JSON → §6 fail-closed。
### 2.1 根对象(L0 不出现;L1 必冻)
| 字段 | 必填 | 类型 | 说明 |
|------|------|------|------|
| `schemaVersion` | 是 | `integer` | 本期冻结为 **`1`** |
| `skill` | 是 | `string` | 必须等于 manifest `skill` |
| `actor` | 是 | `object` | 见 §2.2 |
| `actions` | 是 | `object` | 见 §2.3 |
| `hostCapabilities` | 否 | `string[]` | 见 §2.4;**省略 = 不限制宿主内置按钮**(兼容) |
| `data` | 否 | `object` | 见 §3;省略 = 不限制行/列(L1-only) |
| `extensions` | 否 | `object` | 技能私有扩展;宿主**必须忽略**未知键 |
根级未知字段:宿主**忽略**;**不得**依赖未写入本契约的字段做安全决策。
### 2.2 `actor`(L1 必冻)
| 字段 | 必填 | 说明 |
|------|------|------|
| `userId` | 是 | 非空;应与 `JIANGCHANG_USER_ID` / `{{userId}}` 对齐 |
| `displayName` | 否 | 展示用 |
| `roles` | 否 | `string[]`,角色**码**;仅供展示 / 技能自检。**不得**用于 v1 scope 的 `valueFrom` |
| `attributes` | 否 | `object`,仅允许 JSON 标量值;本期 scope **不**解引用 |
### 2.3 `actions`(L1 必冻)— Skill Action 授权
**最佳实践:** 角色 → 技能内计算 → `allowlist` 的 id 列表。宿主不理解角色名。
```json
"actions": {
"mode": "allowlist",
"ids": ["whoami", "create-record", "register-submit"]
}
```
| 字段 | 必填 | 冻结取值 |
|------|------|----------|
| `mode` | 是 | **仅两值**:`"allowlist"` \| `"open"` |
| `ids` | `mode=allowlist` 时必填 | `string[]`;元素为 **`actions.json` 里的 action.id** |
| `ids` | `mode=open` 时 | 必须省略或 `[]`;宿主视为**不限制** skill Action |
规则:
1. 宿主先按现有规则得到候选集:`placements` ∩ `bind.tables`(及 batch 等)。
2. 若 `mode=allowlist`:候选 ∩ `ids`。漏配 id = 少按钮(安全侧失败)。
3. `ids` 中的未知 id:**忽略**(不报错)。
4. `mode=open`:等价「L1 不限制按钮」,可与 L2 行列限制并存。
5. **`denylist` 不是 v1 合法值**;出现则 §6 fail-closed。
`replaces` 接管的默认新增/编辑/删除:视为对应 Action 的 id 是否在 allowlist 中;不在则宿主隐藏该接管按钮。`mode=open` 时不按 id 限制 replaces。
### 2.4 `hostCapabilities`(L1 可选冻)
控制数据管理**宿主内置**能力,与 skill `action.id` **命名空间分离**。
本期枚举冻结:`refresh` | `export` | `view` | `edit` | `delete` | `import`。
| 写法 | 含义 | 何时用 |
|------|------|--------|
| **省略字段** | **不限制**(与今日一致) | **默认推荐**:只控 skill 按钮 / 行数据时 |
| 写出非空数组 | 仅允许列出的内置能力(allowlist) | 要限导出、删除等时 |
| 写出 `[]` | 禁止全部上表内置能力 | 极少见;慎用 |
未知字符串:忽略。**禁止**把「省略」解释成全禁。
---
## 3. L2:`data` 与 scope 语法白名单
### 3.1 `data` 形状
```json
"data": {
"tables": {
"customs_timelines": {
"accessible": true,
"columns": {
"visible": ["id", "contract_no", "status"],
"writable": ["status"]
},
"rows": {
"mode": "scope",
"scope": {
"all": [
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
]
}
}
}
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `data.tables` | 若存在 `data` 则建议有 | `key` = 物理表名 |
| `accessible` | 否 | 缺省 `true`。`false`:宿主可拒绝打开该表 |
| `columns.visible` / `writable` | 否 | 物理列名;省略 = 不额外限制 |
| `rows` | 否 | 见下 |
`writable` 应 ⊆ `visible`;若违规,宿主按交集降级,不 fail-closed。
### 3.2 `rows.mode`(冻)
| 值 | 本期 | 含义 |
|----|------|------|
| `scope` | **实现目标** | 宿主将 `scope` 编译为安全查询约束 |
| `open` | 允许 | 显式不限制行 |
| `delegate` | **仅占位** | 本期未实现时 fail-closed / 提示;**禁止**当 `open` |
缺省:无 `rows` = `open`。
### 3.3 `scope` 布尔结构(冻)
```text
Scope =
| { "all": Predicate[] } // AND,长度 ≥ 1
| { "any": Predicate[] } // OR,长度 ≥ 1
| Predicate // 单个谓词的语法糖
```
**禁止**:嵌套超过一层。
### 3.4 `Predicate` 白名单(冻)
**运算符(宿主必须实现):** `eq` | `ne` | `in` | `not_in` | `gt` | `gte` | `lt` | `lte` | `is_null` | `is_not_null`
**推荐日常只用:** `eq` / `in` / `is_null`(及 `is_not_null`)。
**明确排除:** `like` / `contains` / `sql` / `raw` / `expr`。
**`valueFrom` v1 仅:** `actor.userId`。
`actor.roles` / `actor.attributes.*` **非法** → fail-closed。
`value` 与 `valueFrom` 互斥。标量:`string` | `number` | `boolean` | `null`(与 null 比较优先用 `is_null`)。
### 3.5 与宿主原有过滤的关系
最终行集 = **原有表查询**(搜索、排序、分页、用户 Filter) **AND** policy `scope`。宿主必须把 scope 下推到查询层。
---
## 4. 宿主消费算法
```text
IF manifest.policy?.enabled !== true:
→ L0;END
调用 entrypoint(替换占位符),解析 Snapshot
IF 失败 / 非法 / skill 不匹配 / mode 非法:
→ fail-closed(§6);END
候选 = filter(placements, bind.tables, …)
IF mode == allowlist: 候选 ∩ ids
IF mode == open: 候选不变
渲染 skill 按钮 ← 候选
渲染宿主内置 ← hostCapabilities(省略则不限制)
应用 data.tables[T](若有)
```
拉取时机(建议):进入表、切换表、刷新。短缓存不得跨 `userId`。
---
## 5. 身份与环境
| 项 | 约定 |
|----|------|
| 用户主键 | `JIANGCHANG_USER_ID` |
| 技能解析 | 飞书 / 本地表 / HTTP IAM 等,**契约不规定** |
---
## 6. Fail-closed(启用后必冻)
当且仅当 `policy.enabled === true`:
| 情况 | 宿主行为 |
|------|----------|
| CLI 非 0 / 超时 / 无 JSON | 不渲染该表 skill 写操作与(建议)导出;明确错误;**禁止**回退 L0 全开 |
| `schemaVersion` ≠ 1 / `skill` 不匹配 / `actions.mode` 非法 | 同上 |
| `rows.mode=scope` 但 predicate 非法 | 不返回行或整表不可用;**禁止**忽略 scope |
| `rows.mode=delegate`(未实现) | 提示不支持;**禁止**当 `open` |
---
## 7. 安全边界
1. Snapshot **只**服务宿主 UI 与查询约束。
2. 写 Action / 敏感读的 **domain service 必须再次鉴权**。
3. 禁止「只藏按钮」宣称为完整权限方案。
4. Cron / Agent **不**自动套用本 Snapshot。
---
## 8. 最小示例
模板仓库另有可复制示例:
- [`../assets/examples/skill-policy-snapshot.allowlist.example.json`](../assets/examples/skill-policy-snapshot.allowlist.example.json)
- [`../assets/examples/skill-policy-snapshot.scope.example.json`](../assets/examples/skill-policy-snapshot.scope.example.json)
### 8.1 L0
不写 `policy` 字段。
### 8.2 L1 allowlist(不碰宿主内置)
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": { "userId": "u1", "displayName": "张三", "roles": ["clerk"] },
"actions": {
"mode": "allowlist",
"ids": ["whoami", "create-record", "register-submit"]
}
}
```
### 8.3 L1 + 限制导出
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": { "userId": "u1", "roles": ["clerk"] },
"actions": { "mode": "allowlist", "ids": ["whoami"] },
"hostCapabilities": ["refresh", "view"]
}
```
### 8.4 L1 open + L2 scope
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": { "userId": "u1" },
"actions": { "mode": "open" },
"data": {
"tables": {
"orders": {
"rows": {
"mode": "scope",
"scope": {
"all": [
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
]
}
}
}
}
}
}
```
---
## 9. 刻意延后
| 项 | 备注 |
|----|------|
| `actions.mode: denylist` | 另开 `schemaVersion` |
| `valueFrom: actor.roles` | **明确不做** |
| `visibleWhen` / 嵌套 scope / `like` | L3 或永不做 |
| `rows.mode: delegate` 宿主实现 | 字段名已占坑 |
| 平台全局 Role UI / cron·agent 套用 Snapshot | 另议 |
---
## 10. 冻结检查清单
- [x] L0 缺省无歧义
- [x] 启用字段仅 `enabled` + `entrypoint` + 可选 `appliesTo`
- [x] `actions.mode` 仅 `allowlist` / `open`
- [x] `hostCapabilities` 省略 = 不限制
- [x] scope 小白名单;`valueFrom` 仅 `actor.userId`
- [x] fail-closed;写路径技能自检

View File

@@ -19,6 +19,8 @@
| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 | | [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 |
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口(可选能力) | | [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口(可选能力) |
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 | | [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理单元格呈现:语义 tone 标色/强调(用途不限;多数技能 P0 不配) |
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) | | [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`。 按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子标色读 `CELL_PRESENTATION.md`。

View File

@@ -10,9 +10,11 @@
- 定义真实 SQLite 表结构 - 定义真实 SQLite 表结构
- 使用英文 `snake_case` 表名和字段名 - 使用英文 `snake_case` 表名和字段名
- 使用 `_jiangchang_tables` 提供表级展示元数据 - 使用 `_jiangchang_tables` 提供表级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据 - 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 字段说明)
- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里)
- 定义哪些字段可见、可搜索、可创建、可编辑 - 定义哪些字段可见、可搜索、可创建、可编辑
- 定义字段展示类型和枚举选项 - 定义字段展示类型和枚举选项
- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
- 保证元数据与真实数据库结构一致 - 保证元数据与真实数据库结构一致
- 保证元数据初始化幂等 - 保证元数据初始化幂等
@@ -22,11 +24,14 @@
- 读取 SQLite `PRAGMA table_info` - 读取 SQLite `PRAGMA table_info`
- 根据 schema 渲染数据管理页面 - 根据 schema 渲染数据管理页面
- 根据 `editable` 推导字段是否可创建或编辑 - 根据 `editable` 推导字段是否可创建或编辑
- 在**新增 / 编辑**表单中,若列有 `_jiangchang_columns.description`,在标签下方展示该说明(灰字;空则不显示)
- 打开**新增**表单时,对可创建字段用 SQLite 列 `DEFAULT`(`PRAGMA table_info.dflt_value`)预填;无 DEFAULT 则留空
- 执行通用查询、新增、编辑、删除、导入、导出 - 执行通用查询、新增、编辑、删除、导入、导出
- 对字段名、表名、类型和权限进行最终校验 - 对字段名、表名、类型和权限进行最终校验
- 不猜测技能业务含义 - 不猜测技能业务含义
- 不根据具体技能名称写特殊逻辑 - 不根据具体技能名称写特殊逻辑
- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果 - 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则
## 初始化入口 ## 初始化入口
@@ -78,6 +83,8 @@ metadata:
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。 不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
若创建/更新/删除带有领域副作用(自动建目录、加密、级联清理),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete` 接管默认按钮,详见 [`ACTIONS.md`](ACTIONS.md)。
## 标准数据同步语义(外源 → 本地库) ## 标准数据同步语义(外源 → 本地库)
适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。 适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。
@@ -131,6 +138,38 @@ metadata:
| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 | | `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 | | `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 | | `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 |
#### 表级默认排序 `default_sort_json`(宿主支持)
技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退:
`updated_at DESC` → `created_at DESC` → 主键 DESC。
JSON 数组(可多字段,按顺序):
```json
[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]
```
规则:
- `column` 必须是该表真实列名;敏感列会被宿主忽略
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
示例(消息时间线):
```python
upsert_table_metadata(
cursor,
table_name="messages",
display_name="消息",
readonly=0,
default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]',
)
```
禁止: 禁止:
@@ -145,17 +184,101 @@ metadata:
|------|------|------| |------|------|------|
| `table_name` | 是 | 所属物理表 | | `table_name` | 是 | 所属物理表 |
| `column_name` | 是 | 真实英文字段名 | | `column_name` | 是 | 真实英文字段名 |
| `display_name` | 是 | 用户可见的中文字段名 | | `display_name` | 是 | 用户可见的中文字段名(表头与表单标签);保持简短 |
| `description` | 否 | 字段说明 | | `description` | 否 | 字段说明(给人看的短文案);**宿主在新增/编辑表单标签下方展示**(见下节);不要塞进 `display_name` |
| `display_order` | 否 | 兼容字段,默认保持 `NULL` | | `display_order` | 否 | 兼容字段,默认保持 `NULL` |
| `visible` | 是 | 是否在数据管理表格中展示 | | `visible` | 是 | 是否在数据管理表格中展示 |
| `searchable` | 是 | 是否参与搜索 | | `searchable` | 是 | 是否参与搜索 |
| `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 | | `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 |
| `display_type` | 否 | 字段渲染类型(如 `text`、`textarea`、`datetime_unix_seconds`) | | `display_type` | 否 | 字段渲染类型;稳定取值见下节「表单与展示:稳定字段类型」 |
| `options_json` | 否 | 枚举选项 JSON | | `options_json` | 否 | 枚举选项 JSON |
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。 这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
#### 表单与展示:稳定字段类型(宿主支持,须与 Action 对称)
数据管理「新增 / 编辑」表单与 Skill Action 参数表单应使用**同一套控件语义**(配置入口不同,见 [`ACTIONS.md`](ACTIONS.md))。新技能只使用下表;宿主须两边都实现,避免「Action 有、CRUD 没有」。
**本版稳定支持(须齐):**
| `display_type`(CRUD) | 物理类型建议 | 列表展示 | 新增/编辑控件 | 存库约定 |
|------------------------|--------------|----------|---------------|----------|
| `text`(或缺省) | `TEXT` / 一般标量 | 原文 | 单行文本 | 原文 |
| `textarea` | `TEXT` | 原文(可截断) | 多行文本 | 原文 |
| `json` | `TEXT` | 原文 | 多行文本(JSON 字符串) | 原文;提交时宿主可做基础 JSON 合法性校验 |
| `number` | `INTEGER` / `REAL` / `NUMERIC` | 原文 | **数字输入**(`type=number`) | 数字;无 `display_type` 时,物理类型为 INT/REAL/NUMERIC 也应走数字控件 |
| `boolean` | `INTEGER` 0/1 或 BOOL | 开关语义展示 | 开关 | `0` / `1` |
| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` |
| `lookup` | 外键式标量 + `options_json` **对象** | 标签映射 | 下拉选择(查关联表) | 关联键值 |
| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) |
| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` |
| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 |
| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 |
| `email` | `TEXT` | 原文 | 单行输入 `type=email`(薄层;校验从宽) | 原文 |
说明灰字与创建默认值见下一小节;与 Action 的对照写在 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。
**明确不在本版稳定范围(勿在新技能宣传为已支持):**
- 文件 / 图片上传
- 多选、标签数组、复杂 `oneOf` / nested object 表单
- 把业务可编辑时间存成毫秒、ISO 文本或 SQLite `datetime` 文本(审计字段规范见「标准时间字段」)
`datetime_unix_milliseconds`:仅兼容**列表展示**旧数据;**新技能不要用**;可编辑时间一律 `datetime_unix_seconds`。
#### 新增/编辑表单:字段说明与默认值(宿主支持)
数据管理「新增记录 / 编辑记录」与 Skill Action 弹窗是**两套表单**;CRUD 侧约定如下(交互对齐 Action 的 `inputSchema.description` / `default`,但**配置入口不同**)。
| 能力 | 技能写什么 | 宿主做什么 |
|------|------------|------------|
| 字段说明 | `_jiangchang_columns.description`(可选) | 新增/编辑表单中,在标签下方以灰字展示;空则不显示 |
| 创建默认值 | 物理列 SQLite `DEFAULT`(如 `limit_n INTEGER NOT NULL DEFAULT 0`) | 打开「新增」时,对可创建字段预填该默认值;无 DEFAULT 则留空 |
| 编辑已有行 | — | **不**用 DEFAULT 覆盖已有行数据 |
| 表格列头 | 只用 `display_name` | 长说明**不**进入列头 |
规则与禁止:
- **推荐**对易填错、语义特殊的可编辑字段写 `description`(示例链接、`0` 表示不限制等);非强制每个字段都有
- **禁止**把说明硬塞进 `display_name`(如 `评论上限(0不限)`);表头会变脏,也无法与 Action 说明对齐
- 业务语义(如「0 = 不限制」)写在 `description` 里;宿主只展示,**不**解读业务含义
- 第一期**不**新增元数据「表单默认值」列;CRUD 默认值 = DDL `DEFAULT`,与 Action 的 `inputSchema.default` 分开配置
- 同一业务字段若同时出现在 Action 与新增表单,**文案应对齐**(短标题 + 说明),但分别写在 `actions.json` 与 `_jiangchang_columns` / DDL
- 数字类可编辑列推荐 `display_type: "number"`(或依赖 INT/REAL/NUMERIC 推断)并在 DDL 写清 `DEFAULT`(如条数上限默认 `0`)
示例(可编辑列种子 + DDL 默认):
```sql
-- 物理列:创建默认 0
comment_limit INTEGER NOT NULL DEFAULT 0
```
```python
seed_column_metadata_batch(
cursor,
table_name="videos",
columns=(
{
"column_name": "video_url",
"display_name": "视频链接",
"editable": 1,
"display_type": "url",
"description": "填平台视频页链接;需包含可识别的视频路径片段。",
},
{
"column_name": "comment_limit",
"display_name": "采集评论条数",
"editable": 1,
"display_type": "number",
"description": "最多采多少条。填 0 = 不限制;填正整数则按该数量封顶。",
},
),
valid_column_names=physical,
)
```
Action 参数表单的 `title` / `description` / `default` / `display_type` 见 [`ACTIONS.md`](ACTIONS.md);勿与本节 CRUD 配置入口混用。
## editable 与 createable 的关系 ## editable 与 createable 的关系
技能模板只维护 `editable` 这一份字段权限声明: 技能模板只维护 `editable` 这一份字段权限声明:
@@ -260,10 +383,22 @@ readonly = 0
| 存储格式 | Unix 秒级整数 | | 存储格式 | Unix 秒级整数 |
| 展示元数据 `display_type` | `datetime_unix_seconds` | | 展示元数据 `display_type` | `datetime_unix_seconds` |
| 中文名 | `created_at` → **创建时间**;`updated_at` → **更新时间** | | 中文名 | `created_at` → **创建时间**;`updated_at` → **更新时间** |
| 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果) | | 用户编辑 | **审计字段** `editable = 0`(系统维护,列表只展示格式化结果,**不**进可编辑表单) |
| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` | | 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` |
| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` | | 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` |
### 业务可编辑时间 vs 审计时间
| 场景 | `editable` | 列表 | 新增/编辑表单 |
|------|------------|------|----------------|
| `created_at` / `updated_at` 等审计列 | `0` | 格式化为 `YYYY-MM-DD HH:mm:ss` | 不出现(或只读,由宿主既有规则) |
| 业务时间列(预约、截止、活动开始等) | `1` | 同上格式化 | **日期时间控件**;提交写入 Unix **秒**整数 |
宿主对 `display_type = datetime_unix_seconds`:
- **列表**:格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不**改库内原始整数
- **可编辑表单**:提供日期时间选择;保存时写回 Unix **秒**(不要写 ISO 字符串、不要写毫秒)
### 自动生成与维护 ### 自动生成与维护
- **created_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。 - **created_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。
@@ -276,8 +411,6 @@ readonly = 0
- 中文元数据种子:`scripts/db/display_metadata.py` - 中文元数据种子:`scripts/db/display_metadata.py`
- 自检:`scripts/db/display_metadata_validator.py` - 自检:`scripts/db/display_metadata_validator.py`
宿主只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。
### 模板默认:`task_logs` ### 模板默认:`task_logs`
`task_logs` 是技能内部**系统自动写入**的任务审计日志(由 `task_logs_repository` INSERT/UPDATE),不是供用户在宿主数据管理中手工维护的业务表。 `task_logs` 是技能内部**系统自动写入**的任务审计日志(由 `task_logs_repository` INSERT/UPDATE),不是供用户在宿主数据管理中手工维护的业务表。
@@ -354,7 +487,11 @@ CREATE TABLE demo_items (
## 枚举字段 options_json ## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`: `options_json` 支持两种形态:
### 1)静态枚举(数组)
每项包含 `value` 与 `label`:
```json ```json
[ [
@@ -366,6 +503,31 @@ CREATE TABLE demo_items (
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。 宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
### 2)表关联 lookup(对象,推荐用于外键式字段)
当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组):
```json
{
"ref_table": "platforms",
"value_column": "platform_key",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1
}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `ref_table` | 是 | 同库被引用表名(snake_case 标识符) |
| `value_column` | 是 | 写入当前字段的值列 |
| `label_column` | 是 | 下拉展示名列 |
| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) |
宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 下拉选项;**物理列仍存标量**(如 `platform_key` 文本、`account_id` 整数),不做嵌套子表。
示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表。
## 幂等初始化标准 ## 幂等初始化标准
- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化 - `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化

View File

@@ -24,7 +24,8 @@ CREATE TABLE IF NOT EXISTS _jiangchang_tables (
description TEXT, description TEXT,
sort_order INTEGER, sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1, visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0 readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT
) )
""" """
@@ -82,23 +83,50 @@ TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
) )
def _ensure_jiangchang_tables_columns(cursor) -> None:
"""旧库补列:CREATE IF NOT EXISTS 不会给已有表加新字段。"""
cursor.execute("PRAGMA table_info('_jiangchang_tables')")
cols = {str(row[1]) for row in cursor.fetchall()}
if "default_sort_json" not in cols:
cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN default_sort_json TEXT")
def init_display_metadata_tables(cursor) -> None: def init_display_metadata_tables(cursor) -> None:
cursor.execute(_JIANGCHANG_TABLES_DDL) cursor.execute(_JIANGCHANG_TABLES_DDL)
cursor.execute(_JIANGCHANG_COLUMNS_DDL) cursor.execute(_JIANGCHANG_COLUMNS_DDL)
_ensure_jiangchang_tables_columns(cursor)
def upsert_table_metadata(cursor, *, table_name: str, display_name: str, description: str | None = None, visible: int = 1, readonly: int = 0) -> None: def upsert_table_metadata(
cursor,
*,
table_name: str,
display_name: str,
description: str | None = None,
visible: int = 1,
readonly: int = 0,
default_sort_json: str | None = None,
) -> None:
"""写入表级元数据。
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
为空则宿主回退到 updated_at → created_at → 主键。
"""
cursor.execute( cursor.execute(
""" """
INSERT INTO _jiangchang_tables (table_name, display_name, description, visible, readonly) INSERT INTO _jiangchang_tables (
VALUES (?, ?, ?, ?, ?) table_name, display_name, description, visible, readonly, default_sort_json
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name, display_name = excluded.display_name,
description = excluded.description, description = excluded.description,
visible = excluded.visible, visible = excluded.visible,
readonly = excluded.readonly readonly = excluded.readonly,
default_sort_json = excluded.default_sort_json
""", """,
(table_name, display_name, description, visible, readonly), (table_name, display_name, description, visible, readonly, default_sort_json),
) )

View File

@@ -196,7 +196,7 @@ def _default_uses_unixepoch(default_value: object) -> bool:
def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]: def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]:
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。""" """校验 options_json:静态枚举数组,或 lookup 对象。"""
errors: list[str] = [] errors: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
if options_json is None or str(options_json).strip() == "": if options_json is None or str(options_json).strip() == "":
@@ -206,8 +206,24 @@ def validate_options_json(options_json: str | None) -> tuple[list[str], list[str
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
errors.append(f"options_json 不是合法 JSON:{exc}") errors.append(f"options_json 不是合法 JSON:{exc}")
return errors, warnings return errors, warnings
if isinstance(parsed, dict):
for key in ("ref_table", "value_column", "label_column"):
if not str(parsed.get(key) or "").strip():
errors.append(f"options_json lookup 必须包含非空 {key}")
ident = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
for key in ("ref_table", "value_column", "label_column", "filter_column"):
raw = parsed.get(key)
if raw is None or raw == "":
continue
if not ident.match(str(raw)):
errors.append(f"options_json.{key} 必须是安全标识符,当前为 {raw!r}")
if parsed.get("filter_column") not in (None, "") and "filter_value" not in parsed:
warnings.append("options_json lookup 声明了 filter_column 但未提供 filter_value")
return errors, warnings
if not isinstance(parsed, list): if not isinstance(parsed, list):
errors.append("options_json 必须是 JSON 数组") errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)")
return errors, warnings return errors, warnings
for index, item in enumerate(parsed): for index, item in enumerate(parsed):
if not isinstance(item, dict): if not isinstance(item, dict):

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug" SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.60" SKILL_VERSION = "1.0.77"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.2" PLATFORM_KIT_MIN_VERSION = "1.2.2"

View File

@@ -10,18 +10,19 @@ from util.constants import SKILL_SLUG
_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STANDARD_SUBDIR_DOWNLOADS = "downloads" # 用户可见的上行/下行目录(数据管理侧栏只挂这两个)
STANDARD_SUBDIR_IMPORTS = "imports" STANDARD_SUBDIR_INPUT = "input"
STANDARD_SUBDIR_EXPORTS = "exports" STANDARD_SUBDIR_OUTPUT = "output"
# 内部/可选目录(不上侧栏)
STANDARD_SUBDIR_UPLOADS = "uploads" STANDARD_SUBDIR_UPLOADS = "uploads"
STANDARD_SUBDIR_CACHE = "cache" STANDARD_SUBDIR_CACHE = "cache"
STANDARD_SUBDIR_TEMP = "temp" STANDARD_SUBDIR_TEMP = "temp"
STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts" STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts"
STANDARD_SUBDIR_VIDEOS = "videos" STANDARD_SUBDIR_VIDEOS = "videos"
CONFIG_KEY_DOWNLOAD_DIR = "SKILL_DOWNLOAD_DIR" CONFIG_KEY_INPUT_DIR = "SKILL_INPUT_DIR"
CONFIG_KEY_IMPORT_DIR = "SKILL_IMPORT_DIR" CONFIG_KEY_OUTPUT_DIR = "SKILL_OUTPUT_DIR"
CONFIG_KEY_EXPORT_DIR = "SKILL_EXPORT_DIR"
CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR" CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR"
CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_DIR" CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_DIR"
CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR" CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR"
@@ -81,27 +82,25 @@ def resolve_data_path(
def resolve_input_path(raw_path: str, *, create_parent: bool = False) -> str: def resolve_input_path(raw_path: str, *, create_parent: bool = False) -> str:
"""解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/imports/。""" """解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/input/。"""
text = (raw_path or "").strip() text = (raw_path or "").strip()
if not text: if not text:
return text return text
expanded = os.path.expanduser(text) expanded = os.path.expanduser(text)
if os.path.isabs(expanded): if os.path.isabs(expanded):
return os.path.abspath(expanded) return os.path.abspath(expanded)
imports_dir = get_imports_dir(create=create_parent) input_dir = get_input_dir(create=create_parent)
return os.path.abspath(os.path.join(imports_dir, os.path.normpath(expanded))) return os.path.abspath(os.path.join(input_dir, os.path.normpath(expanded)))
def get_downloads_dir(*, create: bool = True) -> str: def get_input_dir(*, create: bool = True) -> str:
return resolve_data_path(CONFIG_KEY_DOWNLOAD_DIR, STANDARD_SUBDIR_DOWNLOADS, create=create) """上行:用户/Agent 放入的待处理文件(清单、CSV、产品表等)。"""
return resolve_data_path(CONFIG_KEY_INPUT_DIR, STANDARD_SUBDIR_INPUT, create=create)
def get_imports_dir(*, create: bool = True) -> str: def get_output_dir(*, create: bool = True) -> str:
return resolve_data_path(CONFIG_KEY_IMPORT_DIR, STANDARD_SUBDIR_IMPORTS, create=create) """下行:技能/宿主交给用户拿走的文件(视频、报表、导出 CSV 等)。"""
return resolve_data_path(CONFIG_KEY_OUTPUT_DIR, STANDARD_SUBDIR_OUTPUT, create=create)
def get_exports_dir(*, create: bool = True) -> str:
return resolve_data_path(CONFIG_KEY_EXPORT_DIR, STANDARD_SUBDIR_EXPORTS, create=create)
def get_uploads_dir(*, create: bool = True) -> str: def get_uploads_dir(*, create: bool = True) -> str:
@@ -135,9 +134,8 @@ def list_resolved_data_paths() -> dict[str, str]:
"""供 health / config-path 输出已解析的数据子目录(不创建目录)。""" """供 health / config-path 输出已解析的数据子目录(不创建目录)。"""
return { return {
"skill_data_dir": get_skill_data_dir(), "skill_data_dir": get_skill_data_dir(),
"downloads_dir": get_downloads_dir(create=False), "input_dir": get_input_dir(create=False),
"imports_dir": get_imports_dir(create=False), "output_dir": get_output_dir(create=False),
"exports_dir": get_exports_dir(create=False),
"uploads_dir": get_uploads_dir(create=False), "uploads_dir": get_uploads_dir(create=False),
"cache_dir": get_cache_dir(create=False), "cache_dir": get_cache_dir(create=False),
"temp_dir": get_temp_dir(create=False), "temp_dir": get_temp_dir(create=False),

View File

@@ -190,7 +190,21 @@ class TestActionsManifest(unittest.TestCase):
placements = action.get("placements") placements = action.get("placements")
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array") self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty") replaces = action.get("replaces")
if replaces is not None:
self.assertIn(
replaces,
{"create", "update", "delete"},
msg=f"{action_id} invalid replaces {replaces!r}",
)
bind = action.get("bind") or {}
tables = bind.get("tables") or []
self.assertTrue(
isinstance(tables, list) and len(tables) > 0,
msg=f"{action_id} replaces requires non-empty bind.tables",
)
else:
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
for placement in placements: for placement in placements:
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}") self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
@@ -216,6 +230,55 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(bind_def.get("required"), ["tables"]) self.assertEqual(bind_def.get("required"), ["tables"])
self.assertIs(bind_def.get("additionalProperties"), False) self.assertIs(bind_def.get("additionalProperties"), False)
def test_schema_defines_interaction_fields(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("interaction", props)
self.assertEqual(
set(props["interaction"].get("enum") or []),
{"auto", "direct", "confirm", "form"},
)
self.assertIn("interactionByPlacement", props)
by_placement = props["interactionByPlacement"]
self.assertIs(by_placement.get("additionalProperties"), False)
for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete"):
self.assertIn(key, by_placement.get("properties") or {})
self.assertEqual(
set((by_placement["properties"][key].get("enum") or [])),
{"direct", "confirm", "form"},
)
self.assertIn("replaces", props)
self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind")
self.assertEqual(
set(schema["$defs"]["mutationKind"].get("enum") or []),
{"create", "update", "delete"},
)
def test_schema_defines_host_effects_and_steps(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("hostEffects", props)
self.assertEqual(props["hostEffects"].get("$ref"), "#/$defs/hostEffects")
self.assertIn("steps", props)
effects = schema["$defs"]["hostEffects"]
self.assertIs(effects.get("additionalProperties"), False)
for key in ("onSuccess", "onFailure"):
self.assertIn(key, effects.get("properties") or {})
effect_ids = set(schema["$defs"]["hostEffectId"].get("enum") or [])
self.assertEqual(effect_ids, {"reloadTable", "refreshCatalog", "clearSelection"})
step = schema["$defs"]["actionStep"]
self.assertEqual(
set(step["properties"]["when"].get("enum") or []),
{"before", "afterSuccess", "afterFailure"},
)
self.assertEqual(
set(step["properties"]["do"].get("enum") or []),
{"deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"},
)
self.assertEqual(props["steps"].get("maxItems"), 8)
def test_manifest_validates_against_json_schema(self) -> None: def test_manifest_validates_against_json_schema(self) -> None:
try: try:
import jsonschema import jsonschema
@@ -469,8 +532,41 @@ class TestActionsManifest(unittest.TestCase):
"additionalProperties", "additionalProperties",
"placements", "placements",
"executionProfile", "executionProfile",
"interaction",
"confirmation",
"数据管理技能按钮顺序",
"数组相对顺序",
"全部直接展示",
"左右分栏",
"行级入口",
"行右键",
"六职责正交",
"POLICY.md",
"hostEffects",
"steps",
"reloadTable",
"afterSuccess",
"afterFailure",
"cellPresentation",
"CELL_PRESENTATION.md",
): ):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
self.assertNotIn(
"其余进「更多操作」",
text,
msg="ACTIONS.md must not document removed overflow more-menu truncation",
)
self.assertIn(
"新增 `row-context-menu` 等 placement",
text,
msg="ACTIONS.md must forbid inventing a separate row-context-menu placement",
)
placement_enum = _load_actions_schema().get("$defs", {}).get("placement", {}).get("enum") or []
self.assertNotIn(
"row-context-menu",
placement_enum,
msg="skill-actions.schema must not add row-context-menu to placements enum",
)
for forbidden in ( for forbidden in (
"数据管理 toolbar " + "只挂", "数据管理 toolbar " + "只挂",
"toolbar " + "只放 async", "toolbar " + "只放 async",

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""Cell Presentation 契约:schema + 文档 + 示例。"""
from __future__ import annotations
import json
import os
import unittest
from util.runtime_paths import get_skill_root
def _load_schema() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"schemas",
"skill-cell-presentation.schema.json",
)
with open(path, encoding="utf-8") as f:
return json.load(f)
def _load_example() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"examples",
"skill-cell-presentation.example.json",
)
with open(path, encoding="utf-8") as f:
return json.load(f)
class TestCellPresentationContract(unittest.TestCase):
def test_schema_file_exists_and_parses(self) -> None:
schema = _load_schema()
self.assertEqual(schema.get("title"), "SkillCellPresentationDocument")
self.assertIs(schema.get("additionalProperties"), False)
self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1)
tones = set(schema["$defs"]["tone"].get("enum") or [])
self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"})
def test_example_validates_against_schema(self) -> None:
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
jsonschema.validate(instance=_load_example(), schema=_load_schema())
def test_actions_schema_defines_cell_presentation_enable_block(self) -> None:
path = os.path.join(
get_skill_root(), "assets", "schemas", "skill-actions.schema.json"
)
with open(path, encoding="utf-8") as f:
actions_schema = json.load(f)
self.assertIn("cellPresentation", actions_schema["properties"])
self.assertIn("cellPresentationDeclaration", actions_schema["$defs"])
def test_docs_and_example_exist(self) -> None:
root = get_skill_root()
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
)
)
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "examples", "skill-cell-presentation.example.json")
)
)
def test_cell_presentation_md_covers_contract(self) -> None:
path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
"tone",
"danger",
"warning",
"alert",
"_jiangchang_cell_presentation",
"cellPresentation",
"派生",
"任意 CSS",
"用途不限",
"schemaVersion",
):
self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}")
if __name__ == "__main__":
unittest.main()

View File

@@ -299,9 +299,21 @@ class TestDocsStandards(unittest.TestCase):
root = get_skill_root() root = get_skill_root()
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json"))) self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md"))) self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "POLICY.md")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
self.assertTrue( self.assertTrue(
os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json")) os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json"))
) )
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-policy-snapshot.schema.json")
)
)
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
)
)
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py"))) self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py")))
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py"))) self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py")))
@@ -325,9 +337,28 @@ class TestDocsStandards(unittest.TestCase):
"options_json", "options_json",
"demo_items", "demo_items",
"readonly", "readonly",
"新增/编辑表单:字段说明与默认值(宿主支持)",
"表单与展示:稳定字段类型(宿主支持,须与 Action 对称)",
"datetime_unix_seconds",
"display_type: \"number\"",
"DEFAULT",
"description",
"文件 / 图片上传",
): ):
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}") self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
def test_actions_md_documents_form_schema_split(self) -> None:
text = self._read("references/ACTIONS.md")
for marker in (
"inputSchema 与参数表单",
"表单字段类型对照(须与 CRUD 对称)",
"数据管理新增/编辑",
"_jiangchang_columns.description",
"datetime_unix_seconds",
"SCHEMA.md",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None: def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
forbidden = ( forbidden = (
"download-settlement-report-amazon", "download-settlement-report-amazon",

View File

@@ -89,11 +89,11 @@ class TestRuntimePaths(unittest.TestCase):
import util.runtime_paths as rp import util.runtime_paths as rp
importlib.reload(rp) importlib.reload(rp)
path = rp.resolve_data_path(None, "downloads", create=True) path = rp.resolve_data_path(None, "output", create=True)
norm = os.path.normcase(os.path.normpath(path)) norm = os.path.normcase(os.path.normpath(path))
norm_tmp = os.path.normcase(os.path.normpath(tmp)) norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(norm.startswith(norm_tmp)) self.assertTrue(norm.startswith(norm_tmp))
self.assertTrue(norm.endswith(os.path.normcase(os.path.join("downloads")))) self.assertTrue(norm.endswith(os.path.normcase(os.path.join("output"))))
def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None: def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None:
with IsolatedDataRoot() as tmp: with IsolatedDataRoot() as tmp:
@@ -102,20 +102,20 @@ class TestRuntimePaths(unittest.TestCase):
import util.runtime_paths as rp import util.runtime_paths as rp
importlib.reload(rp) importlib.reload(rp)
os.environ["SKILL_DOWNLOAD_DIR"] = "downloads/videos" os.environ["SKILL_OUTPUT_DIR"] = "output/videos"
from jiangchang_skill_core import config from jiangchang_skill_core import config
config.reset_cache() config.reset_cache()
importlib.reload(rp) importlib.reload(rp)
path = rp.resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads", create=False) path = rp.resolve_data_path("SKILL_OUTPUT_DIR", "output", create=False)
norm = os.path.normcase(os.path.normpath(path)) norm = os.path.normcase(os.path.normpath(path))
self.assertIn(os.path.normcase("downloads"), norm) self.assertIn(os.path.normcase("output"), norm)
self.assertIn(os.path.normcase("videos"), norm) self.assertIn(os.path.normcase("videos"), norm)
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
os.environ.pop("SKILL_DOWNLOAD_DIR", None) os.environ.pop("SKILL_OUTPUT_DIR", None)
config.reset_cache() config.reset_cache()
def test_resolve_input_path_relative_under_imports(self) -> None: def test_resolve_input_path_relative_under_input(self) -> None:
with IsolatedDataRoot() as tmp: with IsolatedDataRoot() as tmp:
import importlib import importlib
@@ -124,9 +124,24 @@ class TestRuntimePaths(unittest.TestCase):
importlib.reload(rp) importlib.reload(rp)
resolved = rp.resolve_input_path("urls.txt", create_parent=True) resolved = rp.resolve_input_path("urls.txt", create_parent=True)
norm = os.path.normcase(os.path.normpath(resolved)) norm = os.path.normcase(os.path.normpath(resolved))
self.assertIn(os.path.normcase("imports"), norm) self.assertIn(os.path.normcase("input"), norm)
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp)))) self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
def test_get_input_and_output_dirs(self) -> None:
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
input_dir = rp.get_input_dir(create=True)
output_dir = rp.get_output_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(input_dir.replace("\\", "/").endswith("/input"))
self.assertTrue(output_dir.replace("\\", "/").endswith("/output"))
def test_list_resolved_data_paths_keys(self) -> None: def test_list_resolved_data_paths_keys(self) -> None:
with IsolatedDataRoot(): with IsolatedDataRoot():
import importlib import importlib
@@ -137,12 +152,14 @@ class TestRuntimePaths(unittest.TestCase):
paths = rp.list_resolved_data_paths() paths = rp.list_resolved_data_paths()
for key in ( for key in (
"skill_data_dir", "skill_data_dir",
"downloads_dir", "input_dir",
"imports_dir", "output_dir",
"exports_dir",
): ):
self.assertIn(key, paths) self.assertIn(key, paths)
self.assertTrue(paths[key]) self.assertTrue(paths[key])
self.assertNotIn("downloads_dir", paths)
self.assertNotIn("imports_dir", paths)
self.assertNotIn("exports_dir", paths)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,5 +1,15 @@
# skill-template 工具 # skill-template 工具
## UI 拾取(开发期)
`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓:
- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder
- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`
- 启动:`python run.py`
模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。
## 业务仓也可以直接 clone(来源 A) ## 业务仓也可以直接 clone(来源 A)
若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接: 若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接: