Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db2325dbe4 | |||
| 16ee121ab3 | |||
| 139d176841 | |||
| 0b6bf0b2fa | |||
| 51be1517c0 | |||
| 98f057e26b | |||
| 8878a90315 | |||
| 2ecb1172f4 | |||
| d2c8c3240c | |||
| 2c9f610a8e | |||
| 2c9bf56054 | |||
| 633ecff4e2 |
18
.env.example
18
.env.example
@@ -33,21 +33,19 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
|
||||
|
||||
# 以下为可选目录配置。一般不用改;取消注释并填写后,才覆盖默认子文件夹。
|
||||
# 填写相对路径时,相对于本技能的数据目录(不是当前工作目录)。
|
||||
# 用户可见只有 input(上行)与 output(下行);见 development/DATA_PATHS.md。
|
||||
|
||||
# 下载目录:技能保存下载文件的位置
|
||||
# SKILL_DOWNLOAD_DIR=downloads # 相对本技能数据目录;留空或保持注释则用默认 downloads
|
||||
# 输入目录:用户/Agent 放入的待处理文件(清单、CSV、产品表等)
|
||||
# SKILL_INPUT_DIR=input # 相对本技能数据目录;留空或保持注释则用默认 input
|
||||
|
||||
# 导入目录:待导入文件放置的位置
|
||||
# SKILL_IMPORT_DIR=imports # 相对本技能数据目录;留空或保持注释则用默认 imports
|
||||
# 输出目录:技能/宿主交给用户拿走的结果(视频、报表、导出文件等)
|
||||
# SKILL_OUTPUT_DIR=output # 相对本技能数据目录;留空或保持注释则用默认 output
|
||||
|
||||
# 导出目录:技能导出文件的保存位置
|
||||
# SKILL_EXPORT_DIR=exports # 相对本技能数据目录;留空或保持注释则用默认 exports
|
||||
|
||||
# 上传目录:待上传文件放置的位置
|
||||
# 上传目录:待发布到外部平台的 staging(可选,不上侧栏)
|
||||
# SKILL_UPLOAD_DIR=uploads # 相对本技能数据目录;留空或保持注释则用默认 uploads
|
||||
|
||||
# 缓存目录:临时缓存文件的位置
|
||||
# 缓存目录:临时缓存文件的位置(不上侧栏)
|
||||
# SKILL_CACHE_DIR=cache # 相对本技能数据目录;留空或保持注释则用默认 cache
|
||||
|
||||
# 临时目录:运行中临时文件的位置
|
||||
# 临时目录:运行中临时文件的位置(不上侧栏)
|
||||
# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp
|
||||
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -9,6 +9,63 @@
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
## 1.0.76
|
||||
|
||||
- 固化数据管理「新增/编辑」表单契约:列 `description` 须在标签下展示;创建默认值取自 SQLite 列 `DEFAULT`(见 `references/SCHEMA.md`)
|
||||
- 明确禁止把说明塞进 `display_name`;与 Skill Action 的 `inputSchema.description` / `default` 分工写清(见 `ACTIONS.md`)
|
||||
- 宿主须按该契约实现下发与预填;技能侧用元数据说明 + DDL DEFAULT 即可,第一期不新增表单默认值元数据列
|
||||
|
||||
## 1.0.75
|
||||
|
||||
- 正式沉淀数据管理「单元格呈现」通用契约(`references/CELL_PRESENTATION.md`):技能声明某表某行某列的语义 tone,宿主映射主题样式
|
||||
- 用途不限(超时未填、库存预警、状态异常、审核驳回等);推荐旁路表 `_jiangchang_cell_presentation`;填完后重算即取消强调
|
||||
- 新增 Schema / 示例;`actions.json` 可选顶层 `cellPresentation.enabled`
|
||||
|
||||
## 1.0.74
|
||||
|
||||
- 沉淀宿主 Skill Action 编排契约:`hostEffects`(`onSuccess` / `onFailure`)与 `steps`(`before` / `afterSuccess` / `afterFailure`)
|
||||
- 支持成功后 `reloadTable` 等 UI 副作用与级联删除步骤;需匠厂宿主 ≥2.8.5;省略则不自动刷表
|
||||
- 更新 `skill-actions.schema.json`、ACTIONS.md、SKILL_ACTION_RUNTIME
|
||||
|
||||
## 1.0.73
|
||||
|
||||
- 正式发布 Data Policy 契约(`references/POLICY.md` v1.0):可选身份授权,默认 L0 零配置
|
||||
- 新增 Snapshot Schema 与示例;`actions.json` 支持顶层 `policy` 与 Action 可选 `authz` 提示
|
||||
- ACTIONS 扩展为六职责正交(含 policy)
|
||||
|
||||
## 1.0.72
|
||||
|
||||
- 澄清 `placements: "row"` 契约:表示数据管理**行级入口**;技能配置决定菜单项,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action,无独立菜单 DSL / `row-context-menu` placement)
|
||||
- 更新 ACTIONS.md、SKILL_ACTION_RUNTIME、DEVELOPMENT 验收表与 skill-actions.schema 描述
|
||||
|
||||
## 1.0.71
|
||||
|
||||
- 文档对齐:Skill Action `sync` 默认超时约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`);接近或超过约 60s 的任务应标 `async`
|
||||
- 更新 `SKILL_ACTION_RUNTIME.md`
|
||||
|
||||
## 1.0.70
|
||||
|
||||
- 对齐宿主:数据管理表顶栏技能按钮全部直接展示,取消「更多操作」溢出菜单
|
||||
- 补充宽窄布局说明:够宽左右横排一行;不够宽左右分栏、栏内竖排
|
||||
- 更新 ACTIONS.md 与 skill-actions.schema.json
|
||||
|
||||
## 1.0.69
|
||||
|
||||
- 用户数据目录收敛为上行 `input/` 与下行 `output/`(取代 downloads / imports / exports 三分法)
|
||||
- `runtime_paths` 提供 `get_input_dir()` / `get_output_dir()`;相对输入路径解析到 `input/`
|
||||
- 更新 DATA_PATHS.md、CONFIG、`.env.example`;缓存 / 临时 / RPA 录屏目录仍为内部目录
|
||||
|
||||
## 1.0.68
|
||||
|
||||
- 明确数据管理技能按钮顺序契约:同一 placement(toolbar / row / batch)下按 `actions` 数组相对顺序展示;调整数组先后即可改序
|
||||
- 说明宿主内置刷新 / 导出 / 查看等不参与该顺序;无独立 `sortOrder` 字段
|
||||
- 更新 ACTIONS.md 与 skill-actions.schema.json
|
||||
|
||||
## 1.0.67
|
||||
|
||||
- 移除模板内嵌的 `tools/ui_inspector`;UI 录制器产品迁至独立仓库 `jiangchang-ui-recorder`
|
||||
- 相关开发文档改为指向 Gitea 产品仓库入口
|
||||
|
||||
## 1.0.66
|
||||
|
||||
- 数据管理支持表级默认排序:`_jiangchang_tables.default_sort_json`(多字段 JSON);宿主优先采用,未配置则仍按 updated_at → created_at → 主键
|
||||
|
||||
8
SKILL.md
8
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 技能开发模板(通用业务版)
|
||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||
version: 1.0.66
|
||||
version: 1.0.76
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -32,9 +32,11 @@ allowed-tools:
|
||||
### 架构铁律(先读)
|
||||
|
||||
- **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑,禁止按 `source` 分叉业务行为。
|
||||
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。
|
||||
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。其中 `row` 表示**行级入口**(宿主可呈现为操作列、行右键、行内「⋯」等;技能配什么就显示什么,无独立菜单 DSL)。数据管理技能按钮顺序见 `actions` **数组相对顺序**(详见 `references/ACTIONS.md`),无独立 sortOrder。
|
||||
- **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。
|
||||
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。
|
||||
- **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。
|
||||
- **`cellPresentation`(可选)**:需要数据管理「某格特殊标色/强调」时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。
|
||||
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。
|
||||
- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。
|
||||
|
||||
@@ -68,7 +70,7 @@ allowed-tools:
|
||||
5. **一个业务能力一个 Action**,用多个 `placements` 挂入口;底层仍是同一 `scripts/main.py` CLI。
|
||||
6. 是否进任务中心**只**看该 Action 的 `executionProfile`,与数据管理 / 定时 / Agent 入口无关。
|
||||
|
||||
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`(不要把 `development/` 当 Agent 主读路径)。
|
||||
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`、按需 `references/POLICY.md`(不要把 `development/` 当 Agent 主读路径)。
|
||||
|
||||
## 共享 Python(必读)
|
||||
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
|
||||
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
|
||||
- `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。
|
||||
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
|
||||
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
|
||||
|
||||
29
assets/examples/skill-cell-presentation.example.json
Normal file
29
assets/examples/skill-cell-presentation.example.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"cells": [
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "qty",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "status",
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
},
|
||||
{
|
||||
"rowId": "99",
|
||||
"column": "print_done_at",
|
||||
"tone": "danger",
|
||||
"message": "环节到期未填",
|
||||
"alert": true
|
||||
}
|
||||
]
|
||||
}
|
||||
13
assets/examples/skill-policy-snapshot.allowlist.example.json
Normal file
13
assets/examples/skill-policy-snapshot.allowlist.example.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": {
|
||||
"userId": "u1",
|
||||
"displayName": "张三",
|
||||
"roles": ["clerk"]
|
||||
},
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "sync-records"]
|
||||
}
|
||||
}
|
||||
28
assets/examples/skill-policy-snapshot.scope.example.json
Normal file
28
assets/examples/skill-policy-snapshot.scope.example.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": {
|
||||
"userId": "u1"
|
||||
},
|
||||
"actions": {
|
||||
"mode": "open"
|
||||
},
|
||||
"data": {
|
||||
"tables": {
|
||||
"orders": {
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{
|
||||
"field": "owner_user_id",
|
||||
"op": "eq",
|
||||
"valueFrom": "actor.userId"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
|
||||
"title": "SkillActionManifest",
|
||||
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
|
||||
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "actions"],
|
||||
"additionalProperties": false,
|
||||
@@ -19,14 +19,68 @@
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/action" }
|
||||
"items": { "$ref": "#/$defs/action" },
|
||||
"description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序展示。toolbar/batch:表顶栏全部直接展示(无「更多操作」溢出)。row:行级入口,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action、同一顺序);不在本字段配置排序、截断或菜单树。调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。表顶栏宽时左右横排,窄时左右分栏且栏内竖排。"
|
||||
},
|
||||
"policy": {
|
||||
"$ref": "#/$defs/policyDeclaration",
|
||||
"description": "可选。启用 Data Policy(L1/L2)。缺省或不 enabled = L0,宿主不拉 Snapshot。详见 references/POLICY.md。"
|
||||
},
|
||||
"cellPresentation": {
|
||||
"$ref": "#/$defs/cellPresentationDeclaration",
|
||||
"description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"cellPresentationDeclaration": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "仅 true 启用;false 等同 P0。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"policyDeclaration": {
|
||||
"type": "object",
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "仅 true 启用;false 等同 L0。"
|
||||
},
|
||||
"entrypoint": {
|
||||
"$ref": "#/$defs/entrypoint",
|
||||
"description": "enabled=true 时必填。推荐 command=policy,args 可含 {{table}}/{{skill}}/{{userId}}。"
|
||||
},
|
||||
"appliesTo": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["data-management"]
|
||||
},
|
||||
"description": "缺省 [\"data-management\"]。本期宿主只实现 data-management。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "enabled": { "const": true } },
|
||||
"required": ["enabled"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["entrypoint"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"placement": {
|
||||
"type": "string",
|
||||
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
|
||||
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。placements 与 executionProfile 正交,不做条件限制。"
|
||||
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。row=行级入口(操作列/行右键/⋯ 等由宿主呈现,无独立 row-context-menu placement)。placements 与 executionProfile 正交,不做条件限制。"
|
||||
},
|
||||
"mutationKind": {
|
||||
"type": "string",
|
||||
@@ -144,6 +198,75 @@
|
||||
},
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping。concurrency/locks 勿写入。"
|
||||
},
|
||||
"hostEffectId": {
|
||||
"type": "string",
|
||||
"enum": ["reloadTable", "refreshCatalog", "clearSelection"],
|
||||
"description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。"
|
||||
},
|
||||
"hostEffects": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"onSuccess": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/hostEffectId" },
|
||||
"uniqueItems": true,
|
||||
"description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。"
|
||||
},
|
||||
"onFailure": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/hostEffectId" },
|
||||
"uniqueItems": true,
|
||||
"description": "sync 失败,或 async 启动被拒时触发。"
|
||||
}
|
||||
},
|
||||
"description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。"
|
||||
},
|
||||
"actionStep": {
|
||||
"type": "object",
|
||||
"required": ["when", "do"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"when": {
|
||||
"type": "string",
|
||||
"enum": ["before", "afterSuccess", "afterFailure"],
|
||||
"description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。"
|
||||
},
|
||||
"do": {
|
||||
"type": "string",
|
||||
"enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"],
|
||||
"description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "deleteByFk / deleteRows 目标表(snake_case)。"
|
||||
},
|
||||
"fk": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "deleteByFk 时子表外键列名。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "do": { "const": "deleteByFk" } },
|
||||
"required": ["do"]
|
||||
},
|
||||
"then": { "required": ["table", "fk"] }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": { "do": { "const": "deleteRows" } },
|
||||
"required": ["do"]
|
||||
},
|
||||
"then": { "required": ["table"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
"action": {
|
||||
"type": "object",
|
||||
"required": ["id", "label", "description", "placements", "entrypoint", "executionProfile"],
|
||||
@@ -166,7 +289,7 @@
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"items": { "$ref": "#/$defs/placement" },
|
||||
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。"
|
||||
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。"
|
||||
},
|
||||
"replaces": { "$ref": "#/$defs/mutationKind" },
|
||||
"entrypoint": { "$ref": "#/$defs/entrypoint" },
|
||||
@@ -198,7 +321,25 @@
|
||||
},
|
||||
"description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。"
|
||||
},
|
||||
"bind": { "$ref": "#/$defs/bind" }
|
||||
"bind": { "$ref": "#/$defs/bind" },
|
||||
"hostEffects": { "$ref": "#/$defs/hostEffects" },
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": { "$ref": "#/$defs/actionStep" },
|
||||
"description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。"
|
||||
},
|
||||
"authz": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"capability": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选文档/静态提示用能力名;宿主不得仅凭此显隐按钮,运行时以 policy Snapshot 为准。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"properties": {
|
||||
|
||||
78
assets/schemas/skill-cell-presentation.schema.json
Normal file
78
assets/schemas/skill-cell-presentation.schema.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json",
|
||||
"title": "SkillCellPresentationDocument",
|
||||
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "table", "rowKey", "cells"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"minLength": 1,
|
||||
"description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "业务表物理名(snake_case)"
|
||||
},
|
||||
"rowKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "用于匹配行的业务表列名(如 id、feishu_record_id);cells[].rowId 的值取自该列"
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选。ISO-8601 或技能约定的生成时间,便于排障"
|
||||
},
|
||||
"cells": {
|
||||
"type": "array",
|
||||
"description": "当前应对该表生效的呈现项;空数组 = 该表无特殊呈现(清除旧样式)。同 (rowId, column) 重复时宿主可后者覆盖前者。",
|
||||
"items": { "$ref": "#/$defs/cellItem" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"tone": {
|
||||
"type": "string",
|
||||
"enum": ["danger", "warning", "info", "success", "muted"],
|
||||
"description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。"
|
||||
},
|
||||
"cellItem": {
|
||||
"type": "object",
|
||||
"required": ["rowId", "column", "tone"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rowId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "rowKey 列的字符串形式主键/业务键"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "要强调的业务列名(snake_case),必须是真实列"
|
||||
},
|
||||
"tone": { "$ref": "#/$defs/tone" },
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"description": "可选。悬停/无障碍说明(短文案);不要塞长报告"
|
||||
},
|
||||
"alert": {
|
||||
"type": "boolean",
|
||||
"description": "可选,默认 false。true = 可计入进页/同步后的提醒汇总(如 Toast);填完数据并重算后该项消失则提醒自然关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
196
assets/schemas/skill-policy-snapshot.schema.json
Normal file
196
assets/schemas/skill-policy-snapshot.schema.json
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-policy-snapshot.schema.json",
|
||||
"title": "SkillDataPolicySnapshot",
|
||||
"description": "匠厂 Skill Data Policy Snapshot(schemaVersion 1)。由技能 policy CLI 输出到 stdout;宿主用于数据管理按钮/行列过滤。详见 references/POLICY.md。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "actor", "actions"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"required": ["userId"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"userId": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string" },
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"attributes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": ["string", "number", "boolean", "null"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"type": "object",
|
||||
"required": ["mode"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["allowlist", "open"],
|
||||
"description": "v1 仅允许 allowlist/open;denylist 非法。"
|
||||
},
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
},
|
||||
"description": "mode=allowlist 时必填;mode=open 时应省略或 []。"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "mode": { "const": "allowlist" } },
|
||||
"required": ["mode"]
|
||||
},
|
||||
"then": {
|
||||
"required": ["ids"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"hostCapabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["refresh", "export", "view", "edit", "delete", "import"]
|
||||
},
|
||||
"description": "省略=不限制宿主内置能力;写出则按 allowlist(含 []=全禁)。"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tables": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/tablePolicy" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "技能私有扩展;宿主必须忽略未知键。"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"predicate": {
|
||||
"type": "object",
|
||||
"required": ["field", "op"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eq",
|
||||
"ne",
|
||||
"in",
|
||||
"not_in",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"is_null",
|
||||
"is_not_null"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"description": "与 valueFrom 互斥;in/not_in 时为非空数组。"
|
||||
},
|
||||
"valueFrom": {
|
||||
"type": "string",
|
||||
"const": "actor.userId",
|
||||
"description": "v1 仅允许 actor.userId。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/predicate" },
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["all"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"all": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/predicate" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["any"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"any": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/predicate" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"tablePolicy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"accessible": { "type": "boolean" },
|
||||
"columns": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"visible": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
}
|
||||
},
|
||||
"writable": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rows": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["scope", "open", "delegate"]
|
||||
},
|
||||
"scope": { "$ref": "#/$defs/scope" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,15 +108,15 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
|
||||
|
||||
四档运行模式的**实现与测试约定**见 [`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)
|
||||
- `.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`)
|
||||
|
||||
|
||||
@@ -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. 标准子目录树
|
||||
|
||||
用户可见目录(数据管理侧栏只挂这两个)按 **上行 / 下行** 划分:
|
||||
|
||||
| 子目录 | 方向 | 用途 | 默认 helper |
|
||||
|--------|------|------|-------------|
|
||||
| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` |
|
||||
| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` |
|
||||
|
||||
内部 / 可选目录(**不上侧栏**):
|
||||
|
||||
| 子目录 | 用途 | 默认 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()` |
|
||||
| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` |
|
||||
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
|
||||
@@ -45,6 +51,15 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
|
||||
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。
|
||||
|
||||
**开发者硬规则:**
|
||||
|
||||
- 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`)
|
||||
- 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`)
|
||||
- 禁止再引入业务侧栏第三目录(如旧的 `downloads` / `imports` / `exports` 三分法)
|
||||
- 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」
|
||||
|
||||
宿主约定(与本模板对齐):工具栏「导入」默认从 `input/` 取文件;「导出」默认写到 `output/`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 两类路径
|
||||
@@ -54,6 +69,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。
|
||||
|
||||
- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。
|
||||
- 用户可拿走的业务产出 → **`output/`**。
|
||||
- **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
|
||||
|
||||
### B. 用户显式输入(user-owned)
|
||||
@@ -61,7 +77,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
用户在 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
|
||||
# ── 数据目录子路径(见 development/DATA_PATHS.md)──
|
||||
# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace
|
||||
# SKILL_DOWNLOAD_DIR=downloads/videos
|
||||
# SKILL_IMPORT_DIR=imports
|
||||
# SKILL_EXPORT_DIR=exports
|
||||
# SKILL_INPUT_DIR=input
|
||||
# SKILL_OUTPUT_DIR=output/videos
|
||||
# SKILL_UPLOAD_DIR=uploads
|
||||
# SKILL_CACHE_DIR=cache
|
||||
# SKILL_TEMP_DIR=temp
|
||||
@@ -83,8 +98,8 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 未配置 | `{skill_data_dir}/{default_subdir}` |
|
||||
| 相对值 `downloads/videos` | `{skill_data_dir}/downloads/videos` |
|
||||
| 绝对值 `D:\nas\exports` | 高级用户/NAS 挂载,原样使用 |
|
||||
| 相对值 `output/videos` | `{skill_data_dir}/output/videos` |
|
||||
| 绝对值 `D:\nas\output` | 高级用户/NAS 挂载,原样使用 |
|
||||
| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 |
|
||||
|
||||
业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。
|
||||
@@ -95,20 +110,20 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
|
||||
```python
|
||||
from util.runtime_paths import (
|
||||
get_downloads_dir,
|
||||
get_imports_dir,
|
||||
get_input_dir,
|
||||
get_output_dir,
|
||||
resolve_data_path,
|
||||
resolve_input_path,
|
||||
list_resolved_data_paths,
|
||||
)
|
||||
|
||||
# 下载保存(A 类)
|
||||
dest = os.path.join(get_downloads_dir(), f"{vid}.mp4")
|
||||
# 下行产出(A 类)——视频、报表等
|
||||
dest = os.path.join(get_output_dir(), f"{vid}.mp4")
|
||||
|
||||
# 或带 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)
|
||||
|
||||
# health 诊断
|
||||
@@ -123,6 +138,8 @@ os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR"))
|
||||
|
||||
# ❌ .env.example 里写 ./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)
|
||||
- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3)
|
||||
|
||||
业务 `downloads/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
|
||||
业务 `output/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -841,7 +841,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
|-------------------------|----------------|--------------|
|
||||
| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
|
||||
| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 |
|
||||
| **数据管理 · 行内** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 行内操作链接可见(需有主键列);点按后入参/行为正确 |
|
||||
| **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 |
|
||||
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
|
||||
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
|
||||
| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 |
|
||||
|
||||
@@ -67,3 +67,6 @@
|
||||
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
|
||||
| platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |
|
||||
| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 |
|
||||
| Data Policy 未启用(L0)时宿主不得拉 Snapshot / 不得改按钮与行列行为 | references/POLICY.md | soft;宿主实现契约 |
|
||||
| Data Policy 启用后 fail-closed(禁止回退 L0 全开);写路径 domain 必须再鉴权 | references/POLICY.md §6–§7 | soft;`POLICY-SKILL-AUTHZ-*` 硬门禁可后续加 |
|
||||
| `actions.mode` 仅 allowlist/open;`hostCapabilities` 省略=不限制;scope `valueFrom` 仅 `actor.userId` | references/POLICY.md §0.1 | soft;Schema 已约束 |
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
未完成第 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. 元素清单
|
||||
|
||||
每个关键输入、按钮、列表、弹窗、成功状态和错误状态都必须登记。推荐 YAML:
|
||||
|
||||
@@ -104,7 +104,7 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均
|
||||
{...}/{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`)。
|
||||
- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。
|
||||
2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。
|
||||
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。
|
||||
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格语义标色;省略 = P0。
|
||||
4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。
|
||||
5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。
|
||||
6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
| 值 | 宿主行为 | 超时 | 任务中心 |
|
||||
|----|----------|------|----------|
|
||||
| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **30s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
|
||||
| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
|
||||
| **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 |
|
||||
|
||||
直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。
|
||||
@@ -48,7 +48,7 @@
|
||||
| 场景 | 建议 |
|
||||
|------|------|
|
||||
| 浏览器 / Playwright / RPA 主路径 | `async` + 建议 `confirmation` |
|
||||
| 单次可能超过 ~20s 的网络/下载/同步 | `async` |
|
||||
| 单次可能接近或超过约 60s 的网络/下载/同步 | `async` |
|
||||
| `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` |
|
||||
| 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 |
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
|-------------------------|-------------------|--------|--------------|
|
||||
| **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 |
|
||||
| **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 |
|
||||
| **数据管理 · 行内** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 同行操作链接(需主键) |
|
||||
| **数据管理 · 行级** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 操作列和/或**行右键**(及可选「⋯」)可见并触发(需主键);技能配什么就显示什么 |
|
||||
| **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 |
|
||||
| **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 |
|
||||
| **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement |
|
||||
@@ -141,7 +141,7 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context
|
||||
| 能力 | 约定 |
|
||||
|------|------|
|
||||
| 队列表 | `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` |
|
||||
| 重试 | `reset` 或数据管理改 `pending`;**failed 不要自动重试** |
|
||||
| 批次 | 可选 `batch_id`,便于筛选与任务中心对照 |
|
||||
|
||||
@@ -72,39 +72,46 @@ Action 是**可选能力**:
|
||||
|------|------|------|
|
||||
| `schemaVersion` | 是 | 当前固定为 `1` |
|
||||
| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 |
|
||||
| `actions` | 是 | Action 数组,每项 `id` 唯一 |
|
||||
| `actions` | 是 | Action 数组,每项 `id` 唯一;**数组相对顺序**决定数据管理 toolbar / row / batch 技能按钮的展示顺序(见下节) |
|
||||
|
||||
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
|
||||
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) 与 [`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
|
||||
|
||||
## 五职责正交(必读)
|
||||
## 六职责正交(必读)
|
||||
|
||||
| 字段 / 层 | 只决定什么 | 不决定什么 |
|
||||
|-----------|------------|------------|
|
||||
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async,不决定点了是否弹窗 |
|
||||
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 |
|
||||
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除」 | 不强制;无配置时宿主仍直写 SQLite |
|
||||
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
|
||||
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
|
||||
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
|
||||
| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 |
|
||||
| **`cellPresentation`** | **可选**:某表某行某列的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) |
|
||||
| **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 |
|
||||
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
|
||||
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
|
||||
|
||||
一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。
|
||||
一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。需要按用户/角色藏按钮或滤行时,**不要**按角色复制 Action,应启用顶层 `policy` 并由技能算出 Snapshot(见 [`POLICY.md`](POLICY.md))。
|
||||
|
||||
## Phase 1 当前稳定支持(新技能应只使用这些)
|
||||
|
||||
| 层级 | 稳定字段 / 能力 |
|
||||
|------|----------------|
|
||||
| 顶层 | `schemaVersion`、`skill`、`actions` |
|
||||
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`** |
|
||||
| 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(单元格呈现启用块) |
|
||||
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) |
|
||||
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
|
||||
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
|
||||
| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` — 接管默认新增/编辑/删除(须 `bind.tables`) |
|
||||
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) |
|
||||
| **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 |
|
||||
| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载单元格呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 |
|
||||
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` |
|
||||
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
|
||||
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
|
||||
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
|
||||
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
|
||||
| **hostEffects** | 可选;`onSuccess` / `onFailure` → `reloadTable` \| `refreshCatalog` \| `clearSelection`(匠厂宿主 ≥2.8.5) |
|
||||
| **steps** | 可选;有序 `{ when, do, table?, fk? }`,`when`=`before`\|`afterSuccess`\|`afterFailure`;新技能优先(≥2.8.5) |
|
||||
|
||||
## `bind.tables`(表级绑定)
|
||||
|
||||
@@ -246,6 +253,84 @@ Action 是**可选能力**:
|
||||
- **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。
|
||||
- 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。
|
||||
|
||||
## `hostEffects` / `steps`(执行前后宿主编排,≥2.8.5)
|
||||
|
||||
与 `placements` / `executionProfile` / `interaction` **正交**。决定的是:数据管理里点完 Action 之后(或之前),宿主要不要**刷新当前表、刷新目录、清空多选、级联删子表**等。
|
||||
|
||||
- **省略 / 空** ⇒ 宿主**不**额外改 UI(≥2.8.5 起不再对 mutation 硬编码刷新)。本地库若已由技能写新,界面仍可能旧,直到用户点内置「刷新」或本 Action 声明了 `reloadTable`。
|
||||
- **仅数据管理交互路径生效**(toolbar / row / batch / skill-detail / `replaces`);**cron 与 agent 忽略**(与 `interaction` 相同)。
|
||||
- 需匠厂宿主 **≥ 2.8.5**;更旧宿主会忽略未知字段(若未做严格 Schema 校验)。
|
||||
- **新技能优先写 `steps`**;`hostEffects` 仍可作为「只刷 UI」的简写。两者可并存;同一 UI 效果若两边都声明,宿主可能各跑一次。
|
||||
|
||||
### `hostEffects`(UI 副作用简写)
|
||||
|
||||
```json
|
||||
{
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"],
|
||||
"onFailure": ["clearSelection"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 触发时机 |
|
||||
|------|----------|
|
||||
| `onSuccess` | **sync** 退出码 0;或 **async** 任务已被任务中心**接收**(不是 Job 跑完) |
|
||||
| `onFailure` | **sync** 失败;或 **async** **启动被拒** |
|
||||
|
||||
闭集 effect id:`reloadTable`(重载当前表 + 常伴随 Data Policy 再拉)、`refreshCatalog`(刷新技能目录)、`clearSelection`(清空多选)。未知 id 丢弃。
|
||||
|
||||
**async 注意**:`sync-data` 一类长同步若写 `onSuccess: ["reloadTable"]`,会在**入队成功时**就刷表,可能仍是旧数据;更适合 sync 的新建/登记,或等 Job 真正完成后再刷(若宿主后续扩展完成回调)。
|
||||
|
||||
### `steps`(有序编排,推荐)
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{ "when": "before", "do": "deleteByFk", "table": "child_rows", "fk": "parent_id" },
|
||||
{ "when": "afterSuccess", "do": "reloadTable" },
|
||||
{ "when": "afterFailure", "do": "clearSelection" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| `when` | 含义 |
|
||||
|--------|------|
|
||||
| `before` | 调用 CLI **之前** |
|
||||
| `afterSuccess` | 同 `hostEffects.onSuccess` |
|
||||
| `afterFailure` | 同 `hostEffects.onFailure` |
|
||||
|
||||
| `do` | 说明 |
|
||||
|------|------|
|
||||
| `reloadTable` / `refreshCatalog` / `clearSelection` | 同 hostEffects |
|
||||
| `deleteByFk` | 按外键批量删子表;须 `table` + `fk`;行按钮会把当前行主键当父键 |
|
||||
| `deleteRows` | 删指定表行;须 `table` |
|
||||
|
||||
规则:单次最多 **8** 步;未知步骤丢弃;`deleteByFk` / `deleteRows` 仅走 Skill Action steps 路径时可删 `metadata_readonly` 投影表(人工 CRUD 仍守只读)。整库只读 / 视图仍禁止写。
|
||||
|
||||
典型:业务表「新建 / 登记 / 更新」成功后加 `{ "when": "afterSuccess", "do": "reloadTable" }`,避免用户傻等旧表格。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "register-one",
|
||||
"label": "登记本行",
|
||||
"description": "更新当前行业务字段。",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["business_records"],
|
||||
"inputMapping": { "recordId": "$row.$pk" }
|
||||
},
|
||||
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "register",
|
||||
"args": ["--id", "{{recordId}}"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例:
|
||||
|
||||
```json
|
||||
@@ -352,12 +437,44 @@ HINT: <next step>
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) |
|
||||
| `row` | 数据管理行内操作(建议 `bind.tables` + `inputMapping`;表需有主键) |
|
||||
| `row` | 数据管理**行级入口**(建议 `bind.tables` + `inputMapping`;表需有主键)。技能声明后,由宿主呈现为操作列链接、**行右键菜单**和/或行内「⋯」等壳;**不**新增 `row-context-menu` 等 placement |
|
||||
| `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) |
|
||||
| `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) |
|
||||
| `agent` | Agent 可直接调用(`run_skill_action`) |
|
||||
| `cron` | 定时任务「技能直调」 |
|
||||
|
||||
### 行级入口 `row`(宿主呈现契约)
|
||||
|
||||
- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;配什么就出现什么。
|
||||
- **宿主决定怎么摆**:可用操作列、行右键、行内「⋯」等呈现**同一批** Action;点击均走同一 `launch`(`placement: "row"`),`interaction` / `executionProfile` 与壳无关。
|
||||
- **顺序**:各壳内技能项顺序 = 下方「数据管理技能按钮顺序」契约(`actions` 数组相对顺序)。
|
||||
- **宿主内置项**:查看 / 编辑 / 删除由宿主提供,**不是**技能 `actions.json` 配置出来的;若出现在行右键,须与技能项分组(例如分隔线后),且不得要求技能再配一份菜单 DSL。
|
||||
- **禁止**:为右键单独发明 placement、菜单注册表或第二套 Action 列表。
|
||||
|
||||
### 数据管理技能按钮顺序(宿主正式契约)
|
||||
|
||||
宿主**没有**单独的 `sortOrder` / `uiOrder` 字段。同一 `placement`(`toolbar` / `row` / `batch`)下,技能按钮的展示顺序 = `actions` 数组中**匹配该项 placement(且通过 `bind.tables` 过滤)的相对先后**。
|
||||
|
||||
规则:
|
||||
|
||||
1. 想调整顺序 → **调整 `actions` 数组先后**(不必改 `id` / `label`)。
|
||||
2. 无关 placement 的 Action(如仅 `skill-detail` 的 `health`)夹在中间**不影响** toolbar/row 的相对顺序;宿主按匹配子集保序渲染。
|
||||
3. 同一 Action 可同时挂多个 placement;它在各入口的位置,分别是该入口匹配子集里的相对位置。
|
||||
4. **宿主内置按钮不参与本契约、位置固定**:
|
||||
- 表顶栏右侧:删除(有勾选时)/ 刷新 / 新增 / 导入 / 导出 —— 技能 toolbar/batch 按钮在左侧,不能与右侧内置按钮混排。
|
||||
- 行级:技能 `row` 项按本顺序出现在操作列 / 行右键(及可选「⋯」)中;宿主「查看」→(非只读)「编辑」→「删除」为内置项,位置由宿主固定(操作列常在技能项之后;右键中可与技能项分组)。
|
||||
5. **表顶栏全部直接展示,无「更多操作」溢出菜单**:匹配到的 toolbar/batch 技能按钮一律渲染(顺序仍按本契约)。**行级**不以「顶栏溢出菜单」模型截断;行右键是 `row` 的正式呈现壳之一,不是 toolbar 式「更多操作」截断。
|
||||
6. **表顶栏宽窄布局(宿主行为,技能无需配置)**:
|
||||
- 够宽:左右各横排一行(左技能按钮 + 右宿主工具)。
|
||||
- 不够宽:保持左右分栏,栏内按钮上下竖排(左栏技能、右栏刷新/导出等)。
|
||||
|
||||
禁止:
|
||||
|
||||
- 臆造 `sortOrder` / `order` / `priority` 等字段指望宿主排序(模板 Schema `additionalProperties: false` 会拒收)。
|
||||
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
|
||||
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
|
||||
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
|
||||
|
||||
### Action 类型 × placements(常见示例,不是限制)
|
||||
|
||||
下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。
|
||||
@@ -390,8 +507,18 @@ HINT: <next step>
|
||||
|
||||
- `object` / `string` / `number` / `boolean`
|
||||
- `enum` / `default` / `required` / `sensitive` / `readOnly`
|
||||
- 属性级 `description`(表单标签下方灰字说明)
|
||||
- 简单模板替换(`{{fieldName}}`)
|
||||
|
||||
**与数据管理「新增 / 编辑」表单的分工(勿混用配置入口):**
|
||||
|
||||
| 表单 | 短标题 | 说明灰字 | 默认值 |
|
||||
|------|--------|----------|--------|
|
||||
| Skill Action 弹窗 | `inputSchema.title` | `inputSchema.description` | `inputSchema.default` |
|
||||
| 数据管理新增/编辑 | `_jiangchang_columns.display_name` | `_jiangchang_columns.description` | 物理列 SQLite `DEFAULT` |
|
||||
|
||||
CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「新增/编辑表单:字段说明与默认值(宿主支持)」。同一业务含义两边文案应对齐,但分别配置。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
@@ -402,11 +529,13 @@ HINT: <next step>
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"title": "查询条件"
|
||||
"title": "查询条件",
|
||||
"description": "支持关键词或完整业务编号"
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"title": "返回数量",
|
||||
"description": "最多返回多少条;填 0 表示不限制(若业务支持)",
|
||||
"default": 50
|
||||
}
|
||||
}
|
||||
|
||||
210
references/CELL_PRESENTATION.md
Normal file
210
references/CELL_PRESENTATION.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 数据管理单元格呈现(Cell Presentation)契约(v1.0)
|
||||
|
||||
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
|
||||
> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。
|
||||
> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。
|
||||
用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。
|
||||
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 能力档位
|
||||
|
||||
| 档位 | 技能行为 | 宿主行为 |
|
||||
|------|----------|----------|
|
||||
| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 |
|
||||
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 |
|
||||
|
||||
多数技能保持 **P0**。需要视觉强调时再启用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 职责边界
|
||||
|
||||
| 角色 | 负责 | 不负责 |
|
||||
|------|------|--------|
|
||||
| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
|
||||
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
|
||||
|
||||
**派生状态(重要):** 呈现是计算结果,不是粘性开关。
|
||||
用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 文档形状(冻)
|
||||
|
||||
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"cells": [
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "qty",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"rowId": "42",
|
||||
"column": "status",
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `schemaVersion` | 是 | 固定 `1` |
|
||||
| `skill` | 是 | 与 slug 一致 |
|
||||
| `table` | 是 | 当前文档作用的业务表 |
|
||||
| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) |
|
||||
| `generatedAt` | 否 | 排障用 |
|
||||
| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** |
|
||||
|
||||
### 2.1 `cells[]` 项
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `rowId` | 是 | `rowKey` 列值的字符串形式 |
|
||||
| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 |
|
||||
| `tone` | 是 | 见 §3 |
|
||||
| `message` | 否 | 短说明(≤500);悬停 / 无障碍 |
|
||||
| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 |
|
||||
|
||||
同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。
|
||||
`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。
|
||||
|
||||
---
|
||||
|
||||
## 3. `tone` 闭集(冻)
|
||||
|
||||
| tone | 语义(技能怎么选) | 宿主怎么呈现(示例,主题可调) |
|
||||
|------|-------------------|-------------------------------|
|
||||
| `danger` | 必须立刻处理 / 已违规 / 严重超时 | 强强调(如浅红底) |
|
||||
| `warning` | 即将到期 / 需注意 | 中等强调(如浅黄) |
|
||||
| `info` | 提示性标记 | 轻强调(如浅蓝) |
|
||||
| `success` | 正向完成态(少用在「待填」场景) | 浅绿等 |
|
||||
| `muted` | 弱化 / 次要 | 灰底或灰字 |
|
||||
|
||||
**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。
|
||||
以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。
|
||||
|
||||
「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐持久化(给宿主实现)
|
||||
|
||||
v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
row_key TEXT NOT NULL,
|
||||
row_id TEXT NOT NULL,
|
||||
column_name TEXT NOT NULL,
|
||||
tone TEXT NOT NULL,
|
||||
message TEXT,
|
||||
alert INTEGER NOT NULL DEFAULT 0,
|
||||
generated_at TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
UNIQUE (table_name, row_id, column_name)
|
||||
);
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。
|
||||
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。
|
||||
- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。
|
||||
|
||||
未创建该旁路表 = P0,宿主忽略。
|
||||
|
||||
---
|
||||
|
||||
## 5. 在 `actions.json` 中启用(可选,推荐)
|
||||
|
||||
与 `policy` 类似,顶层可选:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"cellPresentation": {
|
||||
"enabled": true
|
||||
},
|
||||
"actions": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `cellPresentation` | 省略 = P0 |
|
||||
| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 |
|
||||
|
||||
v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。
|
||||
|
||||
---
|
||||
|
||||
## 6. 何时重算(技能必须遵守)
|
||||
|
||||
至少在以下时机用**同一套 domain 规则**重算并覆盖该表呈现:
|
||||
|
||||
1. 全量/增量把外部数据写入本地投影之后(如同步成功)
|
||||
2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等)
|
||||
3. (可选)用户显式「刷新呈现」类 Action
|
||||
|
||||
进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。
|
||||
提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 与其它契约的关系
|
||||
|
||||
| 契约 | 关系 |
|
||||
|------|------|
|
||||
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 |
|
||||
| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 |
|
||||
| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 |
|
||||
| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 反模式
|
||||
|
||||
- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案
|
||||
- 技能下发 CSS / 内联颜色
|
||||
- 只在首次超时时打标、填完不重算导致红字残留
|
||||
- 宿主写死某个技能 slug 的特殊染色逻辑
|
||||
- 把 Cell Presentation 做成仅服务 SLA 的专用协议
|
||||
|
||||
---
|
||||
|
||||
## 9. 示例场景(说明用途不限)
|
||||
|
||||
| 场景 | column | tone | message 例 |
|
||||
|------|--------|------|------------|
|
||||
| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 |
|
||||
| 库存低于安全线 | `qty` | `warning` | 低于安全库存 |
|
||||
| 审核驳回 | `status` | `danger` | 已驳回,请修改 |
|
||||
| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 |
|
||||
|
||||
完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。
|
||||
@@ -45,6 +45,7 @@ python {baseDir}/scripts/main.py init-db
|
||||
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
|
||||
- **`version`**:输出 JSON(`version`、`skill`)。
|
||||
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
|
||||
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
|
||||
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
|
||||
- **排查顺序**(由快到慢):
|
||||
1. `python {baseDir}/scripts/main.py logs`
|
||||
|
||||
382
references/POLICY.md
Normal file
382
references/POLICY.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Skill Data Policy 契约(v1.0)
|
||||
|
||||
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
|
||||
> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。
|
||||
> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(`visibleWhen` 等)。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 能力档位(必须按此理解)
|
||||
|
||||
| 档位 | 技能配置 | 宿主行为 |
|
||||
|------|----------|----------|
|
||||
| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 |
|
||||
| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 |
|
||||
| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 |
|
||||
| **L3** | (预留,本契约不冻结行为) | 如行态 `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等 |
|
||||
|
||||
**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 已冻结最佳实践(v1.0)
|
||||
|
||||
| 议题 | 决定 | 技能开发者怎么做 |
|
||||
|------|------|------------------|
|
||||
| Skill Action 授权模式 | **仅** `allowlist` / `open`;v1 **禁止** `denylist` | 有角色就算出允许的 action id 列表;只控行不控按钮时用 `open` |
|
||||
| `hostCapabilities` | **省略 = 不限制**宿主内置;写出则按 allowlist(`[]` = 全禁内置) | 只控业务按钮时**不要写**该字段;要限导出/删除时再显式列出 |
|
||||
| scope 运算符 | 保留小白名单(见 §3.4);**禁止** like/raw/嵌套 | 日常优先 `eq` / `in` / `is_null`;比较符按需 |
|
||||
| `valueFrom` | v1 **仅** `actor.userId` | 角色只在技能内用来算 Snapshot,**不要**用 `actor.roles` 做行过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 启用字段(冻在 `assets/actions.json` 顶层)
|
||||
|
||||
`actions.json` 根对象增加**可选**字段 `policy`(与 `schemaVersion` / `skill` / `actions` 同级)。
|
||||
|
||||
### 1.1 形状(L0/L1 必冻)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"policy": {
|
||||
"enabled": true,
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "policy",
|
||||
"args": ["--table", "{{table}}"]
|
||||
},
|
||||
"appliesTo": ["data-management"]
|
||||
},
|
||||
"actions": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 冻结说明 |
|
||||
|------|------|----------|
|
||||
| `policy` | 否 | 缺省 = L0 |
|
||||
| `policy.enabled` | 若存在 `policy` 则**必填** | 仅 `true` 启用;`false` 等同 L0 |
|
||||
| `policy.entrypoint` | `enabled: true` 时必填 | 与 Action `entrypoint` 同构:`type` 目前仅 `"cli"` |
|
||||
| `policy.entrypoint.command` | 是 | 子命令名;**推荐**固定为 `policy` |
|
||||
| `policy.entrypoint.args` | 是 | JSON 数组;可含占位符,由宿主替换后再拼接 CLI |
|
||||
| `policy.appliesTo` | 否 | 缺省 `["data-management"]`。本期宿主**只实现** `data-management`;其它值忽略但不报错 |
|
||||
|
||||
### 1.2 占位符白名单(冻)
|
||||
|
||||
| 占位符 | 含义 | 例 |
|
||||
|--------|------|----|
|
||||
| `{{table}}` | 当前数据管理选中的**物理表名** | `customs_timelines` |
|
||||
| `{{skill}}` | 当前技能 slug | 与 manifest `skill` 一致 |
|
||||
| `{{userId}}` | 当前宿主用户 id | 与注入的 `JIANGCHANG_USER_ID` 一致 |
|
||||
|
||||
未列出的 `{{…}}`:**禁止**;宿主应视为契约错误(fail-closed,见 §6)。
|
||||
|
||||
### 1.3 显式不放进启用块的内容(防推翻)
|
||||
|
||||
以下**不得**出现在 `actions.json.policy` 中(属于技能内部或 Snapshot):
|
||||
|
||||
- 角色列表、人员表、飞书 app id、外部 IAM URL
|
||||
- 具体 allowlist / scope(那是 Snapshot 的事)
|
||||
- 按角色复制的 Action 定义
|
||||
|
||||
### 1.4 Action 级可选提示(不冻为运行时权威)
|
||||
|
||||
允许(文档/静态检查用),**宿主不得仅凭此显隐按钮**:
|
||||
|
||||
```json
|
||||
"authz": { "capability": "optional.skill.defined.string" }
|
||||
```
|
||||
|
||||
运行时权威仅为 Snapshot §2 的 `actions`。
|
||||
|
||||
---
|
||||
|
||||
## 2. Snapshot 最小字段集(`policy` CLI 标准输出)
|
||||
|
||||
- 编码:UTF-8 JSON 对象,写在 **stdout**(可先打日志到 stderr/文件,但 stdout 必须是单 JSON)。
|
||||
- 成功退出码 `0`;业务上「无权限看任何东西」仍是 `0` + 合法 Snapshot(例如空 allowlist),不是靠非 0 表示未授权。
|
||||
- 非 0 或非合法 JSON → §6 fail-closed。
|
||||
|
||||
### 2.1 根对象(L0 不出现;L1 必冻)
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `schemaVersion` | 是 | `integer` | 本期冻结为 **`1`** |
|
||||
| `skill` | 是 | `string` | 必须等于 manifest `skill` |
|
||||
| `actor` | 是 | `object` | 见 §2.2 |
|
||||
| `actions` | 是 | `object` | 见 §2.3 |
|
||||
| `hostCapabilities` | 否 | `string[]` | 见 §2.4;**省略 = 不限制宿主内置按钮**(兼容) |
|
||||
| `data` | 否 | `object` | 见 §3;省略 = 不限制行/列(L1-only) |
|
||||
| `extensions` | 否 | `object` | 技能私有扩展;宿主**必须忽略**未知键 |
|
||||
|
||||
根级未知字段:宿主**忽略**;**不得**依赖未写入本契约的字段做安全决策。
|
||||
|
||||
### 2.2 `actor`(L1 必冻)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `userId` | 是 | 非空;应与 `JIANGCHANG_USER_ID` / `{{userId}}` 对齐 |
|
||||
| `displayName` | 否 | 展示用 |
|
||||
| `roles` | 否 | `string[]`,角色**码**;仅供展示 / 技能自检。**不得**用于 v1 scope 的 `valueFrom` |
|
||||
| `attributes` | 否 | `object`,仅允许 JSON 标量值;本期 scope **不**解引用 |
|
||||
|
||||
### 2.3 `actions`(L1 必冻)— Skill Action 授权
|
||||
|
||||
**最佳实践:** 角色 → 技能内计算 → `allowlist` 的 id 列表。宿主不理解角色名。
|
||||
|
||||
```json
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "register-submit"]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 冻结取值 |
|
||||
|------|------|----------|
|
||||
| `mode` | 是 | **仅两值**:`"allowlist"` \| `"open"` |
|
||||
| `ids` | `mode=allowlist` 时必填 | `string[]`;元素为 **`actions.json` 里的 action.id** |
|
||||
| `ids` | `mode=open` 时 | 必须省略或 `[]`;宿主视为**不限制** skill Action |
|
||||
|
||||
规则:
|
||||
|
||||
1. 宿主先按现有规则得到候选集:`placements` ∩ `bind.tables`(及 batch 等)。
|
||||
2. 若 `mode=allowlist`:候选 ∩ `ids`。漏配 id = 少按钮(安全侧失败)。
|
||||
3. `ids` 中的未知 id:**忽略**(不报错)。
|
||||
4. `mode=open`:等价「L1 不限制按钮」,可与 L2 行列限制并存。
|
||||
5. **`denylist` 不是 v1 合法值**;出现则 §6 fail-closed。
|
||||
|
||||
`replaces` 接管的默认新增/编辑/删除:视为对应 Action 的 id 是否在 allowlist 中;不在则宿主隐藏该接管按钮。`mode=open` 时不按 id 限制 replaces。
|
||||
|
||||
### 2.4 `hostCapabilities`(L1 可选冻)
|
||||
|
||||
控制数据管理**宿主内置**能力,与 skill `action.id` **命名空间分离**。
|
||||
|
||||
本期枚举冻结:`refresh` | `export` | `view` | `edit` | `delete` | `import`。
|
||||
|
||||
| 写法 | 含义 | 何时用 |
|
||||
|------|------|--------|
|
||||
| **省略字段** | **不限制**(与今日一致) | **默认推荐**:只控 skill 按钮 / 行数据时 |
|
||||
| 写出非空数组 | 仅允许列出的内置能力(allowlist) | 要限导出、删除等时 |
|
||||
| 写出 `[]` | 禁止全部上表内置能力 | 极少见;慎用 |
|
||||
|
||||
未知字符串:忽略。**禁止**把「省略」解释成全禁。
|
||||
|
||||
---
|
||||
|
||||
## 3. L2:`data` 与 scope 语法白名单
|
||||
|
||||
### 3.1 `data` 形状
|
||||
|
||||
```json
|
||||
"data": {
|
||||
"tables": {
|
||||
"customs_timelines": {
|
||||
"accessible": true,
|
||||
"columns": {
|
||||
"visible": ["id", "contract_no", "status"],
|
||||
"writable": ["status"]
|
||||
},
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `data.tables` | 若存在 `data` 则建议有 | `key` = 物理表名 |
|
||||
| `accessible` | 否 | 缺省 `true`。`false`:宿主可拒绝打开该表 |
|
||||
| `columns.visible` / `writable` | 否 | 物理列名;省略 = 不额外限制 |
|
||||
| `rows` | 否 | 见下 |
|
||||
|
||||
`writable` 应 ⊆ `visible`;若违规,宿主按交集降级,不 fail-closed。
|
||||
|
||||
### 3.2 `rows.mode`(冻)
|
||||
|
||||
| 值 | 本期 | 含义 |
|
||||
|----|------|------|
|
||||
| `scope` | **实现目标** | 宿主将 `scope` 编译为安全查询约束 |
|
||||
| `open` | 允许 | 显式不限制行 |
|
||||
| `delegate` | **仅占位** | 本期未实现时 fail-closed / 提示;**禁止**当 `open` |
|
||||
|
||||
缺省:无 `rows` = `open`。
|
||||
|
||||
### 3.3 `scope` 布尔结构(冻)
|
||||
|
||||
```text
|
||||
Scope =
|
||||
| { "all": Predicate[] } // AND,长度 ≥ 1
|
||||
| { "any": Predicate[] } // OR,长度 ≥ 1
|
||||
| Predicate // 单个谓词的语法糖
|
||||
```
|
||||
|
||||
**禁止**:嵌套超过一层。
|
||||
|
||||
### 3.4 `Predicate` 白名单(冻)
|
||||
|
||||
**运算符(宿主必须实现):** `eq` | `ne` | `in` | `not_in` | `gt` | `gte` | `lt` | `lte` | `is_null` | `is_not_null`
|
||||
|
||||
**推荐日常只用:** `eq` / `in` / `is_null`(及 `is_not_null`)。
|
||||
|
||||
**明确排除:** `like` / `contains` / `sql` / `raw` / `expr`。
|
||||
|
||||
**`valueFrom` v1 仅:** `actor.userId`。
|
||||
`actor.roles` / `actor.attributes.*` **非法** → fail-closed。
|
||||
|
||||
`value` 与 `valueFrom` 互斥。标量:`string` | `number` | `boolean` | `null`(与 null 比较优先用 `is_null`)。
|
||||
|
||||
### 3.5 与宿主原有过滤的关系
|
||||
|
||||
最终行集 = **原有表查询**(搜索、排序、分页、用户 Filter) **AND** policy `scope`。宿主必须把 scope 下推到查询层。
|
||||
|
||||
---
|
||||
|
||||
## 4. 宿主消费算法
|
||||
|
||||
```text
|
||||
IF manifest.policy?.enabled !== true:
|
||||
→ L0;END
|
||||
|
||||
调用 entrypoint(替换占位符),解析 Snapshot
|
||||
IF 失败 / 非法 / skill 不匹配 / mode 非法:
|
||||
→ fail-closed(§6);END
|
||||
|
||||
候选 = filter(placements, bind.tables, …)
|
||||
IF mode == allowlist: 候选 ∩ ids
|
||||
IF mode == open: 候选不变
|
||||
|
||||
渲染 skill 按钮 ← 候选
|
||||
渲染宿主内置 ← hostCapabilities(省略则不限制)
|
||||
应用 data.tables[T](若有)
|
||||
```
|
||||
|
||||
拉取时机(建议):进入表、切换表、刷新。短缓存不得跨 `userId`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 身份与环境
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 用户主键 | `JIANGCHANG_USER_ID` |
|
||||
| 技能解析 | 飞书 / 本地表 / HTTP IAM 等,**契约不规定** |
|
||||
|
||||
---
|
||||
|
||||
## 6. Fail-closed(启用后必冻)
|
||||
|
||||
当且仅当 `policy.enabled === true`:
|
||||
|
||||
| 情况 | 宿主行为 |
|
||||
|------|----------|
|
||||
| CLI 非 0 / 超时 / 无 JSON | 不渲染该表 skill 写操作与(建议)导出;明确错误;**禁止**回退 L0 全开 |
|
||||
| `schemaVersion` ≠ 1 / `skill` 不匹配 / `actions.mode` 非法 | 同上 |
|
||||
| `rows.mode=scope` 但 predicate 非法 | 不返回行或整表不可用;**禁止**忽略 scope |
|
||||
| `rows.mode=delegate`(未实现) | 提示不支持;**禁止**当 `open` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全边界
|
||||
|
||||
1. Snapshot **只**服务宿主 UI 与查询约束。
|
||||
2. 写 Action / 敏感读的 **domain service 必须再次鉴权**。
|
||||
3. 禁止「只藏按钮」宣称为完整权限方案。
|
||||
4. Cron / Agent **不**自动套用本 Snapshot。
|
||||
|
||||
---
|
||||
|
||||
## 8. 最小示例
|
||||
|
||||
模板仓库另有可复制示例:
|
||||
|
||||
- [`../assets/examples/skill-policy-snapshot.allowlist.example.json`](../assets/examples/skill-policy-snapshot.allowlist.example.json)
|
||||
- [`../assets/examples/skill-policy-snapshot.scope.example.json`](../assets/examples/skill-policy-snapshot.scope.example.json)
|
||||
|
||||
### 8.1 L0
|
||||
|
||||
不写 `policy` 字段。
|
||||
|
||||
### 8.2 L1 allowlist(不碰宿主内置)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1", "displayName": "张三", "roles": ["clerk"] },
|
||||
"actions": {
|
||||
"mode": "allowlist",
|
||||
"ids": ["whoami", "create-record", "register-submit"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 L1 + 限制导出
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1", "roles": ["clerk"] },
|
||||
"actions": { "mode": "allowlist", "ids": ["whoami"] },
|
||||
"hostCapabilities": ["refresh", "view"]
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 L1 open + L2 scope
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actor": { "userId": "u1" },
|
||||
"actions": { "mode": "open" },
|
||||
"data": {
|
||||
"tables": {
|
||||
"orders": {
|
||||
"rows": {
|
||||
"mode": "scope",
|
||||
"scope": {
|
||||
"all": [
|
||||
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 刻意延后
|
||||
|
||||
| 项 | 备注 |
|
||||
|----|------|
|
||||
| `actions.mode: denylist` | 另开 `schemaVersion` |
|
||||
| `valueFrom: actor.roles` | **明确不做** |
|
||||
| `visibleWhen` / 嵌套 scope / `like` | L3 或永不做 |
|
||||
| `rows.mode: delegate` 宿主实现 | 字段名已占坑 |
|
||||
| 平台全局 Role UI / cron·agent 套用 Snapshot | 另议 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 冻结检查清单
|
||||
|
||||
- [x] L0 缺省无歧义
|
||||
- [x] 启用字段仅 `enabled` + `entrypoint` + 可选 `appliesTo`
|
||||
- [x] `actions.mode` 仅 `allowlist` / `open`
|
||||
- [x] `hostCapabilities` 省略 = 不限制
|
||||
- [x] scope 小白名单;`valueFrom` 仅 `actor.userId`
|
||||
- [x] fail-closed;写路径技能自检
|
||||
@@ -19,6 +19,8 @@
|
||||
| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 |
|
||||
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口(可选能力) |
|
||||
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
|
||||
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
|
||||
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理单元格呈现:语义 tone 标色/强调(用途不限;多数技能 P0 不配) |
|
||||
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
|
||||
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`。
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子标色读 `CELL_PRESENTATION.md`。
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
- 定义真实 SQLite 表结构
|
||||
- 使用英文 `snake_case` 表名和字段名
|
||||
- 使用 `_jiangchang_tables` 提供表级展示元数据
|
||||
- 使用 `_jiangchang_columns` 提供字段级展示元数据
|
||||
- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 字段说明)
|
||||
- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里)
|
||||
- 定义哪些字段可见、可搜索、可创建、可编辑
|
||||
- 定义字段展示类型和枚举选项
|
||||
- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
|
||||
- 保证元数据与真实数据库结构一致
|
||||
- 保证元数据初始化幂等
|
||||
|
||||
@@ -22,11 +24,14 @@
|
||||
- 读取 SQLite `PRAGMA table_info`
|
||||
- 根据 schema 渲染数据管理页面
|
||||
- 根据 `editable` 推导字段是否可创建或编辑
|
||||
- 在**新增 / 编辑**表单中,若列有 `_jiangchang_columns.description`,在标签下方展示该说明(灰字;空则不显示)
|
||||
- 打开**新增**表单时,对可创建字段用 SQLite 列 `DEFAULT`(`PRAGMA table_info.dflt_value`)预填;无 DEFAULT 则留空
|
||||
- 执行通用查询、新增、编辑、删除、导入、导出
|
||||
- 对字段名、表名、类型和权限进行最终校验
|
||||
- 不猜测技能业务含义
|
||||
- 不根据具体技能名称写特殊逻辑
|
||||
- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果
|
||||
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则
|
||||
|
||||
## 初始化入口
|
||||
|
||||
@@ -179,8 +184,8 @@ upsert_table_metadata(
|
||||
|------|------|------|
|
||||
| `table_name` | 是 | 所属物理表 |
|
||||
| `column_name` | 是 | 真实英文字段名 |
|
||||
| `display_name` | 是 | 用户可见的中文字段名 |
|
||||
| `description` | 否 | 字段说明 |
|
||||
| `display_name` | 是 | 用户可见的中文字段名(表头与表单标签);保持简短 |
|
||||
| `description` | 否 | 字段说明(给人看的短文案);**宿主在新增/编辑表单标签下方展示**(见下节);不要塞进 `display_name` |
|
||||
| `display_order` | 否 | 兼容字段,默认保持 `NULL` |
|
||||
| `visible` | 是 | 是否在数据管理表格中展示 |
|
||||
| `searchable` | 是 | 是否参与搜索 |
|
||||
@@ -190,6 +195,56 @@ upsert_table_metadata(
|
||||
|
||||
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
|
||||
|
||||
#### 新增/编辑表单:字段说明与默认值(宿主支持)
|
||||
|
||||
数据管理「新增记录 / 编辑记录」与 Skill Action 弹窗是**两套表单**;CRUD 侧约定如下(交互对齐 Action 的 `inputSchema.description` / `default`,但**配置入口不同**)。
|
||||
|
||||
| 能力 | 技能写什么 | 宿主做什么 |
|
||||
|------|------------|------------|
|
||||
| 字段说明 | `_jiangchang_columns.description`(可选) | 新增/编辑表单中,在标签下方以灰字展示;空则不显示 |
|
||||
| 创建默认值 | 物理列 SQLite `DEFAULT`(如 `limit_n INTEGER NOT NULL DEFAULT 0`) | 打开「新增」时,对可创建字段预填该默认值;无 DEFAULT 则留空 |
|
||||
| 编辑已有行 | — | **不**用 DEFAULT 覆盖已有行数据 |
|
||||
| 表格列头 | 只用 `display_name` | 长说明**不**进入列头 |
|
||||
|
||||
规则与禁止:
|
||||
|
||||
- **推荐**对易填错、语义特殊的可编辑字段写 `description`(示例链接、`0` 表示不限制等);非强制每个字段都有
|
||||
- **禁止**把说明硬塞进 `display_name`(如 `评论上限(0不限)`);表头会变脏,也无法与 Action 说明对齐
|
||||
- 业务语义(如「0 = 不限制」)写在 `description` 里;宿主只展示,**不**解读业务含义
|
||||
- 第一期**不**新增元数据「表单默认值」列;CRUD 默认值 = DDL `DEFAULT`,与 Action 的 `inputSchema.default` 分开配置
|
||||
- 同一业务字段若同时出现在 Action 与新增表单,**文案应对齐**(短标题 + 说明),但分别写在 `actions.json` 与 `_jiangchang_columns` / DDL
|
||||
|
||||
示例(可编辑列种子 + DDL 默认):
|
||||
|
||||
```sql
|
||||
-- 物理列:创建默认 0
|
||||
comment_limit INTEGER NOT NULL DEFAULT 0
|
||||
```
|
||||
|
||||
```python
|
||||
seed_column_metadata_batch(
|
||||
cursor,
|
||||
table_name="videos",
|
||||
columns=(
|
||||
{
|
||||
"column_name": "video_url",
|
||||
"display_name": "视频链接",
|
||||
"editable": 1,
|
||||
"description": "填平台视频页链接;需包含可识别的视频路径片段。",
|
||||
},
|
||||
{
|
||||
"column_name": "comment_limit",
|
||||
"display_name": "采集评论条数",
|
||||
"editable": 1,
|
||||
"description": "最多采多少条。填 0 = 不限制;填正整数则按该数量封顶。",
|
||||
},
|
||||
),
|
||||
valid_column_names=physical,
|
||||
)
|
||||
```
|
||||
|
||||
Action 参数表单的 `title` / `description` / `default` 见 [`ACTIONS.md`](ACTIONS.md);勿与本节 CRUD 配置入口混用。
|
||||
|
||||
## editable 与 createable 的关系
|
||||
|
||||
技能模板只维护 `editable` 这一份字段权限声明:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.66"
|
||||
SKILL_VERSION = "1.0.76"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.2"
|
||||
|
||||
@@ -10,18 +10,19 @@ from util.constants import SKILL_SLUG
|
||||
|
||||
_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
STANDARD_SUBDIR_DOWNLOADS = "downloads"
|
||||
STANDARD_SUBDIR_IMPORTS = "imports"
|
||||
STANDARD_SUBDIR_EXPORTS = "exports"
|
||||
# 用户可见的上行/下行目录(数据管理侧栏只挂这两个)
|
||||
STANDARD_SUBDIR_INPUT = "input"
|
||||
STANDARD_SUBDIR_OUTPUT = "output"
|
||||
|
||||
# 内部/可选目录(不上侧栏)
|
||||
STANDARD_SUBDIR_UPLOADS = "uploads"
|
||||
STANDARD_SUBDIR_CACHE = "cache"
|
||||
STANDARD_SUBDIR_TEMP = "temp"
|
||||
STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts"
|
||||
STANDARD_SUBDIR_VIDEOS = "videos"
|
||||
|
||||
CONFIG_KEY_DOWNLOAD_DIR = "SKILL_DOWNLOAD_DIR"
|
||||
CONFIG_KEY_IMPORT_DIR = "SKILL_IMPORT_DIR"
|
||||
CONFIG_KEY_EXPORT_DIR = "SKILL_EXPORT_DIR"
|
||||
CONFIG_KEY_INPUT_DIR = "SKILL_INPUT_DIR"
|
||||
CONFIG_KEY_OUTPUT_DIR = "SKILL_OUTPUT_DIR"
|
||||
CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR"
|
||||
CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_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:
|
||||
"""解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/imports/。"""
|
||||
"""解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/input/。"""
|
||||
text = (raw_path or "").strip()
|
||||
if not text:
|
||||
return text
|
||||
expanded = os.path.expanduser(text)
|
||||
if os.path.isabs(expanded):
|
||||
return os.path.abspath(expanded)
|
||||
imports_dir = get_imports_dir(create=create_parent)
|
||||
return os.path.abspath(os.path.join(imports_dir, os.path.normpath(expanded)))
|
||||
input_dir = get_input_dir(create=create_parent)
|
||||
return os.path.abspath(os.path.join(input_dir, os.path.normpath(expanded)))
|
||||
|
||||
|
||||
def get_downloads_dir(*, create: bool = True) -> str:
|
||||
return resolve_data_path(CONFIG_KEY_DOWNLOAD_DIR, STANDARD_SUBDIR_DOWNLOADS, create=create)
|
||||
def get_input_dir(*, create: bool = True) -> str:
|
||||
"""上行:用户/Agent 放入的待处理文件(清单、CSV、产品表等)。"""
|
||||
return resolve_data_path(CONFIG_KEY_INPUT_DIR, STANDARD_SUBDIR_INPUT, create=create)
|
||||
|
||||
|
||||
def get_imports_dir(*, create: bool = True) -> str:
|
||||
return resolve_data_path(CONFIG_KEY_IMPORT_DIR, STANDARD_SUBDIR_IMPORTS, 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_output_dir(*, create: bool = True) -> str:
|
||||
"""下行:技能/宿主交给用户拿走的文件(视频、报表、导出 CSV 等)。"""
|
||||
return resolve_data_path(CONFIG_KEY_OUTPUT_DIR, STANDARD_SUBDIR_OUTPUT, create=create)
|
||||
|
||||
|
||||
def get_uploads_dir(*, create: bool = True) -> str:
|
||||
@@ -135,9 +134,8 @@ def list_resolved_data_paths() -> dict[str, str]:
|
||||
"""供 health / config-path 输出已解析的数据子目录(不创建目录)。"""
|
||||
return {
|
||||
"skill_data_dir": get_skill_data_dir(),
|
||||
"downloads_dir": get_downloads_dir(create=False),
|
||||
"imports_dir": get_imports_dir(create=False),
|
||||
"exports_dir": get_exports_dir(create=False),
|
||||
"input_dir": get_input_dir(create=False),
|
||||
"output_dir": get_output_dir(create=False),
|
||||
"uploads_dir": get_uploads_dir(create=False),
|
||||
"cache_dir": get_cache_dir(create=False),
|
||||
"temp_dir": get_temp_dir(create=False),
|
||||
|
||||
@@ -255,6 +255,30 @@ class TestActionsManifest(unittest.TestCase):
|
||||
{"create", "update", "delete"},
|
||||
)
|
||||
|
||||
def test_schema_defines_host_effects_and_steps(self) -> None:
|
||||
schema = _load_actions_schema()
|
||||
action_def = schema["$defs"]["action"]
|
||||
props = action_def["properties"]
|
||||
self.assertIn("hostEffects", props)
|
||||
self.assertEqual(props["hostEffects"].get("$ref"), "#/$defs/hostEffects")
|
||||
self.assertIn("steps", props)
|
||||
effects = schema["$defs"]["hostEffects"]
|
||||
self.assertIs(effects.get("additionalProperties"), False)
|
||||
for key in ("onSuccess", "onFailure"):
|
||||
self.assertIn(key, effects.get("properties") or {})
|
||||
effect_ids = set(schema["$defs"]["hostEffectId"].get("enum") or [])
|
||||
self.assertEqual(effect_ids, {"reloadTable", "refreshCatalog", "clearSelection"})
|
||||
step = schema["$defs"]["actionStep"]
|
||||
self.assertEqual(
|
||||
set(step["properties"]["when"].get("enum") or []),
|
||||
{"before", "afterSuccess", "afterFailure"},
|
||||
)
|
||||
self.assertEqual(
|
||||
set(step["properties"]["do"].get("enum") or []),
|
||||
{"deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"},
|
||||
)
|
||||
self.assertEqual(props["steps"].get("maxItems"), 8)
|
||||
|
||||
def test_manifest_validates_against_json_schema(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
@@ -510,8 +534,39 @@ class TestActionsManifest(unittest.TestCase):
|
||||
"executionProfile",
|
||||
"interaction",
|
||||
"confirmation",
|
||||
"数据管理技能按钮顺序",
|
||||
"数组相对顺序",
|
||||
"全部直接展示",
|
||||
"左右分栏",
|
||||
"行级入口",
|
||||
"行右键",
|
||||
"六职责正交",
|
||||
"POLICY.md",
|
||||
"hostEffects",
|
||||
"steps",
|
||||
"reloadTable",
|
||||
"afterSuccess",
|
||||
"afterFailure",
|
||||
"cellPresentation",
|
||||
"CELL_PRESENTATION.md",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
self.assertNotIn(
|
||||
"其余进「更多操作」",
|
||||
text,
|
||||
msg="ACTIONS.md must not document removed overflow more-menu truncation",
|
||||
)
|
||||
self.assertIn(
|
||||
"新增 `row-context-menu` 等 placement",
|
||||
text,
|
||||
msg="ACTIONS.md must forbid inventing a separate row-context-menu placement",
|
||||
)
|
||||
placement_enum = _load_actions_schema().get("$defs", {}).get("placement", {}).get("enum") or []
|
||||
self.assertNotIn(
|
||||
"row-context-menu",
|
||||
placement_enum,
|
||||
msg="skill-actions.schema must not add row-context-menu to placements enum",
|
||||
)
|
||||
for forbidden in (
|
||||
"数据管理 toolbar " + "只挂",
|
||||
"toolbar " + "只放 async",
|
||||
|
||||
93
tests/test_cell_presentation_contract.py
Normal file
93
tests/test_cell_presentation_contract.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cell Presentation 契约:schema + 文档 + 示例。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from util.runtime_paths import get_skill_root
|
||||
|
||||
|
||||
def _load_schema() -> dict:
|
||||
path = os.path.join(
|
||||
get_skill_root(),
|
||||
"assets",
|
||||
"schemas",
|
||||
"skill-cell-presentation.schema.json",
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _load_example() -> dict:
|
||||
path = os.path.join(
|
||||
get_skill_root(),
|
||||
"assets",
|
||||
"examples",
|
||||
"skill-cell-presentation.example.json",
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestCellPresentationContract(unittest.TestCase):
|
||||
def test_schema_file_exists_and_parses(self) -> None:
|
||||
schema = _load_schema()
|
||||
self.assertEqual(schema.get("title"), "SkillCellPresentationDocument")
|
||||
self.assertIs(schema.get("additionalProperties"), False)
|
||||
self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1)
|
||||
tones = set(schema["$defs"]["tone"].get("enum") or [])
|
||||
self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"})
|
||||
|
||||
def test_example_validates_against_schema(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema not installed")
|
||||
jsonschema.validate(instance=_load_example(), schema=_load_schema())
|
||||
|
||||
def test_actions_schema_defines_cell_presentation_enable_block(self) -> None:
|
||||
path = os.path.join(
|
||||
get_skill_root(), "assets", "schemas", "skill-actions.schema.json"
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
actions_schema = json.load(f)
|
||||
self.assertIn("cellPresentation", actions_schema["properties"])
|
||||
self.assertIn("cellPresentationDeclaration", actions_schema["$defs"])
|
||||
|
||||
def test_docs_and_example_exist(self) -> None:
|
||||
root = get_skill_root()
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "examples", "skill-cell-presentation.example.json")
|
||||
)
|
||||
)
|
||||
|
||||
def test_cell_presentation_md_covers_contract(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for marker in (
|
||||
"tone",
|
||||
"danger",
|
||||
"warning",
|
||||
"alert",
|
||||
"_jiangchang_cell_presentation",
|
||||
"cellPresentation",
|
||||
"派生",
|
||||
"任意 CSS",
|
||||
"用途不限",
|
||||
"schemaVersion",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -299,9 +299,21 @@ class TestDocsStandards(unittest.TestCase):
|
||||
root = get_skill_root()
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "POLICY.md")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
|
||||
self.assertTrue(
|
||||
os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json"))
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "schemas", "skill-policy-snapshot.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py")))
|
||||
|
||||
@@ -325,9 +337,22 @@ class TestDocsStandards(unittest.TestCase):
|
||||
"options_json",
|
||||
"demo_items",
|
||||
"readonly",
|
||||
"新增/编辑表单:字段说明与默认值(宿主支持)",
|
||||
"DEFAULT",
|
||||
"description",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
|
||||
|
||||
def test_actions_md_documents_form_schema_split(self) -> None:
|
||||
text = self._read("references/ACTIONS.md")
|
||||
for marker in (
|
||||
"inputSchema 与参数表单",
|
||||
"数据管理新增/编辑",
|
||||
"_jiangchang_columns.description",
|
||||
"SCHEMA.md",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
|
||||
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
|
||||
forbidden = (
|
||||
"download-settlement-report-amazon",
|
||||
|
||||
@@ -89,11 +89,11 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
import util.runtime_paths as 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_tmp = os.path.normcase(os.path.normpath(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:
|
||||
with IsolatedDataRoot() as tmp:
|
||||
@@ -102,20 +102,20 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
import util.runtime_paths as rp
|
||||
|
||||
importlib.reload(rp)
|
||||
os.environ["SKILL_DOWNLOAD_DIR"] = "downloads/videos"
|
||||
os.environ["SKILL_OUTPUT_DIR"] = "output/videos"
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
config.reset_cache()
|
||||
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))
|
||||
self.assertIn(os.path.normcase("downloads"), norm)
|
||||
self.assertIn(os.path.normcase("output"), norm)
|
||||
self.assertIn(os.path.normcase("videos"), norm)
|
||||
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()
|
||||
|
||||
def test_resolve_input_path_relative_under_imports(self) -> None:
|
||||
def test_resolve_input_path_relative_under_input(self) -> None:
|
||||
with IsolatedDataRoot() as tmp:
|
||||
import importlib
|
||||
|
||||
@@ -124,9 +124,24 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
importlib.reload(rp)
|
||||
resolved = rp.resolve_input_path("urls.txt", create_parent=True)
|
||||
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))))
|
||||
|
||||
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:
|
||||
with IsolatedDataRoot():
|
||||
import importlib
|
||||
@@ -137,12 +152,14 @@ class TestRuntimePaths(unittest.TestCase):
|
||||
paths = rp.list_resolved_data_paths()
|
||||
for key in (
|
||||
"skill_data_dir",
|
||||
"downloads_dir",
|
||||
"imports_dir",
|
||||
"exports_dir",
|
||||
"input_dir",
|
||||
"output_dir",
|
||||
):
|
||||
self.assertIn(key, paths)
|
||||
self.assertTrue(paths[key])
|
||||
self.assertNotIn("downloads_dir", paths)
|
||||
self.assertNotIn("imports_dir", paths)
|
||||
self.assertNotIn("exports_dir", paths)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# skill-template 工具
|
||||
|
||||
## 桌面 UIA 拾取(开发期)
|
||||
## UI 拾取(开发期)
|
||||
|
||||
```powershell
|
||||
cd D:\OpenClaw\client-commons\skill-template
|
||||
python tools/ui_inspector/run.py --scene demo
|
||||
```
|
||||
`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓:
|
||||
|
||||
说明见 [`ui_inspector/README.md`](ui_inspector/README.md)。企微暖树依赖本机 `jiangchang-platform-kit>=1.2.5`(与技能同调 `ensure_ui_ready`)。
|
||||
- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder
|
||||
- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`
|
||||
- 启动:`python run.py`
|
||||
|
||||
模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。
|
||||
|
||||
## 业务仓也可以直接 clone(来源 A)
|
||||
|
||||
|
||||
5
tools/ui_inspector/.gitignore
vendored
5
tools/ui_inspector/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
# 仅本工具目录使用;不修改 skill-template 根 .gitignore
|
||||
_captures/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
@@ -1,49 +0,0 @@
|
||||
# OpenClaw UI Inspector(桌面试验版 v0.8.1)
|
||||
|
||||
> **有焦点的窗口 = 采集目标**(与大小无关)。
|
||||
> 企微暖树:**只调用** `jiangchang_desktop_wake.ensure_ui_ready`(与技能同路径),不复制注入逻辑。
|
||||
> 主窗 → profile `wecom`;搜索浮层 `SearchResultWindow2` → profile `wecom_search`(需 kit≥1.2.6)。
|
||||
|
||||
## 启动
|
||||
|
||||
```powershell
|
||||
cd D:\OpenClaw\client-commons\skill-template
|
||||
# 建议使用已安装 jiangchang-platform-kit>=1.2.5 的 Python(与宿主共享 venv 一致)
|
||||
python tools/ui_inspector/run.py --scene demo
|
||||
```
|
||||
|
||||
依赖:
|
||||
|
||||
```powershell
|
||||
pip install -r tools/ui_inspector/requirements.txt
|
||||
# 企微自动暖树(强烈建议)
|
||||
pip install "jiangchang-platform-kit>=1.2.5" # 或指向你们 Gitea/本地 index
|
||||
```
|
||||
|
||||
## 规则
|
||||
|
||||
1. 焦点在谁就采谁(排除 Inspector 自身 hwnd)
|
||||
2. **自动拾取默认勾选**;点「开始流程」后先 **5 秒倒数**,请在此期间点击目标窗
|
||||
3. Inspector **保持置顶**便于看进度;置顶 ≠ 焦点,不因此改采本面板
|
||||
4. **不强制最大化**目标窗,保持操作者当时的大小/位置(仅最小化时会还原)
|
||||
5. 不会因为窗口小 / FindAll 少而改采别的软件
|
||||
6. **企微自动暖树(无感)**:识别到 `WeWorkWindow` 时,在准备/拾取前静默调用
|
||||
`ensure_ui_ready("wecom")`;已暖则快速返回。结果写入每步 `meta.json` → `dump_stats.kit_wake`
|
||||
|
||||
## 空壳说明
|
||||
|
||||
- **已装 kit**:冷企微应被 n2-wake 暖到 `findall` 较大、`provider_cold=false`。若仍冷,看状态栏 `kit_wake: FAILED …`。
|
||||
- **未装 kit**:行为与旧版相同,可能 `findall≈2`、`provider_cold=true`;状态栏会提示未安装 platform-kit。
|
||||
|
||||
## 验收建议(企微)
|
||||
|
||||
1. 打开并登录桌面企业微信
|
||||
2. 启动 Inspector →「开始流程」→ 倒数内点到企微主窗
|
||||
3. 看详情区 / 第一步 `meta.json`:`kit_wake.warm=true`,`findall` 通常 ≫ 80
|
||||
4. 再搜联系人、开会话,确认 `groupbuddyname` 的 Value 能采到
|
||||
|
||||
## 冒烟(不弹 GUI)
|
||||
|
||||
```powershell
|
||||
python tools/ui_inspector/smoke_check.py
|
||||
```
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Desktop UI Inspector — development-only UIA capture tool (additive, disposable)."""
|
||||
|
||||
__version__ = "0.8.1"
|
||||
@@ -1,12 +0,0 @@
|
||||
"""python -m tools.ui_inspector (from skill-template root, if tools is a package)
|
||||
|
||||
Prefer: python tools/ui_inspector/run.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
|
||||
if __name__ == "__main__":
|
||||
runpy.run_path(str(Path(__file__).resolve().parent / "app.py"), run_name="__main__")
|
||||
@@ -1,784 +0,0 @@
|
||||
"""Tk panel v0.8.1: 开始流程 → 手动拾取 / 自动串行循环 → 结束流程。
|
||||
|
||||
企微主窗 / 搜索浮层在识别/拾取前静默调用 kit ensure(wecom / wecom_search)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, simpledialog, ttk
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
import artifacts as art # noqa: E402
|
||||
import desktop_uia as uia # noqa: E402
|
||||
|
||||
try:
|
||||
from . import __version__
|
||||
except Exception:
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"ui_inspector_pkg", _HERE / "__init__.py"
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
|
||||
assert _spec and _spec.loader
|
||||
_spec.loader.exec_module(_mod)
|
||||
__version__ = getattr(_mod, "__version__", "0.8.1")
|
||||
except Exception:
|
||||
__version__ = "0.8.1"
|
||||
|
||||
# Auto loop: after each dump finishes, wait this long, then dump again.
|
||||
_AUTO_GAP_MS = 2000
|
||||
# After frontmost target is resolved, short delay before first dump.
|
||||
_AUTO_START_DELAY_MS = 400
|
||||
# After「开始流程」with auto-pick: countdown so user can focus the target app.
|
||||
_START_COUNTDOWN_SEC = 5
|
||||
_FRONTMOST_WAIT_SEC = 15.0
|
||||
|
||||
|
||||
class InspectorApp:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
captures_root: Path,
|
||||
scene: str = "desktop",
|
||||
max_depth: int = 16,
|
||||
max_nodes: int = 8000,
|
||||
) -> None:
|
||||
self.captures_root = captures_root
|
||||
self.scene = scene
|
||||
self.max_depth = max_depth
|
||||
self.max_nodes = max_nodes
|
||||
|
||||
self.flow_dir: Path | None = None
|
||||
self.flow: dict | None = None
|
||||
self.last_window: dict | None = None
|
||||
self.sticky_pid: int | None = None
|
||||
self._warmed_pids: set[int] = set()
|
||||
self._busy = False
|
||||
self._auto_loop_active = False
|
||||
self._pending_end = False
|
||||
self._auto_after_id: str | None = None
|
||||
self._countdown_after_id: str | None = None
|
||||
self._countdown_left = 0
|
||||
|
||||
self.root = tk.Tk()
|
||||
self.root.title(f"OpenClaw UI Inspector v{__version__} (desktop)")
|
||||
self.root.geometry("840x660")
|
||||
self.root.minsize(720, 540)
|
||||
self.root.attributes("-topmost", True)
|
||||
|
||||
self.flow_name_var = tk.StringVar(value=self.scene)
|
||||
self.auto_pick_var = tk.BooleanVar(value=True)
|
||||
self.flow_var = tk.StringVar(value="流程:未开始(请先点「开始流程」)")
|
||||
self.target_var = tk.StringVar(value="尚未拾取")
|
||||
self.status_var = tk.StringVar(
|
||||
value="用法:开始流程 →(默认自动拾取)5 秒倒数 → 串行 dump。"
|
||||
"企微将自动 ensure_ui_ready(需 platform-kit≥1.2.5);选窗看焦点,不强制放大。"
|
||||
)
|
||||
|
||||
self._build_ui()
|
||||
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
pad = {"padx": 8, "pady": 4}
|
||||
|
||||
top = ttk.Frame(self.root)
|
||||
top.pack(fill=tk.X, **pad)
|
||||
ttk.Label(top, text="流程名").pack(side=tk.LEFT)
|
||||
ttk.Entry(top, textvariable=self.flow_name_var, width=28).pack(side=tk.LEFT, padx=4)
|
||||
ttk.Button(top, text="打开输出目录", command=self.on_open_out).pack(side=tk.LEFT, padx=8)
|
||||
|
||||
btns = ttk.Frame(self.root)
|
||||
btns.pack(fill=tk.X, **pad)
|
||||
self.start_btn = ttk.Button(btns, text="1. 开始流程", command=self.on_start_flow)
|
||||
self.start_btn.pack(side=tk.LEFT, padx=2)
|
||||
self.pick_btn = ttk.Button(btns, text="2. 拾取当前页", command=self.on_pick_page)
|
||||
self.pick_btn.pack(side=tk.LEFT, padx=2)
|
||||
self.end_btn = ttk.Button(btns, text="3. 结束流程", command=self.on_end_flow)
|
||||
self.end_btn.pack(side=tk.LEFT, padx=2)
|
||||
self.auto_chk = ttk.Checkbutton(
|
||||
btns,
|
||||
text="自动拾取(串行·步间2秒)",
|
||||
variable=self.auto_pick_var,
|
||||
command=self.on_auto_toggle,
|
||||
)
|
||||
self.auto_chk.pack(side=tk.LEFT, padx=12)
|
||||
|
||||
info = ttk.LabelFrame(self.root, text="状态")
|
||||
info.pack(fill=tk.X, **pad)
|
||||
ttk.Label(info, textvariable=self.flow_var, wraplength=800, justify=tk.LEFT).pack(
|
||||
anchor=tk.W, padx=6, pady=2
|
||||
)
|
||||
ttk.Label(info, textvariable=self.target_var, wraplength=800, justify=tk.LEFT).pack(
|
||||
anchor=tk.W, padx=6, pady=2
|
||||
)
|
||||
ttk.Label(info, textvariable=self.status_var, wraplength=800, justify=tk.LEFT).pack(
|
||||
anchor=tk.W, padx=6, pady=2
|
||||
)
|
||||
|
||||
tree_box = ttk.LabelFrame(self.root, text="本步拾取结果")
|
||||
tree_box.pack(fill=tk.BOTH, expand=True, **pad)
|
||||
scroll = ttk.Scrollbar(tree_box)
|
||||
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.detail = tk.Text(
|
||||
tree_box,
|
||||
height=24,
|
||||
wrap=tk.WORD,
|
||||
yscrollcommand=scroll.set,
|
||||
)
|
||||
self.detail.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)
|
||||
scroll.config(command=self.detail.yview)
|
||||
self.detail.insert(
|
||||
tk.END,
|
||||
"1) 填写流程名,点「开始流程」——新建一份流程文档\n"
|
||||
"2) 默认已勾选「自动拾取」:先 5 秒倒数(请点目标窗到最前端),\n"
|
||||
" 然后串行:dump → 保存 → 等 2 秒 → 再 dump … 直到「结束流程」\n"
|
||||
" 本窗口保持置顶方便看进度;选目标看焦点并排除自身\n"
|
||||
" 不强制最大化目标窗(保持你当时的大小/位置)\n"
|
||||
"3) 也可取消自动,改为手动点「拾取当前页」\n"
|
||||
"4) 点「结束流程」——退出循环并关闭文档\n\n"
|
||||
"完整内容在 _captures/<流程>/flow.json、SUMMARY.md、steps/\n",
|
||||
)
|
||||
self.detail.configure(state=tk.DISABLED)
|
||||
|
||||
tip = (
|
||||
"一个流程 = 一份文档;多次拾取 = 多个 steps。"
|
||||
"自动拾取严格串行:上一拍完全结束才会开始下一拍。"
|
||||
"给 AI 时优先丢 SUMMARY.md + 各步 locatable.json。"
|
||||
)
|
||||
ttk.Label(self.root, text=tip, wraplength=800, foreground="#444").pack(
|
||||
fill=tk.X, padx=8, pady=6
|
||||
)
|
||||
self._refresh_button_states()
|
||||
|
||||
def _flow_is_open(self) -> bool:
|
||||
return bool(self.flow_dir and self.flow and self.flow.get("status") == "open")
|
||||
|
||||
def _in_start_countdown(self) -> bool:
|
||||
return self._countdown_after_id is not None or self._countdown_left > 0
|
||||
|
||||
def _refresh_button_states(self) -> None:
|
||||
open_flow = self._flow_is_open()
|
||||
counting = self._in_start_countdown()
|
||||
# Manual pick only when open, idle, and not in auto loop / countdown
|
||||
can_manual = (
|
||||
open_flow
|
||||
and not self._busy
|
||||
and not self._auto_loop_active
|
||||
and not counting
|
||||
)
|
||||
self.pick_btn.configure(state=tk.NORMAL if can_manual else tk.DISABLED)
|
||||
# End must stay clickable during a long dump so the loop can be stopped
|
||||
self.end_btn.configure(state=tk.NORMAL if open_flow else tk.DISABLED)
|
||||
self.start_btn.configure(
|
||||
state=tk.DISABLED if (self._busy or counting) else tk.NORMAL
|
||||
)
|
||||
# Don't flip checkbox mid-dump in a confusing way; allow toggle to stop loop
|
||||
self.auto_chk.configure(state=tk.NORMAL)
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
self.status_var.set(text)
|
||||
|
||||
def _set_detail(self, text: str) -> None:
|
||||
self.detail.configure(state=tk.NORMAL)
|
||||
self.detail.delete("1.0", tk.END)
|
||||
self.detail.insert(tk.END, text)
|
||||
self.detail.see("1.0")
|
||||
self.detail.configure(state=tk.DISABLED)
|
||||
|
||||
def _show_picking_placeholder(self, *, auto: bool) -> None:
|
||||
n = int((self.flow or {}).get("step_count") or 0) + 1
|
||||
mode = "自动" if auto else "手动"
|
||||
self.target_var.set(f"{mode}拾取中…(即将写入第 {n} 步)")
|
||||
self._set_status(f"正在{mode}拾取:已清空本框上次结果,请稍候…")
|
||||
self._set_detail(
|
||||
"(已清空上次本步结果)\n\n"
|
||||
f"正在读取控件树,完成后写入当前流程第 {n} 步…\n"
|
||||
)
|
||||
|
||||
def _exclude_hwnds(self) -> set[int]:
|
||||
try:
|
||||
return {int(self.root.winfo_id())}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def _update_flow_label(self) -> None:
|
||||
if not self.flow_dir or not self.flow:
|
||||
self.flow_var.set("流程:未开始(请先点「开始流程」)")
|
||||
return
|
||||
if self._auto_loop_active:
|
||||
auto = "LOOP"
|
||||
elif self.auto_pick_var.get() and self._flow_is_open():
|
||||
auto = "armed"
|
||||
else:
|
||||
auto = "off"
|
||||
self.flow_var.set(
|
||||
f"流程:{self.flow.get('flow_name')} | status={self.flow.get('status')} | "
|
||||
f"steps={self.flow.get('step_count')} | auto={auto} | {self.flow_dir}"
|
||||
)
|
||||
|
||||
def _cancel_auto_timer(self) -> None:
|
||||
if self._auto_after_id is not None:
|
||||
try:
|
||||
self.root.after_cancel(self._auto_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._auto_after_id = None
|
||||
|
||||
def _cancel_countdown(self) -> None:
|
||||
if self._countdown_after_id is not None:
|
||||
try:
|
||||
self.root.after_cancel(self._countdown_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._countdown_after_id = None
|
||||
self._countdown_left = 0
|
||||
|
||||
def _stop_auto_loop(self) -> None:
|
||||
self._auto_loop_active = False
|
||||
self._cancel_auto_timer()
|
||||
self._cancel_countdown()
|
||||
|
||||
def _start_auto_loop(self, *, first_delay_ms: int = _AUTO_START_DELAY_MS) -> None:
|
||||
self._auto_loop_active = True
|
||||
self._pending_end = False
|
||||
self._update_flow_label()
|
||||
self._refresh_button_states()
|
||||
self._schedule_auto_next(first_delay_ms)
|
||||
|
||||
def _set_inspector_topmost(self, enabled: bool) -> None:
|
||||
try:
|
||||
self.root.attributes("-topmost", bool(enabled))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _ensure_inspector_visible(self) -> None:
|
||||
"""Keep panel on-screen for progress; do not force-focus (avoids stealing FG)."""
|
||||
try:
|
||||
if str(self.root.state()) == "iconic":
|
||||
self.root.deiconify()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _arm_auto_with_countdown(self, *, seconds: int = _START_COUNTDOWN_SEC) -> None:
|
||||
"""Visible countdown, then resolve frontmost and enter auto loop."""
|
||||
if not self._flow_is_open():
|
||||
return
|
||||
# Stop prior loop/timers without clearing the countdown we are about to arm.
|
||||
self._auto_loop_active = False
|
||||
self._cancel_auto_timer()
|
||||
self._cancel_countdown()
|
||||
self._ensure_inspector_visible()
|
||||
# Stay topmost for progress; capture still uses focus + exclude self.
|
||||
self._set_inspector_topmost(True)
|
||||
self._countdown_left = max(1, int(seconds))
|
||||
self._update_flow_label()
|
||||
self._set_status(
|
||||
f"倒计时 {self._countdown_left} 秒:请把要采集的窗口点到最前端"
|
||||
"(本面板置顶看进度;不强制放大目标窗)。"
|
||||
)
|
||||
self._set_detail(
|
||||
f"流程:{self.flow_dir}\n\n"
|
||||
f"- {self._countdown_left} 秒内请点击目标窗口(给它焦点)\n"
|
||||
f"- 本 Inspector 保持置顶,方便看进度;选窗排除自身\n"
|
||||
f"- 倒计时结束后:识别最前端 → dump → 等 2 秒 → 再 dump …\n"
|
||||
f"- 目标窗保持你当前的大小/位置(不强制最大化)\n"
|
||||
f"- 可点「结束流程」或取消「自动拾取」中止\n"
|
||||
)
|
||||
self._refresh_button_states()
|
||||
self._countdown_after_id = self.root.after(1000, self._countdown_tick)
|
||||
|
||||
def _countdown_tick(self) -> None:
|
||||
self._countdown_after_id = None
|
||||
if not self._flow_is_open():
|
||||
self._countdown_left = 0
|
||||
self._refresh_button_states()
|
||||
return
|
||||
if not self.auto_pick_var.get():
|
||||
self._countdown_left = 0
|
||||
self._refresh_button_states()
|
||||
self._set_status("已取消自动拾取倒计时;可手动点「拾取当前页」。")
|
||||
return
|
||||
self._countdown_left -= 1
|
||||
if self._countdown_left > 0:
|
||||
self._set_status(
|
||||
f"倒计时 {self._countdown_left} 秒:请把要采集的窗口点到最前端…"
|
||||
)
|
||||
self._refresh_button_states()
|
||||
self._countdown_after_id = self.root.after(1000, self._countdown_tick)
|
||||
return
|
||||
self._countdown_left = 0
|
||||
self._refresh_button_states()
|
||||
self._set_status("倒计时结束,正在识别最前端窗口…")
|
||||
self._begin_auto_with_probe()
|
||||
|
||||
def _begin_auto_with_probe(self) -> None:
|
||||
"""Resolve frontmost target (not Inspector), warmup best-effort, then loop."""
|
||||
if self._busy:
|
||||
return
|
||||
self._busy = True
|
||||
self._auto_loop_active = False
|
||||
self._refresh_button_states()
|
||||
# Keep topmost for progress; target = focused window (Inspector hwnd excluded).
|
||||
self._set_inspector_topmost(True)
|
||||
self._ensure_inspector_visible()
|
||||
self._set_status(
|
||||
"自动拾取:正在识别最前端窗口(焦点窗=采集目标,已排除本面板)…"
|
||||
)
|
||||
self._set_detail(
|
||||
"识别最前端窗口中…\n\n"
|
||||
"- 规则:谁有焦点就采谁(与窗口大小无关)\n"
|
||||
"- 本面板可置顶;选目标时排除 Inspector 自身\n"
|
||||
"- 不强制最大化目标窗\n"
|
||||
"- 若是企微:自动调用 kit ensure_ui_ready(与技能同路径)\n"
|
||||
"- FindAll 多少只作提示,不会因此改采别的软件\n"
|
||||
)
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
uia.ensure_com_sta()
|
||||
report = uia.prepare_frontmost_for_capture(
|
||||
exclude_hwnds=self._exclude_hwnds(),
|
||||
timeout_sec=_FRONTMOST_WAIT_SEC,
|
||||
)
|
||||
self.root.after(0, lambda r=report: self._on_frontmost_ready(r))
|
||||
except Exception as exc:
|
||||
err = exc
|
||||
self.root.after(0, lambda e=err: self._on_frontmost_fail(e))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_frontmost_ready(self, report: dict) -> None:
|
||||
self._busy = False
|
||||
self._refresh_button_states()
|
||||
win = report.get("window") or {}
|
||||
if win.get("pid"):
|
||||
self.sticky_pid = int(win["pid"])
|
||||
self._warmed_pids.add(int(win["pid"]))
|
||||
import kit_wake
|
||||
|
||||
kit = report.get("kit_wake") or {}
|
||||
kit_line = kit_wake.format_kit_wake_line(kit)
|
||||
sparse_note = ""
|
||||
if report.get("provider_cold") or report.get("uia_sparse"):
|
||||
if kit.get("reason") == "kit_not_installed":
|
||||
sparse_note = (
|
||||
f"(仍偏稀 FindAll={report.get('findall')}:未安装 platform-kit,"
|
||||
"企微无法走 n2-wake)"
|
||||
)
|
||||
elif kit.get("applicable") and not kit.get("ok"):
|
||||
sparse_note = (
|
||||
f"(kit 暖树未成功 FindAll={report.get('findall')} "
|
||||
f"code={kit.get('error_code')};仍按此窗采集)"
|
||||
)
|
||||
else:
|
||||
sparse_note = (
|
||||
f"(UIA 仍偏稀 FindAll={report.get('findall')};仍按此前端窗采集)"
|
||||
)
|
||||
self._set_status(
|
||||
f"已锁定最前端:{win.get('title')!r} / {win.get('class_name')!r} "
|
||||
f"findall={report.get('findall')} — 开始自动串行拾取。{sparse_note}"
|
||||
)
|
||||
self._set_detail(
|
||||
f"采集目标(最前端):\n"
|
||||
f" title={win.get('title')!r}\n"
|
||||
f" class={win.get('class_name')!r}\n"
|
||||
f" hwnd={win.get('hwnd')} pid={win.get('pid')}\n"
|
||||
f" findall={report.get('findall')} sparse={report.get('uia_sparse')} "
|
||||
f"cold={report.get('provider_cold')}\n"
|
||||
f" {kit_line}\n\n"
|
||||
"即将进入自动循环(步间 2 秒)…\n"
|
||||
)
|
||||
self._set_inspector_topmost(True)
|
||||
self._ensure_inspector_visible()
|
||||
self._start_auto_loop(first_delay_ms=_AUTO_START_DELAY_MS)
|
||||
|
||||
def _on_frontmost_fail(self, exc: BaseException) -> None:
|
||||
self._busy = False
|
||||
self._auto_loop_active = False
|
||||
# Keep auto checked (default on); user can retry or end flow.
|
||||
self._set_inspector_topmost(True)
|
||||
self._ensure_inspector_visible()
|
||||
self._refresh_button_states()
|
||||
self._set_status(f"未能锁定最前端目标: {exc}")
|
||||
messagebox.showerror("UI Inspector", f"未能锁定最前端目标:\n{exc}")
|
||||
|
||||
def _schedule_auto_next(self, delay_ms: int) -> None:
|
||||
self._cancel_auto_timer()
|
||||
if not self._auto_loop_active or not self._flow_is_open():
|
||||
return
|
||||
self._auto_after_id = self.root.after(delay_ms, self._auto_loop_tick)
|
||||
|
||||
def _auto_loop_tick(self) -> None:
|
||||
self._auto_after_id = None
|
||||
if not self._auto_loop_active or not self._flow_is_open():
|
||||
return
|
||||
if self._busy:
|
||||
# Should not happen in serial design; re-check soon.
|
||||
self._schedule_auto_next(200)
|
||||
return
|
||||
self.on_pick_page(auto=True)
|
||||
|
||||
def _continue_auto_loop_after_pick(self) -> None:
|
||||
if self._pending_end:
|
||||
self._pending_end = False
|
||||
self._stop_auto_loop()
|
||||
self._finish_end_flow()
|
||||
return
|
||||
if self._auto_loop_active and self._flow_is_open():
|
||||
self._set_status(
|
||||
f"自动循环:第 {self.flow.get('step_count') if self.flow else '?'} 步已处理;"
|
||||
f"{_AUTO_GAP_MS // 1000} 秒后开始下一拍。点「结束流程」退出循环。"
|
||||
)
|
||||
self._schedule_auto_next(_AUTO_GAP_MS)
|
||||
|
||||
def on_auto_toggle(self) -> None:
|
||||
if self.auto_pick_var.get():
|
||||
if (
|
||||
self._flow_is_open()
|
||||
and not self._auto_loop_active
|
||||
and not self._busy
|
||||
and not self._in_start_countdown()
|
||||
):
|
||||
self._arm_auto_with_countdown()
|
||||
else:
|
||||
self._update_flow_label()
|
||||
else:
|
||||
was = self._auto_loop_active or self._in_start_countdown()
|
||||
self._stop_auto_loop()
|
||||
self._update_flow_label()
|
||||
self._refresh_button_states()
|
||||
if was and self._flow_is_open():
|
||||
self._set_status("已停止自动循环/倒计时;仍可手动点「拾取当前页」。")
|
||||
elif self._flow_is_open():
|
||||
self._set_status("自动拾取已关闭;可手动点「拾取当前页」。")
|
||||
|
||||
def on_open_out(self) -> None:
|
||||
path = self.flow_dir if self.flow_dir else self.captures_root
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.startfile(str(path)) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
subprocess.Popen(["explorer", str(path)])
|
||||
|
||||
def on_start_flow(self) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
if self.flow and self.flow.get("status") == "open":
|
||||
if not messagebox.askyesno(
|
||||
"UI Inspector",
|
||||
"当前已有进行中的流程。\n是否结束旧流程并开始新流程?",
|
||||
):
|
||||
return
|
||||
self._stop_auto_loop()
|
||||
self._pending_end = False
|
||||
try:
|
||||
art.end_flow(self.flow_dir, self.flow) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
name = (self.flow_name_var.get() or "").strip()
|
||||
if not name:
|
||||
name = simpledialog.askstring("流程名", "请输入流程名称:", initialvalue="desktop-flow")
|
||||
if not name:
|
||||
return
|
||||
self.flow_name_var.set(name)
|
||||
|
||||
try:
|
||||
self.flow_dir = art.create_flow(self.captures_root, flow_name=name)
|
||||
self.flow = art.load_flow(self.flow_dir)
|
||||
self._pending_end = False
|
||||
self._warmed_pids.clear()
|
||||
self._update_flow_label()
|
||||
self._refresh_button_states()
|
||||
if self.auto_pick_var.get():
|
||||
# No modal / no minimize: keep panel visible for countdown + progress.
|
||||
self._arm_auto_with_countdown(seconds=_START_COUNTDOWN_SEC)
|
||||
else:
|
||||
self._stop_auto_loop()
|
||||
self._ensure_inspector_visible()
|
||||
self._set_status(
|
||||
f"已开始流程:{self.flow_dir.name}。请翻到目标页后点「拾取当前页」。"
|
||||
)
|
||||
self._set_detail(
|
||||
f"流程已创建:{self.flow_dir}\n\n"
|
||||
f"- flow.json / SUMMARY.md / steps/\n"
|
||||
f"- 手动点「拾取当前页」追加一步;或勾选「自动拾取」进入串行循环。\n"
|
||||
)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("UI Inspector", str(exc))
|
||||
|
||||
def _finish_end_flow(self) -> None:
|
||||
if not self.flow_dir or not self.flow:
|
||||
return
|
||||
if self.flow.get("status") != "open":
|
||||
self._refresh_button_states()
|
||||
return
|
||||
try:
|
||||
self.flow = art.end_flow(self.flow_dir, self.flow)
|
||||
self._update_flow_label()
|
||||
self._refresh_button_states()
|
||||
n = self.flow.get("step_count") or 0
|
||||
self._set_status(f"流程已结束,共 {n} 步。可再点「开始流程」开新文档。")
|
||||
self._set_detail(
|
||||
f"流程已结束:{self.flow_dir}\n\n"
|
||||
f"- 共 {n} 步\n"
|
||||
f"- 请打开 SUMMARY.md 查看总览\n"
|
||||
)
|
||||
messagebox.showinfo("UI Inspector", f"流程已结束\n共 {n} 步\n{self.flow_dir}")
|
||||
except Exception as exc:
|
||||
messagebox.showerror("UI Inspector", str(exc))
|
||||
|
||||
def on_end_flow(self) -> None:
|
||||
if not self.flow_dir or not self.flow:
|
||||
messagebox.showwarning("UI Inspector", "没有进行中的流程。")
|
||||
return
|
||||
if self.flow.get("status") != "open":
|
||||
messagebox.showinfo("UI Inspector", "流程已经结束。")
|
||||
return
|
||||
|
||||
self._stop_auto_loop()
|
||||
# Keep「自动拾取」勾选状态(默认开),方便下一轮直接开始。
|
||||
|
||||
if self._busy:
|
||||
self._pending_end = True
|
||||
self._set_status("已请求结束:等待当前这一拍 dump/保存完成…")
|
||||
self._refresh_button_states()
|
||||
return
|
||||
|
||||
self._pending_end = False
|
||||
self._finish_end_flow()
|
||||
|
||||
def on_pick_page(self, *, auto: bool = False) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
if not self._flow_is_open():
|
||||
if not auto:
|
||||
messagebox.showwarning("UI Inspector", "请先点「开始流程」。")
|
||||
return
|
||||
if auto and not self._auto_loop_active:
|
||||
return
|
||||
|
||||
self._busy = True
|
||||
self._refresh_button_states()
|
||||
self._show_picking_placeholder(auto=auto)
|
||||
self.root.update_idletasks()
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
uia.ensure_com_sta()
|
||||
# Keep panel topmost for progress; selection excludes Inspector hwnd.
|
||||
self.root.after(0, lambda: self._set_inspector_topmost(True))
|
||||
flow = art.load_flow(self.flow_dir) # type: ignore[arg-type]
|
||||
if flow.get("status") != "open":
|
||||
raise RuntimeError("流程已结束,取消本步拾取。")
|
||||
info, cands = uia.find_capture_target(
|
||||
exclude_hwnds=self._exclude_hwnds(),
|
||||
prefer_pid=None if auto else self.sticky_pid,
|
||||
)
|
||||
# Prefer light after PID already warmed; dump_window_tree escalates
|
||||
# to full poke if locatable still sparse (still no force maximize).
|
||||
warmup_mode = (
|
||||
"light" if int(info.pid) in self._warmed_pids else "full"
|
||||
)
|
||||
tree, index, stats, locatable = uia.dump_window_tree(
|
||||
info.hwnd,
|
||||
max_depth=self.max_depth,
|
||||
max_nodes=self.max_nodes,
|
||||
only_visible=False,
|
||||
warmup_mode=warmup_mode,
|
||||
merge_same_pid_if_sparse=True,
|
||||
)
|
||||
self._warmed_pids.add(int(info.pid))
|
||||
window = info.to_dict()
|
||||
label = art.resolve_step_label(
|
||||
flow,
|
||||
window_title=info.title or "",
|
||||
window_class=info.class_name or "",
|
||||
locatable=locatable,
|
||||
)
|
||||
step_dir, step_idx, step_name = art.add_step_dir(
|
||||
self.flow_dir, # type: ignore[arg-type]
|
||||
flow,
|
||||
window_title=info.title or "",
|
||||
window_class=info.class_name or "",
|
||||
locatable=locatable,
|
||||
label=label,
|
||||
)
|
||||
meta = art.build_meta(
|
||||
window=window,
|
||||
scene=flow.get("flow_name") or "flow",
|
||||
extra={
|
||||
"flow_dir": str(self.flow_dir),
|
||||
"step_index": step_idx,
|
||||
"step_name": step_name,
|
||||
"step_label": label,
|
||||
"auto_pick": auto,
|
||||
"dump_stats": stats,
|
||||
"selection": {
|
||||
"strategy": "z-order+last-active-popup",
|
||||
"sticky_pid": self.sticky_pid,
|
||||
"candidate_count": len(cands),
|
||||
},
|
||||
},
|
||||
)
|
||||
art.write_json(step_dir / "meta.json", meta)
|
||||
art.write_json(step_dir / "tree.json", tree)
|
||||
art.write_jsonl(step_dir / "index.jsonl", index)
|
||||
art.write_json(step_dir / "locatable.json", locatable)
|
||||
png_ok = art.capture_window_png(info.hwnd, step_dir / "screen.png")
|
||||
noteworthy = art.noteworthy_from_index(index)
|
||||
art.write_step_summary(
|
||||
step_dir, meta=meta, stats=stats, noteworthy=noteworthy
|
||||
)
|
||||
art.register_step(
|
||||
self.flow_dir, # type: ignore[arg-type]
|
||||
flow,
|
||||
step_index=step_idx,
|
||||
step_name=step_name,
|
||||
window=window,
|
||||
stats=stats,
|
||||
label=label,
|
||||
)
|
||||
flow = art.load_flow(self.flow_dir) # type: ignore[arg-type]
|
||||
|
||||
loc_text = uia.format_locatable_text(locatable)
|
||||
tree_text = uia.format_tree_text(tree)
|
||||
import kit_wake
|
||||
|
||||
warm = stats.get("warmup") or {}
|
||||
kit = stats.get("kit_wake") or {}
|
||||
kit_line = kit_wake.format_kit_wake_line(kit)
|
||||
sparse_note = ""
|
||||
if stats.get("uia_sparse"):
|
||||
sparse_note = "\n⚠ uia_sparse=true:可定位候选偏少,请结合 screen.png。\n"
|
||||
mode = "自动" if auto else "手动"
|
||||
header = (
|
||||
f"流程步骤: #{step_idx} `{step_name}`({mode})\n"
|
||||
f"窗口: title={info.title!r} class={info.class_name!r} "
|
||||
f"pid={info.pid} hwnd={info.hwnd}\n"
|
||||
f"kit: {kit_line}\n"
|
||||
f"软poke: findall {warm.get('findall_before')} → {warm.get('findall_after')} "
|
||||
f"woke={warm.get('woke')}\n"
|
||||
f"统计: kept={stats.get('nodes_kept')} findall={stats.get('findall_count')} "
|
||||
f"locatable={stats.get('locatable_count')} png={png_ok} "
|
||||
f"sparse={stats.get('uia_sparse')} cold={stats.get('provider_cold')}\n"
|
||||
f"已写入: {step_dir}\n"
|
||||
f"流程文档: {self.flow_dir}\n"
|
||||
f"{sparse_note}"
|
||||
f"{'=' * 60}\n"
|
||||
)
|
||||
body = header + "\n" + loc_text + "\n\n" + ("=" * 60) + "\n控件树:\n" + tree_text
|
||||
self.root.after(
|
||||
0,
|
||||
lambda i=info, f=flow, t=body, sn=step_name, a=auto: self._on_pick_ok(
|
||||
i, f, t, sn, auto=a
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
err = exc
|
||||
self.root.after(0, lambda e=err, a=auto: self._on_fail(e, auto=a))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_pick_ok(
|
||||
self,
|
||||
info: uia.WindowInfo,
|
||||
flow: dict,
|
||||
text: str,
|
||||
step_name: str,
|
||||
*,
|
||||
auto: bool = False,
|
||||
) -> None:
|
||||
self._busy = False
|
||||
self.flow = flow
|
||||
self.last_window = info.to_dict()
|
||||
self.sticky_pid = int(info.pid)
|
||||
self._update_flow_label()
|
||||
self._refresh_button_states()
|
||||
self.target_var.set(
|
||||
f"本步目标: title={info.title!r} class={info.class_name!r} | step=`{step_name}`"
|
||||
)
|
||||
self._set_detail(text)
|
||||
if not auto:
|
||||
self._set_status(
|
||||
f"第 {flow.get('step_count')} 步已写入。可换页继续拾取,或点「结束流程」。"
|
||||
)
|
||||
try:
|
||||
self.root.lift()
|
||||
self.root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
if self._pending_end:
|
||||
self._pending_end = False
|
||||
self._finish_end_flow()
|
||||
return
|
||||
|
||||
# auto: either pending end, or continue loop after gap
|
||||
self._continue_auto_loop_after_pick()
|
||||
|
||||
def _on_fail(self, exc: BaseException, *, auto: bool = False) -> None:
|
||||
self._busy = False
|
||||
self._refresh_button_states()
|
||||
self._set_status(f"失败: {exc}")
|
||||
if not auto:
|
||||
messagebox.showerror("UI Inspector", str(exc))
|
||||
if self._pending_end:
|
||||
self._pending_end = False
|
||||
self._finish_end_flow()
|
||||
return
|
||||
# Auto: do not pop modal; keep looping after gap (unless ending)
|
||||
self._continue_auto_loop_after_pick()
|
||||
|
||||
def _on_close(self) -> None:
|
||||
self._stop_auto_loop()
|
||||
self._pending_end = False
|
||||
self.root.destroy()
|
||||
|
||||
def run(self) -> None:
|
||||
self.captures_root.mkdir(parents=True, exist_ok=True)
|
||||
self.root.mainloop()
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="OpenClaw desktop UI Inspector v0.6(流程文档:开始/拾取/结束/自动串行)"
|
||||
)
|
||||
p.add_argument("--out", type=Path, default=None, help="输出根目录")
|
||||
p.add_argument("--scene", default="desktop", help="默认流程名")
|
||||
p.add_argument("--max-depth", type=int, default=16)
|
||||
p.add_argument("--max-nodes", type=int, default=8000)
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
out = args.out.resolve() if args.out else art.default_captures_root(_HERE)
|
||||
try:
|
||||
uia.ensure_uia()
|
||||
except Exception as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
app = InspectorApp(
|
||||
captures_root=out,
|
||||
scene=args.scene,
|
||||
max_depth=args.max_depth,
|
||||
max_nodes=args.max_nodes,
|
||||
)
|
||||
app.run()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,694 +0,0 @@
|
||||
"""Flow-oriented capture artifacts: one flow document, many steps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
try:
|
||||
from . import __version__
|
||||
except ImportError:
|
||||
try:
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"ui_inspector_pkg", Path(__file__).resolve().parent / "__init__.py"
|
||||
)
|
||||
_mod = importlib.util.module_from_spec(_spec) # type: ignore[arg-type]
|
||||
assert _spec and _spec.loader
|
||||
_spec.loader.exec_module(_mod)
|
||||
__version__ = getattr(_mod, "__version__", "0.8.0")
|
||||
except Exception:
|
||||
__version__ = "0.8.0"
|
||||
|
||||
|
||||
def _local_ts() -> str:
|
||||
now = datetime.now().astimezone()
|
||||
return now.strftime("%Y%m%dT%H%M%S%z")
|
||||
|
||||
|
||||
def safe_slug(text: str, *, fallback: str = "session") -> str:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return fallback
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff\-]+", "", text, flags=re.UNICODE)
|
||||
text = text.strip("-_") or fallback
|
||||
return text[:80]
|
||||
|
||||
|
||||
def _clean_ui_text(text: str) -> str:
|
||||
"""Strip WeCom-style rich markup and collapse whitespace."""
|
||||
s = (text or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
s = re.sub(r"<[^>]+>", "", s)
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
# Name/AutomationId keys that usually mark "what page am I on"
|
||||
_PAGE_HINT_KEYS: dict[str, int] = {
|
||||
"panel_title_label": 100,
|
||||
"title": 95,
|
||||
"ww_btn_text": 70,
|
||||
"main_chattext": 45,
|
||||
}
|
||||
|
||||
_LAYOUT_NOISE = {
|
||||
"bg",
|
||||
"navigation_bar",
|
||||
"outer_layout",
|
||||
"content_container",
|
||||
"title_layout",
|
||||
"closebtn",
|
||||
"VerticalLayoutUI",
|
||||
"HorizontalLayoutUI",
|
||||
"wbtn_container",
|
||||
"search_layout",
|
||||
"search_layout_border",
|
||||
"inner_search_layout",
|
||||
"ControlUI",
|
||||
"LabelUI",
|
||||
"item",
|
||||
"externals",
|
||||
"status_layout",
|
||||
"search_button_region",
|
||||
"search_icon",
|
||||
"ww_btn_icon",
|
||||
"wbadge_label",
|
||||
"lasttime",
|
||||
"groupbuddydescription",
|
||||
"bottom_text",
|
||||
"chattext",
|
||||
"pane",
|
||||
"Pane",
|
||||
}
|
||||
|
||||
_CHROME_TEXT = {
|
||||
"关闭",
|
||||
"最小化",
|
||||
"最大化",
|
||||
"还原",
|
||||
"取消",
|
||||
"确定",
|
||||
"关闭窗口",
|
||||
}
|
||||
|
||||
# Left-rail / chrome values that appear on every WeCom page — not page identity
|
||||
_GLOBAL_NAV_TEXT = {
|
||||
"消息",
|
||||
"邮件",
|
||||
"文档",
|
||||
"日程",
|
||||
"待办",
|
||||
"会议",
|
||||
"智能文档",
|
||||
"通讯录",
|
||||
"微盘",
|
||||
"高级功能",
|
||||
"工作台",
|
||||
"收藏",
|
||||
"智能总结",
|
||||
"回到最新位置",
|
||||
}
|
||||
|
||||
|
||||
def _row_key(r: dict[str, Any]) -> str:
|
||||
return _clean_ui_text(str(r.get("name") or r.get("automation_id") or ""))
|
||||
|
||||
|
||||
def _looks_like_search_dialog(
|
||||
window_title: str, window_class: str, rows: list[dict[str, Any]]
|
||||
) -> bool:
|
||||
t = window_title or ""
|
||||
c = window_class or ""
|
||||
if "Search" in c or "搜索" in t or "添加客户" in t or "添加联系人" in t:
|
||||
return True
|
||||
for r in rows:
|
||||
if _row_key(r) == "title":
|
||||
v = _clean_ui_text(str(r.get("value") or ""))
|
||||
if "添加" in v or "搜索" in v:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _short_entity(text: str, *, limit: int = 16) -> str:
|
||||
"""Clean contact/chat title: strip markup leftovers and @微信 suffix."""
|
||||
s = _clean_ui_text(text)
|
||||
if not s:
|
||||
return ""
|
||||
s = re.sub(r"@微信\s*$", "", s)
|
||||
s = re.sub(r"@WeCom\s*$", "", s, flags=re.I)
|
||||
s = s.strip(" -_|")
|
||||
if len(s) > limit:
|
||||
s = s[:limit].rstrip("-_")
|
||||
return s
|
||||
|
||||
|
||||
def _raw_value(r: dict[str, Any]) -> str:
|
||||
return str(r.get("value") or "")
|
||||
|
||||
|
||||
def _is_selected_buddy(raw_value: str) -> bool:
|
||||
# WeCom marks the active conversation with semi-transparent white color markup.
|
||||
return "#99ffffff" in (raw_value or "").lower()
|
||||
|
||||
|
||||
def derive_step_label(
|
||||
*,
|
||||
window_title: str = "",
|
||||
window_class: str = "",
|
||||
locatable: Optional[list[dict[str, Any]]] = None,
|
||||
max_hints: int = 2,
|
||||
) -> str:
|
||||
"""
|
||||
Build a human-readable step label from window + page-specific UIA hints.
|
||||
|
||||
Prefers: search box text, page title, selected chat, compose draft, action buttons.
|
||||
Avoids bare「会话列表」when a concrete chat/search/input signal exists.
|
||||
"""
|
||||
rows = list(locatable or [])
|
||||
base = _clean_ui_text(window_title)
|
||||
if not base:
|
||||
for r in rows:
|
||||
if (r.get("control_type") or "") == "WindowControl":
|
||||
base = _clean_ui_text(str(r.get("name") or ""))
|
||||
if base:
|
||||
break
|
||||
if not base and window_class:
|
||||
base = window_class
|
||||
|
||||
has_panel_title = any(
|
||||
_row_key(r) == "panel_title_label" and _clean_ui_text(str(r.get("value") or ""))
|
||||
for r in rows
|
||||
)
|
||||
has_groupbuddy = any(_row_key(r) == "groupbuddyname" for r in rows)
|
||||
has_search_result = any(_row_key(r) == "search_result" for r in rows)
|
||||
has_searchedit = any(_row_key(r) in {"searchedit", "search_edit"} for r in rows)
|
||||
search_dialog = _looks_like_search_dialog(window_title, window_class, rows)
|
||||
|
||||
scored: list[tuple[int, str]] = []
|
||||
searchedit_value = ""
|
||||
selected_buddy = ""
|
||||
first_buddy = ""
|
||||
|
||||
for r in rows:
|
||||
key = _row_key(r)
|
||||
if not key or key in _LAYOUT_NOISE:
|
||||
continue
|
||||
raw = _raw_value(r)
|
||||
value = _clean_ui_text(raw)
|
||||
name_disp = _clean_ui_text(str(r.get("name") or ""))
|
||||
ctype = str(r.get("control_type") or "")
|
||||
|
||||
if key in {"searchedit", "search_edit"} and value:
|
||||
searchedit_value = _short_entity(value, limit=20)
|
||||
# Main-window search AND add-friend dialog both matter.
|
||||
scored.append((98, f"搜索{searchedit_value}"))
|
||||
continue
|
||||
|
||||
if key == "send_rich_edit" and value:
|
||||
draft = _short_entity(value, limit=12)
|
||||
if draft:
|
||||
scored.append((86, f"输入{draft}"))
|
||||
continue
|
||||
|
||||
if key == "groupbuddyname" and value:
|
||||
entity = _short_entity(value, limit=14)
|
||||
if not entity:
|
||||
continue
|
||||
if _is_selected_buddy(raw) and not selected_buddy:
|
||||
selected_buddy = entity
|
||||
scored.append((92, entity))
|
||||
elif not first_buddy and not r.get("is_offscreen"):
|
||||
first_buddy = entity
|
||||
continue
|
||||
|
||||
pri = _PAGE_HINT_KEYS.get(key)
|
||||
if pri is not None and value:
|
||||
if value in _CHROME_TEXT or value in _GLOBAL_NAV_TEXT:
|
||||
continue
|
||||
if key == "title" and value == base:
|
||||
continue
|
||||
if key == "main_chattext" and (has_panel_title or has_groupbuddy):
|
||||
continue
|
||||
entity = _short_entity(value, limit=16)
|
||||
if not entity:
|
||||
continue
|
||||
scored.append((pri, entity))
|
||||
continue
|
||||
|
||||
if ctype in {
|
||||
"ButtonControl",
|
||||
"EditControl",
|
||||
"TabItemControl",
|
||||
"TextControl",
|
||||
"HyperlinkControl",
|
||||
"MenuItemControl",
|
||||
"CheckBoxControl",
|
||||
"RadioButtonControl",
|
||||
"ComboBoxControl",
|
||||
"ListItemControl",
|
||||
}:
|
||||
label = _short_entity(value or name_disp, limit=16)
|
||||
if (
|
||||
not label
|
||||
or label in _CHROME_TEXT
|
||||
or label in _LAYOUT_NOISE
|
||||
or label in _GLOBAL_NAV_TEXT
|
||||
):
|
||||
continue
|
||||
scored.append((40, label))
|
||||
|
||||
# Conversation context when no selected buddy / search text yet
|
||||
if (
|
||||
has_groupbuddy
|
||||
and not selected_buddy
|
||||
and first_buddy
|
||||
and not has_panel_title
|
||||
and not searchedit_value
|
||||
):
|
||||
scored.append((78, first_buddy))
|
||||
|
||||
strong = any(p >= 90 for p, _ in scored)
|
||||
if search_dialog and has_searchedit and not searchedit_value and not strong:
|
||||
scored.append((75, "搜索框"))
|
||||
elif search_dialog and has_search_result and searchedit_value and not strong:
|
||||
scored.append((50, "搜索结果"))
|
||||
elif (
|
||||
has_groupbuddy
|
||||
and not has_panel_title
|
||||
and not strong
|
||||
and not first_buddy
|
||||
and not selected_buddy
|
||||
):
|
||||
scored.append((60, "会话列表"))
|
||||
|
||||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||||
hints: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for _pri, text in scored:
|
||||
if not text or text == base or text in seen:
|
||||
continue
|
||||
if text in _CHROME_TEXT or text in _GLOBAL_NAV_TEXT:
|
||||
continue
|
||||
compact = (
|
||||
text[2:] if text.startswith(("搜索", "输入")) and len(text) > 2 else text
|
||||
)
|
||||
if compact and any(
|
||||
compact == s or s.endswith(compact) or compact in s for s in seen
|
||||
):
|
||||
continue
|
||||
seen.add(text)
|
||||
if compact:
|
||||
seen.add(compact)
|
||||
hints.append(text)
|
||||
if len(hints) >= max_hints:
|
||||
break
|
||||
|
||||
parts = [p for p in [base, *hints] if p]
|
||||
if not parts:
|
||||
parts = ["step"]
|
||||
return "_".join(parts)
|
||||
|
||||
|
||||
def _label_slug_key(label: str) -> str:
|
||||
return safe_slug(label, fallback="step")
|
||||
|
||||
|
||||
def _unique_step_dirname(steps_root: Path, idx: int, label: str) -> str:
|
||||
"""NN_label, with -2/-3 suffix if the folder already exists."""
|
||||
slug = _label_slug_key(label)
|
||||
base = f"{idx:02d}_{slug}"
|
||||
if not (steps_root / base).exists():
|
||||
return base
|
||||
n = 2
|
||||
while (steps_root / f"{base}-{n}").exists():
|
||||
n += 1
|
||||
return f"{base}-{n}"
|
||||
|
||||
|
||||
def _existing_label_slugs(flow: dict[str, Any]) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for s in flow.get("steps") or []:
|
||||
lab = s.get("label") or ""
|
||||
name = str(s.get("name") or "")
|
||||
if lab:
|
||||
out.add(_label_slug_key(str(lab)))
|
||||
m = re.match(r"^\d{2}_(.+?)(?:-\d+)?$", name)
|
||||
if m:
|
||||
out.add(m.group(1))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_step_label(
|
||||
flow: dict[str, Any],
|
||||
*,
|
||||
window_title: str = "",
|
||||
window_class: str = "",
|
||||
locatable: Optional[list[dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
"""Derive label; if it collides with earlier steps, add more hints."""
|
||||
used = _existing_label_slugs(flow)
|
||||
label = derive_step_label(
|
||||
window_title=window_title,
|
||||
window_class=window_class,
|
||||
locatable=locatable,
|
||||
max_hints=2,
|
||||
)
|
||||
if _label_slug_key(label) not in used:
|
||||
return label
|
||||
label3 = derive_step_label(
|
||||
window_title=window_title,
|
||||
window_class=window_class,
|
||||
locatable=locatable,
|
||||
max_hints=3,
|
||||
)
|
||||
if _label_slug_key(label3) not in used:
|
||||
return label3
|
||||
# Last resort: keep richest label; folder uniquifier adds -2
|
||||
return label3
|
||||
|
||||
|
||||
def default_captures_root(tool_dir: Optional[Path] = None) -> Path:
|
||||
base = tool_dir or Path(__file__).resolve().parent
|
||||
return base / "_captures"
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as fh:
|
||||
for row in rows:
|
||||
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_meta(
|
||||
*,
|
||||
window: dict[str, Any],
|
||||
scene: str,
|
||||
skill_root: Optional[str] = None,
|
||||
extra: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
now = datetime.now().astimezone()
|
||||
meta = {
|
||||
"schema": "openclaw.ui_inspector.capture.v1",
|
||||
"surface": "windows",
|
||||
"captured_at": now.isoformat(),
|
||||
"scene": scene,
|
||||
"skill_root": skill_root,
|
||||
"app": {
|
||||
"title": window.get("title"),
|
||||
"class_name": window.get("class_name"),
|
||||
"process_name": window.get("process_name"),
|
||||
"pid": window.get("pid"),
|
||||
"hwnd": window.get("hwnd"),
|
||||
},
|
||||
"environment": {
|
||||
"os": "windows",
|
||||
"tz": now.tzname(),
|
||||
},
|
||||
"tool": {
|
||||
"name": "openclaw-ui-inspector",
|
||||
"version": __version__,
|
||||
"driver": "uiautomation",
|
||||
},
|
||||
}
|
||||
if extra:
|
||||
meta["extra"] = extra
|
||||
return meta
|
||||
|
||||
|
||||
def noteworthy_from_index(index: list[dict[str, Any]], *, limit: int = 80) -> list[dict[str, Any]]:
|
||||
rows = [
|
||||
r
|
||||
for r in index
|
||||
if (r.get("name") or r.get("value") or r.get("automation_id"))
|
||||
]
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
0 if r.get("value") else 1,
|
||||
0 if r.get("name") else 1,
|
||||
int(r.get("depth") or 0) if isinstance(r.get("depth"), int) and r.get("depth") >= 0 else 99,
|
||||
)
|
||||
)
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def capture_window_png(hwnd: int, dest: Path) -> bool:
|
||||
try:
|
||||
from PIL import ImageGrab
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
rect = wintypes.RECT()
|
||||
if not user32.GetWindowRect(int(hwnd), ctypes.byref(rect)):
|
||||
return False
|
||||
bbox = (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
|
||||
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
||||
return False
|
||||
img = ImageGrab.grab(bbox=bbox, all_screens=True)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(dest))
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
img = ImageGrab.grab(all_screens=True)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(str(dest))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# --- Flow document API -------------------------------------------------
|
||||
|
||||
def create_flow(
|
||||
captures_root: Path,
|
||||
*,
|
||||
flow_name: str = "desktop-flow",
|
||||
) -> Path:
|
||||
"""开始流程:新建一份流程文档目录。"""
|
||||
captures_root.mkdir(parents=True, exist_ok=True)
|
||||
slug = safe_slug(flow_name, fallback="flow")
|
||||
flow_dir = captures_root / f"{_local_ts()}_{slug}"
|
||||
flow_dir.mkdir(parents=True, exist_ok=False)
|
||||
(flow_dir / "steps").mkdir(parents=True, exist_ok=True)
|
||||
now = datetime.now().astimezone().isoformat()
|
||||
flow = {
|
||||
"schema": "openclaw.ui_inspector.flow.v1",
|
||||
"flow_name": flow_name,
|
||||
"status": "open",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"ended_at": None,
|
||||
"step_count": 0,
|
||||
"steps": [],
|
||||
"tool": {
|
||||
"name": "openclaw-ui-inspector",
|
||||
"version": __version__,
|
||||
},
|
||||
}
|
||||
write_json(flow_dir / "flow.json", flow)
|
||||
write_flow_summary(flow_dir, flow)
|
||||
return flow_dir
|
||||
|
||||
|
||||
def load_flow(flow_dir: Path) -> dict[str, Any]:
|
||||
return read_json(flow_dir / "flow.json")
|
||||
|
||||
|
||||
def save_flow(flow_dir: Path, flow: dict[str, Any]) -> None:
|
||||
flow["updated_at"] = datetime.now().astimezone().isoformat()
|
||||
write_json(flow_dir / "flow.json", flow)
|
||||
write_flow_summary(flow_dir, flow)
|
||||
|
||||
|
||||
def next_step_index(flow: dict[str, Any]) -> int:
|
||||
return int(flow.get("step_count") or 0) + 1
|
||||
|
||||
|
||||
def add_step_dir(
|
||||
flow_dir: Path,
|
||||
flow: dict[str, Any],
|
||||
*,
|
||||
window_title: str = "",
|
||||
window_class: str = "",
|
||||
locatable: Optional[list[dict[str, Any]]] = None,
|
||||
label: Optional[str] = None,
|
||||
) -> tuple[Path, int, str]:
|
||||
"""Allocate steps/NN_<page-fingerprint>/ under an open flow."""
|
||||
if flow.get("status") != "open":
|
||||
raise RuntimeError("当前流程已结束,请先「开始流程」。")
|
||||
idx = next_step_index(flow)
|
||||
if not label:
|
||||
label = resolve_step_label(
|
||||
flow,
|
||||
window_title=window_title,
|
||||
window_class=window_class,
|
||||
locatable=locatable,
|
||||
)
|
||||
steps_root = flow_dir / "steps"
|
||||
steps_root.mkdir(parents=True, exist_ok=True)
|
||||
step_name = _unique_step_dirname(steps_root, idx, label)
|
||||
step_dir = steps_root / step_name
|
||||
step_dir.mkdir(parents=True, exist_ok=False)
|
||||
return step_dir, idx, step_name
|
||||
|
||||
|
||||
def register_step(
|
||||
flow_dir: Path,
|
||||
flow: dict[str, Any],
|
||||
*,
|
||||
step_index: int,
|
||||
step_name: str,
|
||||
window: dict[str, Any],
|
||||
stats: dict[str, Any],
|
||||
label: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
entry = {
|
||||
"index": step_index,
|
||||
"name": step_name,
|
||||
"label": label or step_name,
|
||||
"captured_at": datetime.now().astimezone().isoformat(),
|
||||
"window_title": window.get("title"),
|
||||
"window_class": window.get("class_name"),
|
||||
"pid": window.get("pid"),
|
||||
"hwnd": window.get("hwnd"),
|
||||
"findall": stats.get("findall_count"),
|
||||
"locatable": stats.get("locatable_count"),
|
||||
"kept": stats.get("nodes_kept"),
|
||||
"uia_sparse": stats.get("uia_sparse"),
|
||||
"path": f"steps/{step_name}",
|
||||
}
|
||||
steps = list(flow.get("steps") or [])
|
||||
steps.append(entry)
|
||||
flow["steps"] = steps
|
||||
flow["step_count"] = len(steps)
|
||||
save_flow(flow_dir, flow)
|
||||
return entry
|
||||
|
||||
|
||||
def end_flow(flow_dir: Path, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
if flow.get("status") == "ended":
|
||||
return flow
|
||||
flow["status"] = "ended"
|
||||
flow["ended_at"] = datetime.now().astimezone().isoformat()
|
||||
save_flow(flow_dir, flow)
|
||||
return flow
|
||||
|
||||
|
||||
def write_flow_summary(flow_dir: Path, flow: dict[str, Any]) -> None:
|
||||
lines = [
|
||||
f"# 流程:{flow.get('flow_name')}",
|
||||
"",
|
||||
f"- status: `{flow.get('status')}`",
|
||||
f"- created_at: `{flow.get('created_at')}`",
|
||||
f"- updated_at: `{flow.get('updated_at')}`",
|
||||
f"- ended_at: `{flow.get('ended_at')}`",
|
||||
f"- step_count: `{flow.get('step_count')}`",
|
||||
"",
|
||||
"## 步骤",
|
||||
"",
|
||||
]
|
||||
steps = flow.get("steps") or []
|
||||
if not steps:
|
||||
lines.append("(尚无拾取步骤。点「拾取当前页」追加。)")
|
||||
lines.append("")
|
||||
else:
|
||||
for s in steps:
|
||||
lines.append(f"### {s.get('index')}. `{s.get('name')}`")
|
||||
lines.append("")
|
||||
if s.get("label"):
|
||||
lines.append(f"- label: `{s.get('label')}`")
|
||||
lines.append(
|
||||
f"- window: `{s.get('window_title')}` / `{s.get('window_class')}`"
|
||||
)
|
||||
lines.append(
|
||||
f"- stats: findall=`{s.get('findall')}` locatable=`{s.get('locatable')}` "
|
||||
f"kept=`{s.get('kept')}` sparse=`{s.get('uia_sparse')}`"
|
||||
)
|
||||
lines.append(f"- path: `{s.get('path')}/`")
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
[
|
||||
"## 说明",
|
||||
"",
|
||||
"- 一个流程对应本目录一份文档(`flow.json` + `SUMMARY.md` + `steps/`)。",
|
||||
"- 每拾取一页,在 `steps/` 下新增一步;给 AI 时优先读本 `SUMMARY.md` 与各步 `locatable.json`。",
|
||||
"- Name ≠ 界面中文时请看 Value。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
(flow_dir / "SUMMARY.md").write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def write_step_summary(
|
||||
step_dir: Path,
|
||||
*,
|
||||
meta: dict[str, Any],
|
||||
stats: Optional[dict[str, Any]] = None,
|
||||
noteworthy: Optional[list[dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
app = meta.get("app") or {}
|
||||
lines = [
|
||||
"# Step Summary",
|
||||
"",
|
||||
f"- captured_at: `{meta.get('captured_at')}`",
|
||||
f"- window: `{app.get('title')}` / `{app.get('class_name')}` pid=`{app.get('pid')}`",
|
||||
"",
|
||||
]
|
||||
if stats:
|
||||
lines.extend(
|
||||
[
|
||||
"## Stats",
|
||||
"",
|
||||
f"- kept: `{stats.get('nodes_kept')}`",
|
||||
f"- findall: `{stats.get('findall_count')}`",
|
||||
f"- locatable: `{stats.get('locatable_count')}`",
|
||||
f"- uia_sparse: `{stats.get('uia_sparse')}`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if noteworthy:
|
||||
lines.extend(["## Notable", ""])
|
||||
for item in noteworthy[:40]:
|
||||
lines.append(
|
||||
f"- `{item.get('control_type')}` Name=`{item.get('name')}` "
|
||||
f"Value=`{item.get('value')}`"
|
||||
)
|
||||
lines.append("")
|
||||
lines.extend(
|
||||
[
|
||||
"## Files",
|
||||
"",
|
||||
"- `meta.json` / `tree.json` / `index.jsonl` / `locatable.json` / `screen.png`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
(step_dir / "SUMMARY.md").write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
# Back-compat aliases used by older smoke scripts
|
||||
def create_session_dir(captures_root: Path, *, scene: str = "desktop") -> Path:
|
||||
return create_flow(captures_root, flow_name=scene)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,192 +0,0 @@
|
||||
"""调用 jiangchang_desktop_wake(只调不复制注入逻辑)。
|
||||
|
||||
- 主窗 WeWorkWindow → ``ensure_ui_ready("wecom")``
|
||||
- 搜索浮层 SearchResultWindow2 → ``ensure_ui_ready("wecom_search")``
|
||||
未安装 kit 时降级为跳过,不阻断采集。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
_CACHE_TTL_SEC = 45.0
|
||||
|
||||
# profile -> cache
|
||||
_caches: dict[str, dict[str, Any]] = {}
|
||||
|
||||
_PROFILE_WECOM = "wecom"
|
||||
_PROFILE_WECOM_SEARCH = "wecom_search"
|
||||
|
||||
_THRESHOLDS = {
|
||||
_PROFILE_WECOM: 80,
|
||||
_PROFILE_WECOM_SEARCH: 20,
|
||||
}
|
||||
|
||||
|
||||
def resolve_wake_profile(*, class_name: str = "", title: str = "") -> Optional[str]:
|
||||
"""返回应调用的 kit profile id;非企微相关则 None。"""
|
||||
cls = (class_name or "").strip()
|
||||
title = title or ""
|
||||
if cls == "SearchResultWindow2":
|
||||
return _PROFILE_WECOM_SEARCH
|
||||
if cls == "WeWorkWindow":
|
||||
return _PROFILE_WECOM
|
||||
if "WeWork" in cls and ("企业微信" in title or "WeCom" in title or "wecom" in title.lower()):
|
||||
return _PROFILE_WECOM
|
||||
return None
|
||||
|
||||
|
||||
def looks_like_wecom(*, class_name: str = "", title: str = "") -> bool:
|
||||
return resolve_wake_profile(class_name=class_name, title=title) is not None
|
||||
|
||||
|
||||
def kit_available() -> bool:
|
||||
try:
|
||||
from jiangchang_desktop_wake import ensure_ui_ready # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def auto_ensure_if_wecom(
|
||||
*,
|
||||
class_name: str = "",
|
||||
title: str = "",
|
||||
hwnd: Optional[int] = None,
|
||||
force: bool = False,
|
||||
findall_hint: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""若目标像企微主窗/搜索浮层则静默 ensure;否则跳过。"""
|
||||
profile = resolve_wake_profile(class_name=class_name, title=title)
|
||||
if profile is None:
|
||||
return {
|
||||
"applicable": False,
|
||||
"skipped": True,
|
||||
"reason": "not_wecom",
|
||||
"available": kit_available(),
|
||||
}
|
||||
|
||||
warm_min = int(_THRESHOLDS.get(profile, 80))
|
||||
cache = _caches.setdefault(
|
||||
profile, {"t": 0.0, "warm": False, "hwnd": 0, "findall": 0}
|
||||
)
|
||||
now = time.monotonic()
|
||||
hint = int(findall_hint) if findall_hint is not None else -1
|
||||
if (
|
||||
not force
|
||||
and cache.get("warm")
|
||||
and (now - float(cache.get("t") or 0.0)) < _CACHE_TTL_SEC
|
||||
and (hint < 0 or hint >= warm_min)
|
||||
):
|
||||
return {
|
||||
"applicable": True,
|
||||
"available": True,
|
||||
"skipped": True,
|
||||
"reason": "cache_warm",
|
||||
"ok": True,
|
||||
"warm": True,
|
||||
"wake_applied": False,
|
||||
"findall": int(cache.get("findall") or 0),
|
||||
"hwnd": int(cache.get("hwnd") or 0),
|
||||
"profile": profile,
|
||||
}
|
||||
|
||||
try:
|
||||
from jiangchang_desktop_wake import ensure_ui_ready
|
||||
from jiangchang_desktop_wake.errors import DesktopWakeError
|
||||
except ImportError:
|
||||
return {
|
||||
"applicable": True,
|
||||
"available": False,
|
||||
"skipped": True,
|
||||
"reason": "kit_not_installed",
|
||||
"ok": False,
|
||||
"profile": profile,
|
||||
"hint": "请安装 jiangchang-platform-kit>=1.2.6(含 wecom_search)后再采企微",
|
||||
}
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result = ensure_ui_ready(
|
||||
profile,
|
||||
hwnd=int(hwnd) if hwnd else None,
|
||||
raise_on_cold=True,
|
||||
)
|
||||
except DesktopWakeError as exc:
|
||||
raw = (getattr(exc, "code", None) or "").strip()
|
||||
code = raw[6:] if raw.startswith("ERROR:") else (raw or "WAKE_FAILED")
|
||||
return {
|
||||
"applicable": True,
|
||||
"available": True,
|
||||
"skipped": False,
|
||||
"ok": False,
|
||||
"warm": False,
|
||||
"wake_applied": False,
|
||||
"error_code": code,
|
||||
"message": str(getattr(exc, "message", None) or exc),
|
||||
"elapsed_ms": int(round((time.perf_counter() - t0) * 1000)),
|
||||
"profile": profile,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"applicable": True,
|
||||
"available": True,
|
||||
"skipped": False,
|
||||
"ok": False,
|
||||
"warm": False,
|
||||
"error_code": "ENSURE_EXCEPTION",
|
||||
"message": f"{type(exc).__name__}: {exc}",
|
||||
"elapsed_ms": int(round((time.perf_counter() - t0) * 1000)),
|
||||
"profile": profile,
|
||||
}
|
||||
|
||||
after = result.probe_after
|
||||
findall = int(after.findall_count) if after is not None else 0
|
||||
report = {
|
||||
"applicable": True,
|
||||
"available": True,
|
||||
"skipped": False,
|
||||
"ok": bool(result.warm),
|
||||
"warm": bool(result.warm),
|
||||
"wake_applied": bool(result.wake_applied),
|
||||
"findall": findall,
|
||||
"hwnd": int(result.hwnd) if result.hwnd else 0,
|
||||
"elapsed_ms": int(round((time.perf_counter() - t0) * 1000)),
|
||||
"profile": profile,
|
||||
"markers_found": list(after.markers_found) if after is not None else [],
|
||||
}
|
||||
if result.warm:
|
||||
cache["t"] = now
|
||||
cache["warm"] = True
|
||||
cache["hwnd"] = report["hwnd"]
|
||||
cache["findall"] = findall
|
||||
else:
|
||||
cache["warm"] = False
|
||||
return report
|
||||
|
||||
|
||||
def format_kit_wake_line(report: Optional[dict[str, Any]]) -> str:
|
||||
if not report:
|
||||
return "kit_wake: (none)"
|
||||
if not report.get("applicable"):
|
||||
return "kit_wake: n/a (非企微)"
|
||||
if report.get("reason") == "kit_not_installed":
|
||||
return "kit_wake: 未安装 platform-kit(企微可能冷树)"
|
||||
prof = report.get("profile") or "?"
|
||||
if report.get("skipped") and report.get("reason") == "cache_warm":
|
||||
return (
|
||||
f"kit_wake[{prof}]: cache warm=True findall={report.get('findall')} "
|
||||
f"hwnd={report.get('hwnd')}"
|
||||
)
|
||||
if report.get("ok"):
|
||||
return (
|
||||
f"kit_wake[{prof}]: warm={report.get('warm')} "
|
||||
f"wake_applied={report.get('wake_applied')} "
|
||||
f"findall={report.get('findall')} elapsed_ms={report.get('elapsed_ms')}"
|
||||
)
|
||||
return (
|
||||
f"kit_wake[{prof}]: FAILED code={report.get('error_code')} "
|
||||
f"msg={report.get('message')}"
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
uiautomation>=2.0.18
|
||||
# 可选:导出 screen.png 需要 Pillow;没有也能 dump JSON
|
||||
Pillow>=10.0.0
|
||||
# 企微自动暖树:与技能同款 ensure_ui_ready(请用宿主共享 venv 或本机安装)
|
||||
# jiangchang-platform-kit>=1.2.5
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry: python tools/ui_inspector/run.py [--out DIR] [--scene NAME]"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
|
||||
if __name__ == "__main__":
|
||||
runpy.run_path(str(Path(__file__).resolve().parent / "app.py"), run_name="__main__")
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Smoke test for flow document API + step labeling + kit_wake helpers (v0.8.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
import artifacts as art
|
||||
import kit_wake
|
||||
|
||||
|
||||
def _assert_kit_wake_detect() -> None:
|
||||
assert kit_wake.looks_like_wecom(class_name="WeWorkWindow", title="企业微信")
|
||||
assert kit_wake.resolve_wake_profile(class_name="SearchResultWindow2") == "wecom_search"
|
||||
assert not kit_wake.looks_like_wecom(class_name="Chrome_WidgetWin_1", title="Google")
|
||||
r = kit_wake.auto_ensure_if_wecom(class_name="NotWeCom", title="x")
|
||||
assert r.get("skipped") and r.get("reason") == "not_wecom"
|
||||
print("OK kit_wake detect")
|
||||
|
||||
|
||||
def _assert_label() -> None:
|
||||
msg_page = [
|
||||
{"control_type": "TreeItemControl", "name": "groupbuddyname", "value": "某群"},
|
||||
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "你好"},
|
||||
{"control_type": "TreeItemControl", "name": "con_op", "value": "消息"},
|
||||
]
|
||||
searching = [
|
||||
{"control_type": "TreeItemControl", "name": "groupbuddyname", "value": "徐书建"},
|
||||
{"control_type": "TreeControl", "name": "searchedit", "value": "陈德炼"},
|
||||
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "陈德炼"},
|
||||
]
|
||||
selected = [
|
||||
{
|
||||
"control_type": "TreeItemControl",
|
||||
"name": "groupbuddyname",
|
||||
"value": "陈德炼<c #99ffffff>@微信</c>",
|
||||
},
|
||||
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "您好"},
|
||||
]
|
||||
contacts = [
|
||||
{"control_type": "TreeItemControl", "name": "panel_title_label", "value": "RPA之家"},
|
||||
{"control_type": "TreeItemControl", "name": "main_chattext", "value": "新的客户"},
|
||||
]
|
||||
new_customer = [
|
||||
{"control_type": "TreeItemControl", "name": "panel_title_label", "value": "新的客户"},
|
||||
{"control_type": "TreeItemControl", "name": "ww_btn_text", "value": "添加"},
|
||||
]
|
||||
add_empty = [
|
||||
{"control_type": "TreeItemControl", "name": "title", "value": "添加客户"},
|
||||
{"control_type": "TreeControl", "name": "searchedit", "value": ""},
|
||||
{"control_type": "WindowControl", "name": "添加客户", "value": ""},
|
||||
]
|
||||
add_result = [
|
||||
{"control_type": "TreeItemControl", "name": "title", "value": "添加客户"},
|
||||
{"control_type": "TreeControl", "name": "searchedit", "value": "13316463772"},
|
||||
{"control_type": "TreeItemControl", "name": "main_chattext", "value": "Nancy"},
|
||||
{"control_type": "TreeItemControl", "name": "ww_btn_text", "value": "添加为"},
|
||||
{"control_type": "TreeControl", "name": "search_result", "value": ""},
|
||||
]
|
||||
|
||||
a = art.derive_step_label(window_title="企业微信", locatable=msg_page)
|
||||
b = art.derive_step_label(window_title="企业微信", locatable=searching)
|
||||
c = art.derive_step_label(window_title="企业微信", locatable=selected)
|
||||
d = art.derive_step_label(window_title="企业微信", locatable=contacts)
|
||||
e = art.derive_step_label(window_title="企业微信", locatable=new_customer)
|
||||
f = art.derive_step_label(window_title="添加客户", locatable=add_empty)
|
||||
g = art.derive_step_label(window_title="添加客户", locatable=add_result)
|
||||
|
||||
assert "输入你好" in a or "某群" in a, a
|
||||
assert "搜索陈德炼" in b, b
|
||||
assert "陈德炼" in c and "输入您好" in c, c
|
||||
assert "RPA之家" in d, d
|
||||
assert "新的客户" in e and "添加" in e, e
|
||||
assert f.startswith("添加客户"), f
|
||||
assert "13316463772" in g or "搜索13316463772" in g, g
|
||||
assert "会话列表" not in a and "会话列表" not in b and "会话列表" not in c
|
||||
print(f"OK labels:\n {a}\n {b}\n {c}\n {d}\n {e}\n {f}\n {g}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_assert_kit_wake_detect()
|
||||
_assert_label()
|
||||
with tempfile.TemporaryDirectory(prefix="ui_flow_smoke_") as tmp:
|
||||
root = Path(tmp)
|
||||
flow_dir = art.create_flow(root, flow_name="demo-flow")
|
||||
flow = art.load_flow(flow_dir)
|
||||
|
||||
loc = [
|
||||
{
|
||||
"control_type": "TreeItemControl",
|
||||
"name": "groupbuddyname",
|
||||
"value": "陈德炼<c #99ffffff>@微信</c>",
|
||||
},
|
||||
{"control_type": "TreeItemControl", "name": "send_rich_edit", "value": "您好"},
|
||||
]
|
||||
label = art.resolve_step_label(
|
||||
flow, window_title="企业微信", window_class="WeWorkWindow", locatable=loc
|
||||
)
|
||||
step_dir, idx, name = art.add_step_dir(
|
||||
flow_dir,
|
||||
flow,
|
||||
window_title="企业微信",
|
||||
window_class="WeWorkWindow",
|
||||
locatable=loc,
|
||||
label=label,
|
||||
)
|
||||
assert idx == 1
|
||||
assert "陈德炼" in name, name
|
||||
art.write_json(step_dir / "meta.json", {"ok": True})
|
||||
art.register_step(
|
||||
flow_dir,
|
||||
flow,
|
||||
step_index=idx,
|
||||
step_name=name,
|
||||
window={"title": "企业微信", "class_name": "WeWorkWindow", "pid": 1, "hwnd": 2},
|
||||
stats={"findall_count": 10, "locatable_count": 5, "nodes_kept": 3, "uia_sparse": False},
|
||||
label=label,
|
||||
)
|
||||
flow = art.end_flow(flow_dir, art.load_flow(flow_dir))
|
||||
assert flow["status"] == "ended"
|
||||
print(f"OK flow={flow_dir.name} step={name}")
|
||||
print("OK ui_inspector flow smoke passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user