Compare commits

...

11 Commits

Author SHA1 Message Date
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
28 changed files with 835 additions and 153 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,54 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 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.69
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -32,7 +32,7 @@ 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)。数据管理技能按钮顺序见 `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`。
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。 - **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。

View File

@@ -19,7 +19,8 @@
"actions": { "actions": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,
"items": { "$ref": "#/$defs/action" } "items": { "$ref": "#/$defs/action" },
"description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序渲染技能按钮;调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。"
} }
}, },
"$defs": { "$defs": {
@@ -28,6 +29,11 @@
"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(对齐匠厂宿主)。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 +58,12 @@
"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 下拉"
} }
} }
}, },
@@ -120,9 +132,18 @@
"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 勿写入。"
}, },
"action": { "action": {
"type": "object", "type": "object",
@@ -144,10 +165,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 数组相对顺序决定,不在本字段配置排序。"
}, },
"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,6 +178,27 @@
"enum": ["sync", "async"], "enum": ["sync", "async"],
"description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。" "description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。"
}, },
"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" } "bind": { "$ref": "#/$defs/bind" }
}, },
"if": { "if": {
@@ -169,7 +212,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

@@ -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 / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。

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

@@ -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,7 +72,7 @@ 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)。
@@ -80,9 +80,11 @@ Action 是**可选能力**:
| 字段 / 层 | 只决定什么 | 不决定什么 | | 字段 / 层 | 只决定什么 | 不决定什么 |
|-----------|------------|------------| |-----------|------------|------------|
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async | | **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async,不决定点了是否弹窗 |
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除」 | 不强制;无配置时宿主仍直写 SQLite |
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | | **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口 | | **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 | | **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 | | **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
@@ -93,13 +95,16 @@ Action 是**可选能力**:
| 层级 | 稳定字段 / 能力 | | 层级 | 稳定字段 / 能力 |
|------|----------------| |------|----------------|
| 顶层 | `schemaVersion`、`skill`、`actions` | | 顶层 | `schemaVersion`、`skill`、`actions` |
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`bind`** | | action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`** |
| 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` | | inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` |
| confirmation | `{ "message": "..." }` | | confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) | | **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
## `bind.tables`(表级绑定) ## `bind.tables`(表级绑定)
@@ -122,6 +127,125 @@ 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/预填,作者须自觉。
默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例: 默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例:
```json ```json
@@ -234,6 +358,25 @@ HINT: <next step>
| `agent` | Agent 可直接调用(`run_skill_action`) | | `agent` | Agent 可直接调用(`run_skill_action`) |
| `cron` | 定时任务「技能直调」 | | `cron` | 定时任务「技能直调」 |
### 数据管理技能按钮顺序(宿主正式契约)
宿主**没有**单独的 `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. 表顶栏技能按钮过多时,宿主可能只常驻前若干个,其余进「更多操作」;**溢出菜单仍遵循同一数组相对顺序**。
禁止:
- 臆造 `sortOrder` / `order` / `priority` 等字段指望宿主排序(模板 Schema `additionalProperties: false` 会拒收)。
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
### Action 类型 × placements(常见示例,不是限制) ### Action 类型 × placements(常见示例,不是限制)
下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。 下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。

View File

@@ -78,6 +78,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 +133,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"}]',
)
```
禁止: 禁止:
@@ -354,7 +388,11 @@ CREATE TABLE demo_items (
## 枚举字段 options_json ## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`: `options_json` 支持两种形态:
### 1)静态枚举(数组)
每项包含 `value` 与 `label`:
```json ```json
[ [
@@ -366,6 +404,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.69"
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,31 @@ 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_manifest_validates_against_json_schema(self) -> None: def test_manifest_validates_against_json_schema(self) -> None:
try: try:
import jsonschema import jsonschema
@@ -469,6 +508,10 @@ class TestActionsManifest(unittest.TestCase):
"additionalProperties", "additionalProperties",
"placements", "placements",
"executionProfile", "executionProfile",
"interaction",
"confirmation",
"数据管理技能按钮顺序",
"数组相对顺序",
): ):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
for forbidden in ( for forbidden in (

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 官网)开好业务技能仓库并授权,技术人员应直接: