Compare commits

...

5 Commits

Author SHA1 Message Date
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
19 changed files with 616 additions and 80 deletions

View File

@@ -9,6 +9,32 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 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
- 强化日志规范:验收铁律、固定事件词表、失败须先写文件日志再 print

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.60
version: 1.0.65
author: 深圳匠厂科技有限公司
metadata:
openclaw:

View File

@@ -28,6 +28,11 @@
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"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": {
"type": ["string", "number", "boolean"]
},
@@ -52,6 +57,12 @@
"readOnly": {
"type": "boolean",
"description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等"
},
"column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "可选。绑定业务表物理列名;replaces create/update 时宿主可复用该列的中文名与 lookup 下拉"
}
}
},
@@ -120,9 +131,18 @@
"type": "object",
"additionalProperties": { "type": "string", "minLength": 1 },
"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": {
"type": "object",
@@ -144,10 +164,11 @@
},
"placements": {
"type": "array",
"minItems": 1,
"minItems": 0,
"items": { "$ref": "#/$defs/placement" },
"description": "决定 Action 出现在哪里;与 executionProfile 正交"
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。"
},
"replaces": { "$ref": "#/$defs/mutationKind" },
"entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" },
@@ -156,6 +177,27 @@
"enum": ["sync", "async"],
"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" }
},
"if": {
@@ -169,7 +211,17 @@
"then": {
"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

@@ -158,7 +158,7 @@ scripts/
```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
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/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。
**技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。

View File

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

View File

@@ -155,7 +155,7 @@ verified_at: "YYYY-MM-DD"
1. 先阅读本总标准和对应分端手册。
2. 先输出业务步骤表和元素清单,再生成代码。
3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。
3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。桌面端须同时记录 **Name 与 Value**(可见中文常在 Value;见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md) §2)。
4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。
5. 为每个动作实现前置状态、定位、动作和后置状态。
6. 把诊断探针、界面树导出和失败截图作为实现的一部分。

View File

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

View File

@@ -2,6 +2,10 @@ source_case: add-friend-wecom
status: verified_source_redacted_example
surface: windows
driver: uiautomation
notes:
- 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label
- 禁止全树第一个 status_layout / wbtn_container
- 主窗流程先 maximize;activate 勿无条件 SW_RESTORE
elements:
- logical_name: main_window
purpose: 企业微信主窗口
@@ -11,7 +15,7 @@ elements:
window_class: WeWorkWindow
title: 企业微信
expected_count: 1
postcondition: UIA root 可读取
postcondition: UIA root 可读取;业务需要时已最大化
replace_in_new_skill: true
- logical_name: contacts_entry
@@ -21,8 +25,57 @@ elements:
primary_locator:
name: org_op_new
control_type: TreeItem
value_hint: 通讯录
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
- logical_name: add_customer_window

View File

@@ -10,3 +10,17 @@
<uia name="inner_search_layout" index="1" />
<uia name="searchedit" index="1" />
</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 ntpath
import time
from ctypes import wintypes
from dataclasses import dataclass
from typing import Iterable
@@ -31,6 +32,7 @@ class WindowCandidate:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
SW_RESTORE = 9
SW_MAXIMIZE = 3
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
@@ -105,7 +107,23 @@ def resolve_unique_window(
return matched[0]
def activate_window(window: WindowCandidate) -> None:
user32.ShowWindow(window.hwnd, SW_RESTORE)
if not user32.SetForegroundWindow(window.hwnd):
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={window.hwnd}")
def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None:
"""激活窗口。
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)
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]:
if depth > max_depth:
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)
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:
if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name:
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 (
getattr(control, "AutomationId", None) or ""
) != spec.automation_id:
@@ -52,6 +73,9 @@ def _matches(control: Any, spec: ElementSpec) -> bool:
getattr(control, "ClassName", None) or ""
) != spec.class_name:
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
@@ -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)
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:
raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != spec.expected_count:
@@ -75,8 +101,13 @@ def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
return matched[0]
def click_control(control: Any, *, window: WindowCandidate) -> None:
activate_window(window)
def click_control(
control: Any,
*,
window: WindowCandidate,
maximize: bool = False,
) -> None:
activate_window(window, maximize=maximize)
rect = control.BoundingRectangle
if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000:
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:
auto = require_uiautomation()
click_control(control, window=window)
click_control(control, window=window, maximize=False)
auto.SendKeys("{Ctrl}a", waitTime=0.05)
auto.SendKeys("{Delete}", waitTime=0.05)
for char in text:

View File

@@ -5,28 +5,49 @@ from __future__ import annotations
from dataclasses import asdict
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 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]:
"""最小 probe:主窗 + 通讯录入口。
生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。
调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。
"""
main = resolve_unique_window(MAIN_WINDOW)
activate_window(main, maximize=True)
root = root_from_window(main)
contacts = locate_unique(root, CONTACTS_ENTRY)
return {
payload: dict[str, Any] = {
"success": True,
"minimized_probe": True,
"main_window": asdict(main),
"contacts_entry": {
"name": getattr(contacts, "Name", ""),
"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]:
"""要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。"""
search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW)
activate_window(search_window, maximize=False)
root = root_from_window(search_window)
search_input = locate_unique(root, SEARCH_INPUT)
type_text(search_input, target, window=search_window)

View File

@@ -18,10 +18,16 @@ class WindowSpec:
class ElementSpec:
logical_name: str
name: Optional[str] = None
"""UIA Name(内部 id,未必是界面中文)。"""
value: Optional[str] = None
"""UIA Value(可见中文文案常在此)。"""
automation_id: Optional[str] = None
control_type: Optional[str] = None
class_name: Optional[str] = None
ancestor_name: Optional[str] = None
"""先定位到该 Name 的祖先,再在其下搜索。"""
child_value_name: Optional[str] = None
"""在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。"""
expected_count: int = 1
@@ -37,6 +43,19 @@ CONTACTS_ENTRY = ElementSpec(
name="org_op_new",
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",
name="searchedit",

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
@@ -11,9 +12,11 @@ sys.path.insert(0, str(ROOT / "scripts"))
from service.desktop_session import ( # noqa: E402
DesktopRpaError,
WindowCandidate,
activate_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):
@@ -39,6 +42,58 @@ class SelectorContractTest(unittest.TestCase):
resolve_unique_window(MAIN_WINDOW, candidates=[hidden])
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__":
unittest.main()

View File

@@ -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`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
@@ -93,13 +95,16 @@ Action 是**可选能力**:
| 层级 | 稳定字段 / 能力 |
|------|----------------|
| 顶层 | `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`) |
| 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`(行内/批量预填,见下节) |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` |
| confirmation | `{ "message": "..." }` |
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
## `bind.tables`(表级绑定)
@@ -122,6 +127,125 @@ Action 是**可选能力**:
- **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。
- `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。业务示例:
```json

View File

@@ -78,6 +78,8 @@ metadata:
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
若创建/更新/删除带有领域副作用(自动建目录、加密、级联清理),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete` 接管默认按钮,详见 [`ACTIONS.md`](ACTIONS.md)。
## 标准数据同步语义(外源 → 本地库)
适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。
@@ -354,7 +356,11 @@ CREATE TABLE demo_items (
## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`:
`options_json` 支持两种形态:
### 1)静态枚举(数组)
每项包含 `value` 与 `label`:
```json
[
@@ -366,6 +372,31 @@ CREATE TABLE demo_items (
宿主据此渲染选择框;技能侧负责保证 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 初始化

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]]:
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。"""
"""校验 options_json:静态枚举数组,或 lookup 对象。"""
errors: list[str] = []
warnings: list[str] = []
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:
errors.append(f"options_json 不是合法 JSON:{exc}")
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):
errors.append("options_json 必须是 JSON 数组")
errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)")
return errors, warnings
for index, item in enumerate(parsed):
if not isinstance(item, dict):

View File

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

View File

@@ -190,7 +190,21 @@ class TestActionsManifest(unittest.TestCase):
placements = action.get("placements")
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:
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.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:
try:
import jsonschema
@@ -469,6 +508,8 @@ class TestActionsManifest(unittest.TestCase):
"additionalProperties",
"placements",
"executionProfile",
"interaction",
"confirmation",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
for forbidden in (